Interview prep · Junior → Senior

Android Interview Questions

The questions an Android interviewer actually asks about the platform — the app components and the manifest, the lifecycles (Activity, Fragment, and the Fragment view lifecycle), configuration changes and process death, ViewModel and app architecture, the View system, Jetpack Compose, coroutines and background work, navigation, Room and DataStore, the data layer, Hilt, permissions and Intents, performance and testing, and the classic gotchas — each answered with a Kotlin example and a link to the source. This page covers the Android framework and assumes Kotlin fluency; for the language beneath it see the Kotlin interview questions. Pair it with the Android version reference and the open Android roles on the Indiana jobs boards.

Difficulty

Junior — expected of anyone shipping an Android app; the components, the manifest, the Activity lifecycle, layouts and lists, basic Compose.
Mid — the platform in practice: configuration changes, ViewModel, coroutine scoping, Room and DataStore, navigation, Hilt, permissions.
Senior — process death and restoration, the Fragment view lifecycle, Compose recomposition and stability, background-execution limits, leaks, and the gotchas.
Difficulty

Showing 0 of 0 questions

1 · Fundamentals & architecture

What is Android — the OS, the platform, and what your app actually runs inside? Junior

Android is two things at once. It is an operating system built on a modified Linux kernel, and it is an application platform — the SDK, the framework classes, and the runtime your app is written against. The open-source core is the Android Open Source Project; device makers layer their own software on top, and Google's proprietary apps and Play services ship separately.

Two facts about what your app runs inside come up constantly. First, each installed app gets its own Linux user ID and its own process — that per-app sandbox is the primary security boundary, which is why one app cannot read another's files without an explicit mechanism. Second, your Kotlin compiles to JVM bytecode, which is translated to DEX and executed by ART (the Android Runtime), which ahead-of-time and just-in-time compiles it to native code. ART replaced the older Dalvik VM as the default in Android 5.0; Dalvik is a history answer, not a live one.

// Your app is a Linux process with its own UID. Nothing about this is virtual:
// $ adb shell ps -A | grep com.example.app
// u0_a412  9134  1284  ...  com.example.app

// And the runtime version is a plain platform check.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    // API 33+ only path
}

Why it's asked / follow-up: it is the warm-up, and it separates a candidate who understands the process/sandbox model from one who thinks of Android as "a place my Kotlin runs." Follow-up: “what is an API level?” — the integer identifying the framework API a platform release exposes; you compile against one (compileSdk), declare the behavior you have adapted to (targetSdk), and set the oldest you support (minSdk). See the version and API-level reference for the full mapping.

Source: Android — Platform architecture.

What are the four app components, and where do Fragments fit? Junior

Four component types are the entry points the system itself can start. An Activity is a single screen with a UI — the entry point for user interaction. A Service is a component with no UI for longer-running work (in modern code, usually a foreground service for user-visible ongoing work; deferrable work belongs to WorkManager). A BroadcastReceiver responds to system- or app-wide events. A ContentProvider exposes a structured data set to other apps behind a URI-addressed interface — it is the mechanism for cross-app data sharing, and you rarely write one for storage inside your own app.

A Fragment is not one of the four. It is a reusable, lifecycle-owning piece of UI hosted inside an Activity; the system cannot start one directly. That distinction is the point of the question.

// Components the SYSTEM can start are declared in the manifest.
<activity
    android:name=".MainActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

// A Fragment is not declared here — its host Activity puts it on screen.

Why it's asked / follow-up: naming four things is easy; knowing that a Fragment is hosted rather than started is the discriminator, and it sets up the lifecycle questions. Follow-up: “when would you actually write a ContentProvider?” — when another app needs your data (or a framework component like the system file picker or a sync adapter requires one), not as an internal database wrapper.

Source: Android — Application fundamentals.

What does AndroidManifest.xml declare? Junior

The manifest is how your app describes itself to the system, before any of your code runs. It declares the app's components (so the system knows what it may start and through which <intent-filter>s), the permissions the app requests, hardware and software features it requires, and app-wide attributes such as the Application class, theme, and backup rules. It also carries the android:exported flag on every component with an intent filter — required explicitly for apps targeting Android 12 or later, and a real security decision rather than boilerplate.

Two things people expect to find here and won't: the SDK versions (minSdk, targetSdk, compileSdk) live in the Gradle build file and are merged in at build time, and library manifests are merged into yours — which is why a permission you never asked for can appear in the final merged manifest.

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-feature android:name="android.hardware.camera" android:required="false" />

    <application android:name=".MyApp" android:theme="@style/Theme.App">
        <activity android:name=".MainActivity" android:exported="true"> ... </activity>
    </application>
</manifest>

Why it's asked / follow-up: it checks that you know the system reads a static declaration before your process exists. Follow-up: “how would you find out what's actually in your shipped manifest?” — the Merged Manifest view in Android Studio, which shows every library's contribution and where each node came from.

Source: Android — App manifest overview.

Kotlin or Java for Android — and does it matter? Junior

Kotlin is Google's preferred language for Android and has been since 2019; new APIs, samples, documentation, and Jetpack libraries are Kotlin-first, and Jetpack Compose is Kotlin-only (it depends on a compiler plugin). Java still runs — the platform APIs are Java-defined, the two interoperate freely in one module, and large codebases are usually mixed — so “we still have Java” is a normal answer, not a red flag.

What an interviewer wants is the why: null safety at the type level (which removes a large class of NullPointerExceptions at the API boundary), coroutines for asynchronous work instead of callbacks, and far less ceremony (data classes, extension functions, named and default arguments). The trade-off worth naming honestly is build time — Kotlin adds a compilation stage, and annotation processing is the usual culprit, which is why the ecosystem moved from kapt to KSP.

// The single most-cited difference: nullability is in the type.
val name: String  = intent.getStringExtra("name")   // ❌ won't compile — platform type is nullable
val name: String? = intent.getStringExtra("name")   // ✅ the caller must handle absence

val shown = name ?: "Anonymous"

Why it's asked / follow-up: it is a cheap way to find out whether you have written modern Android or inherited a 2016 codebase and never looked up. Follow-up: “what breaks at the Java boundary?” — platform types: a value coming from unannotated Java has unknown nullability, so Kotlin cannot protect you and a null slips through. The language-level detail is on the Kotlin interview page.

Source: Android — Kotlin-first development.

APK vs AAB — what do you actually ship, and what does the build do to your code? Mid

An APK is the installable artifact a device runs. An Android App Bundle (AAB) is the publishing format: you upload one bundle containing every density, language, and ABI, and Google Play generates and signs the specific APK each device needs. The practical consequence is a smaller download, and the practical cost is that Play holds the app-signing key. New apps on Play have been required to publish as AABs since August 2021.

On the way there, R8 does the compile-time work: it shrinks (drops unreachable code), optimizes, and obfuscates, and it takes the -keep rule syntax inherited from ProGuard — ProGuard itself is no longer the tool. R8 is also why a crash report from a release build needs a mapping file to be readable, and why anything reached only by reflection needs an explicit keep rule.

// build.gradle.kts — release builds shrink code and resources.
android {
    buildTypes {
        release {
            isMinifyEnabled = true       // R8: shrink + optimize + obfuscate
            isShrinkResources = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"),
                          "proguard-rules.pro")
        }
    }
}

Why it's asked / follow-up: it checks that you have shipped something, not just run it on an emulator. Follow-up: “what breaks when you turn on minification for the first time?” — reflection-based code: a JSON model whose field names are obfuscated, or a class loaded by name. The fix is a keep rule, not turning R8 off.

Sources: Android — About Android App Bundles, Android — Enable app optimization (R8).

2 · Lifecycle & app architecture

Walk through the Activity lifecycle. What runs when? Junior

Six callbacks, in three nested pairs. onCreate runs once per instance — inflate or set the UI, wire up the ViewModel, restore saved state. onStart means the Activity is visible; onResume means it is in the foreground and taking input. Going the other way, onPause means it has lost focus but may still be partially visible (a dialog, a multi-window split), onStop means it is no longer visible, and onDestroy means this instance is finishing or being recreated.

Two ordering points matter more than the list. First, the pairs nest: onCreate/onDestroy wrap onStart/onStop, which wrap onResume/onPause. Second, when you launch a new Activity, the outgoing one's onPause runs before the incoming one's onCreate — which is why doing slow work in onPause visibly delays the next screen. Save durable data in onStop, not onPause.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { AppRoot() }        // once per instance
    }
    override fun onStart()   { super.onStart();   /* visible: start UI-facing updates */ }
    override fun onStop()    { super.onStop();    /* not visible: persist, release */ }
    override fun onDestroy() { super.onDestroy(); /* may be a recreate, not a farewell */ }
}

Why it's asked / follow-up: it is the single most-asked Android question, and the answers that fail are the ones that recite six names without knowing what “visible” versus “focused” means. Follow-up: “where do you release a camera or a sensor?” — acquire in onStart/onResume and release in the matching onStop/onPause, so the resource is not held by an invisible screen.

Source: Android — The activity lifecycle.

What is the difference between the Fragment lifecycle and the Fragment view lifecycle? Senior

A Fragment has two lifecycles that do not run in step, and this is the single most consequential subtlety in the View-based Fragment API. The Fragment instance lives from onCreate to onDestroy. Its view lives from onCreateView to onDestroyView — and the view can be destroyed and recreated many times while the same Fragment instance survives. The classic case is the back stack: navigating forward destroys the view but keeps the instance, and navigating back inflates a brand-new view into that same instance.

The consequence is the rule: when you observe anything from a Fragment that touches the view, use viewLifecycleOwner, not this. Observing with the Fragment as the owner means the observer outlives the view it writes to — so after a back-stack round trip you get a second observer, both firing, the older one holding a destroyed view. It is a leak and a crash source at once, and it is the reason viewLifecycleOwner exists.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    // ❌ observer outlives the view; a back-stack return adds a second one
    viewModel.state.observe(this) { render(it) }

    // ✅ torn down at onDestroyView, recreated with the next view
    viewModel.state.observe(viewLifecycleOwner) { render(it) }

    // Same rule for Flow collection.
    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.uiState.collect { render(it) }
        }
    }
}

Why it's asked / follow-up: it separates candidates who have debugged a real Fragment bug from those who have only read the lifecycle diagram. Follow-up: “what else must you clean up in onDestroyView?” — the view binding reference: null it out there, or the Fragment instance keeps the whole destroyed view tree alive.

Source: Android — Fragment lifecycle.

What is a configuration change, and why does it recreate the Activity? Mid

A configuration change is any change to the device state that your resources depend on — rotation, window size, locale, font scale, dark mode, and so on. Because Android picks resources by configuration qualifier (values-es/, layout-sw600dp/, values-night/), the cleanest way to apply a new configuration is to destroy the Activity and build it again against the new resources. That is not a bug; it is the resource system working, and it is why the platform hands you a recreation instead of a mutation.

Two modern qualifications. Since Android 17 (API 37) the system no longer restarts an Activity for the changes that do not need a full redraw — keyboard, keyboard-hidden, navigation, touchscreen and color-mode, among a few others — delivering onConfigurationChanged instead; you opt back into recreation with the new android:recreateOnConfigChanges attribute. Size and orientation changes still recreate. And declaring android:configChanges to “fix” rotation is the wrong instinct: you then own re-applying every resource by hand, and you have hidden the state bug rather than solved it. Hoist the state into a ViewModel and let the recreation happen.

// Since Android 17, opt IN to recreation for the changes it stopped restarting for.
<activity
    android:name=".MainActivity"
    android:recreateOnConfigChanges="colorMode|touchscreen" />

// Handling a change yourself means you re-apply the resources yourself.
override fun onConfigurationChanged(newConfig: Configuration) {
    super.onConfigurationChanged(newConfig)
    // nothing re-inflates on its own here — that is the whole cost
}

Why it's asked / follow-up: the follow-up is always “so what happens to my data?”, which is the entry point to ViewModel, SavedStateHandle, and process death. Follow-up: “does a large-screen app get to refuse rotation?” — increasingly no: from Android 16 onward, and enforced for apps targeting API 37, orientation and resizability restrictions are ignored on large screens, so adaptive layout is the requirement rather than a nicety.

Sources: Android — Handle configuration changes, Android 17 — Behavior changes.

onSaveInstanceState vs SavedStateHandle — when do you use which? Mid

They are the same mechanism seen from two places. onSaveInstanceState is the Activity/Fragment callback that writes a small Bundle the system stores outside your process, so it survives both a configuration change and process death. SavedStateHandle is the same saved-state bundle injected into a ViewModel, so the state can live next to the logic that owns it instead of being copied through the UI class.

The size rule is the part people get wrong: this bundle is transacted through the system's Binder IPC and is strictly for small values — a selected id, a scroll index, a search query. Putting a list of parsed models in it earns a TransactionTooLargeException in production on exactly the devices you cannot reproduce on. Big data gets re-fetched or re-read from the database on restore; saved state holds the key you re-fetch with.

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val handle: SavedStateHandle,
    private val repo: SearchRepository,
) : ViewModel() {

    // Survives config change AND process death — it is written to the saved bundle.
    val query: StateFlow<String> = handle.getStateFlow("query", "")

    fun setQuery(q: String) { handle["query"] = q }   // small: a key, not a result list
}

Why it's asked / follow-up: it is the bridge question between the lifecycle and the architecture answers — you cannot answer it well without knowing what process death does. Follow-up: “when is onSaveInstanceState called?” — before the Activity becomes killable, so after onStop on modern releases; it is not guaranteed on a normal user-initiated finish, because there is nothing to restore.

Source: Android — Saved state module for ViewModel.

What is process death, and how is it different from a configuration change? Senior

When your app is in the background, the system can kill the whole process to reclaim memory. Every object you were holding is gone: your ViewModels, your singletons, your in-memory caches, static state, everything. What survives is only what was written outside the process — the saved-state bundle and whatever you persisted to disk. When the user returns, the system starts a fresh process and recreates the Activity back stack, handing each screen its saved bundle. To the user it looks like they never left.

That is the difference that matters: a configuration change destroys an Activity instance inside a living process, so a ViewModel survives it. Process death destroys the process, so a ViewModel does not survive it — only its SavedStateHandle contents do. Any state that exists only as a field somewhere is silently lost, which is why the bug shows up as “the user came back after twenty minutes and their form was empty.”

// Reproduce it deliberately — this is the test most Android bugs of this shape fail:
// 1. Put the app in the background (Home).
// 2. $ adb shell am kill com.example.app     ← kills the process, keeps the task
// 3. Return to the app from Recents.

// Survives config change only:
private var draft = ""                                   // ❌ gone after step 2
// Survives process death too:
val draft = handle.getStateFlow("draft", "")          // ✅ restored at step 3

Why it's asked / follow-up: it is the state question that most candidates have never actually tested, and it is trivially testable, so it separates careful engineers from confident ones. Follow-up: “does ‘Don't keep activities’ test this?” — not fully: that developer option simulates Activity destruction, not the loss of the whole process; use adb shell am kill for the real thing.

Source: Android — Save UI states.

What does a ViewModel actually do, and what does it survive? Mid

A ViewModel holds UI state and exposes it to the screen, and it is scoped to a lifecycle owner rather than to an instance of it. When an Activity is recreated for a configuration change, the framework hands the new instance the same ViewModel, because the store that holds it is keyed to the owner's identity and not to the object. That is the whole trick, and it is why rotating no longer needs to re-run your network call.

The precise boundary is the answer interviewers listen for: a ViewModel survives a configuration change; it does not survive process death. It is cleared in onCleared() when the owner is finished for good — the Activity is finishing, or the Fragment is permanently removed — and its viewModelScope is cancelled at that moment. State that must outlive the process goes into SavedStateHandle or onto disk.

The other half of the rule: a ViewModel must never hold a reference to a View, an Activity, a Fragment, or an Activity Context. It outlives them by design, so holding one is a guaranteed leak.

class ProfileViewModel(private val repo: ProfileRepository) : ViewModel() {

    private val _state = MutableStateFlow<UiState>(UiState.Loading)
    val state: StateFlow<UiState> = _state.asStateFlow()   // read-only outward

    init {
        viewModelScope.launch {                            // cancelled in onCleared()
            _state.value = UiState.Ready(repo.load())
        }
    }
    // ❌ never: private var activity: Activity
}

Why it's asked / follow-up: “why does a ViewModel survive rotation?” is the fastest way to find out whether someone understands scoping or just uses the class. Follow-up: “how do two Fragments share one?” — scope it to the shared owner: by activityViewModels() in the View world, or hoist the ViewModel to the navigation-graph scope in Compose.

Source: Android — ViewModel overview.

LiveData vs StateFlow vs SharedFlow for UI state Mid

LiveData is the original architecture-components holder: it always has a current value, and it is lifecycle-aware by construction — it only emits to observers in the started state and detaches on destroy. Its limits are that it is Android-only, main-thread-oriented, and has essentially no operators.

StateFlow is the coroutines equivalent for the same job: a hot flow with a current value that conflates and only emits distinct values. It is plain Kotlin (so it works in shared and non-Android modules), it composes with the whole Flow operator set, and it is what current guidance uses for UI state. The one thing it does not do for free is lifecycle awareness — you have to collect it correctly (see the next question). SharedFlow is the sibling with no current value and a configurable replay: use it for one-off events (show a snackbar, navigate) where re-delivering the last value on re-subscribe would be wrong.

Worth stating precisely, because the open web gets it wrong: LiveData is not deprecated. It is still supported and still a sensible choice in a Java codebase; it is simply not the recommendation for new Kotlin code.

// State: always has a value, conflated, distinct-until-changed.
private val _state = MutableStateFlow(CartUi())
val state: StateFlow<CartUi> = _state.asStateFlow()

// Events: no current value, not replayed to a new collector.
private val _events = MutableSharedFlow<CartEvent>()
val events: SharedFlow<CartEvent> = _events.asSharedFlow()

// Interop both ways while a codebase migrates:
val asLive = state.asLiveData()

Why it's asked / follow-up: it is the “have you kept up?” question, and the trap is answering “LiveData is deprecated” — a claim the documentation does not make. Follow-up: “why is a plain StateFlow for a one-off event a bug?” — it replays its current value to every new collector, so the snackbar fires again after every rotation.

Sources: Android — LiveData overview, Android — StateFlow and SharedFlow.

Why is lifecycleScope.launch { flow.collect { } } wrong? Senior

Because lifecycleScope is cancelled at onDestroy, not at onStop. So a bare collection inside it keeps running while the screen is in the background — the upstream flow stays subscribed, location updates or a socket keep flowing, and every emission does work against a UI nobody is looking at. It is a battery and correctness problem rather than a crash, which is why it survives code review.

repeatOnLifecycle(State.STARTED) fixes it properly: it runs the block when the lifecycle reaches STARTED and cancels it when it drops below, restarting on the next STARTED. In Compose, collectAsStateWithLifecycle() is the same behavior in one call, and it is the reason to prefer it over the plain collectAsState() for anything coming from a ViewModel. Note the asymmetry that makes this worth knowing: LiveData gave you this for free, and moving to StateFlow is exactly where a team loses it by accident.

// ❌ collects while the app is backgrounded — the upstream never unsubscribes
lifecycleScope.launch { viewModel.state.collect { render(it) } }

// ✅ Views: cancelled below STARTED, restarted on return
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.state.collect { render(it) }
    }
}

// ✅ Compose: the same guarantee, one call
@Composable
fun CartScreen(viewModel: CartViewModel) {
    val state by viewModel.state.collectAsStateWithLifecycle()
}

Why it's asked / follow-up: it is a favourite senior question precisely because the wrong version works — the bug is invisible until someone profiles a background session. Follow-up: “what about flowWithLifecycle?” — the operator form of the same thing, fine for a single flow; repeatOnLifecycle is the right shape when you are collecting several in one block.

Source: Android — Use Kotlin coroutines with lifecycle-aware components.

Describe the recommended app architecture. What is unidirectional data flow? Mid

Google's guidance is three layers. The UI layer renders state and forwards user events; it holds a state holder (a ViewModel) that exposes an immutable UI state. The data layer owns application data behind repositories, each the single source of truth for its slice, hiding whether a value came from the network, a database, or a cache. An optional domain layer holds use cases when logic is complex or shared across screens. Dependencies point one way: UI → domain → data, never back.

Unidirectional data flow is the loop that makes it predictable: state flows down from the state holder to the UI, and events flow up from the UI to the state holder, which is the only thing allowed to produce the next state. The UI never mutates state in place. That is what makes a screen reproducible from a single value — and it is the same idea whether the label on it is MVVM or MVI; MVI simply makes the “single state object plus explicit intents” part mandatory rather than conventional.

// One immutable state object down; explicit events up.
data class CartUi(
    val items: List<Item> = emptyList(),
    val loading: Boolean = false,
    val error: String? = null,
)

class CartViewModel(private val repo: CartRepository) : ViewModel() {
    private val _state = MutableStateFlow(CartUi())
    val state: StateFlow<CartUi> = _state.asStateFlow()

    fun onRemove(id: String) = viewModelScope.launch {   // event up
        _state.update { it.copy(loading = true) }         // new state down
        repo.remove(id)
    }
}

Why it's asked / follow-up: it is the design question, and a good answer is one the candidate can defend rather than recite — including where they would not add a domain layer. Follow-up: “why does the repository, not the ViewModel, decide between cache and network?” — because the single source of truth belongs in one place; a ViewModel that knows about HTTP has leaked the data layer into the UI.

Sources: Android — Guide to app architecture, Android — UI layer.

3 · UI: the View system & layouts

How does the View system lay out a screen, and which layout do you reach for? Junior

A View-based screen is a tree of Views and ViewGroups, and the framework renders it in three passes: measure, layout, draw. Measure walks the tree asking each child how big it wants to be under a parent-supplied constraint; layout assigns each child its final position; draw paints. The reason nesting hurts is in that first pass — some parents measure their children more than once, so deeply nested weighted layouts can measure the same subtree exponentially.

Which one to use: ConstraintLayout for almost anything non-trivial, because it expresses relationships between siblings and keeps the hierarchy flat. LinearLayout for a genuinely simple row or column (and be wary of layout_weight, which forces a second measure pass). FrameLayout for a single child or a stack of overlapping ones — it is the standard container for a Fragment.

One current-state note an interviewer may be probing for: as of the Compose-first announcement, the View toolkit and its Jetpack companions — ConstraintLayout, RecyclerView, Fragment, view-based Navigation, data binding, the View Material components — are in maintenance mode, receiving critical fixes only. They are not going away and they still run most of the installed base; they are simply no longer where new capability lands.

// Flat and declarative about relationships — siblings, not nesting.
<androidx.constraintlayout.widget.ConstraintLayout ... >
    <TextView android:id="@+id/title"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent" />
    <Button
        app:layout_constraintTop_toBottomOf="@id/title"
        app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Why it's asked / follow-up: it is the baseline UI question for anyone maintaining an existing app, and the measure/layout/draw answer is what distinguishes “I know the XML tags” from “I know why the frame dropped.” Follow-up: “how do you find a layout problem?” — Layout Inspector for the hierarchy, and GPU rendering / overdraw tooling for the draw cost.

Sources: Android — Layouts in views, Android is Compose-first.

How does RecyclerView work, and what is the ViewHolder pattern? Junior

RecyclerView renders a long list with a small, fixed number of views by recycling them: when a row scrolls off, its view goes into a pool and is re-bound to new data as a row scrolls on. Three collaborators do the work — a LayoutManager decides positioning (linear, grid, staggered), an Adapter creates and binds views, and a ViewHolder caches the findViewById lookups for one row so binding does not re-walk the hierarchy on every scroll frame.

The division of labour between the two adapter callbacks is the thing to get right: onCreateViewHolder is called rarely (roughly once per screenful) and is where inflation belongs; onBindViewHolder is called on every scroll and must be cheap — no allocation, no formatting a date from scratch, no starting work you do not cancel. And because views are recycled, onBindViewHolder must set every field, including back to a default: forgetting to reset one is why a badge from row 3 reappears on row 40.

class ItemAdapter : ListAdapter<Item, ItemAdapter.VH>(DIFF) {

    class VH(val binding: RowItemBinding) : RecyclerView.ViewHolder(binding.root)

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =   // rare
        VH(RowItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))

    override fun onBindViewHolder(holder: VH, position: Int) {           // every scroll
        val item = getItem(position)
        holder.binding.title.text = item.title
        holder.binding.badge.isVisible = item.isNew    // reset it, always
    }
}

Why it's asked / follow-up: every Android codebase has one, and the recycling model explains a whole family of “wrong data on the wrong row” bugs. Follow-up: “what is the Compose equivalent?” — LazyColumn, which does the same recycling without an adapter; the ViewHolder concept disappears, but the “give items stable keys” requirement does not.

Source: Android — Create dynamic lists with RecyclerView.

What does DiffUtil do, and why is notifyDataSetChanged() a bad default? Mid

notifyDataSetChanged() tells the list “everything might have changed,” so it rebinds every visible row, runs no item animations, and loses per-row state. DiffUtil instead computes the minimal edit script between the old and new lists off the main thread and dispatches precise insert / remove / move / change events — so only the rows that actually changed rebind, and the framework can animate the difference.

It works through two callbacks whose difference is the whole point. areItemsTheSame asks “is this the same entity?” and must compare a stable identity, normally an id. areContentsTheSame asks “does it render identically?” and compares the fields the row displays (a data class's == usually does it). Swapping those two is a common bug: compare contents in the identity callback and every edit looks like a remove-plus-insert, so rows flash and lose focus. ListAdapter wraps all of this — call submitList() and it diffs in the background for you.

val DIFF = object : DiffUtil.ItemCallback<Item>() {
    // same entity?  → identity only
    override fun areItemsTheSame(a: Item, b: Item) = a.id == b.id
    // renders the same? → displayed content
    override fun areContentsTheSame(a: Item, b: Item) = a == b
}

adapter.submitList(newItems)   // diffed off the main thread, animated, minimal rebinds

Why it's asked / follow-up: it is a concrete performance-and-correctness question with a visible symptom, and the two-callback distinction is a reliable depth probe. Follow-up: “what are stable ids for?” — setHasStableIds(true) plus getItemId() lets the recycler keep a view attached to the same item across changes; with ListAdapter + DiffUtil you usually do not need them, and setting them wrong is worse than not setting them.

Source: Android — DiffUtil reference.

View binding vs findViewById vs data binding — and what happened to Kotlin synthetics? Junior

View binding generates one binding class per XML layout with a typed, non-null property for every @+id in it. That buys two things findViewById cannot: null safety (an id that does not exist fails to compile instead of returning null at runtime) and type safety (no casting, no mismatch between the id and the type you assumed). It is the recommended replacement, and it is essentially free — no annotation processor.

Data binding is a different, heavier feature: expressions in the XML itself, two-way binding, and a build-time processor. It solves a problem most modern apps solve in Kotlin instead, and it is in maintenance mode along with the rest of the View stack; new code should not adopt it. Kotlin synthetics (kotlinx.android.synthetic) were the popular shortcut that let you write title.text = … with no lookup at all — they were deprecated and then removed, because they leaked ids across layouts into a single global namespace and would happily resolve an id that was not in the layout you had inflated, producing a null at runtime.

// Fragment: create in onCreateView, and NULL IT OUT in onDestroyView.
private var _binding: FragmentHomeBinding? = null
private val binding get() = _binding!!

override fun onCreateView(i: LayoutInflater, c: ViewGroup?, s: Bundle?): View {
    _binding = FragmentHomeBinding.inflate(i, c, false)
    return binding.root
}
override fun onDestroyView() {
    super.onDestroyView()
    _binding = null          // ← the view outlives the Fragment otherwise: a leak
}

Why it's asked / follow-up: the answer doubles as a date stamp on the candidate's experience, and the Fragment null-out is a real leak with a one-line fix. Follow-up: “why null the binding at all?” — because the Fragment instance outlives its view (see the view-lifecycle question); a retained binding keeps the entire destroyed view tree in memory.

Source: Android — View binding.

4 · Jetpack Compose

What is Jetpack Compose, and how is declarative UI different from the View system? Junior

Compose is Android's declarative UI toolkit, written entirely in Kotlin. You do not build a tree of objects and then mutate it; you write @Composable functions that describe the UI for a given state, and when the state changes the framework re-runs the affected functions and updates the screen. There is no XML, no findViewById, no adapter, and no separate inflation step — the layout is Kotlin, so conditionals and loops are just conditionals and loops.

The mental shift interviewers listen for: in the View system you hold a reference and issue commands (“set this text, hide that view”), so the UI has state of its own that can drift from your model. In Compose the UI is a function of state, so there is one source of truth and no imperative path to get them out of sync. Since Google's Compose-first announcement, Compose is where all new Android UI capability lands, and the View toolkit is in maintenance mode.

@Composable
fun Greeting(name: String, onClear: () -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        if (name.isNotEmpty()) {          // plain Kotlin control flow — no ViewStub
            Text("Hello, $name", style = MaterialTheme.typography.titleLarge)
            Button(onClick = onClear) { Text("Clear") }
        }
    }
}

Why it's asked / follow-up: it opens the Compose section and reveals immediately whether the candidate thinks in state or in widget references. Follow-up: “what is a Modifier?” — an ordered, immutable chain describing layout, drawing, and input for one element; order matters, so padding().background() and background().padding() paint differently.

Sources: Android — Thinking in Compose, Android is Compose-first.

What is recomposition, and how does Compose decide what to redraw? Mid

Recomposition is Compose re-running composable functions to update the UI after state changes. The key is that it is targeted, not wholesale: while a composable runs, Compose records which snapshot state objects it read. When one of those objects is written, only the composables that read it are invalidated and re-run. Everything else is skipped, including siblings and most of the tree above.

Three properties follow, and each is a separate follow-up waiting to happen. Recomposition is optimistic — it can be cancelled and restarted if state changes again mid-pass. It can run frequently and in any order, and composables may execute in parallel. Therefore a composable must be side-effect free: no writing to shared variables, no starting a network call in the function body, no assuming it runs once. Anything with a side effect belongs in an effect handler, and anything expensive belongs behind remember.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Column {
        // Reads `count` → this Text is what recomposes when it changes.
        Text("Count: $count")
        // Reads nothing → skipped on every recomposition of Counter.
        Text("A static label")
        Button(onClick = { count++ }) { Text("Add") }
    }
}

Why it's asked / follow-up: it is the load-bearing Compose concept — nearly every Compose performance bug is a misunderstanding of it. Follow-up: “how would you see recomposition happening?” — the Layout Inspector's recomposition counts, or the Compose compiler's stability/skippability report for a whole module.

Source: Android — Thinking in Compose: recomposition.

remember vs rememberSaveable vs a plain variable — what is the difference? Junior

Three levels of survival, and the question is really asking whether you know which one you are getting. A plain local variable is re-initialised on every recomposition — useless for state. remember { } stores a value in the composition, so it survives recomposition but is discarded when the composable leaves the composition or the Activity is recreated. rememberSaveable { } additionally writes to the saved-instance-state bundle, so it survives a configuration change and process death.

Two things they are all missing on their own. mutableStateOf is what makes a value observableremember alone gives you persistence but no recomposition on change, so you need both together. And rememberSaveable can only store what fits in a Bundle; for a custom type you supply a Saver, or use rememberSerializable where kotlinx.serialization is already in play. State that belongs to the screen rather than the widget — anything a ViewModel should own — belongs in the ViewModel, not in either of these.

@Composable
fun SearchField() {
    var a = ""                                    // ❌ reset on every recomposition
    var b by remember { mutableStateOf("") }        // survives recomposition only
    var c by rememberSaveable { mutableStateOf("") } // + rotation + process death

    // remember with a key: recomputed only when the key changes.
    val formatted = remember(c) { expensiveHighlight(c) }

    TextField(value = c, onValueChange = { c = it })
}

Why it's asked / follow-up: it is the first thing that trips people coming from the View system, and the answer reveals whether they understand that a composable body re-runs. Follow-up: “what does the key argument to remember do?” — it invalidates the cached value when the key changes, which is how you tie a derived or expensive computation to its input.

Sources: Android — State and Jetpack Compose, Android — Save UI states.

What is state hoisting, and why does it matter? Mid

State hoisting is moving a composable's state up to its caller and replacing it with two parameters: a value to display and an onValueChange callback to report changes. The composable becomes stateless — it renders what it is given and asks its caller to change it — which is unidirectional data flow expressed at the function level.

Four things fall out of it, and naming them is what makes the answer good rather than definitional: the component is reusable (nothing about it assumes where its state lives), testable (call it with a value, assert what it renders), previewable (a @Preview just passes literals), and shareable (two siblings can now read the same value, which is impossible when each holds its own). The rule of thumb is to hoist to the lowest common ancestor of every composable that reads or writes the state — and when that state outlives the screen's composition, hoist it all the way to a ViewModel.

// Stateless: knows nothing about where the value lives.
@Composable
fun NameField(value: String, onValueChange: (String) -> Unit) {
    TextField(value = value, onValueChange = onValueChange)
}

// Stateful caller owns it — and can now also show it somewhere else.
@Composable
fun SignUp(vm: SignUpViewModel) {
    val state by vm.state.collectAsStateWithLifecycle()
    NameField(value = state.name, onValueChange = vm::onNameChange)
    Text("Hello, ${state.name}")
}

Why it's asked / follow-up: it is the Compose design question, and a candidate who can explain why to hoist rather than just how is one who will write composables the rest of the team can reuse. Follow-up: “can you hoist too far?” — yes: pushing genuinely local state (an expanded/collapsed flag on one card) all the way to the ViewModel bloats the UI state object and couples the screen to a detail nobody else needs.

Source: Android — Where to hoist state.

LaunchedEffect, DisposableEffect, rememberCoroutineScope — which one and when? Senior

A composable body must be side-effect free, so anything that reaches outside the composition goes through an effect handler, and each one answers a different question. LaunchedEffect(key) runs a suspending block when the composable enters the composition and cancels and relaunches it whenever the key changes — use it for work driven by composition, such as loading on first display or starting an animation. DisposableEffect(key) is for anything needing symmetric cleanup: register a listener, and return an onDispose block that unregisters it. rememberCoroutineScope() gives you a scope tied to the composition that you launch from outside composition — in a click handler, where a LaunchedEffect would be wrong because there is no state change to key off.

Three more complete the set. derivedStateOf computes a value from other state and only notifies readers when the result changes — the fix for “recomposes on every scroll pixel because it reads firstVisibleItemIndex to decide whether to show a button.” produceState converts non-Compose state into Compose state. snapshotFlow goes the other way, turning Compose state reads into a cold Flow. Getting the key argument wrong is the classic bug: LaunchedEffect(Unit) when the work depends on an id means it never re-runs for a new id.

@Composable
fun Detail(id: String, vm: DetailViewModel) {
    // Re-runs when `id` changes; cancelled if it leaves the composition.
    LaunchedEffect(id) { vm.load(id) }        // ❌ LaunchedEffect(Unit) → stale on new id

    // Symmetric register/unregister.
    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(lifecycle) {
        val obs = LifecycleEventObserver { _, e -> vm.onLifecycle(e) }
        lifecycle.addObserver(obs)
        onDispose { lifecycle.removeObserver(obs) }
    }

    // Launch from an event, not from composition.
    val scope = rememberCoroutineScope()
    Button(onClick = { scope.launch { vm.refresh() } }) { Text("Refresh") }
}

Why it's asked / follow-up: it is where a candidate either shows they understand that composition is not a lifecycle callback, or reveals they have been calling suspend functions from composable bodies. Follow-up: “why can't you just call the suspend function in the body?” — the body is not a coroutine and may re-run many times; you would fire the request on every recomposition with nothing to cancel it.

Source: Android — Side-effects in Compose.

What is a CompositionLocal, and when should you avoid one? Senior

A CompositionLocal passes a value implicitly down the composition tree, so a deeply nested composable can read it without every intermediate function taking it as a parameter. The framework uses them for exactly the right kind of thing: LocalContext, LocalDensity, LocalConfiguration, and the whole of MaterialTheme — ambient values that nearly every composable might want and no one wants to thread through by hand.

The two flavours matter: compositionLocalOf invalidates only the composables that read it when the value changes, while staticCompositionLocalOf skips that tracking and instead recomposes the entire CompositionLocalProvider content on change — cheaper to read, much more expensive to change, so it is for values that essentially never change.

When to avoid one: any time the value is a real dependency of the composable rather than ambient context. A CompositionLocal makes a function's inputs invisible at the call site, which hurts readability, testability, and previewability, and it fails at runtime rather than compile time if no provider is above you. The guidance is to keep them for cross-cutting ambient concerns — theme, density, context — and pass everything else as a parameter.

// Ambient, cross-cutting, rarely changes → a reasonable CompositionLocal.
val LocalSpacing = staticCompositionLocalOf { Spacing() }

@Composable
fun AppTheme(content: @Composable () -> Unit) {
    CompositionLocalProvider(LocalSpacing provides Spacing(gutter = 16.dp)) {
        MaterialTheme(content = content)
    }
}

@Composable
fun Card() = Box(Modifier.padding(LocalSpacing.current.gutter))

// ❌ don't: LocalCurrentUser — that is a dependency, so make it a parameter.

Why it's asked / follow-up: it is a good judgement question — the mechanism is easy, knowing when not to reach for it is the signal. Follow-up: “what happens if nothing provided it?” — you get the default lambda you passed to whichever factory you used, which is why a CompositionLocal whose sensible default is “error” is usually the wrong tool.

Source: Android — Locally scoped data with CompositionLocal.

How does LazyColumn work, and why do items need a key? Mid

LazyColumn and LazyRow compose only the items currently visible (plus a small buffer) and dispose the rest, which is what makes them the Compose answer to RecyclerView. Their content block is not a normal composable body but a LazyListScope DSL — item { }, items(list) { } — which is why you cannot simply write a for loop full of composables and get laziness. Wrapping a Column in verticalScroll is the non-lazy alternative, and it composes every child, so it is only right for a short, bounded list.

Without a key, an item's identity is its index. Insert at the top and every index shifts, so Compose believes every item changed: it recomposes the whole visible window and, worse, discards each item's remembered state — a half-typed field, an expanded row, a scroll position inside a nested list — because that state was keyed to a position that now holds different data. Supplying a stable domain key fixes both the correctness problem and the wasted work, and it is what makes item animations line up with the right rows.

LazyColumn {
    // ✅ stable identity survives inserts, removals and reorders
    items(messages, key = { it.id }) { message ->
        MessageRow(message)
    }
}

// ❌ no key → identity is the index; inserting at the top invalidates everything
LazyColumn { items(messages) { MessageRow(it) } }

Why it's asked / follow-up: it maps one-to-one onto the DiffUtil/stable-id question in the View world, so it works as a “do you understand list identity?” probe in either toolkit. Follow-up: “what makes a good key?” — something stable and unique for the item's lifetime: a server id, not the index and not a hash of mutable content.

Source: Android — Lists and grids.

What is stability in Compose, and what is strong skipping? Senior

Compose can skip re-running a composable when none of its parameters changed — but only if it can trust the comparison. A type is stable if its public properties will not change without notifying the composition and its equals is consistent. Primitives, String, function types, and data classes whose properties are all stable vals qualify. The classic offender is a List<T> parameter: the compiler cannot know that a kotlin.collections.List is not secretly a mutable list behind the interface, so it treats it as unstable and the composable is never skipped.

Strong skipping, enabled by default since Kotlin 2.0.20, changes the arithmetic: composables with unstable parameters become skippable too, compared by instance equality, and lambdas are remembered automatically. That removes most of the old ceremony — but not the reason for it. If you pass a new ArrayList instance on every emission, instance comparison still says “different,” so the real fix is unchanged: expose immutable types (ImmutableList from kotlinx.collections.immutable, or a stable wrapper), and annotate a type you know is safe with @Immutable or @Stable.

// Unstable parameter: List is an interface the compiler can't trust.
@Composable fun Feed(items: List<Post>) { /* ... */ }

// ✅ a type you promise never changes after construction
@Immutable
data class Post(val id: String, val title: String)

// ✅ or make the collection's immutability part of the type
@Composable fun Feed(items: ImmutableList<Post>) { /* ... */ }

Why it's asked / follow-up: it is the deepest routinely-asked Compose question, and it is where a 2023-era answer goes stale — a candidate reciting the pre-strong-skipping rules is telling you when they last read up. Follow-up: “how do you find unstable parameters?” — turn on the Compose compiler's stability reports and read the per-class output; guessing from the source is unreliable.

Sources: Android — Stability in Compose, Android — Strong skipping mode.

How do Compose and the View system interoperate in one app? Mid

Both directions are supported, which is what makes an incremental migration possible — and every real Android team is somewhere in the middle of one, so this is a practical question rather than a trivia one. To put Compose inside Views, add a ComposeView to a layout (or return one from onCreateView) and call setContent on it. To put Views inside Compose, use the AndroidView composable, which takes a factory to create the view once and an update block that runs whenever the state it reads changes.

The detail worth knowing is composition lifetime. A ComposeView inside a Fragment needs an explicit strategy for when its composition is disposed — the default disposes when the window detaches, which is wrong inside a Fragment on the back stack, so you set ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed. The usual migration order is leaf-first: convert individual list rows and small components, then whole screens, and leave the navigation host until last.

// Compose inside a Fragment
override fun onCreateView(i: LayoutInflater, c: ViewGroup?, s: Bundle?) =
    ComposeView(requireContext()).apply {
        setViewCompositionStrategy(
            ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
        setContent { AppTheme { HomeScreen() } }
    }

// A View inside Compose (a MapView, a chart, anything not yet ported)
@Composable
fun Chart(points: List<Point>) {
    AndroidView(
        factory = { ctx -> LegacyChartView(ctx) },   // created once
        update  = { it.setPoints(points) },          // re-runs on state change
    )
}

Why it's asked / follow-up: nobody rewrites an app in one go, so “how would you introduce Compose here?” is a question about judgement as much as API. Follow-up: “what does AndroidView cost?” — you are back to an imperative view: no recomposition-driven diffing inside it, and it will not be skipped the way a composable subtree is, so keep the wrapped view narrow.

Source: Android — Using Compose in Views.

5 · Concurrency & background work

What is the main thread, and what causes an ANR? Junior

Every app process has one main thread (the UI thread), and it is the only thread allowed to touch the UI toolkit. It runs a message loop: input events, lifecycle callbacks, and drawing are all messages on that one queue. So any work you do on it delays every other message — at a 60 Hz refresh rate a frame's budget is about 16 ms, and blocking past that drops frames and produces visible jank.

An ANR (“Application Not Responding”) is the system's harder response: if the main thread fails to handle input within roughly five seconds — or a broadcast receiver or service exceeds its own limit — the system offers the user the option to kill your app. The causes are always the same shapes: network or disk I/O on the main thread, a synchronous database query, a lock held by a background thread, or a badly-sized bitmap decode. StrictMode exists to catch exactly these in debug builds, by crashing or logging the moment you do disk or network work on the main thread.

// Turn the invisible mistake into a loud one, in debug builds only.
if (BuildConfig.DEBUG) {
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder()
            .detectDiskReads().detectDiskWrites().detectNetwork()
            .penaltyLog()
            .build())
}

Why it's asked / follow-up: it is the foundation for every concurrency answer that follows, and ANR rate is a metric Play actually reports, so teams care. Follow-up: “how do you diagnose an ANR you cannot reproduce?” — the ANR trace file, which contains a stack dump of every thread at the moment it fired; the main thread's frame usually names the culprit, and Play Console aggregates them by cluster.

Source: Android — ANRs.

How do you use coroutines on Android — which scope, which dispatcher? Mid

The Android-specific part of coroutines is which scope you launch in, because the scope decides when your work is cancelled. viewModelScope is cancelled in onCleared(), so it is the right scope for work whose result belongs to the screen's state — it survives configuration changes with the ViewModel. lifecycleScope is cancelled at onDestroy of its owner, for work genuinely tied to one Activity or Fragment instance. Launching in GlobalScope is the mistake: nothing cancels it, so it outlives the screen and leaks whatever it captured.

The other half is main safety: a suspend function should be safe to call from the main thread, which means the function itself moves to the right dispatcher with withContext rather than making every caller remember to. Dispatchers.Main is the UI thread, Dispatchers.IO is for blocking I/O, and Dispatchers.Default is for CPU-bound work. Note that the well-behaved libraries — Room, DataStore, Retrofit's suspend support — are already main-safe, so wrapping their calls in withContext(Dispatchers.IO) is a redundancy worth naming in an interview.

class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
    fun refresh() = viewModelScope.launch {      // cancelled in onCleared()
        _state.update { it.copy(loading = true) }
        val posts = repo.load()                  // main-safe: the repo owns its dispatcher
        _state.update { FeedUi(posts) }
    }
}

class FeedRepository(private val io: CoroutineDispatcher = Dispatchers.IO) {
    suspend fun load(): List<Post> = withContext(io) { blockingParse() }
}

Why it's asked / follow-up: it is the most common source of leaked work in a modern Android app, and injecting the dispatcher (as above) is the small detail that separates people who have written tests for this code. Follow-up: “what if the work must finish even if the user leaves?” — then it does not belong in a UI-scoped coroutine at all; use WorkManager. Coroutine language mechanics — structured concurrency, Job, cancellation, async — are on the Kotlin interview page.

Sources: Android — Kotlin coroutines on Android, Android — Coroutines best practices.

Cold flows vs hot flows — and what does stateIn solve? Senior

A cold flow does nothing until it is collected, and it runs its producer once per collector — two collectors means two database queries. A hot flow (StateFlow, SharedFlow) exists independently of collectors and broadcasts the same emissions to all of them. On Android that distinction decides whether rotating a screen re-runs your query or reuses a value.

stateIn converts a cold flow into a hot StateFlow within a scope, and its started argument is the interesting part. SharingStarted.Eagerly starts immediately and never stops. Lazily starts at the first collector and never stops. WhileSubscribed(5_000) starts on the first collector and stops five seconds after the last one goes away — which is precisely the right shape for Android, because it keeps the upstream alive across a rotation (the gap between the old and new collector is milliseconds) while still shutting it down when the user actually leaves. Getting this wrong is why an app either re-queries on every rotation or keeps a location listener running in the background.

val uiState: StateFlow<FeedUi> = repo.observePosts()   // cold: one query per collector
    .map { FeedUi(posts = it) }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),  // survives rotation, not backgrounding
        initialValue = FeedUi(loading = true),
    )

Why it's asked / follow-up: the five-second timeout is a piece of received Android wisdom that candidates often repeat without being able to explain, so asking “why five seconds?” is a fast depth check. Follow-up: “why not Eagerly?” — it starts collecting before anything is on screen and never stops, so an upstream with real cost (a location or socket subscription) runs for the life of the ViewModel regardless of whether anyone is watching.

Source: Android — StateFlow and SharedFlow.

When do you use WorkManager instead of a coroutine? Mid

The dividing line is deferrable and guaranteed. A coroutine in viewModelScope is right for work whose result the current screen is waiting for; if the user leaves, cancelling it is correct. WorkManager is right for work that must eventually happen even if the user closes the app or the device reboots — uploading a queued photo, syncing a draft, periodic cleanup. It persists the request in its own database, so the guarantee survives process death and reboot.

Its two headline features are constraints (run only on unmetered network, only while charging, only when the battery is not low — the system decides the moment) and chaining with unique work (beginWith().then(), plus a policy such as KEEP or REPLACE so a re-enqueued sync does not run twice). What it is not is a way to run something at an exact time or right now: it is deliberately subject to Doze and app standby. For exact timing you need an alarm; for immediate user-visible work, a foreground service.

class UploadWorker(ctx: Context, params: WorkerParameters) :
    CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result =
        if (upload(inputData.getString("uri")!!)) Result.success()
        else Result.retry()          // exponential backoff, handled for you
}

val request = OneTimeWorkRequestBuilder<UploadWorker>()
    .setConstraints(Constraints(requiredNetworkType = NetworkType.UNMETERED))
    .setInputData(workDataOf("uri" to uri.toString()))
    .build()

WorkManager.getInstance(context)
    .enqueueUniqueWork("upload", ExistingWorkPolicy.KEEP, request)

Why it's asked / follow-up: choosing the wrong tool here is a real production bug — either work that silently never happens, or a background service the system kills. Follow-up: “how do you report progress to the UI?” — observe the work's WorkInfo by id or unique name; do not try to hold a reference to the worker itself, which runs in a process you do not control the lifetime of.

Source: Android — WorkManager.

What are the background-execution limits, and when is a foreground service the right answer? Senior

Since Android 8.0 an app in the background may not freely start a background service, and implicit broadcast registration in the manifest is largely gone; Doze and app standby further batch and defer work when the device is idle. The intent is battery life, and the practical consequence is that the 2016 pattern — “start a Service and let it run” — simply fails on a modern device.

A foreground service is the exception, and it is a bargain: you get to keep running, and in exchange the user sees a persistent notification and knows you are there. That makes it correct only for work the user is actively aware of — playback, navigation, a workout recording, an upload they started. An app targeting Android 14 or later must declare a foregroundServiceType on every foreground service and hold the matching permission, and each type has its own eligibility rules. The tightening has continued since: targeting Android 15 brings a timeout for dataSync work and a new mediaProcessing type, and Android 17 hardened background audio. (Note how these rules are phrased — nearly all of them are gated on your targetSdk, not on the OS the device happens to run, which is the distinction an interviewer is often really probing.) The decision tree an interviewer wants: user-visible ongoing work → foreground service; deferrable guaranteed work → WorkManager; work tied to a visible screen → a coroutine in the right scope; exact time → an alarm.

// Declared type + matching permission — required since Android 14 (API 34).
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />

<service
    android:name=".PlayerService"
    android:foregroundServiceType="mediaPlayback"
    android:exported="false" />

Why it's asked / follow-up: it is the question that dates a candidate's Android knowledge most precisely, because the rules have tightened in nearly every release since Oreo. Follow-up: “what happens if you start one from the background?” — an app targeting Android 12 or later gets a ForegroundServiceStartNotAllowedException unless it falls into a documented exemption; the correct fix is usually an expedited WorkManager job, not a workaround.

Sources: Android — Foreground services, Android — Background work overview.

6 · Navigation & app structure

How does navigation work in a modern Android app? Mid

There are two generations, and in 2026 an interviewer may probe either. Navigation 2 — the Jetpack Navigation component — centralises destinations in a graph, owns the back stack, and moves between destinations through a NavController. In Compose that is NavHost plus composable(…) routes; in the View world it is a nav graph plus Safe Args for type-safe arguments. It is what the overwhelming majority of shipped apps use.

Navigation 3 reached 1.0 in November 2025 and inverts the model: your code owns the back stack as an ordinary observable list of keys, and NavDisplay renders it. That makes the back stack inspectable and mutable like any other state — its keys are your own @Serializable types implementing NavKey, so navigating is ordinary list manipulation — (and makes adaptive layouts — showing two panes from one stack — a normal operation rather than a fight), at the cost of writing the state management that Navigation 2 hid. Navigation 2 is now in maintenance mode, receiving critical fixes only, so Nav3 is the answer for new Compose work and “we are on Nav2 and it is fine” is the honest answer for existing apps.

// Navigation 2 (Compose): the library owns the back stack.
NavHost(navController, startDestination = "feed") {
    composable("feed") { FeedScreen(onOpen = { navController.navigate("post/$it") }) }
    composable("post/{id}") { PostScreen() }
}

// Navigation 3: you own the back stack; NavDisplay renders it.
@Serializable data object Feed : NavKey
@Serializable data class Post(val id: String) : NavKey

val backStack = rememberNavBackStack(Feed)
NavDisplay(backStack = backStack, entryProvider = entryProvider {
    entry<Feed> { FeedScreen(onOpen = { backStack.add(Post(it)) }) }
    entry<Post> { key -> PostScreen(key.id) }
})

Why it's asked / follow-up: it is currently the most movement-prone area of Android, so it doubles as a “are you keeping up?” question — and the good answer names both generations rather than pretending one does not exist. Follow-up: “why did they change it?” — because a library-owned, opaque back stack is hard to reconcile with adaptive layouts and with state that the app wants to inspect or restore itself.

Sources: Android — Navigation, Android — Navigation 3.

What is single-Activity architecture, and why is it the default? Mid

One Activity hosts the whole app; screens are Fragments or composables swapped inside it, and navigation is in-process rather than an Intent to the system. The advantages are concrete: navigation between screens is your transaction rather than a system task-stack operation, shared elements and transitions actually work, a screen-scoped ViewModel and an app-scoped one become easy to distinguish, and the app has one clear entry point for deep links.

It is a default, not a law. Genuinely separate flows still deserve their own Activity — something launched by another app through an intent filter, a share target, a picker, a widget's configuration screen, or a flow with a different task affinity. And on the module side, a multi-module app usually keeps the destination definitions in a navigation module that feature modules contribute to, so features do not need to depend on one another to link between screens.

// The whole app behind one entry point in the manifest.
<activity android:name=".MainActivity" android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

// A separate flow another app can launch keeps its own Activity.
<activity android:name=".ShareTargetActivity" android:exported="true"> ... </activity>

Why it's asked / follow-up: it is an architecture-opinion question with a right answer and real exceptions, so it rewards nuance over dogma. Follow-up: “how do you share state between screens then?” — a ViewModel scoped to the shared owner (the Activity, or a navigation graph) rather than passing objects through navigation arguments, which should carry ids, not payloads.

Source: Android — Principles of navigation.

What is the difference between Up and Back? Junior

Back is chronological: it reverses the user's history, and that history can cross apps — pressing Back from a screen you opened via a link can return to the browser or the email client you came from. Up is hierarchical: it moves to the logical parent within your app, defined by your navigation structure, and it never leaves the app. Within a single app they usually coincide; the case that separates them is arriving from a deep link, where there is no history above you and Up must synthesize the parent.

The modern practicality is predictive back, which shows the user a preview of where the gesture will take them. Supporting it means declaring the opt-in and, if you intercept Back, doing so through OnBackPressedDispatcher callbacks rather than overriding onBackPressed() — the callback API is what lets the system know in advance whether you will handle the gesture. Apps targeting Android 16 and later are expected to have migrated.

// Intercept Back the way predictive back requires — and only while it applies.
val callback = object : OnBackPressedCallback(/* enabled = */ false) {
    override fun handleOnBackPressed() { closeEditor() }
}
requireActivity().onBackPressedDispatcher
    .addCallback(viewLifecycleOwner, callback)

// Toggle it as state changes, so the system knows who owns the gesture.
callback.isEnabled = hasUnsavedChanges

Why it's asked / follow-up: it is a small question that catches a common product bug — a toolbar arrow wired to finish(), which strands a deep-linked user outside the app. Follow-up: “why is overriding onBackPressed() discouraged?” — it is a single point of interception that fragments and components cannot compose with, and it cannot participate in predictive back.

Sources: Android — Navigate back, Android — Predictive back gesture.

7 · Data & persistence

What is Room, and what does it give you over raw SQLite? Junior

Room is a persistence library over SQLite built from three annotated pieces: an @Entity maps a class to a table, a @Dao declares the queries, and a @Database ties them together and hands out the DAOs. The headline benefit is that your SQL is verified at compile time — a typo in a column name, or a query whose result does not match the return type, fails the build instead of throwing at runtime on a user's device.

Two more things matter in practice. A DAO method can return a Flow, and Room re-emits automatically whenever the underlying tables change — which is how the database becomes the single source of truth for a screen rather than something you poll. And Room's suspend and Flow APIs are main-safe: it moves the work off the main thread itself, and it will refuse a blocking query on the main thread rather than let you ship the ANR.

@Entity(tableName = "posts")
data class PostEntity(@PrimaryKey val id: String, val title: String)

@Dao
interface PostDao {
    // Re-emits whenever the posts table changes. No polling, no invalidation code.
    @Query("SELECT * FROM posts ORDER BY title")
    fun observeAll(): Flow<List<PostEntity>>

    @Upsert suspend fun upsert(posts: List<PostEntity>)
}

Why it's asked / follow-up: it is the standard persistence answer, and the Flow-returning DAO is what connects it to the architecture questions. Follow-up: “where does the entity stop and the domain model start?” — keep them separate once they diverge: an entity is shaped by the table, and letting storage concerns leak into the model the UI renders is a small decision that gets expensive.

Source: Android — Save data in a local database using Room.

How do Room migrations work, and what happens if you skip one? Senior

Every schema change means bumping the version on @Database and providing a path from the old version to the new one. Room compares the schema it expects against the one on the device at open time; if the versions differ and no migration covers the gap, it throws IllegalStateException — on the user's device, at launch, with their data. That failure mode is why this question is asked at senior level: a bad migration is one of the few Android bugs you cannot fix with a hotfix, because the data is already gone or already wrong.

Three mechanisms, in order of preference. Automatic migrations (autoMigrations) handle additive changes from the exported schema JSON, with an AutoMigrationSpec for the cases Room cannot infer, such as a column rename. A hand-written Migration is for anything that transforms data. fallbackToDestructiveMigration() deletes and recreates the database — acceptable for a pure cache, catastrophic for anything the user typed, and the thing to flag as a deliberate choice rather than a default.

The non-obvious requirement: export the schema (Room's schemaLocation) and commit the JSON. Without it there is no record of what version 4 looked like, automatic migrations cannot work, and Room's migration tests have nothing to verify against.

@Database(
    entities = [PostEntity::class],
    version = 3,
    autoMigrations = [AutoMigration(from = 2, to = 3)],   // additive: derived from schema JSON
)
abstract class AppDatabase : RoomDatabase()

// Anything that moves data needs writing by hand — and testing.
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE posts ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0")
    }
}

Why it's asked / follow-up: it separates candidates who have shipped a schema change to real users from those who have only built a database once. Follow-up: “how do you test a migration?” — MigrationTestHelper from room-testing: create the database at the old version with real rows, run the migration, and assert the data survived. It is the only way to catch a migration that compiles and destroys data.

Source: Android — Migrate your Room database.

Why replace SharedPreferences with DataStore? Mid

SharedPreferences has two design problems that cannot be patched. Its API is synchronous: getString() can block on disk the first time, and commit() blocks outright — both on the main thread, which is a documented source of ANRs. And it has no error signalling: apply() is fire-and-forget, so a failed write is silent.

DataStore fixes both by being coroutine- and Flow-based end to end: reads are a Flow you collect (so changes push to you), writes are suspend functions that are transactional and surface exceptions, and nothing touches the main thread. It comes in two forms — Preferences DataStore, key-value like the thing it replaces but with no type safety across the boundary, and Proto DataStore, which stores a typed object defined by a schema. Current guidance is explicit that a SharedPreferences-based app should consider migrating, and DataStore ships a migration helper that reads the old file once.

The honest boundary: DataStore is for small data — settings, flags, a token, the last-sync timestamp. It rewrites the whole file on each change, so it is not a database. Lists and relational data belong in Room.

val Context.settings by preferencesDataStore(name = "settings")
val DARK = booleanPreferencesKey("dark_mode")

// Read: a Flow, so the UI updates when it changes.
val darkMode: Flow<Boolean> = context.settings.data.map { it[DARK] ?: false }

// Write: suspending, transactional, throws on failure.
suspend fun setDark(on: Boolean) {
    context.settings.edit { it[DARK] = on }
}

Why it's asked / follow-up: nearly every app has SharedPreferences somewhere, so the question is really “do you know why the replacement exists?” Follow-up: “when is SharedPreferences still fine?” — when a library or a system API hands you one, and for a small legacy surface you are not actively changing; the argument for migrating is strongest where the reads are on a startup path.

Source: Android — DataStore.

Where can an app write files, and what is scoped storage? Mid

Start with the split. App-specific storagefilesDir, cacheDir, and their external equivalents — is yours alone, needs no permission, and is deleted when the app is uninstalled. Shared storage is for media and documents the user expects to keep and other apps can see. The rule of thumb: if the user would be upset to lose it on uninstall, it belongs in shared storage or a backend, not in filesDir.

Scoped storage, enforced from Android 10–11, ended the old model where a storage permission gave an app the whole external filesystem. Now an app has unrestricted access only to its own directories; for shared media it goes through MediaStore, and for arbitrary documents through the Storage Access Framework (the system file picker), which returns a URI the user explicitly chose. The modern path for images and video is the photo picker, which needs no permission at all — and when you do need the permission, Android 14 added partial access (READ_MEDIA_VISUAL_USER_SELECTED), where the user grants a subset of their library rather than all of it.

// No permission, no MediaStore query — the user picks, you get a URI.
val picker = registerForActivityResult(PickVisualMedia()) { uri ->
    uri?.let { render(it) }
}
picker.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly))

// App-private, no permission, removed on uninstall.
File(context.filesDir, "draft.json").writeText(json)

Why it's asked / follow-up: storage is where a lot of older Android knowledge is now actively wrong, so the answer dates a candidate quickly. Follow-up: “when do you need MANAGE_EXTERNAL_STORAGE?” — almost never: it is for genuine file managers and backup tools, Play polices it, and reaching for it to avoid learning the picker is a rejected-review waiting to happen.

Sources: Android — Data and file storage overview, Android — Photo picker.

8 · Networking & the data layer

How do you make a network call on Android? Junior

The framework parts are unglamorous and worth stating first: you declare the INTERNET permission, you never do the call on the main thread, and for apps targeting Android 9 or later cleartext HTTP is blocked by default — a plain http:// URL fails unless you opt in through a network security configuration, which is a deliberate speed bump rather than a bug.

Above that, essentially every app uses the same third-party stack: OkHttp as the HTTP client and Retrofit to turn a Kotlin interface into typed calls, with kotlinx.serialization or Moshi converting JSON. That is not a first-party recommendation — it is a convention so universal that an interviewer will simply expect you to name it. Retrofit's suspend support means a call is an ordinary suspend function, main-safe, cancelled with its coroutine.

The part candidates skip: errors are the normal case. A mobile network fails constantly, so “what does your code do on timeout, on a 500, on airplane mode” is the real question hiding inside this one.

interface PostApi {
    @GET("posts/{id}")
    suspend fun post(@Path("id") id: String): PostDto   // suspend: main-safe, cancellable
}

suspend fun load(id: String): Result<Post> = runCatching { api.post(id).toDomain() }
    .onFailure { if (it is CancellationException) throw it }   // never swallow cancellation

Why it's asked / follow-up: it is the warm-up for the data-layer questions, and the cleartext and cancellation details are cheap ways to show you have shipped. Follow-up: “where does the auth token go?” — an OkHttp interceptor, so no call site has to remember; refresh belongs in an Authenticator so a 401 retries once rather than everywhere.

Source: Android — Connect to the network.

What does a repository do, and how do you build an offline-first screen? Mid

A repository is the single source of truth for one slice of application data. Its callers ask for data; they do not know or care whether it came from the network, the database, or memory. That is what lets you change caching strategy without touching a ViewModel, and what makes the data layer testable with a fake.

Offline-first is the pattern this enables, and the shape is worth memorising: the database is the source of truth, and the network is an update mechanism for it. The UI observes a Flow from Room and never observes the network directly; a refresh writes into the database, and the UI updates because the database changed. The screen therefore works offline for free, shows cached data instantly on cold start, and has exactly one code path for rendering — instead of the “did this come from cache or network?” branching that makes the naive version fragile.

Two supporting pieces belong here. UI state is usually modelled as a sealed hierarchy (loading / success / error) so an impossible combination cannot be represented. And for a long list, Paging 3 loads pages on demand, with a RemoteMediator when the pages are network-backed and cached in Room.

class PostRepository(private val api: PostApi, private val dao: PostDao) {

    // The UI observes the DATABASE — one path, works offline, updates on write.
    fun observe(): Flow<List<Post>> = dao.observeAll().map { it.map(PostEntity::toDomain) }

    // The network is an update mechanism, not a second source of truth.
    suspend fun refresh() = dao.upsert(api.posts().map { it.toEntity() })
}

Why it's asked / follow-up: it is the data-layer design question, and “the database is the source of truth” is the sentence an interviewer is listening for. Follow-up: “where does the refresh get triggered?” — on screen entry and pull-to-refresh for the interactive case, and WorkManager for the background case; the repository exposes refresh(), it does not schedule itself.

Sources: Android — Data layer, Android — Build an offline-first app.

Why use Paging 3 instead of loading a list yourself? Senior

Because the hand-rolled version is deceptively hard. Loading a page when the user nears the end is easy; what is not easy is doing it once rather than three times on a fast scroll, keeping the page cursor correct through a configuration change, showing a retry footer for a page that failed while keeping the pages that succeeded, and de-duplicating when a refresh overlaps an in-flight append. Paging 3 owns all of that, and exposes the result as a Flow<PagingData<T>> that a LazyColumn or a PagingDataAdapter consumes.

The two building blocks are worth naming. A PagingSource loads one page from one source and returns the keys for the pages either side. A RemoteMediator is what you add when you want the offline-first shape: Room is the PagingSource the UI reads from, and the mediator fetches the next network page and writes it into the database, so paging and caching are the same mechanism rather than two competing ones. It also surfaces its own load states — refresh, prepend, append — which is what lets the UI show a spinner at the bottom rather than blanking the list.

val posts: Flow<PagingData<Post>> = Pager(PagingConfig(pageSize = 20)) {
    dao.pagingSource()                       // Room is the source the UI reads
}.flow.cachedIn(viewModelScope)              // survives configuration changes

@Composable
fun Feed(items: LazyPagingItems<Post>) = LazyColumn {
    items(items.itemCount, key = items.itemKey { it.id }) { i ->
        items[i]?.let { PostRow(it) }
    }
}

Why it's asked / follow-up: it is a good senior question because the naive answer — “I'd just load more at the bottom” — is where the conversation starts, not ends. Follow-up: “what does cachedIn do?” — it makes the PagingData flow shareable and keeps loaded pages across configuration changes; without it, rotating throws away every page and refetches from the start.

Source: Android — Paging 3 library overview.

9 · Dependency injection

Why does an Android app need dependency injection at all? Junior

Dependency injection just means a class receives what it needs instead of constructing it. The moment a ViewModel calls PostRepository(OkHttpClient(), AppDatabase.get(context)) inside itself, three things follow: it is untestable (there is no seam to substitute a fake), it now knows about HTTP and SQLite, and every caller drags the whole graph along.

Android sharpens two problems a plain JVM app does not have. First, you do not construct your own components — the system instantiates Activities, Fragments, Services, and Workers, so you cannot pass anything to their constructors, which is exactly why the field-injection machinery exists. Second, object lifetimes are tied to Android lifecycles: a database should be one instance per application, a repository maybe, and something holding screen state must not outlive its screen. Getting the scope wrong is not a style issue — a too-long scope is a memory leak and a too-short one silently duplicates a cache.

// ❌ builds its own world: untestable, and it now knows about HTTP and SQLite
class FeedViewModel : ViewModel() {
    private val repo = PostRepository(Retrofit.Builder()/*...*/.build())
}

// ✅ asks for what it needs; a test passes a fake, Hilt passes the real one
@HiltViewModel
class FeedViewModel @Inject constructor(
    private val repo: PostRepository,
) : ViewModel()

Why it's asked / follow-up: it checks that the candidate can justify the framework rather than cargo-culting it — and a good answer is allowed to say a small app does fine with manual DI. Follow-up: “what is a service locator, and why is DI preferred?” — a global registry classes pull from; it hides dependencies inside method bodies instead of declaring them in the constructor, so you cannot tell what a class needs by reading its signature.

Source: Android — Dependency injection in Android.

How does Hilt work, and how is it different from plain Dagger? Mid

Hilt is Google's recommended DI library for Android, and it is Dagger underneath — the same compile-time-generated, reflection-free object graph, with the same guarantee that a missing binding is a build error rather than a runtime crash. What Hilt adds is a standard set of Android components and scopes you do not have to define. Plain Dagger on Android meant hand-writing a component hierarchy, an injector for each Android class, and the plumbing to reach it; that boilerplate was the reason Dagger had a reputation, and it is what Hilt deletes.

The pieces you actually write: @HiltAndroidApp on the Application, @AndroidEntryPoint on the Android classes that need injection, @Inject constructor on your own classes (which is the whole binding — nothing else needed), and a @Module with @Provides or @Binds for things you cannot annotate, such as an interface implementation or a Retrofit instance. @HiltViewModel handles the ViewModel factory, which is otherwise the fiddliest part. Note that current guidance uses KSP rather than kapt for the processor — a build-time difference worth knowing.

@Module
@InstallIn(SingletonComponent::class)
object DataModule {
    @Provides @Singleton                       // one per application
    fun database(@ApplicationContext ctx: Context): AppDatabase =
        Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db").build()
}

@Module
@InstallIn(SingletonComponent::class)
abstract class RepoModule {
    @Binds                                     // interface → implementation, no factory body
    abstract fun bind(impl: PostRepositoryImpl): PostRepository
}

Why it's asked / follow-up: it is the standard DI question, and knowing what Hilt adds over Dagger shows you understand it is a convention layer, not a different mechanism. Follow-up: “@Binds or @Provides?” — @Binds for a straight interface-to-implementation mapping (abstract, no body, less generated code); @Provides when you must actually construct or configure the object.

Source: Android — Dependency injection with Hilt.

What do Hilt's components and scopes actually control? Senior

Hilt generates a hierarchy of components that mirror the Android lifecycles — SingletonComponent (the Application), ActivityRetainedComponent (survives configuration changes), ViewModelComponent, ActivityComponent, FragmentComponent, ViewComponent, ServiceComponent. Each has a matching scope annotation (@Singleton, @ActivityRetainedScoped, @ViewModelScoped, @ActivityScoped, and so on), and a component is created and destroyed with the Android object it mirrors.

The distinction people miss is what a scope annotation actually does: it does not control availability, it controls instance reuse. Unscoped bindings are perfectly usable everywhere — they simply produce a new instance at each injection point. Adding @Singleton says “reuse the same instance for the life of this component,” which is what you want for a database or an OkHttp client and precisely what you do not want by default, since a scoped object lives as long as its component.

That is also where the leak lives. Scoping something to SingletonComponent when it holds screen state means it outlives every screen; the classic crash is injecting an Activity-scoped dependency into an application-scoped object, which Hilt rejects at compile time precisely because the lifetimes do not nest. Dependencies may flow down the hierarchy, never up.

// Unscoped: a fresh instance at every injection point. Usually correct.
class TitleFormatter @Inject constructor()

// One per application — expensive, stateless, shared.
@Singleton
class TokenStore @Inject constructor(@ApplicationContext ctx: Context)

// One per ViewModel — dies with the screen's state holder.
@ViewModelScoped
class DraftCache @Inject constructor()

Why it's asked / follow-up: scoping is where DI stops being boilerplate and starts being a memory-management decision, so it is a reliable senior discriminator. Follow-up: “why is @ApplicationContext injected rather than an Activity context?” — because a long-lived object holding an Activity context leaks the whole Activity; the qualifier makes the right one explicit, which is the same rule as the Context-leak gotcha.

Source: Android — Hilt component scopes.

10 · Permissions, Intents & IPC

Walk through requesting a runtime permission. Junior

Since Android 6.0 (API 23), dangerous permissions are granted at runtime rather than at install. The flow is: declare it in the manifest; check whether you already have it (checkSelfPermission); if not, show a rationale when the system says one is warranted (shouldShowRequestPermissionRationale); request it through the Activity Result API; then handle both outcomes. The modern API is registerForActivityResult(RequestPermission()) — not the old onRequestPermissionsResult callback with a request code.

What separates a good answer is the non-binary outcomes. A user can grant while using the app or once for location, and a one-time grant is revoked when the app leaves the foreground. Location has a coarse-versus-precise choice that the user, not you, makes. Media has partial access (READ_MEDIA_VISUAL_USER_SELECTED) where you are granted a selected subset. Permissions are auto-revoked for unused apps. And a permanent denial gives no dialog at all — the request simply returns denied, so the only correct response is to degrade gracefully and point the user at Settings, never to loop the request.

The best answer, though, is often not to ask. The photo picker needs no storage permission; the system contact picker needs no contacts permission; the Android 17 location button grants session-only precise location without a permission dialog. Reaching for a narrower system UI instead of a broad grant is what a senior reviewer wants to hear.

private val requestCamera = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { granted -> if (granted) openCamera() else showDegradedUi() }

fun onCameraClicked() {
    when {
        ContextCompat.checkSelfPermission(this, CAMERA) == PERMISSION_GRANTED ->
            openCamera()
        shouldShowRequestPermissionRationale(CAMERA) -> showRationaleThenRequest()
        else -> requestCamera.launch(CAMERA)
    }
}

Why it's asked / follow-up: it is asked everywhere because it is asked of every app, and the “what if they say no?” branch is the half candidates skip. Follow-up: “how do you tell a first-time request from a permanent denial?” — you cannot from the flags alone: shouldShowRequestPermissionRationale returns false in both cases, so you record that you have asked, and treat a denial after that as permanent.

Source: Android — Request runtime permissions.

Explicit vs implicit Intent — and what does an <intent-filter> do? Junior

An explicit Intent names the exact component to start — a class in your own app — and is what you use for internal navigation. An implicit Intent describes an action ("view this URL", "share this text") and lets the system find a component that has advertised it can handle it, showing a chooser when several can. An <intent-filter> is that advertisement: a declaration in the manifest of the actions, categories, and data your component accepts.

Two safety facts belong in the answer. Declaring an intent filter makes a component reachable by other apps, which is why android:exported must be set explicitly by apps targeting Android 12 or later — an accidentally exported Activity is a real vulnerability class. And an implicit intent may match nothing at all, so a bare startActivity can throw ActivityNotFoundException; the modern habit is to wrap the launch or use Intent.createChooser, which always resolves.

// Explicit — the exact component, inside my app.
startActivity(Intent(this, DetailActivity::class.java).putExtra("id", id))

// Implicit — describe the action, let the system resolve it.
val share = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_TEXT, "https://example.com/post/42")
}
startActivity(Intent.createChooser(share, null))   // always resolves

Why it's asked / follow-up: it is the entry point to the whole inter-app story, and the exported/security half is what turns it from trivia into a design question. Follow-up: “how do you share a file with another app?” — a FileProvider content:// URI with a temporary read grant, never a file:// URI, which has thrown FileUriExposedException since Android 7.

Source: Android — Intents and intent filters.

What is a PendingIntent, and why must you specify mutability? Senior

A PendingIntent is a token you hand to another process — the notification shade, the alarm manager, a widget host, another app — that lets it execute an Intent as you, with your identity and permissions, at some later time. That is the whole point and the whole danger: the foreign process is acting with your app's authority.

Which is why an app targeting Android 12 (API 31) or later must declare FLAG_IMMUTABLE or FLAG_MUTABLE on every PendingIntent — omit both and you get an IllegalArgumentException at creation. Default to FLAG_IMMUTABLE: a mutable pending intent can be filled in by the receiving app, and a mutable one wrapping an implicit intent is the classic escalation bug, because the recipient can redirect it at a component of their choosing while keeping your permissions. You need FLAG_MUTABLE only for the specific APIs that fill in the intent themselves, such as inline notification replies and bubbles.

The other flag that bites is FLAG_UPDATE_CURRENT. Two pending intents that differ only in their extras are considered equal, so without it the second request silently reuses the first one's extras — the reason a notification for item B opens item A.

val intent = Intent(context, DetailActivity::class.java)
    .putExtra("id", id)                 // extras are NOT part of PendingIntent equality

val pending = PendingIntent.getActivity(
    context,
    id.hashCode(),                     // distinct requestCode per target
    intent,
    PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)

Why it's asked / follow-up: it is a security question wearing an API question's clothes, and the “acts with your identity” framing is the answer an interviewer is waiting for. Follow-up: “when is FLAG_MUTABLE correct?” — when the platform must complete the intent, such as a direct-reply action; even then, keep the wrapped intent explicit so it cannot be redirected.

Source: Android — PendingIntent reference.

How do BroadcastReceivers work, and why did manifest-declared ones stop firing? Mid

A BroadcastReceiver reacts to system or app events, and it can be registered two ways. Manifest-declared (static) receivers let the system start your app to deliver the event even when nothing is running. Context-registered (dynamic) receivers live only while you have registered them, which makes them the right choice for anything tied to a screen — and means you must unregister, or leak.

The reason old code breaks: since Android 8.0, apps can no longer receive most implicit broadcasts through a manifest declaration. Waking every installed app for every connectivity change was a battery disaster, so the platform removed it. A handful of exempted broadcasts remain (BOOT_COMPLETED among them, itself increasingly restricted); everything else must be received dynamically while your app is running, or replaced by the right tool — WorkManager with a network constraint rather than a connectivity receiver. And for apps targeting Android 14, a receiver registered for a non-system broadcast must declare RECEIVER_EXPORTED or RECEIVER_NOT_EXPORTED (the flags were added in Android 13, where they were still optional).

And the execution limit that surprises people: onReceive runs on the main thread and the process may be killed as soon as it returns, so it has roughly ten seconds and cannot start a coroutine and expect it to finish. Hand off to goAsync() or enqueue work.

// Dynamic: alive only while registered — and must be unregistered.
ContextCompat.registerReceiver(
    context, receiver, IntentFilter(Intent.ACTION_POWER_CONNECTED),
    ContextCompat.RECEIVER_NOT_EXPORTED,          // required when targeting Android 14
)

// ❌ don't: launch work in onReceive and assume it survives the return.
// ✅ do: WorkManager.getInstance(context).enqueue(request)

Why it's asked / follow-up: it is a reliable way to find out whether someone learned Android before Oreo and never revisited it. Follow-up: “how would you react to connectivity changes today?” — a NetworkCallback from ConnectivityManager while the screen is visible, and a network constraint on a WorkManager request for anything that must happen later.

Source: Android — Broadcasts overview.

11 · Performance, testing & tooling

What causes jank, and how do you find it? Senior

Jank is a frame that misses its deadline. The device asks for a frame on a fixed cadence — about 16 ms at 60 Hz, under 9 ms at 120 Hz — and if the work for that frame is not finished, the previous frame is shown again and the user sees a stutter. So every cause is “something took too long on the frame's critical path”: work on the main thread, an expensive layout pass, overdraw (painting the same pixel several times through stacked opaque backgrounds), a bitmap decoded at full resolution during a scroll, or a garbage-collection pause caused by allocating in a hot loop.

The Compose-specific version is unnecessary recomposition — a composable that reads state changing on every scroll pixel, or an unstable parameter defeating skipping. The View-specific version is a deep hierarchy re-measured repeatedly, or an onBindViewHolder doing real work.

How to find it, in order: measure before guessing. Android Studio's Profiler and a system trace show where the frame time went; the Layout Inspector shows recomposition counts in Compose; Macrobenchmark gives you a repeatable frame-timing number for scroll or startup so a fix can be proven rather than asserted; and Play Console's Android vitals shows the janky-frame rate you actually ship.

// The Compose version of "measure, don't guess": this recomposes on every pixel…
val showButton = listState.firstVisibleItemIndex > 0

// …this recomposes only when the boolean flips.
val showButton by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

Why it's asked / follow-up: performance answers separate people who profile from people who cargo-cult, and the tell is whether the candidate names a measurement before a fix. Follow-up: “why is 120 Hz harder than 60?” — the budget nearly halves, so work that was comfortably inside a frame at 60 Hz starts missing at 120 on exactly the flagship devices your reviewers use.

Source: Android — Slow rendering.

What causes a memory leak on Android, and how do you find one? Senior

A leak on Android is nearly always the same shape: something long-lived holds a reference to something short-lived, so the short-lived object cannot be collected. Because an Activity holds its entire view tree, leaking one Activity can retain several megabytes, and repeated rotations stack them until the app is killed.

The recurring sources are worth listing, because they are the same handful in every codebase: a static or singleton field holding an Activity or a View; a non-static inner class or a lambda capturing the enclosing Activity and outliving it (a Handler post, an animation, a callback); a listener registered and never unregistered; a Fragment view binding not nulled in onDestroyView; a ViewModel holding a UI reference; and a coroutine launched in a scope that is never cancelled.

Finding them: LeakCanary in debug builds is the standard answer — it watches destroyed Activities and Fragments and reports the reference chain that retained them, which tells you not just that something leaked but who is holding it. Beyond that, the Memory Profiler's heap dump with “show only retained” does the same job by hand, and the reproduction is always the same: rotate or navigate away several times, force a GC, and see how many instances remain.

// ❌ the lambda captures `this` (the Activity) and outlives it
Handler(Looper.getMainLooper()).postDelayed({ textView.text = "done" }, 60_000)

// ❌ a static that pins an Activity for the life of the process
companion object { var current: Activity? = null }

// ✅ scope the work so it is cancelled with the screen
lifecycleScope.launch { delay(60_000); textView.text = "done" }

Why it's asked / follow-up: memory is the resource Android is most constrained on, and this question has an unusually clean right answer — name the shape, name the sources, name the tool. Follow-up: “would a WeakReference fix it?” — it prevents the retention, but it usually papers over a lifetime bug; fix the ownership first and reach for a weak reference only where a callback genuinely must not keep its target alive.

Source: Android — Manage your app's memory.

What is a Baseline Profile, and why does it speed up startup? Mid

ART compiles your app's bytecode ahead of time only for code it has learned is hot; everything else starts out interpreted or JIT-compiled, which is why the first few runs after an install are the slowest. A Baseline Profile is a list of the classes and methods on your critical paths — startup, the first screen, the main scroll — that you ship with the app, so ART compiles them ahead of time at install time. The user never pays the warm-up.

You do not write it by hand: you generate it with a Macrobenchmark test that drives the journeys you care about, and the same Macrobenchmark harness is what measures the improvement. That pairing is the point of the question — a profile without a measurement is a guess. The reported gains are meaningful (startup improvements of roughly 20–30% are typical), and it costs no code change to the app itself.

@Test
fun generate() = baselineProfileRule.collect(packageName = "com.example.app") {
    startActivityAndWait()                // the journey worth compiling ahead of time
    device.findObject(By.res("feed")).fling(Direction.DOWN)
}

Why it's asked / follow-up: it is a modern performance answer that a candidate either knows or does not, and it shows whether they think about the cold-start experience real users get. Follow-up: “how is that different from R8?” — R8 changes what code exists (shrinking and optimizing at build time); a Baseline Profile changes how the code is compiled on the device. They are complementary, not alternatives.

Source: Android — Baseline Profiles overview.

Unit tests vs instrumented tests — what goes where, and how do you test a composable? Mid

Local unit tests live in test/ and run on the JVM on your machine — fast, but with no real Android framework, so they suit ViewModels, repositories, mappers, and anything you have kept free of framework types. Instrumented tests live in androidTest/ and run on a device or emulator with the real framework — slower, but the only honest way to test UI, navigation, and database behavior. Robolectric sits between the two, simulating the framework on the JVM.

For UI: Espresso in the View world, and in Compose the createAndroidComposeRule / createComposeRule test rules, which give you a semantics-tree API — you find a node by the text or content description a user would perceive, act on it, and assert. That is why testTag is a last resort: a test written against semantics is a test of what the user sees, and it doubles as an accessibility check. The Compose rule also synchronises with the composition automatically, which is what removes the flaky-wait problem that plagues View-based UI tests.

The architectural point behind the question: how much you can put in fast local tests is a consequence of your architecture. A ViewModel with an injected dispatcher and a repository interface is unit-testable; one that news up a database is not.

@get:Rule val rule = createComposeRule()

@Test
fun emptyQuery_disablesSearch() {
    rule.setContent { SearchBar(query = "", onQueryChange = {}) }

    rule.onNodeWithText("Search").assertIsNotEnabled()   // found the way a user would
}

Why it's asked / follow-up: the split is basic, but the follow-through — what makes code testable at all — is where the real signal is. Follow-up: “fakes or mocks?” — current Android guidance prefers a hand-written fake implementing your own interface: it survives refactors, documents the contract, and does not encode assumptions about call order the way a heavily-stubbed mock does.

Sources: Android — Test apps on Android, Android — Testing your Compose layout.

12 · Classic Android gotchas

Which Context should you use, and why is the wrong one a leak? Senior

There are two you care about and they have very different lifetimes. The Activity context lives as long as that Activity and carries its theme and window — so it is what you need for inflating views, showing a dialog, or starting an Activity with a transition. The application context lives as long as the process and has no theme or window.

The leak is what happens when the two get crossed: give an Activity context to anything that outlives the Activity — a singleton, a static field, a cache, a long-lived listener, a database instance — and that object pins the Activity, and through it the whole view tree, for the life of the process. Rotate a few times and you have several dead Activities in memory. The rule is simple to state and easy to violate: use the application context for anything long-lived, and the Activity context only for UI work whose lifetime you know is shorter. It is also why Hilt makes you spell out @ApplicationContext, and why a ViewModel should never take a Context in its constructor (if it truly needs one, AndroidViewModel gives it the application).

// ❌ singleton pinned to an Activity: every rotation leaks a whole screen
object ImageCache {
    lateinit var context: Context
    fun init(c: Context) { context = c }        // called with `this` from an Activity
}

// ✅ long-lived object, application-lifetime context
fun init(c: Context) { context = c.applicationContext }

// ✅ and a dialog still needs the Activity's theme and window
AlertDialog.Builder(activity).setTitle("Delete?").show()

Why it's asked / follow-up: it is the most classic Android leak there is, and the answer requires understanding lifetimes rather than memorising an API. Follow-up: “so why not always use the application context?” — because it has no theme and no window: inflating with it gives you unthemed views, and showing a dialog with it throws.

Source: Android — Context reference.

Why does my Fragment observer fire twice after returning from the back stack? Senior

Because the observer was registered with the Fragment as its lifecycle owner instead of the view. Navigating forward destroys the Fragment's view but keeps the Fragment instance; navigating back calls onCreateView/onViewCreated again on that same instance, so the registration line runs a second time. The first observer was never removed — the Fragment it was tied to never reached DESTROYED — so now two observers fire, one of them writing into a view that no longer exists.

The symptoms are distinctive and worth being able to name: a callback that runs twice (a duplicated network call, a dialog shown twice, a navigation that fires twice and puts two copies of a screen on the stack), a crash on a detached view, or a slow leak of view trees. It compounds — three round trips give you three observers.

The fix is one word: use viewLifecycleOwner for anything registered in onViewCreated that touches the view. It is the same underlying issue as nulling the binding in onDestroyView — both come from the Fragment instance outliving its view.

override fun onViewCreated(view: View, s: Bundle?) {
    // ❌ one more observer for every back-stack return
    viewModel.events.observe(this) { showSnackbar(it) }

    // ✅ removed at onDestroyView, re-registered with the new view
    viewModel.events.observe(viewLifecycleOwner) { showSnackbar(it) }
}

Why it's asked / follow-up: it is a bug with a memorable symptom and a one-word fix, which makes it a favourite “have you actually debugged this?” question. Follow-up: “why doesn't it happen with a configuration change?” — there the Fragment instance is destroyed and recreated too, so the old observer goes with it; the back stack is the case where the instance survives.

Source: Android — The Fragment view lifecycle.

Why does my list lose scroll position, focus, or per-row state when the data updates? Mid

Because the list has no stable notion of item identity, so an update that should have been “one row changed” is interpreted as “every row is new.” The framework then tears down and rebuilds the rows — and the state that lived in those rows (an editing cursor, an expanded card, a nested scroll offset, an in-flight animation) is destroyed with them. It is the same bug in both toolkits: in Compose, an item's identity defaults to its index unless you supply a key; in the View world, notifyDataSetChanged() asserts that everything changed.

It surfaces the moment a list is not append-only. Insert at the top, sort, filter, or refetch from the network (which produces new object instances for the same rows), and every index shifts. The fix is always to give the list something stable to identify a row by — a server id — and to let it compute the difference.

// Compose — identity is the index without a key.
LazyColumn { items(rows, key = { it.id }) { Row(it) } }        // ✅

// Views — let DiffUtil compute the change set instead of asserting "all of it".
adapter.submitList(rows)                                       // ✅ ListAdapter
// adapter.notifyDataSetChanged()                              ❌

Why it's asked / follow-up: it is a user-visible bug (“my typing jumps”) with an unobvious cause, and it tests whether the candidate understands list diffing in whichever toolkit they use. Follow-up: “is the index ever an acceptable key?” — only for a static list that never reorders or has items inserted; anything sortable, filterable, or refreshed needs a domain id.

Source: Android — Item keys in lazy lists.

My background work runs on my phone but not on the user's. What happened? Mid

Almost always: it ran on your device because your device was plugged in, unlocked, and had the app in the foreground minutes earlier — and it does not run on the user's because the platform is actively preventing it. Doze defers work when the device is idle, app standby buckets throttle apps the user rarely opens, background services cannot be started from the background at all since Android 8, and several OEMs add their own aggressive process-killing on top of the platform's rules.

So “works on my machine” is not a testing gap here, it is a design error: work in the background is not guaranteed to happen when you asked for it, only eventually and under constraints the system chooses. The remedies are the ones from the background-work question, and picking correctly is the whole answer — WorkManager for deferrable guaranteed work (it persists across reboot and respects the constraints for you); a foreground service, with the user's knowledge, for ongoing user-visible work; an exact alarm only for a genuine user-facing schedule such as an alarm clock, since exact alarms are permission-gated and audited; and a high-priority FCM message when the trigger comes from your server.

Two things to name in the answer: test with adb shell dumpsys deviceidle force-idle rather than trusting a foreground run, and treat asking the user to disable battery optimisation as a last resort — it is a bad user experience and Play restricts the permission that requests it.

// Force the conditions the user's device actually has:
// $ adb shell dumpsys deviceidle force-idle        ← enter Doze
// $ adb shell am set-standby-bucket com.example.app restricted

// Then let the system choose the moment, instead of assuming one.
PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
    .setConstraints(Constraints(requiresBatteryNotLow = true))
    .build()

Why it's asked / follow-up: it is the gotcha that most often reaches production, because the failure is invisible in development and shows up as a support ticket. Follow-up: “why is a 15-minute periodic job not every 15 minutes?” — because 15 minutes is the minimum interval, not a guarantee: the system batches work across apps and defers it in Doze, so treat the period as a floor.

Sources: Android — Doze and App Standby, Android — Background work overview.

Every answer links its primary source inline — the official Android developer documentation, whose documentation and sample code are published under the Apache 2.0 license. I link and paraphrase it; nothing here is lifted, and the question set is a curated synthesis of the topics an Android interviewer commonly covers rather than a copy of any question bank. This page covers the Android framework and assumes Kotlin fluency; the language beneath it is covered on the Kotlin interview page, and the third-party library universe (Retrofit/OkHttp, raw Dagger, image loaders, RxJava) is named where an interviewer would but not deep-dived. Feature-arrival and API-level details cross-link to the Android version reference. Last updated September 2026, against Android 17 (API 37).

Mungomash LLC · More on Android