AND

Master the job description

Study Guide

Every requirement a senior Android role commonly asks for — explained at senior depth, then tied to how you'd apply it on a real team. Read the concept, then the “in practice” line so you can speak from experience.

Depth over trivia Senior and staff Android interviews probe how you reason — why StateFlow over LiveData, when recomposition actually costs you, how you survive process death, and where coroutines leak. Each section below pairs the concept with an in practice note so you can answer from a real engineering decision, not a definition.

01 · Kotlin language essentials

What they want: idiomatic, production Kotlin — not Java written in Kotlin syntax. Fluency with val vs var, immutability by default, null-safety (?., ?:, !! as a code smell), data classes, sealed hierarchies for modelling state, and the scope functions (let, run, with, apply, also).

  • Prefer val and immutable collections; expose List, store MutableList privately.
  • Model exhaustive state with sealed interface + when (no else branch → compiler enforces completeness).
  • Delegation: by lazy, by viewModels(), property delegates, and class delegation via by.
  • inline + reified to keep generic type info at runtime; understand the cost (code-size) of inlining.
In practice A senior answer ties null-safety to crash rate: the type system pushes nullability to the boundary (parsing, platform types from Java) so the rest of the app is non-null by construction. !! in review is a red flag — it converts a compile-time guarantee back into a runtime crash.

02 · Coroutines & structured concurrency

What they want: deep coroutines — not just launch { }. Structured concurrency means every coroutine has a parent CoroutineScope; cancelling the scope cancels its children, and a child failure cancels siblings (unless a SupervisorJob isolates them).

  • suspend functions must be main-safe: they can be called on the main thread and internally switch with withContext(Dispatchers.IO).
  • Dispatchers: Main (UI), IO (blocking I/O, large pool), Default (CPU work), Unconfined (rarely).
  • Cancellation is cooperative: check isActive / call ensureActive(), and never swallow CancellationException.
  • coroutineScope vs supervisorScope; async/await for parallel decomposition.
Land this viewModelScope and lifecycleScope are structured scopes wired to a lifecycle — they cancel automatically, which is why you almost never need GlobalScope (which leaks because nothing cancels it).
In practice The senior framing: "I let the scope own cancellation." A network call started in viewModelScope is cancelled when the ViewModel clears, so a config change or back-press doesn't leak a request or update a dead UI.

03 · Flow, StateFlow & SharedFlow

What they want: reactive streams done right. Flow is a cold asynchronous stream; operators (map, filter, combine, flatMapLatest) run in the collector's context unless you flowOn.

StateFlowSharedFlow
Holds valueAlways (initial required)Optional replay
ConflationConflated, distinct-until-changedConfigurable buffer
Use forObservable UI stateOne-shot events (navigate, toast)
  • Collect with repeatOnLifecycle(STARTED) (or collectAsStateWithLifecycle() in Compose) so collection stops in the background — not launchWhenStarted (deprecated, keeps upstream hot).
  • stateIn(scope, SharingStarted.WhileSubscribed(5000), initial) shares a cold flow as state and stops upstream 5s after the last subscriber — surviving rotation without re-fetching.
In practice The classic bug: collecting a flow in onCreate with a plain launch keeps the collector alive in the background, wasting work and risking stale UI updates. repeatOnLifecycle is the fix interviewers want to hear.

04 · Jetpack Compose — composition & recomposition

What they want: the mental model. A @Composable is a function that describes UI from state; Compose builds a tree and recomposes (re-invokes) only the functions whose inputs changed. There is no view hierarchy you mutate — you change state and the framework reconciles.

  • Recomposition scope: the smallest enclosing composable that reads a given state. Reading state low in the tree keeps recomposition local.
  • Stability: Compose skips a composable if its params are @Stable/@Immutable and unchanged. Unstable types (e.g. List from a non-stable source) defeat skipping → use ImmutableList / kotlinx.collections.immutable.
  • Phases: Composition → Layout → Drawing. Defer reads to the latest phase (e.g. Modifier.offset { } lambda) to skip recomposition on scroll/animation.
New concept Donut-hole skipping: a parent can recompose without recomposing stable children. Internalize "what reads this state" and you can explain jank precisely instead of memoizing blindly.
In practice Turn on Compose compiler metrics / Layout Inspector recomposition counts. A senior debugs an over-recomposing list by finding the unstable parameter, not by sprinkling remember.

05 · Compose state & side-effects

What they want: state hoisting and the side-effect APIs. remember caches across recomposition; rememberSaveable survives config change/process death. Hoist state up (stateless composables take value + onValueChange) so UI is reusable and testable.

  • LaunchedEffect(key) — run a suspend block tied to composition; restarts when key changes, cancels on leave.
  • rememberCoroutineScope() — launch from callbacks (e.g. button click), scoped to composition.
  • DisposableEffect — register/unregister (listeners, observers) with an onDispose.
  • derivedStateOf — compute from other state and only recompose when the result changes (e.g. showButton = scrollOffset > 0).
  • produceState — bridge a non-Compose async source into State; snapshotFlow — turn Compose state into a Flow.
In practice The interview trap: launching a coroutine directly in composition body instead of LaunchedEffect — it fires on every recomposition. Saying "effects belong in effect handlers keyed correctly" signals real Compose maturity.

06 · Activity / Fragment lifecycle & configuration changes

What they want: the lifecycle cold. Activity: onCreate → onStart → onResume (foreground) → onPause → onStop → onDestroy. A configuration change (rotation, dark mode, locale, font scale) destroys and recreates the Activity by default.

  • Fragment view lifecycle is separate from the fragment: use viewLifecycleOwner for observers, or you leak when the view is destroyed but the fragment isn't.
  • Don't fight config changes with android:configChanges unless you truly own the redraw — prefer surviving state in a ViewModel.
  • onSaveInstanceState(Bundle) persists small UI state across recreation and process death (parcelable, < ~1MB).
In practice Senior answer: "Rotation isn't special — it's the same recreation path as process death with more state retained. If my state survives process death via SavedStateHandle, rotation is free."

07 · ViewModel, SavedStateHandle & process death

What they want: where state lives and how it survives. A ViewModel outlives configuration changes (scoped to the ViewModelStoreOwner) but is cleared when the owner is truly finished. It does not survive process death on its own — the OS can kill a backgrounded app to reclaim memory.

  • SavedStateHandle is the bridge: a key-value map backed by saved instance state, so values survive process death. Inject it into the ViewModel and expose state via getStateFlow(key, default).
  • Never hold a Context/View/Activity reference in a ViewModel (leak). Use AndroidViewModel's application context only if unavoidable.
  • Test the restore path: kill the process from dev tools ("Don't keep activities") and confirm the screen rebuilds correctly.
Land this ViewModel survives rotation; SavedStateHandle survives process death; a database/DataStore survives everything. Naming the three tiers is the senior tell.
In practice The realistic failure: a search screen loses the query after the OS kills the app in the background. Put the query in SavedStateHandle and it restores — the fix interviewers are listening for.

08 · Architecture — MVVM, MVI & Clean Architecture

What they want: a defensible architecture with unidirectional data flow. MVVM: the ViewModel exposes observable state; the View renders it and sends events up. MVI tightens this into a single immutable UiState + an Intent/Action stream reduced into new state — great for complex, experiment-heavy screens.

  • Clean Architecture layers: UIDomain (use-cases, pure Kotlin, no Android) → Data (repositories, sources). Dependencies point inward; the domain knows nothing about Retrofit or Room.
  • Single source of truth: the repository decides cache vs network; the UI never calls the API directly.
  • Model state as one object: data class UiState(val items: List, val loading: Boolean, val error: String?) — render the whole thing, avoid scattered booleans.
In practice Pragmatism wins: "I use MVVM with a single immutable state per screen and use-cases only where domain logic is non-trivial — I don't add a use-case that just forwards a repo call." That nuance reads as staff-level judgment, not dogma.

09 · Dependency injection — Hilt & Dagger

What they want: DI to keep code testable and decoupled. Hilt sits on Dagger and generates components tied to Android lifecycles: @HiltAndroidApp, @AndroidEntryPoint, @HiltViewModel, and scopes (@Singleton, @ActivityRetainedScoped, @ViewModelScoped).

  • @Module + @Provides for things you don't own (Retrofit, OkHttp); @Binds for interface→impl.
  • Constructor injection (@Inject constructor) everywhere you can — it's the most testable.
  • Qualifiers (@Named / custom) disambiguate multiple bindings of the same type (e.g. two OkHttp clients).
Land this The point of DI isn't "objects get created for me" — it's dependency inversion: high-level code depends on interfaces, so tests swap a fake repository in one line. Frame DI as a testability and modularity tool.
In practice Hilt's compile-time graph means a missing binding is a build error, not a runtime crash — a real advantage over service-locator patterns you should name.

10 · Persistence — Room & DataStore

What they want: local storage chosen correctly. Room is the SQLite ORM: @Entity, @Dao, @Query, with compile-time SQL verification and Flow return types for reactive reads. DataStore replaces SharedPreferences: Preferences DataStore (key-value) or Proto DataStore (typed), both async and transactional.

  • Room DAOs returning Flow<List<T>> emit on every change — the backbone of offline-first.
  • Migrations: provide a Migration or risk a crash on schema change; export the schema for tests.
  • Never do disk I/O on the main thread — Room suspend DAOs and DataStore are main-safe by design.
In practice "SharedPreferences is synchronous and silently does I/O on the main thread (apply() defers the disk write but the in-memory commit still blocks). DataStore fixes that with a Flow + coroutines." That contrast is a frequent senior question.

11 · Networking — Retrofit, OkHttp & serialization

What they want: a robust network layer. Retrofit defines a typed API interface; OkHttp is the engine with the connection pool, cache, and interceptors. Serialize with Moshi or kotlinx.serialization.

  • Interceptors: an application interceptor for auth headers/logging; a network interceptor for cache control. Add a token-refresh Authenticator for 401s.
  • Model results with a sealed Result/Either type — never let raw exceptions reach the UI.
  • Map DTOs → domain models in the repository; DTOs never leak into Compose.
  • OkHttp HTTP cache + ETags for conditional GETs; retry only idempotent calls with backoff.
In practice Offline-first: the repository reads from Room (source of truth) and refreshes from the network, writing back to the DB so the UI updates via its Flow — the user never stares at a spinner, with stale-while-revalidate semantics.

12 · Navigation & deep linking

Core: Navigation-Compose models the app as a graph of destinations with typed routes (type-safe routes via @Serializable route objects in Navigation 2.8+). The NavController owns the back stack; you pass arguments and pop with results.

  • Hoist the NavController at the graph root; pass lambdas (onNavigateToDetail) into screens so composables stay navigation-agnostic and testable.
  • Deep links: declare <intent-filter> + android:autoVerify for App Links (verified https) and a navDeepLink mapping the URI to a destination.
  • Nested graphs for feature modules; SavedStateHandle receives nav args inside the ViewModel.
In practice Auth-gating is a graph decision, not a hidden screen: a logged-out user gets the auth graph as the start destination. App Links need the assetlinks.json on your domain — a step people forget.

13 · Background work — WorkManager, Services & broadcasts

What they want: the right tool for off-screen work. WorkManager is the default for deferrable, guaranteed work (sync, upload) — it survives process death and reboot, respects constraints (network, charging), and backs off on failure.

  • OneTimeWorkRequest vs PeriodicWorkRequest (min 15-min interval); chain with beginWith().then(); unique work to dedupe.
  • Foreground services for user-visible ongoing work (playback, navigation) — require a notification and, on Android 14+, a declared foregroundServiceType.
  • Modern background limits: JobScheduler/WorkManager over implicit wake-locks; BroadcastReceiver for system events, but most implicit broadcasts are restricted.
In practice "Don't use a Service for a one-off network sync — that's WorkManager. Use a foreground service only when the user can see it." Knowing the boundary (and Doze/App Standby) is the senior signal.

14 · Performance — jank, frame timing & baseline profiles

Core: the metric is frames rendered within the 16.6ms budget (60fps; 8.3ms at 120Hz). Jank = a dropped frame, surfaced by JankStats, the Macrobenchmark library, and Perfetto traces.

  • Baseline Profiles: ship AOT-compilation hints for hot paths so the first runs aren't interpreted — major cold-start and scroll wins, generated via Macrobenchmark.
  • Compose: avoid unstable params, defer state reads to layout/draw, use LazyColumn keys, and remember expensive computations.
  • Main-thread discipline: no I/O, no large JSON parse, no bitmap decode on the UI thread.
New concept Measure on a release build with R8 on — debug builds are misleadingly slow and Compose isn't fully optimized. Macrobenchmark drives a real install.
In practice "I profile with Perfetto/Macrobenchmark, fix the proven hot frame, then add a baseline profile and a benchmark to guard it." Profiling-first beats guessing — the line interviewers want.

15 · Memory leaks, ANRs & profiling

Core: two failure modes. Leaks retain objects the GC should free — the classic Android leak is a Context/View held past its lifecycle (static refs, inner classes, callbacks, a Handler posting delayed runnables). ANRs fire when the main thread is blocked > 5s (input dispatch) — almost always main-thread I/O or lock contention.

  • LeakCanary in debug auto-detects retained instances and dumps the reference chain — name it as your first tool.
  • Android Studio Memory Profiler + heap dumps for cumulative growth; ANR traces in Play Console / /data/anr/traces.txt.
  • Common coroutine leak: launching in GlobalScope or collecting a flow without lifecycle awareness.
In practice Leaks present as slow, cumulative jank and OOM crashes after long sessions — you hunt them over time with LeakCanary, not from a single stack trace. ANRs you fix by getting work off the main thread.

16 · App startup & R8 / ProGuard

Core: cold start = process fork → Application.onCreate → first frame. The budget is spent on eager initialization, large dependency graphs, and main-thread work.

  • Use the App Startup library to consolidate and order initializers (and lazy-init the ones not needed at launch).
  • R8 shrinks, optimizes, and obfuscates: dead-code/resource removal cuts size and improves load; keep rules (-keep) protect reflection (Gson, serialization).
  • Defer non-critical work off the launch path; show content fast and hydrate the rest.
In practice "I measure cold start on a release build with Macrobenchmark, trim Application.onCreate, add a baseline profile, and keep R8 on." Treating startup as a defended budget is a senior tell.

17 · Testing — JUnit, MockK, Turbine & Compose UI tests

What they want: a real test pyramid. Many fast unit tests (JUnit + MockK) on ViewModels, use-cases, and mappers; fewer integration tests; a few UI/E2E (Compose test or Espresso) on critical flows.

  • Coroutines/Flow: runTest + a TestDispatcher (inject the dispatcher, never hardcode Dispatchers.IO); assert emissions with Turbine.
  • Fakes vs mocks: prefer a hand-written fake repository for state-based tests; use mocks for interaction verification. Over-mocking couples tests to implementation.
  • Robolectric runs the Android framework on the JVM (fast, no device); Compose UI test uses semantics (onNodeWithText, assertIsDisplayed).
In practice The injectable-dispatcher pattern is the senior tell: class Repo(private val io: CoroutineDispatcher) so tests pass StandardTestDispatcher() and control virtual time. Hardcoded dispatchers make tests flaky.

18 · Gradle — variants, version catalogs & modularization

What they want: you can reason about the build. Build variants = build types (debug/release) × product flavors (free/paid, staging/prod). Version catalogs (libs.versions.toml) centralize dependency versions.

  • KSP vs kapt: KSP is the Kotlin-native annotation processor — much faster than kapt (which generates Java stubs). Room/Hilt/Moshi support KSP; migrate off kapt for build speed.
  • Modularization: split by feature (:feature:home) and layer (:core:data) for parallel builds, faster incremental compilation, and enforced boundaries.
  • Convention plugins (build-logic) DRY up module config; api vs implementation controls what leaks transitively.
In practice "Modularization buys parallel + incremental builds and architectural enforcement — a :feature module physically can't import another feature's internals." Naming build-speed and boundaries is the staff answer.

19 · Security — Keystore, encryption & integrity

Core: the APK ships to the device and can be decompiled — never hardcode secrets. Store keys in the Android Keystore (hardware-backed where available); encrypt local data with Jetpack Security (EncryptedSharedPreferences / EncryptedFile) or SQLCipher for Room.

  • Tokens: short-lived access + refresh; gate sensitive actions behind BiometricPrompt.
  • Network: Network Security Config + certificate pinning (mind rotation), HTTPS only, no cleartext.
  • Play Integrity API attests a genuine app/device; obfuscation (R8) and root detection are defense-in-depth, not silver bullets.
  • Know the OWASP MASVS / Mobile Top 10 vocabulary: insecure storage, weak crypto, etc.
In practice "Parse the threat model out loud": secrets-in-bundle, data at rest, data in transit, and device integrity are four distinct buckets. Naming them shows security maturity.

20 · Accessibility & the on-device AI frontier

Core: ship for TalkBack. In Compose, set Modifier.semantics { contentDescription = ... }, mark headings, group related nodes, honor touch-target size (48dp), Dynamic Type (sp units), and contrast (WCAG AA).

  • Modifier.clickable(onClickLabel=...), stateDescription, and merging semantics for composite controls.
  • Test with the Accessibility Scanner and Compose semantics assertions.

Frontier: on-device generative AI via Gemini Nano (AICore / ML Kit GenAI APIs) and the MediaPipe LLM Inference API, plus classic TensorFlow Lite (LiteRT) for vision/audio. The trade-off: privacy (data stays local), offline, no server cost — against model size, RAM, and device fragmentation.

In practice "Gemini Nano is gated to capable devices (Pixel 8+/flagships via AICore); I feature-detect and fall back to a server model otherwise." Reasoning about the privacy/latency/cost trade-off and device support is the staff-level answer.

A1 · Structured concurrency in depth

The whole model rests on one rule: a coroutine cannot outlive its scope. A scope owns a Job; every coroutine you launch becomes a child. The scope's job won't complete until all children do, cancellation flows down to every child, and (with a normal Job) a child failure cancels the parent and siblings.

  • SupervisorJob changes only the failure direction: a child failing doesn't cancel siblings, but cancelling the parent still cancels children. viewModelScope uses one so one failed load doesn't nuke the screen.
  • Scope from context: CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate). Cancel it in your lifecycle hook.
  • coroutineScope { } is a suspend function that creates a child scope and waits for all children — the building block for parallel decomposition with all-or-nothing semantics.
Interview line "I let the scope own cancellation" — it captures why lifecycle-bound scopes prevent leaks without manual bookkeeping.

A2 · Dispatchers, main-safety & confinement

A suspend function should be callable from the main thread and switch context internally. That's main-safety: withContext(Dispatchers.IO) { blockingIo() } inside the function, so callers never think about threads.

  • Main: UI updates; Main.immediate avoids a re-dispatch when already on Main.
  • Default: CPU-bound work, sized to cores. IO: blocking I/O, large elastic pool. They share threads.
  • limitedParallelism(n): cap concurrency for a resource (e.g. IO.limitedParallelism(1) to serialize DB writes) without a blocking lock.
  • Inject dispatchers (don't hardcode) so tests pass a TestDispatcher and control virtual time.

A3 · Flow operators that show up in interviews

Know the families and what each buys you:

  • Context: flowOn (upstream dispatcher), and why you can't switch the collector's context from inside.
  • Flattening: flatMapLatest (cancel previous — search), flatMapMerge (concurrent fan-out), flatMapConcat (ordered, sequential).
  • Combining: combine (latest of each, reactive state) vs zip (index-paired).
  • Backpressure/rate: buffer, conflate (drop intermediate), debounce, sample.
  • Sharing: stateIn / shareIn with a SharingStarted policy (WhileSubscribed(5000) to survive rotation).
In practice A search screen is the canonical combo: debouncedistinctUntilChangedflatMapLatestflowOn(Default), exposed as StateFlow via stateIn.

B1 · Recomposition, scopes & stability

Compose's performance model is simple to state and easy to get wrong: it re-invokes the smallest scope that read the changed state, and it skips composables whose parameters are stable and equal to last time.

  • Read low: read state as deep in the tree as possible so recomposition stays local (donut-hole skipping).
  • Stability: primitives, String, functional types, and @Stable/@Immutable types are stable. Plain List/Map and types from modules without Compose info are not — wrap or use immutable collections.
  • Strong skipping (recent Compose) auto-remembers lambdas and lets unstable params still be compared by instance — but designing for stability remains best practice.
  • Diagnose: enable the Compose compiler metrics report and the Layout Inspector recomposition counts; find the unstable param instead of guessing.

B2 · The side-effect APIs, chosen correctly

Composition must be side-effect-free; effects belong in effect handlers, keyed correctly:

  • LaunchedEffect(key) — suspend work tied to composition; restart on key change.
  • rememberCoroutineScope() — launch from event callbacks.
  • DisposableEffect(key) — register/unregister with onDispose.
  • SideEffect — publish Compose state to non-Compose code after every successful composition.
  • derivedStateOf — cache a coarse result from noisy state.
  • produceState / snapshotFlow — bridge to/from Flows.
  • rememberUpdatedState — capture the latest callback inside a long-lived effect.
In practice The most common mistake is launching a coroutine in the composable body (fires every recomposition). The senior answer names the right handler and its key.

C1 · State survival: the three tiers

Interviewers love this because it forces precision. Three independent mechanisms, three lifetimes:

  • remember / ViewModel — survive configuration changes (rotation). Lost on process death.
  • rememberSaveable / SavedStateHandle / onSaveInstanceState — survive process death (small, parcelable state). Lost on a full swipe-away by the user (intentional).
  • Room / DataStore / files — survive everything until explicitly cleared.

Design rule: put 'where the user was' in saved state and 'what the user has' in a database. Test the path with developer options 'Don't keep activities' to force recreation, and by killing the process from Studio.

Senior tell "Rotation is just process death with more retained — if I survive process death I get rotation for free."

C2 · Choosing a background-work mechanism

Match the tool to the requirement, and name the trade-off:

NeedTool
Deferrable, guaranteed, constrained (sync/upload)WorkManager
User-visible, must run now (playback, nav)Foreground Service
In-app async tied to a screencoroutine in viewModelScope
Server-initiated wakeup of a killed appFCM (high priority if urgent)
Exact-time alarm (alarm clock)AlarmManager (exact alarms, permission-gated)
  • Respect Doze/App Standby: background timing is approximate by design.
  • Don't hold wake locks manually — let WorkManager and foreground services manage execution.

D1 · Hilt as a compile-time dependency graph

Hilt generates and validates the dependency graph at compile time, so a missing or ambiguous binding is a build error — not a production crash. The pieces:

  • @HiltAndroidApp on the Application bootstraps the graph; @AndroidEntryPoint enables injection into Activities/Fragments/Services.
  • Modules (@Module @InstallIn(component)) provide bindings; @Binds for interfaces, @Provides for constructed/third-party types.
  • Scopes (@Singleton, @ViewModelScoped, …) control instance lifetime; unscoped = new instance per request.
  • Qualifiers disambiguate same-type bindings.

Frame DI around dependency inversion: the ViewModel depends on a repository interface, DI supplies the impl, and tests swap a fake — the testability win is the point, not the object creation.

D2 · The data layer & offline-first

The repository owns a single source of truth and the cache strategy. A canonical offline-first read:

fun observeItems(): Flow<List<Item>> = dao.observeAll().map { it.toDomain() } suspend fun refresh() = withContext(io) { val fresh = api.fetchItems() // network dao.upsertAll(fresh.toEntities()) // write-through to Room } // UI updates via the Flow above
  • Room: Flow DAOs, tested migrations, exported schema.
  • DataStore: async typed preferences (Proto for structured settings).
  • Paging 3: RemoteMediator for network+DB paging; cursor pagination for stable feeds.
  • Mapping: DTO/Entity → domain model in the repo; the UI never sees transport types.

E1 · A testing strategy that scales

Design for testability first, then pick tools:

  • Architecture: pure use-cases, injected dispatchers, interfaces at boundaries — most logic becomes a fast JVM unit test.
  • Unit: JUnit + MockK; fakes for state, mocks for interaction; runTest + Turbine for coroutines/Flow.
  • Integration: in-memory Room, repository with a fake API, migration tests via MigrationTestHelper.
  • UI/E2E: a few Compose-test/Espresso flows on auth/checkout, gated in CI; assert via semantics.
  • Main dispatcher: swap it with a rule (Dispatchers.setMain) so viewModelScope works under test.
In practice The injectable-dispatcher + fake-repository combo is what makes a ViewModel testable in milliseconds. Over-mocking is the anti-pattern to call out.

E2 · Gradle, modularization & build speed

You should be able to reason about the build like any other system:

  • Variants = build types × product flavors; flavors for genuinely different builds, flags for runtime config.
  • Version catalogs centralize versions; convention plugins centralize module config.
  • KSP over kapt, implementation over api, configuration + build caches — the big speed levers.
  • Modularize by feature and layer for parallel/incremental builds and enforced boundaries; profile with a build --scan to target the real bottleneck.
  • R8 on for release: shrink/optimize/obfuscate, with keep rules for reflection; ship an App Bundle for per-device delivery.

F1 · Mobile security as a threat model

Answer security questions by naming the buckets, then the control for each:

  • Secrets in the bundle — don't. Keep them server-side; use short-lived tokens and Play Integrity attestation to gate endpoints.
  • Data at rest — Keystore-backed keys; EncryptedSharedPreferences/EncryptedFile; SQLCipher for Room when needed.
  • Data in transit — HTTPS only via Network Security Config; certificate/SPKI pinning with a backup pin.
  • Device integrity — Play Integrity for genuine app/device; BiometricPrompt tied to a Keystore CryptoObject; R8 obfuscation and root detection as defense-in-depth, not guarantees.
Vocabulary Reference OWASP MASVS / Mobile Top 10 — it shows you reason about classes of risk, not one-off fixes.

F2 · Release engineering & production health

Shipping doesn't end at upload — it ends when the new version is healthy:

  • CI: lint/detekt, unit + a slice of instrumented tests, debug and release builds (to catch R8), all cached and gating merges.
  • Signing: upload key + Play App Signing so a lost key isn't fatal; ship an AAB for per-device delivery.
  • Rollout: staged percentages gated on crash-free rate and ANR rate (Android Vitals); a feature flag + kill switch for risky features.
  • Observability: upload mapping.txt for de-obfuscated traces; alert on regressions; use Play in-app updates to nudge upgrades.
Senior tell "Native Android has no JS-style code OTA, so I design behavior behind server flags and keep the release pipeline fast — and I treat the rollout's crash-free rate as the definition of done."

G1 · On-device GenAI on Android — the landscape

Three layers, chosen by task and reach:

  • Gemini Nano via AICore + ML Kit GenAI — easiest path for text tasks (summarize, rewrite, proofread, image description). The system hosts the model, so your app ships no weights; limited to capable devices.
  • MediaPipe LLM Inference — run a specific open model (Gemma, etc.) you manage, across more devices, with CPU/GPU backends and token streaming.
  • LiteRT (TF Lite) — classic on-device ML for vision/audio with hardware delegates.

Engineering concerns that recur: feature-detect capability and fall back to server; quantize and size to RAM; stream tokens but batch emissions; run off the main thread in a lifecycle scope and release the session; download (don't bundle) large models with progress.

In practice "I'd ship the summarize feature with Gemini Nano where AICore is available, batch the streamed tokens into a StateFlow, and fall back to our server model on unsupported devices — keeping the data on-device when we can."