Recomposed · Free question bank

Every Android interview question, answered

A free, open question bank for Android engineering interviews: 271 real interview questions across 87 Kotlin and Android topics, each with what a strong answer actually covers. No sign-up, no paywall.

Start studying free →

Lessons, quizzes and flashcards are free and need no account.

KOTLIN

Scope Functions

Drills let, run, with, apply, and also: receiver, return value, and idiomatic use.

Walk me through a real scenario where you'd reach for with instead of chaining multiple scope functions.

What a strong answer covers: A strong answer notes with takes the receiver as an argument rather than being called on it, so it fits cases where you already have a non-null object in hand and just want to group several calls or compute a value from it, like initializing a StringBuilder or reading several properties off a config object in one block, without the receiver needing to be part of a fluent chain. They'd contrast it with apply/also, pointing out with is not an extension and doesn't return the receiver, so it's the wrong tool for builder-style chaining.

Have you seen scope-function chains cause debugging pain, e.g. in stack traces or breakpoints? How do responsible teams avoid that?

What a strong answer covers: Chaining several scope functions on one line (e.g. x.let{}.also{}.run{}) compresses multiple logical steps into a single expression, so a crash inside gives a stack trace pointing at one compressed line with little context, and setting a breakpoint on 'the right step' is awkward since they're all inlined lambdas. A strong answer describes keeping chains to one or two calls, extracting named intermediate variables when logic branches, and reserving scope functions for small, single-purpose transformations rather than multi-step business logic.

Because these are all inline functions pasting lambda bodies at the call site, what are the bytecode-size implications of nesting several scope-function calls deeply, and when would you actively avoid them in performance-sensitive code?

What a strong answer covers: Since let/run/with/apply/also are inline, the compiler pastes each lambda body directly into the call site rather than allocating a Function object, so nesting them deeply duplicates that code everywhere it's used, which can bloat method bytecode and in extreme cases contribute to hitting per-method size limits. In genuinely hot paths, like per-frame rendering or per-item RecyclerView binding, a strong answer favors flattening to plain sequential code so behavior stays predictable rather than relying on compiler-generated bytecode duplication.

Lesson, quiz and flashcards for Scope Functions →

Null Safety

How Kotlin's type system prevents NullPointerExceptions at compile time.

When reviewing a PR, you spot heavy use of !! to silence the compiler. How do you push back and what would you suggest instead?

What a strong answer covers: A strong answer treats every !! as a deliberate 'crash here if I'm wrong' assertion and asks whether that's actually the intended behavior; if the value truly can never be null, they'd make that guarantee explicit (a non-null type, requireNotNull with a message, or restructuring so the nullable is resolved earlier), and if null is a legitimate case, they'd replace it with ?.let, elvis (?:), or an early return. They'd also flag that !! throws a bare NullPointerException with no context, so at minimum it deserves a message explaining the invariant.

Walk me through the risk of Java interop platform types (Type!) in a real Android codebase: where do they tend to bite, and how do you defend against them?

What a strong answer covers: Platform types come from Java APIs without nullability annotations, older Android SDK methods and some third-party Java libraries, and Kotlin lets you treat them as either nullable or non-null with zero compiler enforcement either way, so the danger spot is exactly where a team assumes an SDK method never returns null and skips a check. A strong answer describes wrapping such APIs in a thin Kotlin adapter layer that pins down real nullability once, and leaning on @Nullable/@NonNull annotations upstream so Kotlin can infer a proper nullable or non-null type instead of a platform type.

Some teams ban !! entirely via lint rules; others allow it in tests or startup code. Where do you draw the line, and how do you decide when a nullable is genuinely a bug versus a real business case to handle gracefully?

What a strong answer covers: A strong answer frames this around blast radius and recoverability: in test code or app-startup/DI wiring where a null genuinely means a broken build, an immediate crash via !! or requireNotNull is useful fail-fast behavior; on user-facing runtime paths, like parsing a server response or reading a nullable form field, a null is often an expected business state that should be handled with elvis, early-return, or a sealed error state rather than crashing the app. They'd also mention codifying the decision as a team convention or lint suppression policy rather than leaving it to individual judgment call by call.

Lesson, quiz and flashcards for Null Safety →

Properties, lazy & lateinit

Kotlin property mechanics: val/var/const, lazy vs lateinit, backing fields, custom accessors.

Walk me through how you'd decide between lazy and lateinit for a property that depends on Android's lifecycle, e.g. a view binding or an injected dependency.

What a strong answer covers: A strong answer keys off two things: whether the property is a val or needs reassignment, and whether initialization needs a computed block versus simple external assignment. Fragment view bindings typically use a nullable var, not lateinit-of-non-null, because the view can be destroyed and recreated while the Fragment instance survives, whereas a DI-injected non-null dependency set once by the framework is a textbook lateinit var, and by lazy fits values computed once from something already available at property-declaration time.

You've inherited a codebase littered with lateinit vars that occasionally crash with UninitializedPropertyAccessException in production. How would you triage and fix this systemically?

What a strong answer covers: A strong answer starts by checking ::prop.isInitialized at the actual read sites to confirm the crash is really an ordering or lifecycle bug rather than a genuine logic error, then traces why the assumed initialization point didn't run first, which on Android is often a lifecycle mismatch, like reading in a callback that can fire before the expected lifecycle method. The systemic fix is usually converting to a nullable property with explicit handling, lazy if it can be computed on first access, or dependency injection that guarantees the value exists before the object is usable, rather than sprinkling isInitialized checks everywhere.

Why might overusing by lazy on Android actually introduce subtle bugs or memory issues, e.g. in Fragments across configuration changes, and what would you watch for?

What a strong answer covers: The lazy delegate caches its value for the lifetime of the containing object, so if a Fragment or Activity lazily initializes something that captures a Context, View, or the component's own this, and that instance survives longer than expected, the cached value keeps that reference alive too and causes a leak. A strong answer also flags that the default SYNCHRONIZED mode adds locking overhead on every subsequent read even though it's only needed once, so on a hot, single-threaded path LazyThreadSafetyMode.NONE or PUBLICATION may be a better fit.

Lesson, quiz and flashcards for Properties, lazy & lateinit →

Data, Sealed & Value Classes

Kotlin's data, sealed, and value classes and choosing between enum and sealed.

Tell me about a time you used a data class where you probably shouldn't have. What went wrong, and what would you use instead?

What a strong answer covers: A strong answer usually lands on one of two classic misuses: putting a data class with a mutable var property into a HashSet or as a HashMap key, so equals/hashCode change after insertion and lookups silently fail, or using a data class purely as a mutable model that's mutated in place, where the auto-generated equals/copy/toString add no value and just obscure that it's really behaving like a plain class. The fix is usually making the fields val and immutable, or dropping data and writing explicit equality only where it's actually needed.

Why would you introduce a value class for something like UserId, and what's the practical maintenance cost, e.g. extra code, Java interop, or serialization?

What a strong answer covers: A strong answer explains the benefit is compile-time type safety, catching a bug like passing a TripId where a UserId is expected, at effectively zero runtime cost since the wrapper is erased to the underlying value at most call sites. The costs to flag: it only stays unboxed in direct usage since generics, collections, and nullable positions force boxing anyway, Java code sees a mangled or boxed representation and loses the ergonomics, and serialization libraries like Gson or Firestore's mapper often need custom handling since they don't natively understand the wrapper.

You have a sealed class hierarchy for UI state that's grown to 12 subclasses handled in one big when. What's your read on this design, and how would you refactor it?

What a strong answer covers: A strong answer recognizes that a sprawling flat sealed hierarchy handled in a single when is often a sign that orthogonal concerns, like loading/error/content plus several content variants, got merged into one type, making every consumer's when huge and every new case a ripple across the codebase. They'd suggest splitting into nested sealed hierarchies, an outer Loading/Error/Content split with Content itself sealed into its variants, so each when stays small and exhaustive at its own level, or extracting variants into plain data held by a single state where distinct branching isn't actually needed.

Lesson, quiz and flashcards for Data, Sealed & Value Classes →

Generics & Variance

Kotlin generic types, in/out variance, projections, erasure, and reified type parameters.

In plain terms, why does Kotlin let you declare variance at the class declaration (out/in) while Java forces you to specify wildcards at every use site? What's the practical, day-to-day benefit?

What a strong answer covers: Declaration-site variance means the author of a generic type states once, in the class definition, that it only ever produces T and never consumes it, and every caller everywhere automatically gets the covariant subtyping benefit without writing anything extra at each call site, unlike Java where you must remember to add ? extends/? super correctly every time you declare a variable of that generic type. The practical payoff is less boilerplate and fewer mistakes, since the variance rule is enforced once by the type's author instead of trusted to every consumer.

Walk me through a real API design decision: when would you mark a generic interface out T versus leaving it invariant, and what do you give up by doing so?

What a strong answer covers: You mark out T when the interface only returns or produces T and never accepts it as a method parameter, like a Repository that only exposes getAll(): List<T>, which then lets callers substitute a more specific type where a less specific one is expected. What you give up is any method that would take a T as input; the compiler rejects it outright because a producer position and a consumer position can't share a covariant type parameter, forcing either a redesign, a use-site projection, or splitting into separate reader and writer interfaces.

You're building a generic JSON-deserialization helper using reified, similar to Gson's TypeToken workaround. What are you actually gaining, and what are the costs a senior engineer should flag before reaching for it?

What a strong answer covers: The gain is being able to check x is T or reference T::class at runtime inside the function without the caller manually passing a Class token, since inline plus reified lets the compiler substitute the real type at every call site. The costs to flag: the function must be inline, so its body is pasted into every call site and can bloat bytecode if called from many places, it can't be called from Java, and reified type information doesn't propagate through nested generic calls, you can't hand a reified T off to another generic function that isn't itself reified for that parameter.

Lesson, quiz and flashcards for Generics & Variance →

Delegation

Drills Kotlin's by keyword for class delegation and property delegates.

Walk me through why you'd reach for class delegation (by) instead of plain inheritance when wrapping a third-party interface implementation.

What a strong answer covers: A strong answer frames by as composition dressed up with inheritance's syntax convenience: you get an interface's full method set forwarded to a held instance without subclassing a concrete class you don't control, which may be final or whose internals you don't want coupled to, and you can override just the one or two methods you actually need while everything else passes through untouched. This avoids the fragile-base-class problem where extending a third-party class ties you to its implementation details and breaks if the library changes them internally.

Tell me about a gotcha you've hit, or would expect, with by lazy on Android: things like which thread-safety mode to pick, or capturing references that outlive their lifecycle.

What a strong answer covers: The most common gotcha is leaving the default SYNCHRONIZED mode on a property that's only ever read from the main thread, paying unnecessary lock overhead on every access after the first, where LazyThreadSafetyMode.NONE is the correct fix once single-threaded access is guaranteed. The other common one is a lazily-initialized property in a Fragment or Activity capturing this or a Context inside its initializer lambda, so the cached instance keeps that Android component alive longer than its lifecycle would otherwise allow, effectively a memory leak disguised as an optimization.

You added by delegation to reduce boilerplate for an interface, then later needed to override one method, but the override was silently ignored in an internal call path within the delegate. Walk me through why, and what's the fix.

What a strong answer covers: This happens because by only forwards the interface's public entry points to the delegate object; if the delegate's own implementation calls another of its methods internally, that call goes through the delegate's original vtable, not through your subclass, so your override is invisible to it, unlike real inheritance where an overridden method is seen by all internal calls via dynamic dispatch. The fix is either to manually override every method that needs the new behavior so it intercepts that internal call path directly, or to abandon delegation there in favor of real inheritance or explicit wrapping where you control every call.

Lesson, quiz and flashcards for Delegation →

Extension Functions & Properties

Adding behavior to existing types via statically-resolved extensions, plus Android KTX.

When would you choose an extension function over adding a method to a class you own, and where's the line where that becomes an anti-pattern, e.g. burying business logic in scattered extension files?

What a strong answer covers: Extensions earn their keep for utility or convenience behavior on types you don't own, like Android SDK classes or third-party library types, or for keeping a core model class free of concerns that don't belong to its central responsibility, like formatting or UI-adjacent helpers. It becomes an anti-pattern when core business logic that belongs to a type's identity gets scattered across many loosely organized extension files with no discoverability, since extensions don't show up in a type's own member list the way real methods do, making the actual behavior of a class hard to reason about from the class itself.

Android KTX is full of extensions like .dp, .observe, viewModels(). What's actually happening under the hood that makes these 'free', and where would that assumption break down?

What a strong answer covers: Extension functions compile to ordinary static methods taking the receiver as the first parameter, so calling 8.dp or liveData.observe(...) has no more runtime cost than calling a static helper directly, there's no subclassing and no virtual dispatch overhead beyond a plain function call. The 'free' assumption breaks down when the extension itself does real work internally, like viewModels() which is a lazy-backed property delegate wrapping ViewModelProvider construction; that's not free, it just hides real allocation and lookup cost behind a clean one-line call.

You're designing a Kotlin DSL using lambda-with-receiver (T.() -> Unit). Walk me through why you'd choose that over a plain lambda parameter, and what's the risk if the receiver type has ambiguous or shadowed names in nested DSL blocks?

What a strong answer covers: A lambda-with-receiver makes the DSL block's members directly callable without a prefix, since this is implicit, which is what gives builder DSLs like buildString {} their clean declarative look, versus a plain lambda parameter where every call would need an explicit receiver reference. The risk in nested DSL blocks is implicit receiver shadowing: an inner block's receiver hides the outer one's members of the same name, so a call can silently resolve to the wrong scope; the standard fix is @DslMarker, which makes the compiler reject ambiguous unqualified access across nesting levels unless you explicitly qualify it with this@Outer.

Lesson, quiz and flashcards for Extension Functions & Properties →

Lambdas & Inline Functions

Drills higher-order functions, lambda mechanics, closures, and when inline saves or bloats.

Walk me through the trade-off of marking a higher-order function inline: what do you gain, and what's the cost if you do it reflexively on every lambda-taking function?

What a strong answer covers: Marking a function inline avoids allocating a Function object for the lambda argument and avoids a virtual call through invoke(), which matters on genuinely hot paths, and it also unlocks non-local return and reified type parameters. The cost of doing it reflexively is that the compiler pastes the function's whole body plus the lambda body into every call site, so a large inline function called from many places bloats compiled bytecode and method count for a benefit that's usually immaterial outside of small, frequently-called utility functions.

You've got a large inline function whose body itself is fairly big, called from dozens of places. What's the actual downside on Android, and how would you decide whether to keep it inline or convert it to a regular function?

What a strong answer covers: Each call site gets a full copy of the function's bytecode pasted in, so a big inline function called from dozens of places multiplies its own size by the number of call sites, inflating APK size and method count, and it makes debugging slightly odd since stack traces don't show a distinct frame for the inlined function. The decision hinges on whether the function actually needs inlining for lambda performance, reified types, or non-local return; if it doesn't, inline should be dropped, and even if it does, a large body is a signal to extract the non-lambda-dependent logic into a regular helper called from within a small inline wrapper.

Non-local return from inlined lambdas is convenient but changes behavior when a lambda parameter is later marked noinline or crossinline, e.g. because it's now stored or run on another thread. Walk me through a scenario where this bites a team during a refactor, and how you'd catch it.

What a strong answer covers: If a lambda parameter starts as plain inline and its body uses a bare return to exit the enclosing function early, that only compiles because the parameter is directly inlined at the call site; the moment a refactor marks it noinline, to store it as a real object, or crossinline, because it now runs from another lambda or thread, the compiler flags every bare return in that lambda as illegal, since neither supports non-local return. The real danger is a developer 'fixing' the compile error by wrapping the return in a nested lambda without checking what the original early-exit was protecting; a strong answer describes auditing every call site's control flow, not just satisfying the compiler, whenever an inline function's parameter modifiers change.

Lesson, quiz and flashcards for Lambdas & Inline Functions →

Kotlin 2.x, KSP & Collections

Drills Kotlin 2.x's K2 compiler, KSP-over-kapt code generation, and collection semantics and variance.

You migrate a module from kapt to KSP and a previously working annotation processor library now fails to generate code. Walk me through what's likely going on and how KSP's programming model actually differs from kapt's under the hood.

What a strong answer covers: KSP works directly against a symbol/resolver API over the source structure instead of kapt's approach of generating Java stub classes and running the processor through javac, so a processor built against kapt's APT-based assumptions doesn't automatically work on KSP, it needs a dedicated KSP-native rewrite from the library author. The practical answer is to check whether the library ships a KSP artifact rather than assuming it's a drop-in swap, and to know that some processors lag behind on KSP support entirely.

A teammate returns a List<String> from a repository function, then a caller casts it to MutableList<String> and mutates it in place at the call site. What's actually going on here, and why does the compiler allow it?

What a strong answer covers: Kotlin's List is a read-only interface view, not a guarantee of immutability; the underlying runtime object is very likely still an ArrayList, which also implements MutableList, so the unchecked cast succeeds and the mutation works, silently breaking whatever contract the read-only return type implied. A strong answer flags this as a real bug source in shared state and mentions defensive copying or a genuinely immutable collection type when the guarantee actually matters.

How would you decide whether your team should adopt context parameters today versus sticking with extension-function or constructor-based dependency passing?

What a strong answer covers: Context parameters are still an evolving, relatively recent feature (renamed from context receivers), so a senior engineer weighs source and binary compatibility risk, IDE/tooling maturity, and whether the API surface is public (library) versus internal app code where churn is cheaper to absorb. The win is real for implicit cross-cutting dependencies like a Logger or a scope, but adopting it in a public library API today is a different risk calculus than using it inside an app module you fully control.

Lesson, quiz and flashcards for Kotlin 2.x, KSP & Collections →

Coroutines Basics

How suspend, builders, jobs, and scopes run async work without blocking threads.

Walk me through what actually happens, step by step, when you call launch inside viewModelScope and the lambda calls a suspend network function.

What a strong answer covers: launch immediately returns a Job and schedules the block on the scope's dispatcher (Main.immediate by default for viewModelScope) without blocking the caller; when the coroutine hits the suspend call, the compiler-generated state machine saves its continuation and the thread is freed to do other work rather than parking. When the network call resolves, the continuation resumes, potentially on a different thread depending on the dispatcher the suspend function itself uses internally, and execution continues from that saved point.

When would you actually reach for async { }.await() immediately instead of just calling the suspend function directly? Isn't that the same thing?

What a strong answer covers: If you immediately await a single async with nothing else running concurrently, you've bought nothing over calling the suspend function directly, you've just added Deferred overhead; async only earns its keep when you start two or more of them before awaiting any, so the work genuinely overlaps in time. The trap is candidates reflexively wrapping every suspend call in async out of habit rather than because there's real concurrency to exploit.

You inherit code that calls runBlocking inside a coroutine already running on Dispatchers.Main. What's concretely wrong with this, and how would you fix it?

What a strong answer covers: runBlocking parks the current thread until its block completes, so calling it from Main blocks the UI thread for the duration, which risks an ANR and defeats the entire point of being on a coroutine in the first place; it's meant as a bridge from blocking code (like main() or a test) into suspend code, not as glue between two already-suspending contexts. The fix is to just call the suspend function directly, or use withContext if a dispatcher switch is needed, since you're already inside a coroutine and don't need to synchronously block anything.

Lesson, quiz and flashcards for Coroutines Basics →

Dispatchers & Threading

Which thread runs a coroutine: the built-in dispatchers, limitedParallelism, main-safety, and confinement.

A teammate has wrapped every function in the repository layer in withContext(Dispatchers.IO), including ones that only call other suspend functions. What would you say in review?

What a strong answer covers: I would push back, because it treats main-safety as a ritual rather than a property. Main-safety means a function does not block its caller thread; a function whose body only awaits other suspend functions already satisfies that, so the switch buys nothing and costs a real dispatcher round trip each call. The right place for the switch is the innermost point where actual blocking happens: the DAO call, the file read, the synchronous parse. Push it in, not out. The tell that this is cargo cult rather than reasoning is that it is applied uniformly, which means nobody asked per function what blocks. I would also point out the loop case, because it is where the cost stops being theoretical: withContext inside forEach over a thousand rows pays a thousand round trips instead of one.

You have a Room database that starts throwing timeouts under load because too many coroutines are writing at once. How would you bound it?

What a strong answer covers: Dispatchers.IO.limitedParallelism(n) with n matched to what the database can absorb, exposed as the single dispatcher every write goes through. It creates no threads: it borrows from the pool IO already owns and simply refuses to dispatch more than n concurrently, so the excess work queues instead of piling into the driver. The older answer, a private newFixedThreadPool wrapped in asCoroutineDispatcher, does the same job but leaves you owning real threads you have to remember to close, and they sit idle rather than being available to the rest of the app. I would also check whether the real fix is upstream: if the writes are individually tiny, batching them into one transaction inside a single withContext is better than throttling a thousand separate ones.

Explain why Dispatchers.IO can run 64 threads on an 8-core phone when Dispatchers.Default is capped at 8, given they share the same pool.

What a strong answer covers: Because the limit exists for different reasons in each case. Default is capped at the core count because CPU-bound work genuinely cannot go faster than the cores available; more concurrent computation only adds context switching and cache pressure. IO work spends nearly all its wall-clock time blocked on a socket or a disk read, consuming no CPU, so capping it at the core count would throttle throughput for no benefit at all. They share one pool because a thread is a thread: IO is permitted to grow the shared pool past Default limit up to 64. Two consequences worth naming: switching between IO and Default is cheap because there is often no actual thread handoff, and the 64 is an application-wide ceiling rather than per call site, so an unbounded number of blocking calls will queue rather than spawn threads.

How would you make a mutable in-memory cache safe for concurrent access without using a lock?

What a strong answer covers: Confine it. Create a private dispatcher with limitedParallelism(1) and route every read and write through withContext on it. That serialises execution so no two coroutines touch the state at once, and the dispatch that hands work between threads provides the happens-before edge, so one coroutine writes are visible to the next. The underlying collection can be a plain mutableMapOf with no synchronisation at all. The trap is a leaky boundary: if a getter returns the internal map directly rather than a copy, a caller can mutate it from any thread and the confinement is gone, so accessors have to return snapshots. I would weigh it against the alternatives: a ConcurrentHashMap if the operations are individually atomic and expressible with compute or merge, a MutableStateFlow of an immutable map with update if the cache is small and read-heavy, and a Mutex if the critical section needs to suspend, since that is the one thing confinement handles poorly because it serialises the whole slow operation.

What is the difference between Dispatchers.Main and Dispatchers.Main.immediate, and when does it actually matter?

What a strong answer covers: Plain Main always dispatches through Handler.post to the main Looper, even when the calling thread is already the main thread, so the block runs on a later pass of the message queue. Main.immediate checks the current thread first and executes inline when it is already correct. The difference is one frame of latency, which matters for UI state updates: if a StateFlow emits while you are already on the main thread, you want that render on this frame, not the next one. That is precisely why repeatOnLifecycle and collectAsStateWithLifecycle use immediate internally. It matters in the other direction too. If you genuinely want to yield to the message queue, for instance to let a pending layout pass complete before you measure something, plain Main is the correct choice and immediate would defeat it.

Someone suggests using Dispatchers.Unconfined to avoid the cost of dispatching. How do you respond?

What a strong answer covers: That it does avoid the dispatch, and that this is almost never worth what it costs you. Unconfined runs the coroutine body inline on the calling thread up to the first suspension point, and after that resumes on whatever thread happened to complete the suspending call, which for delay is a shared scheduler thread. So the thread your code runs on after any suspension is undefined. That rules it out for anything touching UI, and it makes reasoning about thread confinement impossible, since the same code can run on different threads on different passes. Where it is legitimate is testing, where UnconfinedTestDispatcher runs coroutines eagerly so you can assert without advancing a scheduler, and framework code that only forwards a value onward and genuinely does not care where it lands. In application code, the desire for inline execution nearly always means Main.immediate, which gives you the inline behaviour with a defined thread.

Lesson, quiz and flashcards for Dispatchers & Threading →

Structured Concurrency & Cancellation

Scoping coroutine lifetimes, isolating failure, and making cancellation actually take effect.

Why does Kotlin make cancellation cooperative rather than forcibly terminating a coroutine's thread the way some other concurrency systems kill a task? What trade-off does that design choice introduce?

What a strong answer covers: Forcibly killing a thread mid-execution can leave shared state, resources, or files half-updated with no chance to clean up, so Kotlin instead flips an isActive flag and relies on suspension points and checks like ensureActive() to notice and unwind gracefully, which lets finally blocks and resource cleanup run safely. The trade-off is that a coroutine doing tight CPU work with no suspension points and no manual isActive check simply won't respond to cancellation at all, which is a common source of bugs and a much-loved interview trap.

How would you go about finding and fixing a coroutine that leaks, one that keeps running long after the screen that launched it is gone?

What a strong answer covers: In practice you look for coroutines started on the wrong scope, GlobalScope, a manually created CoroutineScope that's never cancelled, or a scope captured before a screen's lifecycle owner rather than tied to it, and you verify by checking Job.isActive/isCancelled in logs or using StrictMode/leak detection during QA. The fix is almost always ensuring the coroutine's scope is structurally tied to the component's lifecycle, viewModelScope, lifecycleScope with repeatOnLifecycle, so cancellation is automatic rather than something you have to remember to do manually.

You need to fetch three independent pieces of data for a screen: one is critical and must block rendering if it fails, the other two are optional and should just hide their own section on failure. How do you structure the coroutine scopes to get that behavior, and what happens if you get it wrong?

What a strong answer covers: The critical fetch runs inside the normal coroutineScope so its failure propagates and cancels the siblings and fails the whole screen load, while the two optional fetches are wrapped individually, either with their own try/catch around each async's await, or launched inside a supervisorScope so one failing doesn't cancel the others or the critical path. Getting it backwards, putting everything in one coroutineScope, means a failure in an optional section takes down the entire screen instead of just hiding a card, which is the exact bug this design question is probing for.

Lesson, quiz and flashcards for Structured Concurrency & Cancellation →

Coroutine Exceptions & Error Handling

How failure travels through the job hierarchy, and which tool catches it where.

A crash report shows an exception from a coroutine but the stack trace stops at the dispatcher, with none of your code in it. How do you approach that?

What a strong answer covers: That shape is normal, because the stack at the point of failure has nothing to do with the stack that launched the coroutine: the coroutine resumed on a pool thread with a fresh stack. First I would turn on the debug agent with -Dkotlinx.coroutines.debug, which stamps coroutine names into thread names, and give the important coroutines a CoroutineName so the report says which one died rather than which pool thread it died on. Second I would check what we are actually logging. If the handler logs e.message or even e.toString we lose two things: the suppressed array, which is where sibling failures end up when several children fail together, and the cause chain. Logging stackTraceToString instead recovers both. Third, if this is reproducible in development, DebugProbes.dumpCoroutines gives a live picture of every coroutine and where it is suspended, which is usually faster than reasoning about it. The deeper fix is usually that the exception should not have reached the handler at all. If it is an IOException from a network call, the repository should be turning it into a Result at the boundary, so the ViewModel gets a value it can render rather than a crash we have to reconstruct from a log.

Explain why a CoroutineExceptionHandler installed on a nested launch does nothing, and where it should go instead.

What a strong answer covers: Because a handler is consulted only for a root coroutine, meaning one whose failure has nowhere else to go. A nested launch has a parent Job that is willing to take the failure, so the runtime reports upward rather than handling locally, and the handler sitting in that child context is simply never asked. The failure then propagates to the scope root, and if nothing there has a handler it reaches the thread default handler and crashes. The right placement is on the scope itself, CoroutineScope(SupervisorJob() + Dispatchers.Main + handler), so every coroutine started from it is covered by one policy. The SupervisorJob matters as much as the handler: with a regular Job the first failure cancels the scope permanently and every later launch on it silently does nothing, which is a nasty bug because the screen just stops responding rather than crashing. The one exception to the nesting rule is supervisorScope, where each direct child effectively becomes a root because the supervisor declines the failure, so a handler on a direct child does fire there. That is the detail that makes the two cases look contradictory until you know the rule is about roots rather than about depth.

Your app has a bug where the user leaves a screen mid-request and the network call retries three more times anyway. What is the likely cause?

What a strong answer covers: Almost certainly a broad catch swallowing CancellationException. Cancellation is delivered by resuming the coroutine with a CancellationException so its finally blocks run and it unwinds. Any catch (e: Exception), and runCatching in particular since it catches Throwable, matches that and treats it as an ordinary failure. In a retry loop that means cancellation looks exactly like a retryable network error, so the loop sleeps and tries again on a screen that no longer exists. The fix is to put a CancellationException clause first that rethrows, before the broader clause, in every retry helper and every Result wrapper. I would grep the codebase for runCatching around suspending calls, because that is the common source: it reads as harmless and is not. There is a second symptom worth checking for at the same time, which is state updates arriving after onCleared. Same root cause, and it usually shows up as either a leak warning or a crash from touching a released binding.

When would you deliberately use supervisorScope over coroutineScope, and what does it not protect you from?

What a strong answer covers: When the children are genuinely independent and a partial result is still useful. Firing several unrelated analytics calls, or loading three optional widgets on a dashboard where one failing should still leave the other two rendered. coroutineScope is right for the opposite case, parallel decomposition where the result is meaningless unless every part succeeds, because there the all-or-nothing cancellation is a feature: you do not want two of three network calls continuing to burn battery when the screen can never render. What supervisorScope does not protect you from is depth. It isolates direct children only. If a direct child launches its own children and one of those throws, that failure goes to the ordinary Job of the direct child, and the whole inner group cancels together. The supervisor stops it spreading sideways to the outer siblings, but the subtree is not tolerant. It also does not make the failure disappear: an isolated child still needs a handler or a try/catch, or the exception reaches the default uncaught handler and crashes the app just the same. And it has nothing to say about async, whose exception is stored for await regardless of what kind of Job is above it.

How would you design error handling across a repository and ViewModel so failures cannot be forgotten?

What a strong answer covers: Make failure part of the type at the layer boundary. The repository catches the exceptions it knows about and returns a value: Result at minimum, but I would usually prefer a domain sealed type, something like Success, NetworkUnavailable, Unauthorised, NotFound. The reason to prefer the sealed type over Result is that it forces the caller to think about each case rather than collapsing everything into one error string, and it keeps HTTP and IO concepts out of the ViewModel. Then the ViewModel maps that to UI state, which is a total function with no try/catch anywhere in it. The one trap in implementing this is runCatching, because it catches Throwable and therefore swallows CancellationException, which quietly breaks cancellation. So the repository uses a small helper that rethrows CancellationException first and only converts real exceptions into failures. Above all that I would still install a CoroutineExceptionHandler on the application scope, but purely as a crash reporter for the failures nobody anticipated. The distinction I would want to articulate is that the handler is where you find out you had a bug, not where you handle a case you designed for.

Walk me through what happens to the other calls when one async in a parallel load fails.

What a strong answer covers: Inside coroutineScope, the failing async reports to the scope Job immediately, at the moment it throws, not when someone awaits it. The scope cancels its remaining children, so the other async calls are cancelled mid-flight, and then the scope rethrows the original exception to whoever called the enclosing suspend function. So the caller sees one exception and no partial results, and the cancelled calls stop consuming network and CPU straight away, which is the behaviour you want when the screen needs all three pieces. The nuance is that this happens regardless of await. People expect async to hold its exception until awaited, and that is only true for a root async with no parent watching it, like GlobalScope.async. Inside a real scope the parent is always watching. If I wanted one of the three to be optional, catching around the awaitAll would not help, because the scope is already cancelling by then. I would have to isolate that call at its source, either async { runCatching { api.ads() }.getOrNull() } so it can never fail its parent, or run it in a supervisorScope so its failure does not propagate sideways.

Lesson, quiz and flashcards for Coroutine Exceptions & Error Handling →

Coroutines Under the Hood

What suspend compiles to: continuations, the state machine, context elements, and interception.

Explain what happens, mechanically, between a coroutine calling a suspending network function and that coroutine resuming with the response.

What a strong answer covers: The call site passes its Continuation into the network function as the hidden last parameter. That function registers a callback with the HTTP client, stores the continuation, and returns the COROUTINE_SUSPENDED sentinel. The caller state machine sees the sentinel and returns too, so the whole call stack unwinds and the thread goes back to its pool with nothing of ours on it. The paused state is not lost because the state machine object is on the heap: it holds the label saying which suspension point we stopped at, plus any locals that had to survive, promoted from stack slots to fields. Later the HTTP client fires its callback on one of its own threads and calls resumeWith with the response. That continuation is not the raw one, though, because the dispatcher intercepted it: CoroutineDispatcher implements ContinuationInterceptor, so what the callback actually holds is a DispatchedContinuation whose resumeWith packages the resumption as a Runnable and hands it to dispatch. The dispatcher puts that Runnable on a thread from its pool, that thread re-enters the state machine method with the label now pointing at the next branch, and execution continues from the line after the network call. So the resumption is on a different thread from the one that suspended, with the state restored from heap fields rather than a stack.

How would you explain to a junior developer why marking a function suspend does not make it run in the background?

What a strong answer covers: I would separate two ideas that the keyword blurs for people: where code runs, and how it waits. Suspend only changes the second. It gives the function the ability to pause and give the thread back, but it says nothing about which thread it started on, and a suspend function always begins on whatever dispatcher its caller is using. So calling one from viewModelScope, which is Main, runs its body on the main thread. The demonstration that lands is a suspend function containing a tight CPU loop or a Thread.sleep. Call it from Main and the UI freezes, exactly as it would with a normal function, because nothing in the body ever suspends and the compiler cannot enforce the non-blocking promise. Then I would show the fix, which is that withContext is the thing that changes threads, and that it belongs at the innermost place where the blocking actually happens rather than sprinkled everywhere. The third confusion usually follows immediately: that two suspending calls in a row run at the same time. They do not, they run in sequence and the durations add. Getting overlap requires async inside a coroutineScope and awaiting both, which is a different concept, concurrency from builders rather than from the keyword.

A colleague reports that a coroutine holds on to a large bitmap long after they expected it to be freed. How does knowing the compiler transform help you diagnose that?

What a strong answer covers: Because it tells you exactly where that reference lives. When the compiler rewrites the function into a state machine, every local that is still needed after a suspension point cannot stay on the stack, since suspension is a genuine return and the frame is discarded. Those locals become fields on the continuation object, and that object is alive for as long as something holds the continuation, which is whatever the coroutine is currently waiting on. So a bitmap assigned to a local before a long delay or a slow network call is a strong reference on a heap object with the lifetime of the suspension, not a stack value that disappears at the next safepoint. The practical fixes follow from that. Narrow the scope so the value is not live across the suspension point, for example by extracting the part that uses the bitmap into its own function that completes before the wait, or by nulling the reference explicitly before suspending. Check whether the coroutine really needs to be long-lived at all, since a lazily started coroutine nobody starts, or one launched on a detached Job, can keep its captures alive indefinitely. And confirm with a heap dump: the continuation class name in the dominator tree usually names the enclosing function, which points straight at the offending local.

Why is a CoroutineContext a set keyed by element type rather than a simple list, and what practical consequences does that have?

What a strong answer covers: Because lookup has to be by type and has to be typed. Elements each carry a Key that is usually the companion object of the element class, so ctx[CoroutineName] returns a CoroutineName? with no cast, and the machinery can ask for exactly the elements it cares about, such as the Job or the interceptor, without scanning. Element itself extends CoroutineContext, so a single element is a context of one, which is why you can pass Dispatchers.IO where a context is expected and why plus composes them naturally. The consequences are all about plus replacing per key. A context can hold only one dispatcher, mechanically, because dispatchers are stored under the ContinuationInterceptor key rather than under their own type, so adding a second replaces the first. Adding a dispatcher leaves your CoroutineName and Job untouched, because those are different keys. And the classic bug falls out of the same rule: passing a fresh Job() into a builder replaces the scope job under the Job key before the builder reads it, so the new coroutine has no parent and escapes cancellation entirely. Understanding the merge semantics turns that from a mystery into an obvious consequence.

What is the fast path in a suspend function, and why does it matter for something like Flow?

What a strong answer covers: A suspend function does not necessarily suspend. The generated code calls the next suspending function and checks its return value: if it is the COROUTINE_SUSPENDED sentinel, the state machine saves its label and unwinds, but if it is anything else the value was already available and execution simply continues on the same thread with no continuation stored, no dispatch, and no allocation. So a suspend function that finds its answer in a cache costs about as much as a normal method call. It matters for Flow because a flow collection is a long chain of suspending calls, emit into map into filter into the collector, and if every one of those necessarily allocated and dispatched, operator chains would be far more expensive than they are. Most emissions travel the fast path straight through the chain on one thread. It is also the reason flowOn and buffer are explicit opt-ins rather than defaults: inserting a channel between two stages is what forces a real suspension and a dispatch, so the library makes you ask for it. The same idea shows up in Dispatchers.Main.immediate, which exists to avoid a pointless post when you are already on the right thread. The general principle is that the coroutines library works hard to make the common case not actually suspend.

Lesson, quiz and flashcards for Coroutines Under the Hood →

Flow: the Basics

What a Kotlin Flow actually is: an async stream of values over time, the producer/collector model, emit and collect, cold execution, and why it beats callbacks.

A teammate suggests replacing a suspend function that returns a List<Item> with a Flow<Item> that emits one item at a time. When does that change actually earn its complexity, and when is it just churn?

What a strong answer covers: Flow pays for itself when there's genuinely more than one meaningful value over time, live search results streaming in, a Room query that should update the UI as the database changes, progress updates, not when there's a single request/response pair. For a one-shot fetch, a plain suspend function returning a List is simpler, cheaper, and easier to test; reaching for Flow reflexively for anything async is a tell of someone who's memorized the API rather than understood the shape it's solving for.

You call someFlow.collect { } twice in two different coroutines against the same Flow built with flow { }, and each collector triggers its own separate network request even though you only wanted one. Walk me through why that happens and how you'd fix it.

What a strong answer covers: A cold flow's producer block re-runs independently for every collector, there's no shared or cached execution, so two collect calls really do mean two full runs of the flow { } body including any network call inside it. The fix is to convert the flow to hot with shareIn or stateIn and an appropriate SharingStarted policy, so there's a single upstream collection that all downstream collectors observe instead of each triggering their own.

Why does Kotlin insist that emit() only be called from the flow builder's own coroutine context, and not from, say, a background thread pool's callback? What real bug does that rule actually prevent?

What a strong answer covers: flow { } is not thread-safe by design, this is the context preservation guarantee underpinning exception transparency: emit assumes sequential, single-context execution so ordering and correct exception propagation to the collector can be guaranteed, and emitting from a foreign context throws an IllegalStateException at runtime. The real-world trigger is naively wrapping a callback-based API and calling emit() straight from the callback thread; the actual fix is callbackFlow, which is backed by a channel and explicitly supports sending values from other threads via trySend.

Lesson, quiz and flashcards for Flow: the Basics →

Flow Operators

Shaping a stream: transform, the Latest family, combine and zip, timing operators, and the ends of a flow.

For a search-as-you-type feature, you could use debounce, or you could use conflate plus manual dispatch. What's the actual behavioral difference, and why does debounce usually win for this use case?

What a strong answer covers: conflate drops intermediate values only when the collector is slower than the producer, keeping the latest value whenever the collector is ready, but it doesn't wait for any quiet period, so it can still fire a network call on every fast keystroke if the collector keeps pace. debounce actively waits for a pause of N milliseconds since the last emission before emitting anything downstream, which is the actual 'wait until the user stops typing' behavior a search box needs.

You have upstream.map { }.flowOn(Dispatchers.IO).filter { }.flowOn(Dispatchers.Default).collect { }. Walk me through which stage runs on which dispatcher and why stacking flowOn like this is a common source of confusion.

What a strong answer covers: Each flowOn only changes the context of the operators strictly upstream of it up to the previous flowOn boundary, so map runs on IO, filter runs on Default, and collect runs on whatever context the calling coroutine was already on, none of them run where you'd naively guess by just reading top to bottom. The confusion comes from assuming flowOn is like a global 'run everything below this on X' switch, when it actually only affects its own upstream segment, which is exactly why each flowOn call needs to be reasoned about independently.

You need to combine three independent data sources into one UI state and update as soon as any single source emits, without waiting for the others. Which operator fits, and what's the gotcha if one of those three sources is slow to produce its first value?

What a strong answer covers: combine is the right fit since it emits on any upstream update using the latest cached value from the others, but it won't emit anything at all until every upstream has emitted at least once, so a slow or silent source stalls the entire combined flow with no output. The fix is to seed each source with an initial value, for example via onStart { emit(default) } or by backing each source with a StateFlow that already has one, so combine can fire immediately instead of hanging.

Lesson, quiz and flashcards for Flow Operators →

Flow Context, Errors & Backpressure

Which dispatcher a flow stage runs on, where failures can be caught, and what happens when the producer outruns the collector.

A colleague wraps emit in withContext(Dispatchers.IO) inside a flow builder and gets a runtime crash. Explain the error and the correct fix.

What a strong answer covers: The error is IllegalStateException with the message that the flow invariant is violated, saying the flow was collected in one context but emission happened in another. It exists to protect context preservation, which is the guarantee that a collect lambda runs in whatever context called collect. That guarantee is what lets a ViewModel collect on Main and assign to state without wrapping anything, and it would be worthless if a flow could quietly deliver on a pool thread instead. So the library checks and throws rather than trusting the author. The fix is flowOn, which moves the producer declaratively: put the work in the builder, then apply flowOn(Dispatchers.IO) below it, and everything above that call runs on IO while the collector stays where it was. The mental model I would offer is that withContext is imperative and scoped to a block, whereas a flow needs the context decision to be part of the chain so the library can reason about it. There is one legitimate case where you genuinely need to emit from a different coroutine, for instance merging values produced by several concurrent children, and for that the answer is channelFlow rather than flow, because it is backed by a channel and therefore does not carry the restriction.

How would you decide between buffer, conflate, and collectLatest for a given screen?

What a strong answer covers: I would ask two questions: can I afford to lose values, and is the collector work cancellable. buffer is for when I cannot lose anything but want the producer and collector to overlap rather than run in lockstep. It queues, and only suspends the producer once the queue is full, so it is a throughput optimisation with the default no-loss semantics intact. Writing a stream of rows to a database while reading from the network is the shape. conflate is for when only the newest value has meaning and the collector work is cheap and atomic, like rendering a position or a sensor reading. It never suspends the producer and silently drops intermediates, but crucially it lets a slow collect block finish, so the block is never interrupted partway. collectLatest is for when the collector work is expensive and cancellable and only the newest input matters, the canonical case being a search box where each new query should abandon the in-flight request. It does not drop values, it delivers them and cancels the block still working on the previous one. The trap I would mention is fusion: conflate is literally buffer(1, DROP_OLDEST), so if someone later appends a plain buffer(50) the two merge into one configuration, the later one wins, and the conflation silently disappears. If you want a large queue that also conflates you have to say buffer(50, BufferOverflow.DROP_OLDEST) explicitly.

Where do you put catch, retry and onCompletion in a repository flow, and why does the order matter?

What a strong answer covers: Order matters because catch has a strictly upstream field of view: it sees the producer and every operator above it, and nothing below. So catch goes last, or at least below everything I want it to cover. If I put onStart above catch, then a failure inside onStart is also caught; if I put it below, it is not. retry goes above catch, because retry should get its attempts in before anything decides to give up and convert the failure into a state. I would predicate retry on the failure being transient, typically IOException, since retrying a parse failure will fail identically every time, and use retryWhen when I want the attempt index so I can back off between tries. The thing to say about retry explicitly is that it re-collects the entire upstream from scratch, so for a cold flow the producer runs again and any side effects in it repeat. onCompletion is different from both because it is not error handling: it runs on every ending, normal completion, failure, and cancellation, with a cause parameter that is null on success. That makes it the right place for symmetric cleanup like hiding a spinner, and the wrong place to try to recover. And the limit worth stating: none of these see an exception thrown inside the collect lambda itself. That needs an ordinary try/catch around the whole collect call, and the fact that catch cannot swallow it is deliberate, because otherwise a bug in your own collector would silently look like an upstream failure.

Explain Flow backpressure to someone coming from RxJava.

What a strong answer covers: The short version is that Flow does not have a backpressure API because it does not need one. In Reactive Streams the subscriber signals demand with request(n), the producer must respect it, and because a producer often cannot, you pick an overflow strategy up front: buffer, drop, latest, error. Flow replaces the whole protocol with suspension. emit is a suspending function, so if the collector is not ready, the producer suspends inside emit until it is. There is no demand signal because the producer physically cannot get ahead. That means a plain chain never drops a value and never grows an unbounded queue, and there is no strategy to configure or get wrong. What you give up is overlap: producer and collector run in lockstep, so the producer waits while the collector works even when they could proceed in parallel. buffer is the explicit opt-out that reintroduces a queue, and its onBufferOverflow parameter is where the Rx-style decision finally appears: SUSPEND keeps the no-loss default, DROP_OLDEST is exactly what conflate is, DROP_LATEST discards the incoming value. The one place the model genuinely leaks is a producer you do not own, a sensor callback or a click listener, which cannot suspend on your behalf. That is what callbackFlow with trySend and an explicit BufferOverflow is for, and it is the only situation where a Flow user has to make the same up-front choice an Rx user always makes.

Lesson, quiz and flashcards for Flow Context, Errors & Backpressure →

StateFlow & SharedFlow

Hot flows for UI state versus one-off events, and why a Channel is often the better event bus.

You use a SharedFlow with no replay for one-time navigation events, but the collector isn't actively subscribed yet, say during a process-death restore or before the UI has finished setting up its collection. Walk me through what happens to that event.

What a strong answer covers: With replay set to zero, a SharedFlow only delivers a value to collectors that are actively subscribed at the moment of emission, so if nothing is collecting yet the event is simply gone, there's no buffering to catch it later. This is precisely why SharedFlow-based navigation events need collection wired up early and reliably (repeatOnLifecycle) and why some teams accept a small replay or a different pattern for events that must survive a brief gap in collection.

Your team debates whether snackbar and toast events should be a StateFlow wrapped in an Event/consumed-flag class, or a plain SharedFlow. What are the actual trade-offs, and which would you pick?

What a strong answer covers: The Event-wrapped StateFlow approach reuses a familiar type and survives configuration changes trivially since it's just state, but it requires a manual 'has this been consumed' flag and is easy to get wrong with multiple collectors each independently consuming or missing the flag toggle. A plain SharedFlow with no replay models one-off events honestly, since it's not really state, but it demands collection be live and correctly scoped or events are silently dropped; most teams converge on SharedFlow now specifically because it matches the semantics of the problem rather than repurposing state to fake an event.

You're modeling a chat screen: the message list, a typing indicator, and a 'new message arrived, scroll to bottom' signal. How would you decide which of these becomes StateFlow versus SharedFlow, and is there a case here where neither cleanly fits?

What a strong answer covers: The message list and typing indicator are naturally StateFlow, they represent 'what is true right now' that a late subscriber should immediately see and that's fine to conflate. The scroll-to-bottom signal is closer to a SharedFlow event since replaying a stale scroll command to a late subscriber would be wrong, but it also arguably wants to correlate with a specific message, so a strong answer notes that sometimes the cleanest model is folding the signal into the state itself, for example a lastMessageId the UI diffs against, rather than forcing a separate one-off event channel for it.

Lesson, quiz and flashcards for StateFlow & SharedFlow →

Sharing Flows & Lifecycle-Safe Collection

One upstream for many collectors, and collection that stops when the screen does.

Your app re-queries the database and shows a loading spinner every time the user rotates the device. Walk me through diagnosing and fixing that.

What a strong answer covers: The symptom says the upstream is restarting on configuration change, which points at either a cold flow being collected directly or a sharing policy that does not survive the gap between the old subscriber leaving and the new one arriving. First I would check whether the ViewModel exposes a cold flow. If it does, every collector re-runs the producer, so a new Fragment after rotation genuinely means a new query. The fix is stateIn on viewModelScope. Second, if it already uses stateIn, I would look at the SharingStarted argument. WhileSubscribed() with no timeout stops the upstream the instant the last subscriber leaves, which during rotation is a real moment, so the new Fragment subscribing starts everything again. WhileSubscribed(5000) is the conventional fix precisely because the timeout is chosen to outlast a configuration change while still being far shorter than a realistic backgrounding, so rotation never actually stops the producer. Third, if the spinner still appears even though the data is cached, I would check replayExpirationMillis, because setting it to zero drops the cached value when sharing stops, so the returning subscriber gets the initial Loading value rather than last known state. The default keeps the cache indefinitely, which is what you want. Finally I would check the flow is a property rather than a function, since stateIn inside a function builds a fresh shared flow per call and reintroduces the original problem while looking correct.

Explain what actually goes wrong with launchWhenStarted, in enough detail that someone would not reach for it again.

What a strong answer covers: It suspends rather than cancels, and the distinction is the whole problem. When the lifecycle drops below STARTED, launchWhenStarted suspends the coroutine at its next suspension point. Your collect lambda stops receiving values, so from the UI side it looks like it worked. But the coroutine is still alive and the flow collection is still registered, which means the subscription never ends. Everything upstream keeps running: a Room query keeps observing the table, a location listener keeps delivering fixes and burning battery, a network poller keeps polling, for the entire time the user is in another app. Worse, if the ViewModel uses SharingStarted.WhileSubscribed, the subscriber count never reaches zero, so the timeout never fires and the shared upstream never stops either. The optimisation you thought you had is silently disabled by the thing you thought was safe. repeatOnLifecycle cancels the block on the way down and re-runs it on the way up, so the subscription genuinely ends, the subscriber count does drop, and WhileSubscribed can do its job. There is a second, subtler problem: because launchWhenStarted only suspends at suspension points, work already in progress between two suspension points continues, so it is not even a reliable pause. The rule I would leave someone with is that repeatOnLifecycle cancels the subscription so WhileSubscribed can stop the producer, and launchWhenStarted breaks the downstream half of that handshake.

When would you use shareIn rather than stateIn, and what goes wrong if you pick the wrong one?

What a strong answer covers: stateIn when there is always a meaningful current value, which is screen state: there is always some UiState, even if it is Loading. shareIn when there is not, which is events: navigation commands, snackbar messages, one-off errors. Using stateIn for events breaks in two ways that are both nasty. First, StateFlow conflates and compares with equals, so firing the same event twice in a row emits once. Show the same error snackbar twice and the second one silently never appears, which is a bug people spend hours on because the code looks obviously correct. Second, StateFlow always has a current value and replays it to new subscribers, so rotating the device re-delivers the last event and you navigate twice or show the dialog again. Using shareIn for state has the opposite problem: with replay 0 a new subscriber sees nothing until the next emission, so after a rotation the screen is blank until something changes, and the type does not carry an initial value so every collector has to invent one. The wider point I would make is that events are genuinely awkward in this model, and there is a real argument for a Channel with receiveAsFlow instead of a SharedFlow, because a Channel guarantees each event is delivered exactly once to exactly one collector rather than being dropped when nobody happens to be subscribed. Which you pick depends on whether losing an event when the screen is gone is acceptable.

Lesson, quiz and flashcards for Sharing Flows & Lifecycle-Safe Collection →

Channels & Channel-Backed Flows

Coroutine-to-coroutine queues, fan-out and fan-in, and the callbackFlow/channelFlow builders built on them.

When would you reach for a Channel rather than a SharedFlow, and what do you give up?

What a strong answer covers: The deciding question is whether the consumers should divide the work or each see all of it. A channel delivers every element to exactly one receiver, so four coroutines reading from one channel form a load-balanced work queue where a worker finishing early simply takes the next item. No dispatcher arrangement gives you that, because dispatchers distribute coroutines rather than work items. A SharedFlow broadcasts, so every collector sees every value. The second reason is delivery guarantees for one-off events. A SharedFlow with replay 0 drops an emission when nobody is subscribed, which on Android means an event fired while the screen is backgrounded is simply gone. Raising replay to 1 fixes the loss and creates re-delivery on rotation instead, so the snackbar shows twice. A channel buffers, so nothing is lost, and delivers exactly once, so nothing is replayed. What you give up is multicast. If a second collector appears, the two split the events rather than both receiving them, which is a nasty bug because it looks like events are randomly going missing. So my rule is Channel plus receiveAsFlow for one-off events consumed by a single screen, SharedFlow when several independent listeners genuinely each need to see everything, and StateFlow for state.

Explain the difference between consumeAsFlow and receiveAsFlow, and why it matters on Android.

What a strong answer covers: consumeAsFlow takes ownership of the channel. It can be collected exactly once, a second collection throws IllegalStateException, and when that collection ends, whether normally or through cancellation, it cancels the underlying channel. receiveAsFlow does not take ownership: it can be collected more than once, though the collectors share the channel rather than each getting everything, and it leaves the channel alone when collection ends. On Android that difference is the difference between a working event bus and a silently broken one. The standard pattern collects with repeatOnLifecycle, which cancels the collecting coroutine every time the screen drops below STARTED and starts it again on return. With consumeAsFlow, the first time the user backgrounds the app the collection ends, the channel is cancelled, and from then on the ViewModel can send events into a dead channel forever. The screen stops receiving events with no exception and no log line, so it presents as an intermittent bug that only reproduces if you background the app first. With receiveAsFlow the channel survives, the new collection picks up where the old one left off, and buffered events emitted while backgrounded are delivered on return, which is usually exactly what you want.

How would you build a bounded worker pool that processes items from a queue using coroutines?

What a strong answer covers: Channels give you this almost for free, because fan-out is built into the delivery semantics. I would create a channel for the work items, then launch N worker coroutines that each iterate it with a for loop. Each element is delivered to exactly one receiver, so the workers divide the queue automatically and a worker that finishes early takes the next item without any explicit scheduling. Sizing N is the design decision: CPU-bound processing wants roughly the core count, blocking IO wants more. I would produce the items with the produce builder where possible, since it returns a ReceiveChannel and closes it when the block finishes, which removes the most common bug in this shape, which is forgetting to close and leaving every worker loop waiting forever. If several producers feed one channel, closing has to happen exactly once from a coroutine that has joined all of them, because closing inside each producer means the first to finish cuts off the others. I would wrap the whole thing in a coroutineScope so the structured concurrency rules apply: cancelling the caller cancels the producers and the workers together, and the function does not return until they have all finished. Worth mentioning the alternative: if the work is a simple parallelism cap rather than a queue, Dispatchers.IO.limitedParallelism(n) or a coroutine Semaphore is simpler, because you do not need the channel at all.

What is the trap with trySend, and when is it nevertheless the right call?

What a strong answer covers: The trap is that it fails silently. Unlike send, trySend never suspends and never throws: it returns a ChannelResult, and if the channel is full or closed the value is simply not delivered. If you ignore the result, and almost everyone does, values disappear with nothing in the logs to explain it. The specific case that bites is a callbackFlow wrapper written with the default channel capacity, which is RENDEZVOUS, meaning zero buffer. On a rendezvous channel trySend only succeeds if a receiver is already suspended waiting at that exact moment, so in practice almost every send fails and the flow appears to emit nothing. The fix is to give the channel a buffer, callbackFlow(Channel.BUFFERED) or an explicit capacity with a BufferOverflow policy, and to decide deliberately whether dropping under pressure is acceptable. It is the right call when the producer genuinely cannot suspend, which is exactly the callbackFlow case: a location callback or a click listener is called by the framework on its own thread and has no coroutine to suspend. That is the whole reason trySend exists. Elsewhere, inside a coroutine, send is the correct choice because suspending is how backpressure works and silently dropping values is rarely what you meant.

Lesson, quiz and flashcards for Channels & Channel-Backed Flows →

The Java Memory Model (JMM)

The rulebook for thread memory sync: visibility, atomicity, ordering, and happens-before.

Walk me through why 'thread-safe' isn't a single yes/no property. Give an example, like ConcurrentHashMap, where an individual operation is thread-safe but a common two-step usage built on top of it is not.

What a strong answer covers: Each individual method on ConcurrentHashMap, get, put, containsKey, is internally atomic and safe to call from any thread, but a sequence like 'if (!map.containsKey(k)) map.put(k, v)' is not atomic as a whole, another thread can insert between the check and the act, producing a lost update or a duplicate. The fix is to use the compound atomic methods the class provides for exactly this, putIfAbsent or computeIfAbsent, which perform the check-then-act inside the class's own internal locking rather than composing two separate atomic calls at the app level.

A data class with var fields is constructed on one thread and its reference is published to another thread with no lock and no volatile. Is it safe for the second thread to read those fields? Walk me through your reasoning.

What a strong answer covers: No; the JMM's safe-publication guarantee for constructor writes only applies to fields that are actually final, Kotlin val, and that don't escape during construction, so with var fields the compiler and CPU are free to reorder the constructor's writes relative to the publication of the reference itself. That means the second thread can legally observe default/zero values or a partially-constructed object even though it holds a non-null reference; the fix is making the fields val, or adding real synchronization, a volatile reference or a lock, around the publication.

A teammate wraps a slow network call in a synchronized block to 'make it thread-safe.' Why is this actually a worse bug than the race it was meant to fix?

What a strong answer covers: synchronized isn't just a happens-before edge, it's mutual exclusion, so any other thread trying to enter that same monitor now blocks for the entire duration of the slow call, turning a possible data race into a guaranteed contention problem, and on the UI thread that's an ANR waiting to happen. The correct fix is to synchronize only around the actual shared-state access, not the I/O itself, and to reach for non-blocking coordination or a coroutine Mutex when the protected section needs to contain suspending work.

Lesson, quiz and flashcards for The Java Memory Model (JMM) →

Threads, Executors & Thread Pools

What coroutines run on: thread cost, ExecutorService, pool sizing, and why Future.get blocking drove everything since.

A colleague configures a ThreadPoolExecutor with core 2, max 50 and a LinkedBlockingQueue, expecting it to scale under load. What do you tell them?

What a strong answer covers: That they have built a two-thread pool with an unbounded queue attached, and the maximum of 50 will never be reached. The submission algorithm goes: create a thread if below core size, otherwise try to enqueue, and only create threads beyond the core size if the queue refuses the task. A LinkedBlockingQueue with no capacity argument is unbounded, so it never refuses anything, so the growth step is unreachable. Under sustained load they get two threads working and a queue growing until the heap is exhausted, which fails as an OutOfMemoryError far from the actual cause rather than as an obvious saturation. The fix is to bound the queue, which makes the maximum meaningful and forces a decision about what happens when it fills. That decision is the rejection policy, and I would push for CallerRunsPolicy in most Android cases because running the task on the submitting thread slows the producer down, which is a natural brake, rather than throwing or silently dropping work. I would also ask whether they need a custom pool at all. If the goal is a concurrency ceiling for a subsystem, Dispatchers.IO.limitedParallelism(n) does it without owning any threads or needing a shutdown, and if it is genuinely CPU work then Dispatchers.Default already caps at the core count for exactly the reason they were reaching for a maximum.

Trace the line from Future to coroutines. Why did each step happen?

What a strong answer covers: Future arrived with the executor framework and solved submitting work to a pool and getting a result back. Its flaw is that get blocks: there is no completion callback, so if you want to do something when the value arrives you have to occupy a thread waiting for it. Composing two async calls therefore burns a pooled thread doing nothing, which is precisely what the pool existed to prevent. Callbacks were the first workaround, and they compose badly: nesting two or three produces the shape everyone calls callback hell, error handling has no natural place because you cannot try/catch across a callback boundary, and cancellation has to be threaded through by hand. CompletableFuture fixed the composition properly, giving you thenApply and thenCompose, which are map and flatMap, plus exceptionally and allOf. It is genuinely non-blocking and genuinely composable. What it did not fix is readability: a thenCompose chain is still callbacks with better syntax, the code reads inside out, error handling splits across three different methods, and cancellation remains manual so cancelling the returned future does not reliably stop the work feeding it. Coroutines closed that last gap. Suspension gives you CompletableFuture non-blocking behaviour with sequential syntax, ordinary try/catch, and structured cancellation that propagates to children automatically. The one-sentence version I would give: each step kept the non-blocking property and improved how the code reads, and coroutines are the point where async code finally looks like the blocking code it replaced.

How do the coroutine dispatchers relate to what you know about thread pools, and when would you still create a pool yourself?

What a strong answer covers: The dispatchers are thread pools with the sizing decisions already made, using exactly the standard formulas. Dispatchers.Default is capped at the core count because CPU-bound work cannot exceed the cores available and more threads only add context switching. Dispatchers.IO is capped at 64 because IO threads spend their time parked consuming no CPU, so the core-count limit would throttle throughput for nothing. They share one underlying pool, with IO permitted to grow it past the Default limit. That is the same reasoning you would apply by hand, so the honest answer to when to create a pool yourself is: rarely. If the goal is a concurrency ceiling, limitedParallelism(n) gives it without allocating anything, because it borrows from the parent pool and refuses to dispatch more than n at once, so there is nothing to close and no idle threads. If the goal is serialising access to shared state, limitedParallelism(1) gives thread confinement without owning a thread. The genuine cases left are when you need specific, stable, application-owned threads: a native library that insists on being called from one particular thread, a legacy ExecutorService you are wrapping with asCoroutineDispatcher, or a thread that needs a Looper, which is HandlerThread rather than an executor. In all of those you own the lifecycle, so the dispatcher has to be closed, and forgetting is a thread leak that lasts the life of the process.

Lesson, quiz and flashcards for Threads, Executors & Thread Pools →

Locks, Atomics & Coordination

The full toolkit for shared mutable state, from immutability through atomics to locks, and the ways locks fail.

An interviewer asks how you would make a shared counter thread-safe. Walk me through your answer.

What a strong answer covers: I would start by asking what the counter is for, because the answer changes. If it is UI state, it is already a MutableStateFlow and the answer is update { it + 1 }, which is a compare-and-set loop rather than a lock and integrates with the rest of the screen. If it is a plain internal counter, AtomicInteger with incrementAndGet is the right rung: one value, one indivisible read-modify-write, no blocking, no lock to forget. I would explicitly rule out the two things people reach for by mistake. Marking it @Volatile does not work, because volatile gives visibility and ordering but never atomicity, and count++ is three operations, so increments are still lost. And synchronized works but is heavier than necessary for a single value, and brings deadlock risk if the block ever grows. Then I would qualify it, because this is where the interesting part is. Atomics are optimistic: CAS retries when another thread wins the race, so under heavy contention from many threads you get a lot of spinning and discarded work, and a lock can actually be faster. If this is a metrics counter written from many threads and read rarely, LongAdder is the right answer instead, because it keeps per-thread cells and sums them on read, trading an exact instantaneous value for far less contention. And if the counter has to change atomically alongside another field, no atomic helps, because atomicity is per value: that needs a Mutex or a synchronized block covering both.

How would you limit an app to five concurrent image uploads, and why not just use a dispatcher?

What a strong answer covers: Semaphore(5) from kotlinx.coroutines, with each upload wrapped in withPermit. I would launch all hundred uploads as coroutines, which is cheap, and let the semaphore hold back all but five. The reason to prefer that over Dispatchers.IO.limitedParallelism(5) is what each one actually bounds. limitedParallelism caps how many coroutines are executing at once, meaning how many are occupying a thread. But an upload spends nearly all its time suspended on the network, occupying no thread at all, so it does not count against that limit, and you can easily end up with far more than five requests genuinely in flight. A semaphore bounds how many coroutines are inside the section regardless of whether they are running or suspended, which is the property that matches the requirement. A Mutex would be wrong for a different reason: it is a semaphore with one permit, so it would serialise the uploads to one at a time. I would mention two refinements. The coroutine Semaphore suspends rather than blocking, so waiting coroutines cost nothing, which is what makes launching a hundred reasonable. And if the real goal is to be polite to a specific server rather than to the device, OkHttp already has a per-host dispatcher limit, so the right fix might be there rather than in application code.

You are reviewing code that holds a synchronized lock while calling a suspend function. What do you say?

What a strong answer covers: That it is broken, not merely inelegant, and the reason is that a monitor is owned by the thread that acquired it. When the coroutine suspends inside that block, it can resume on a completely different thread from the dispatcher pool, and then the exit of the synchronized block attempts to release a monitor from a thread that never acquired it. Depending on where exactly it suspends you get an IllegalMonitorStateException, or a lock that is never released and eventually deadlocks everything else that needs it. There is a second problem even when it happens to work: holding a monitor blocks the thread, so a coroutine sitting in a critical section around a network call ties up a pool thread for the entire duration, which is exactly what coroutines exist to avoid, and with the IO dispatcher capped at 64 threads you can starve the whole application. The fix is a coroutine Mutex with withLock, which is coroutine-aware: it suspends rather than blocking, so other coroutines can use the thread while one holds it, and ownership is tied to the coroutine rather than the thread so resuming elsewhere is fine. The one thing to flag when making that change is that Mutex is deliberately not reentrant, unlike a monitor. If the code inside the critical section calls back into another function that also takes the same Mutex, it will deadlock, and that is by design because reentrancy hides bugs where code accidentally depends on being called under a lock.

Lesson, quiz and flashcards for Locks, Atomics & Coordination →

HashMap Internals & Collection Choice

HashMap internals, why sharing one breaks, and choosing the right collection on Android.

Walk me through what you'd actually observe, symptom-wise in production, when two threads trigger a HashMap resize at the same time. Not the mechanism, the actual bug report you'd get.

What a strong answer covers: The classic real-world symptom is a thread pegged at 100 percent CPU forever, historically caused by a circular linked list forming inside a bucket during concurrent resize on pre-Java-8 HashMap, distinguishable from a deadlock because the thread is spinning, not blocked, which a thread dump makes obvious. More generally you can also see silently lost or duplicated entries rather than a hang; either way the practical tell is that this shows up under load, not in a unit test, which is why it's such a nasty class of bug to catch before production.

You need a cache that's read far more often than it's written, shared across many threads. Walk me through how you'd choose between ConcurrentHashMap, a synchronized HashMap, and a ReentrantReadWriteLock-guarded HashMap.

What a strong answer covers: ConcurrentHashMap is the default choice for a generic concurrent map since its lock striping and CAS-based operations give near-lock-free reads and fine-grained writes, comfortably beating a synchronized HashMap which serializes even read-only access and kills throughput under read-heavy load. ReentrantReadWriteLock is worth the extra complexity mainly when you need atomicity across a compound operation spanning multiple keys or fields that ConcurrentHashMap's per-key atomic methods can't express on their own, otherwise it's added complexity for little real gain over ConcurrentHashMap.

Your cache class exposes get(key) and put(key, value), each individually synchronized on the same lock, but production shows a stale-then-overwritten value when two threads run get-compute-if-null-put logic concurrently. What's actually wrong, and how do you fix it without giving up concurrency?

What a strong answer covers: Each low-level call is individually atomic, but the read-compute-write sequence composed across two separate synchronized method calls is not atomic as a whole, so another thread can slip in between the get and the put and its write gets clobbered. The real fix is to either hold one lock across the entire read-compute-write sequence, accepting reduced concurrency, or better, switch to ConcurrentHashMap.computeIfAbsent, which performs the whole get-or-create atomically per key without any app-level locking at all.

Lesson, quiz and flashcards for HashMap Internals & Collection Choice →

ConcurrentHashMap

ConcurrentHashMap in depth: lock-free reads, CAS + bin locks, atomic compound ops, and its traps.

In your own words, walk me through how ConcurrentHashMap achieves high write throughput without a single global lock. What actually gets locked, and why doesn't that create the same bottleneck as synchronizing on the whole map?

What a strong answer covers: Writes CAS-install the first node into an empty bin, and once a bin already has a node, only that bin's head node is synchronized on, not the whole table; reads never lock because node value/next fields are volatile, so a writer's lock release happens-before a reader's subsequent read. This gives fine-grained, per-bin locking instead of one lock guarding the entire table, so unrelated keys hashing to different bins never contend with each other.

You're building a shared cache using ConcurrentHashMap and computeIfAbsent to load-once-per-key. What's the trade-off here versus something like a dedicated cache library, and where does computeIfAbsent's locking start to hurt you?

What a strong answer covers: computeIfAbsent holds the target bin's lock for the duration of the lambda, so if the load is slow (a network call, disk read), it blocks every other put or compute that happens to hash-collide into that same bin, not just operations on that one key. A dedicated cache (or a per-key coroutine mutex with its own in-flight tracking) decouples load-in-flight coordination from the map's internal locking and adds features ConcurrentHashMap doesn't have, like TTL and size-based eviction; CHM plus computeIfAbsent is only the right call when the load itself is cheap and fast.

A junior engineer says 'ConcurrentHashMap is thread-safe so I don't need to worry about synchronization in my code.' What's wrong with that statement, and can you give a concrete example of a race that still slips through despite using ConcurrentHashMap throughout?

What a strong answer covers: ConcurrentHashMap only guarantees that individual operations are atomic; it says nothing about compound sequences spanning multiple calls, or about invariants spanning more than one key. A classic example is incrementing a counter via a plain read-then-put pair (val v = map[key]; map[key] = v + 1), which races even though each call is individually atomic, or trying to keep two related maps in sync since CHM can't atomically update both at once. The fix is the atomic compound operations (merge, compute) for single-key updates, and explicit locking or a different structure entirely for cross-key invariants.

Lesson, quiz and flashcards for ConcurrentHashMap →

ANDROID

Activity Lifecycle

Drills the ordered create/start/resume/pause/stop/destroy callbacks, state saving, and multi-window and launch-mode lifecycle effects.

Walk me through, in your own words, why doing heavy work like a database write in onPause() is considered bad practice, even though it seems like the natural 'about to leave' hook.

What a strong answer covers: onPause is meant to be fast because the incoming activity's onResume() is synchronously blocked waiting for the current one to finish onPause, so a slow onPause causes visible jank in the transition or can even trigger an ANR on the activity coming to the foreground. On modern Android targeting API 28+, onSaveInstanceState is already called before onStop, giving a natural place for cheap state capture, so disk or database writes belong in onStop, or better, off the main thread entirely via a repository call scoped to the ViewModel or a background job.

When would you actually reach for a non-standard launch mode like singleTask or singleInstance instead of the default standard, and what goes wrong if you overuse them?

What a strong answer covers: singleTask and singleInstance solve 'there must be exactly one instance of this screen in the whole task or back stack' situations, like a hub screen other apps deep-link into, or a dedicated system-level UI such as a dialer; they're not for ordinary screens. Overusing them causes surprising back-stack behavior, existing instances receiving onNewIntent instead of a fresh onCreate, activities getting reparented into a different task, expected data being lost, which is hard to reason about and test, so the default standard mode with explicit intent flags per launch is usually the safer choice.

Two senior engineers disagree: one says lifecycle-aware components like ViewModel and LiveData make manual onPause/onStop bookkeeping obsolete, the other says you still need to manage raw callbacks for things like camera or sensor release. Who's right, and where's the line?

What a strong answer covers: Both are partially right. ViewModel, LiveData and Flow remove the need to manually wire most UI-state save and restore and observer subscribe or unsubscribe logic across pause and resume, but the raw lifecycle callbacks are still the correct place for anything tied to a system resource or piece of hardware that Android itself scopes to visibility or foreground state, such as releasing a Camera2 session in onPause, unregistering sensor listeners in onStop, or reacting to onTopResumedActivityChanged in multi-window. Lifecycle-aware components abstract UI state, they don't abstract device resource ownership.

Lesson, quiz and flashcards for Activity Lifecycle →

Fragment Lifecycle

Drills the fragment vs view lifecycle split, viewLifecycleOwner, leak-safe observation, and FragmentManager back stack.

Why does Fragment even have two separate lifecycles (the fragment and its view) instead of just one, like Activity has? What real problem does that split solve?

What a strong answer covers: A fragment can stay added to a FragmentManager with its non-view state intact while its view is destroyed and recreated, for example when it's pushed onto the back stack, held offscreen by ViewPager2, or torn down and rebuilt across a configuration change, without the fragment itself being destroyed. The fragment object represents identity and non-view state, the view lifecycle represents whether the inflated hierarchy is currently valid, and Android split them because a fragment can outlive its view but a view can never outlive its fragment. A weak answer just recites onCreateView/onDestroyView without explaining why the split needed to exist.

When would you choose to nest fragments (child fragments) instead of just using multiple top-level fragments in an Activity, and what's a common way nested fragments cause bugs?

What a strong answer covers: Child fragments make sense when a piece of UI genuinely owns and manages its own sub-navigation or sub-state that's meaningless outside its parent, like a tab strip embedded in one screen or a fragment inside a ViewPager2 page. A common bug is using the fragment's parent FragmentManager or the activity's FragmentManager for a transaction that should be a child transaction, which causes id collisions, back-stack confusion, or duplicated transactions when the parent itself gets recreated; the key judgement call is scoping the child strictly to the parent's view lifecycle, not its full lifecycle.

You inherit a codebase where fragments hold view references as class-level vars set in onCreateView and never cleared. What's actually going wrong, and how would you verify and fix it?

What a strong answer covers: Holding the inflated view, or anything view-derived like a RecyclerView adapter or a ViewBinding instance, past onDestroyView leaks the entire view hierarchy because the fragment object survives (on the back stack, or while retained) while its view is torn down and later reinflated, leaving the old view tree pinned in memory. You'd verify it by checking whether onCreateView runs again without a matching field reset, using LeakCanary or simple logging, and fix it by nulling the reference in onDestroyView, typically via a nullable backing property behind ViewBinding, and tying anything view-scoped to viewLifecycleOwner rather than the fragment itself.

Lesson, quiz and flashcards for Fragment Lifecycle →

Config Changes & Process Death

How Android recreates UI on config changes and process death, and what state survives each.

Walk me through the practical difference between a configuration change and system-initiated process death from the app's point of view. Why does conflating the two lead to bugs?

What a strong answer covers: A configuration change destroys and recreates the same process and Activity instance in place, quickly and in memory, and ViewModel survives it because it's retained by the ViewModelStore; process death is the OS killing the whole process to reclaim memory while the user is elsewhere, so everything in memory including the ViewModel is gone, and only what was explicitly serialized via onSaveInstanceState/SavedStateHandle or persisted to disk comes back. Conflating the two causes bugs like assuming ViewModel state survives indefinite backgrounding, or treating a rotation test as equivalent to a real process-death test when rotation never exercises the Bundle serialization path at all.

A junior dev adds android:configChanges="orientation|screenSize" to 'fix' a bug where a video player restarts on rotation. What's the real trade-off they're making, and when, if ever, would you actually recommend that flag?

What a strong answer covers: Setting configChanges tells the system not to recreate the Activity for that config change at all, which means the app now has to manually handle every affected UI concern itself in onConfigurationChanged, layout resources, string resources for locale changes and so on, reintroducing exactly the bug class the recreate-and-restore model exists to prevent; it also does nothing for process death, since that's a full kill, not a config change. This flag is basically never the right general fix for state resetting on rotation, that's what ViewModel is for; the rare legitimate use is something like an active video or camera surface where a full teardown and recreate is genuinely expensive and disruptive and the app can cheaply reposition itself instead.

You've got a screen with a large in-memory list backed by network results. Where do you draw the line between what lives in ViewModel/SavedStateHandle versus what you just refetch or reload from a local cache after process death, and why?

What a strong answer covers: Keep in ViewModel, which survives config change only, anything cheap to hold for the current session; put a small identifier or minimal state in SavedStateHandle, which survives process death, that's enough to redrive a refetch, like a query string, a scroll position, or a selected id, not the fetched list itself. Rely on re-fetching from network or a local Room/DataStore cache to reconstruct the list after process death, since stuffing the whole list into SavedStateHandle would blow past the Bundle's roughly 1MB TransactionTooLargeException ceiling and forces main-thread serialization on every save; a weak answer either serializes the whole list or drops the query entirely and returns the user to a blank state.

Lesson, quiz and flashcards for Config Changes & Process Death →

Intents & Deep Linking

Tests how Android components communicate via intents, filters, PendingIntent, and verified deep links.

Walk me through why Android makes a hard distinction between explicit and implicit intents, rather than just always resolving by action and data like a URL router. What's the security reasoning?

What a strong answer covers: Implicit intents let the OS or other apps resolve 'any app that can handle sharing text' without your app knowing who will handle it, which is powerful for interop but dangerous for intra-app or sensitive navigation, since another app could register a matching intent-filter and intercept the intent. Explicit intents pin the exact component so there's no ambiguity or interception risk, which is why Android requires them to start your own bound Service and recommends them inside a PendingIntent; the reasoning is implicit means 'I don't know or care who handles this, and that's the point', explicit means 'this must go to exactly this component, full stop'.

You're implementing deep linking for a checkout flow that needs to be secure, like a payment confirmation link sent by email. Would you use a plain intent-filter deep link or Android App Links, and why does that choice matter here specifically?

What a strong answer covers: A plain, unverified deep link via a custom scheme or an unverified http/https intent-filter can be claimed by any app declaring a matching filter, leading to a disambiguation dialog or, worse, a malicious app silently registering the same scheme to intercept the flow. For a payment confirmation link you want Android App Links with autoVerify and a correctly served assetlinks.json on the domain, because that cryptographically ties the link to a domain you actually control via Digital Asset Links, so Android skips the disambiguator and only your verified app can handle it, and an attacker can't claim it without controlling your DNS or hosting.

A PendingIntent handed to a notification, widget, or AlarmManager ends up being triggered by a completely different process, not your own app's code. Walk me through the security implications, and what mistakes commonly leave a PendingIntent exploitable.

What a strong answer covers: A PendingIntent lets another process, system UI for notifications, a launcher for widgets, AlarmManager, execute your intent later effectively as your app, at whatever privilege the wrapped intent carries; if it wraps an implicit intent without FLAG_IMMUTABLE, a malicious holder can call fillIn to mutate the extras, action, or data before it fires, redirecting a confirm action or exfiltrating data through crafted extras. The common mistakes are forgetting FLAG_IMMUTABLE, still a real bug below API 31 where it isn't enforced, using an implicit inner intent instead of an explicit one, and putting sensitive data directly in extras rather than looking it up server-side or by id when the PendingIntent actually fires.

Lesson, quiz and flashcards for Intents & Deep Linking →

Services & Background Work

Choosing and using Services, foreground services, and WorkManager for correct background execution.

Walk me through how you'd decide between a started Service, a bound Service, and WorkManager for a given feature. What questions do you ask yourself?

What a strong answer covers: The decision tree is roughly: if the work is deferrable, can tolerate delay, and needs guarantees like surviving reboot or requires constraints such as network or charging, that's WorkManager; if something in-process or in another app needs a live, synchronous connection to call into, like a UI binding to a playback engine, that's a bound Service; if work must start now and keep running independent of any UI, visibly to the user as ongoing, like music or navigation, that's a Service promoted to foreground via startForeground(). Anything that used to be a plain background Service kicked off with startService() for arbitrary async work is almost always wrong today because of Android 8+ background execution limits, and should be WorkManager or a coroutine scope instead.

Foreground services require a persistent notification the user can see. What problem is Android actually solving by forcing that, and what's the tension between that requirement and good UX?

What a strong answer covers: The notification guarantees the user always has visibility into, and a one-tap way to stop, anything running with elevated priority consuming battery or resources while the app isn't in the foreground, which directly counters the abuse pattern of apps silently running indefinitely in the background, like draining battery or tracking location unnoticed. The UX tension is real, for a music player it's an expected, useful notification, but for a rarely-visible background task it can feel like clutter; that tension is exactly why the system nudges you toward WorkManager, which needs no persistent notification, whenever the work doesn't actually need to be foreground-visible.

You've got a music player app. Walk me through the architecture: what's the Service actually for, how does it communicate with the Activity/UI, and what changes if the user swipes the app away from Recents while music is playing?

What a strong answer covers: A foreground Service owns the player instance and the actual playback state so it survives independent of any particular Activity being on screen; the UI communicates with it through a MediaSession/MediaController rather than owning playback itself, which also lets external surfaces like the lock screen or Bluetooth controls drive playback without their own bound connection. If the user swipes the app away, Android can kill the whole process including the Service unless it's a properly declared foreground service with the right foregroundServiceType and, depending on manifest configuration, an explicit opt-out via android:stopWithTask="false"; a strong answer names that combination as what actually keeps music playing after the task is swiped away.

Lesson, quiz and flashcards for Services & Background Work →

Content Providers & Broadcasts

Sharing data across app boundaries and reacting to system or app-wide broadcast events.

In your own words, walk me through when you'd actually reach for a ContentProvider versus just exposing a Room database or a repository class directly. What's the real reason ContentProvider still exists?

What a strong answer covers: ContentProvider exists specifically for cross-process or cross-app data sharing, it's the only first-class IPC-safe way to expose structured data via a stable content:// URI contract, support cursor-based paging, participate in the per-URI permission model, and back things like search suggestions, widgets, or sync adapters without either side depending on the other's concrete classes. If the data never leaves your own process, a Room database or repository behind a ViewModel is simpler and avoids the IPC and serialization overhead a ContentProvider always pays; a strong answer explicitly says the only real reason to reach for it today is cross-app sharing, not general data access.

Ordered broadcasts let a receiver call abortBroadcast() to stop propagation. What's a legitimate use case for that, and why is it risky to depend on broadcast ordering or priority in production code?

What a strong answer covers: A legitimate use is a chain of receivers cooperatively deciding whether an event should proceed, like a security or parental-control receiver inspecting an incoming SMS and aborting further delivery, or a receiver modifying result data before a lower-priority receiver sees it. It's risky in production because priority ordering between receivers declared by different apps is effectively unenforceable and unpredictable at scale, you don't control what other apps declare, and Android has steadily restricted implicit broadcasts anyway; ordered broadcasts are really only safe to rely on within your own app's receivers where you control both ends.

You're reviewing a PR where a BroadcastReceiver does a network call directly inside onReceive(). What's wrong with this, and what would you tell them to do instead, given the constraints on receivers?

What a strong answer covers: onReceive() runs on the main thread with a strict lifetime, the system can kill the receiver and there's no guarantee of more than a few seconds before it's treated like an ANR, so a synchronous network call blocks the main thread and will very likely ANR or get killed mid-request before it returns. The fix is to keep onReceive() itself tiny: enqueue the real work to WorkManager if it can be deferred, or call goAsync() to get a PendingResult with roughly 10 extra seconds if the work is short enough to finish inline, handing off to a coroutine or thread from there and explicitly calling PendingResult.finish() when done.

Lesson, quiz and flashcards for Content Providers & Broadcasts →

Context, Application & Manifest

Picking the right Context, avoiding leaks, and wiring up startup and the manifest.

Walk me through, concretely, what actually leaks when you hold an Activity Context past its lifecycle. What's really being kept alive in memory, step by step?

What a strong answer covers: Holding an Activity Context, directly or indirectly through a View, Drawable, Adapter, or listener that references it, keeps that Activity object reachable from a GC root; because the Activity holds its entire View hierarchy, and each View can hold Bitmaps, listeners, and child views, the whole tree stays resident even after the Activity is destroyed and a new instance is created on rotation. The old Activity plus its full View tree stays pinned in memory for the lifetime of whatever long-lived object, a singleton, a static field, an event bus subscription, is holding the reference; a strong answer traces this chain rather than just saying it leaks memory.

You're designing a DI setup for a new app. Walk me through your rule for which Context each provided dependency should receive, and where in the graph you'd catch it if someone accidentally injected an Activity context into a singleton-scoped class.

What a strong answer covers: The rule is to scope the Context to the scope of the consumer: anything with application or singleton scope, a Retrofit client, a repository, an analytics wrapper, should only ever receive the application context since it must outlive any single screen; anything that genuinely needs an Activity context, like inflating a themed layout or showing a Dialog, should be scoped to that Activity or Fragment and never escape into a broader-scoped class. In a framework like Hilt, application context and activity context are distinct qualifiers, so injecting an activity-scoped context into a class installed in the singleton component is a compile-time graph error rather than a runtime leak, which is a concrete advantage of DI over passing Context around manually.

Application.onCreate() is often treated as 'the place to init everything.' What's actually wrong with that instinct at scale, and how does the App Startup library's design address it without you manually ordering things by hand?

What a strong answer covers: Application.onCreate() runs synchronously on the main thread before the first Activity is even created, so cramming every SDK init in there directly delays time to first frame, and doing it manually requires hand-ordering everything, since SDK B might need SDK A initialized first, which becomes unmaintainable as the number of libraries grows. App Startup addresses this by having each Initializer explicitly declare its own dependencies(), so the library builds a dependency graph and topologically sorts initialization inside a single ContentProvider, which itself runs before Application.onCreate and piggybacks on an otherwise-wasted phase, and it lets you disable auto-run initializers to trigger them lazily instead without losing the dependency guarantees when you do.

Lesson, quiz and flashcards for Context, Application & Manifest →

Looper, Handler & Threading

Drills how the main-thread message loop dispatches work and what triggers ANRs.

Walk me through what's actually happening under the hood when you call handler.post {} from a background thread and that code runs on the UI thread a moment later. Why can't you just mutate a View directly from that background thread instead?

What a strong answer covers: The post enqueues a Message wrapping the Runnable, targeted at the Handler, onto the main thread's MessageQueue; Looper.loop() on the main thread pumps that queue and dispatches the message back to the Handler's handleMessage/callback on the thread the Looper belongs to. The View system has no locking and assumes single-threaded access, so mutating a View from another thread risks races and inconsistent state, which is why everything funnels through the main thread's own queue instead of touching views directly.

When would you actually reach for a HandlerThread with a Handler over coroutines, in a codebase that's otherwise fully on kotlinx.coroutines?

What a strong answer covers: Almost never for new application logic, since Dispatchers.Default/IO give you structured concurrency on a shared pool. Legitimate cases are interfacing with legacy Handler-based APIs that require a single dedicated thread with its own Looper and strict FIFO message ordering, like MediaCodec callbacks or a SurfaceView rendering thread, where the API contract is built around a persistent thread identity rather than a rotating pool.

Your app has a small but persistent rate of ANRs in production that you can't reproduce locally, and StrictMode isn't catching anything. How would you narrow down the root cause?

What a strong answer covers: Pull the actual ANR traces from Play Console vitals or a Perfetto/system trace to see what the main thread's stack was doing at the moment of the freeze, since the blocker is often not obvious application code but a slow Binder call to another process, ContentProvider initialization, or disk I/O triggered indirectly. A common trap is assuming ANRs always mean your code is looping; GC pauses stealing scheduler time and lock contention with a background thread holding a lock the main thread needs are just as common and won't show up in StrictMode's disk/network checks.

Lesson, quiz and flashcards for Looper, Handler & Threading →

ART Runtime

How ART compiles and runs apps: AOT, JIT, profiles, dex, and GC.

Explain why a freshly installed app feels slower on its first few launches than after a week of use, tying it to what ART is doing behind the scenes.

What a strong answer covers: Early launches run mostly interpreted or freshly-JITed bytecode because dex2oat hasn't AOT-compiled the app's hot methods yet; ART builds a profile of frequently-executed methods across launches, and background dexopt (using the speed-profile filter) only compiles those hot paths to native code once the device is idle and charging. The trap is assuming JIT catches up quickly on its own; in practice that idle-and-charging window can take days, which is exactly the gap Baseline Profiles are meant to close.

You're deciding whether it's worth the engineering effort to ship a Baseline Profile, given ART already does profile-guided AOT compilation on-device over time. What's the actual argument for doing it anyway?

What a strong answer covers: On-device profiling only kicks in after enough usage plus idle-and-charging windows, so day-one users, people who reinstall or update frequently, or anyone who rarely leaves their phone idle-charging never benefit from it in time. A Baseline Profile ships pre-computed hot-path info at install, so even the very first cold start is fast; the trade-off is that it only helps if it's kept current with the app's actual critical user journeys, and it adds build complexity most teams underinvest in maintaining.

Your app hits OutOfMemoryError under moderate use despite the Java heap looking reasonable in the profiler. How does ART's concurrent GC model inform where you'd look, and what's a common misconception about 'concurrent' GC here?

What a strong answer covers: The misconception is that 'concurrent' means pause-free; ART's concurrent copying collector still needs brief stop-the-world pauses for things like thread suspension during root marking, and object-moving GC has real CPU and memory overhead of its own. A senior engineer should also look past the Java heap entirely, since native and off-heap allocations like bitmaps and direct ByteBuffers, plus per-app heap limits that vary by device and whether largeHeap is set, can drive OOM even when the visible Java heap graph looks fine.

Lesson, quiz and flashcards for ART Runtime →

Runtime Permissions

Requesting, checking, and gracefully handling Android runtime permissions and modern privacy rules.

Walk me through the full decision tree your app should follow the first time a feature needs camera access, from checking whether it already has permission through to the user denying it twice.

What a strong answer covers: Start with checkSelfPermission to avoid an unnecessary prompt if already granted; if not granted, consult shouldShowRequestPermissionRationale to decide whether to show explanatory UI before the system dialog; launch the request via registerForActivityResult(RequestPermission()); on denial, check shouldShowRequestPermissionRationale again, and if it now returns false, treat that as permanent denial since the system won't show its own dialog again, and route the user to the app's Settings screen via ACTION_APPLICATION_DETAILS_SETTINGS instead of re-prompting.

A PM wants the app to request location permission the moment the user opens the app so it's 'out of the way.' How would you push back, and what's the actual best practice?

What a strong answer covers: Best practice is contextual, just-in-time requesting right before the feature that needs it, paired with rationale UI explaining the benefit, because grant rates are far higher when users understand why in the moment, and Play policy expects permissions to be justified by immediate use. There's also a technical reason to avoid front-loading: Android 11+ auto-resets permissions the user hasn't exercised, so asking at launch for a feature they haven't touched yet risks the grant being revoked before it's ever used.

Design the UX for a photo-editing feature across three outcomes on Android 14: the user grants full photo access, grants only 'Selected photos' partial access, or denies outright. What are the engineering implications of each path?

What a strong answer covers: Full access proceeds normally; partial access (READ_MEDIA_VISUAL_USER_SELECTED) means the app can only see the specific photos the user picked, so if they need a different one later the app should re-invoke the system picker to let them add more rather than assuming visibility into the whole library, and should track this as tri-state rather than a boolean granted/denied. Denied outright should fall back to the Photo Picker API or SAF's per-item selection, which need no runtime permission at all; a well-designed feature might avoid requesting READ_MEDIA_IMAGES entirely by defaulting to the picker.

Lesson, quiz and flashcards for Runtime Permissions →

Resources & Configuration

Resource qualifiers, best-match selection, dp/sp units, drawable densities, and adaptive layouts.

Explain why Android's resource system needs a 'best match' algorithm at all instead of just doing an exact string match on the directory name.

What a strong answer covers: A device's real configuration, density, locale, orientation, API level, night mode, and more, rarely matches any single folder's qualifier string exactly, and multiple qualifiers combine in real devices. The algorithm eliminates qualifiers the device can't satisfy, then picks the most specific remaining match according to a fixed precedence order, falling back toward the unqualified default folder, which is what gives graceful degradation across the huge combinatorial space of devices instead of a crash.

You're building UI that should look meaningfully different on a foldable's inner screen versus a phone. Would you reach for sw600dp resource qualifiers or Compose's WindowSizeClass, and why?

What a strong answer covers: sw600dp-style qualifiers are computed once from smallest width, typically at process start or activity recreation, and don't reactively update when a foldable unfolds or a window is resized in multi-window without a full recreation; WindowSizeClass recomputes from the actual current window size as it changes. For adaptive or resizable-window scenarios, WindowSizeClass is the modern recommended approach, while qualifier directories are still the right tool for genuinely static resources like drawables and strings, not for dynamic layout branching in Compose.

A designer hands you assets at 1x, 2x, and 3x. How do you decide which Android density buckets to actually ship, and what's the risk of just dumping every bucket you're given into the project?

What a strong answer covers: Map those scale factors onto Android's mdpi baseline, roughly 1x to mdpi, 2x to xhdpi, 3x to xxhdpi, and rely on Android's automatic scaling for buckets you don't ship, since the system scales the nearest available density rather than failing on a missing one; shipping every bucket you're handed is pure APK bloat, not a correctness requirement. For simple iconography, vector drawables in an anydpi bucket sidestep density buckets entirely, reserving raster assets for photographic content where vectors would look wrong or cost too much to render.

Lesson, quiz and flashcards for Resources & Configuration →

UI

Compose Mental Model & Phases

How Compose turns state into UI across composition, layout, and drawing phases.

In plain terms, what is recomposition, and what's the most common misconception junior engineers have about when and how often it runs?

What a strong answer covers: Recomposition is Compose re-invoking the composable functions whose tracked inputs changed, to produce an updated UI description; it is not a full-screen redraw or teardown, since Compose skips composables whose inputs are stable and unchanged. The common misconception is treating it like an expensive View invalidate that runs once per state change in a predictable top-to-bottom order, when in reality functions can run zero, one, or many times, in any order, and possibly for reasons unrelated to the specific state you changed.

A composable is recomposing far more than expected, and a teammate suggests wrapping the whole subtree in remember. Why is that often the wrong first move, and what would you actually check first?

What a strong answer covers: remember only caches a computed value across recomposition, it does nothing to stop recomposition from being triggered in the first place, so wrapping a whole subtree is usually a band-aid that hides the real cause. The first thing to check is whether the composable's parameters are actually inferred stable by the compiler, since Compose skips based on stability plus equality, not memoization; unstable collections from Java interop, mutable public vars, or lambdas that recreate an unstable capture are the usual culprits, and compose compiler reports or Layout Inspector's recomposition counts should be used to find that before reaching for remember.

Two composables look identical in code, but one recomposes needlessly on every parent recompose and the other doesn't, and the difference turns out to be how a lambda parameter is created. Walk me through why.

What a strong answer covers: If the lambda captures values from the enclosing scope, like an inline onClick = { viewModel.doThing(item) }, a new lambda instance is allocated on every recomposition unless the compiler can prove the captured values are stable, and even then older compiler behavior can treat the lambda's identity as changed, causing the child that receives it to recompose. Using a stable reference, a remembered lambda, or ensuring captured variables are themselves stable lets Compose treat the lambda as unchanged input and skip the child, tying back to the rule that stability is evaluated per parameter and a single unstable parameter defeats skipping for that call.

Lesson, quiz and flashcards for Compose Mental Model & Phases →

Compose State & Hoisting

Drills observable state, remember, hoisting, unidirectional data flow, and state ownership in Compose.

Reviewing a PR, you see a composable holding its own mutableStateOf instead of taking value and onValueChange as parameters. What questions do you ask before deciding whether that's actually a problem?

What a strong answer covers: Ask who else needs to read or react to this state, whether it needs to survive only recomposition, or also configuration change and process death, and whether it represents app or business data versus purely transient UI state like whether a tooltip is expanded. Genuinely local UI-only state is fine to keep internal, but anything another composable, a ViewModel, or a test needs to observe should be hoisted at least as high as every reader and writer; the real red flag is when the internal state duplicates or silently shadows something already being passed in as a parameter.

When would you put UI state in a plain state holder class remembered in the composable, instead of either keeping it inline or pushing it up into a ViewModel?

What a strong answer covers: A plain state holder fits state that's genuinely UI-only, doesn't need to survive process death, and isn't meaningfully testable outside the UI, but is complex enough, multiple related values, derived fields, coroutine-driven animation, that spreading it across several remember calls in the composable body becomes unreadable. It avoids the lifecycle coupling and overhead of a ViewModel, which is meant for state that outlives a single composable's presence on screen, reserving the ViewModel for state that's screen-level and independently testable.

A ViewModel exposes a single StateFlow<UiState> to a composable via collectAsStateWithLifecycle. A teammate wants to split it into several separate mutableStateOf properties for 'better recomposition granularity.' What's the real trade-off, and when does splitting actually help?

What a strong answer covers: Splitting can genuinely reduce recomposition because Compose tracks reads per State object, not per field, so a single composite UiState means any field change recomposes every reader of the whole object; but splitting adds ViewModel complexity and loses the atomicity of one consistent snapshot, risking readers observing fields torn across different logical updates. The right default is to keep one state object and let derivedStateOf or the reading composable absorb finer granularity, only splitting once profiling shows a real cost on a hot path like scroll-driven UI, not preemptively.

Lesson, quiz and flashcards for Compose State & Hoisting →

Compose Side Effects

Running lifecycle-aware effects safely from side-effect-free composables in Jetpack Compose.

Walk me through how you decide which effect API to reach for: LaunchedEffect, DisposableEffect, or SideEffect. What's the actual distinguishing question you ask?

What a strong answer covers: The question is what kind of work it is and what cleanup it needs: LaunchedEffect for suspend or coroutine work scoped to composition, like network calls or timed animations, that should cancel automatically on leaving composition or a key change; DisposableEffect for imperative, non-suspending work that needs explicit teardown via onDispose, like registering a listener or receiver; SideEffect for publishing the latest Compose state to non-Compose code after every successful recomposition with no cleanup at all. The common trap is using LaunchedEffect(Unit) to do a one-off imperative registration when DisposableEffect's guaranteed onDispose is what correctness actually requires.

You've inherited a composable using LaunchedEffect(true) to kick off a coroutine meant to run exactly once, ever, for that composable's lifetime. What's wrong with that key choice, and does 'exactly once' even mean what you'd think?

What a strong answer covers: LaunchedEffect(true) restarts every time the composable re-enters composition, which happens if it's inside a conditional branch, a LazyColumn item scrolling back on screen, or a screen re-pushed by navigation without being retained in saved state, so 'once' really means once per entry into composition, not once ever. If truly once-per-app-lifetime semantics are needed, that state belongs in a ViewModel or SavedStateHandle that survives configuration change and re-entry, not in a composition-scoped effect.

Give me a scenario where rememberUpdatedState is necessary for correctness, not just an optimization, and explain what actually breaks if you omit it.

What a strong answer covers: Classic case: a LaunchedEffect(Unit) running a delay-based timeout that then calls an onTimeout lambda parameter which can change across recompositions, for example because the parent's handler closes over newer data. Since the effect's key is Unit, the coroutine captures that lambda reference once at launch and never updates it, so the effect ends up invoking a stale first version of the callback against outdated data, a real correctness bug, not wasted work. rememberUpdatedState wraps the changing value in a State the long-lived effect reads at call time, giving it the latest lambda without needing to include it in the key, which would otherwise restart the timer on every parent recompose and defeat the point of one continuous delay.

Lesson, quiz and flashcards for Compose Side Effects →

Compose Layout, Lists & Modifiers

Drills Compose containers, lazy lists, modifier-order semantics, list keys, and CompositionLocal.

Explain modifier order to me like I'm a mid-level engineer who keeps getting surprised by it. Why does Compose care about order when a typical Android View builder pattern doesn't?

What a strong answer covers: Each Modifier wraps the next like a decorator, applying its effect around whatever comes after it in the chain, so order changes both what area an effect covers and what constraints flow to children; background before padding paints behind the padded area while background after padding excludes it, and the same logic applies to clickable, clip, and size modifiers. This is fundamentally different from XML View attributes, which are just properties on one object with no ordering, because a Modifier chain is actually a sequence of composed layout and draw nodes.

You're choosing between LazyColumn and a plain Column with verticalScroll for a list. Walk me through the actual decision criteria beyond 'lazy is always better.'

What a strong answer covers: LazyColumn only composes and measures items near the viewport, which matters for large or unbounded lists, but for short fixed lists like a handful of settings rows, a plain scrollable Column is simpler, has less bookkeeping overhead from lazy layout, item keys, and item-scope machinery, and avoids bugs like nesting a LazyColumn inside another scrollable container. The real criterion is expected item count and whether the list can grow unbounded, not an assumption that lazy is intrinsically superior, and LazyColumn does unlock things like per-item animation and sticky headers that a plain Column would need manual work to replicate.

A LazyColumn using animateItem() animates item moves correctly, but inserting a new item at the top makes the whole list flicker and the entrance animation look wrong. Beyond having animateItem() present, what's actually going on and how would you fix it?

What a strong answer covers: This usually comes down to the items() key not being a stable identity derived from the data, so on insertion at the top, every subsequent item is treated as a different item by position rather than recognized as the same item that moved down, which defeats animateItem()'s move detection that relies on key continuity across the update. Separately, wanting an explicit entrance animation for the genuinely new item requires composing that yourself, for example with AnimatedVisibility inside the item or by tracking newly-added keys, since animateItem() only animates position and size changes of items that persist across the update, not the appearance of brand-new ones.

Lesson, quiz and flashcards for Compose Layout, Lists & Modifiers →

Compose Animation

Picking the right Compose animation API, from animate*AsState to Animatable.

Walk me through how you'd decide between animate*AsState and a raw Animatable when you need to animate a value in Compose.

What a strong answer covers: animate*AsState is for value-driven, one-shot transitions tied to a composable's own state: it's stateless from the caller's perspective, restarts automatically when the target changes, and can't be interrupted or sequenced beyond changing the target. Animatable is for imperative control: manual snapTo, chaining multiple animateTo calls in a coroutine, cancelling mid-flight for gesture-driven motion, or cases where the animation isn't purely a function of one composable's state. The trap is reaching for Animatable out of habit when animate*AsState would be simpler and already manages its own coroutine scope.

Several UI properties (size, color, elevation) need to animate together whenever a card's selected state flips. Why is stacking several animate*AsState calls worse here than updateTransition, and where does the difference actually bite?

What a strong answer covers: Independent animate*AsState calls each run on their own animation clock, so nothing guarantees they start, finish, or stay in sync frame to frame; you can end up with elevation finishing before color, which reads as uncoordinated jank. updateTransition (or rememberTransition) creates one Transition keyed off a single target state, so all child animateX values derived from it share the same state machine and finish together, and it exposes currentState/targetState for staggering. The nuance a weak answer misses: this also gives you one source of truth for whether a transition is still running, useful for disabling input mid-animation, instead of stitching together several isRunning checks.

Describe how you'd build a swipe-to-dismiss gesture backed by Animatable, including what happens if the user starts a new drag while the settle animation from the previous release is still running.

What a strong answer covers: Drag deltas call offsetX.snapTo(newValue) on every move so the value tracks the finger instantly with no animation, and on drag end you launch offsetX.animateTo(target, spring or decay) to settle to the dismissed position or back to zero based on velocity and threshold. The core trap is re-entrancy: Animatable enforces mutual exclusion internally, so calling snapTo or animateTo while a previous animateTo is in flight automatically cancels it, meaning you don't need to manually cancel a Job, but you do need the drag's coroutine scope to survive recomposition (rememberCoroutineScope) and to avoid two different callers racing animateTo calls on the same Animatable from separate scopes.

Lesson, quiz and flashcards for Compose Animation →

Compose Performance & Stability

Skipping recomposition via stability, strong skipping, deferred reads, and lifecycle-aware state.

Walk me through what 'skipping' actually means when Compose decides not to recompose a composable, and why that's different from Compose simply not redrawing pixels.

What a strong answer covers: Skipping happens at composition time: when every parameter is stable and equal by == to the previous composition, the runtime skips re-executing the composable function's body entirely rather than executing it and finding nothing changed. This is distinct from layout and draw phases; skipping avoids the composition-phase cost, which matters most when a function is invoked thousands of times in a list. The nuance a weak answer misses: one unstable parameter disables skipping for the whole function even if that specific parameter's value didn't change.

You've profiled a screen and found a LazyColumn item recomposing on every scroll frame even though its underlying data hasn't changed. Walk me through how you'd track down the cause.

What a strong answer covers: First check whether the item reads something that changes every frame directly in its body instead of deferring the read, like computing a value from scroll position inline instead of inside a Modifier.offset { } lambda or derivedStateOf, since that forces recomposition every frame rather than just relayout. If bind logic looks clean, check for an unstable parameter, an unremembered List, a raw lambda, or a data class the compiler can't infer stability for, which forces the whole composable to be treated as always-changed. A strong candidate reaches for the Compose Compiler metrics or Layout Inspector's recomposition counts to confirm the exact composable and parameter before guessing.

Strong Skipping Mode in Compose Compiler 2.0 changed what counts as skippable and how lambdas get memoized. What's the trade-off, and when would a team deliberately opt a module out of it?

What a strong answer covers: Strong Skipping Mode makes unstable-parameter composables skippable too and auto-memoizes lambdas without explicit remember, cutting a lot of boilerplate and unnecessary recomposition. The trap: because more things now skip by default, a composable relying on side-effecting reads in its body rather than tracked snapshot state can silently stop updating when it previously recomposed 'by accident', and auto-memoized lambdas capturing mutable non-state variables can go stale since the same lambda instance gets reused across recompositions. Teams opt a module out or mark specific composables non-skippable when they have intentional non-snapshot side effects on every call, or when incrementally migrating and want to isolate behavior changes before rolling out app-wide.

Lesson, quiz and flashcards for Compose Performance & Stability →

Material Design 3 & Adaptive Layouts

Theming, components, and window-size-aware adaptive UI with Material 3 in Compose.

When would you deliberately turn off or override dynamic color instead of using it everywhere, and what's the risk of leaving it on unexamined?

What a strong answer covers: You override it for brand-critical surfaces, marketing screens, empty states, illustrations, anywhere color carries brand identity that shouldn't shift with the user's wallpaper, or where your accessibility/contrast testing assumed a fixed palette. The risk of leaving it on everywhere untested is that dynamic color derives from the wallpaper, so certain wallpapers can produce low-contrast or muddy combinations you never verified, like two adjacent surfaces landing on nearly the same tone. A strong answer also flags that dynamic color is API 31+ only, so testing solely on a device with a vivid wallpaper hides regressions in the static fallback scheme that most of your install base actually sees.

Walk me through how you'd adapt a single-pane phone screen to work well on a foldable or tablet without maintaining two separate screen implementations.

What a strong answer covers: The strong approach computes the window size class once near the root, via currentWindowAdaptiveInfo or WindowSizeClass, and lets one composable branch its layout, not its logic, based on width, for example swapping a NavHost-driven list/detail flow for a ListDetailPaneScaffold that shows both panes side by side at Expanded width while reusing the exact same ViewModel and state. The trap is hardcoding device checks like isTablet or branching at the Activity level, which duplicates state management; the correct mental model is that business logic and state don't change, only which composables are shown together on screen at once.

What problem does Material 3's tonal elevation actually solve compared to Material 2's shadow-only elevation, and what new problems does it introduce, particularly in dark theme?

What a strong answer covers: M2 elevation was purely a shadow effect, which is nearly invisible in dark theme because shadows read poorly against dark backgrounds, so M3 instead lightens a surface's color by overlaying a percentage of the primary color at higher elevations, keeping elevated surfaces visually distinguishable in both light and dark themes. The trade-off a weak answer misses: a Card and its parent Surface no longer look identical just because they share a nominal color, so overlapping elevated or translucent surfaces can produce color banding or reduced contrast that wasn't a concern under M2's model, and teams sometimes still need explicit shadowElevation for genuine depth cues on top of the tonal shift.

Lesson, quiz and flashcards for Material Design 3 & Adaptive Layouts →

RecyclerView, DiffUtil & XML Layouts

RecyclerView as an abstraction, DiffUtil and ItemDecoration, and flat, fast XML layouts.

Walk me through what actually happens end-to-end when a user scrolls a RecyclerView and a new row comes into view.

What a strong answer covers: As a view scrolls off-screen the LayoutManager detaches it and hands it to the Recycler, which first tries a quick scrap reuse if the position is still nearby, otherwise the ViewHolder moves into the shared RecycledViewPool keyed by view type; when a new row appears, the Adapter is asked for a ViewHolder of that type, and if the pool has one it's reused via onBindViewHolder, skipping the expensive inflate, otherwise onCreateViewHolder inflates a fresh one first. The nuance worth stating explicitly: recycling means rebinding, not 'the same data stays', so onBindViewHolder must fully set every field including resetting anything conditionally set like visibility or listeners, or recycled rows show stale state from whatever they previously displayed.

You inherit a RecyclerView screen that's janky while scrolling. Walk me through how you'd figure out whether the cause is layout complexity, bind-time work, or something else.

What a strong answer covers: Start with onBindViewHolder: object allocation, image decoding on the main thread, findViewById calls that should be cached in the ViewHolder, or new listener objects created on every bind instead of reused. If bind code is clean, the next suspect is layout complexity, deeply nested ViewGroups or nested weighted LinearLayouts causing multiple measure passes per row, which a profiling trace shows as high measure and layout time rather than high bind time, fixed by flattening with ConstraintLayout or merge. A strong candidate also checks whether notifyDataSetChanged() is being called for small updates instead of DiffUtil or ListAdapter, since that alone rebinds and re-animates the whole visible window and looks identical to generic jank until you check the call site.

In 2026, when would you still choose RecyclerView and XML over a Compose LazyColumn for a new screen, and what are you actually giving up either way?

What a strong answer covers: You'd still reach for RecyclerView when joining an existing large View-based module where a ComposeView adds interop overhead and inconsistent theming for marginal benefit, or in extremely performance-sensitive lists with huge item counts and custom decorations where the team has already tuned RecyclerView's recycling and prefetch behavior and doesn't want to re-verify perf under Compose. Choosing RecyclerView gives up Compose's simpler state-driven item animation model, since DiffUtil payloads have to be hand-rolled; choosing LazyColumn gives up RecyclerView's maturity in custom LayoutManagers, ItemTouchHelper, and established performance tuning. A weak answer frames this as old versus new rather than a real, still-live trade-off.

Lesson, quiz and flashcards for RecyclerView, DiffUtil & XML Layouts →

ARCHITECTURE

MVVM & Unidirectional Data Flow

How ViewModels expose immutable UI state and drive the View via unidirectional data flow.

Walk me through how you'd model a screen that needs to show a loading spinner, then either a success list or an error message. What does the UiState actually look like?

What a strong answer covers: A strong answer models this as a single sealed interface or class, Loading, Success(data), Error(message), rather than separate booleans like isLoading and hasError, because independent flags can represent impossible combinations, like isLoading and hasError both true, that the UI then has to defensively handle. The nuance: this should be exhaustive in a when expression so the compiler forces every branch to be handled, and the Error case should carry enough context, like a message or the failed action, to render a useful retry UI rather than just a generic flag.

Why is 'state down, events up' easy to violate accidentally, and what's an example of a subtle violation?

What a strong answer covers: It gets violated when a nested composable or View reaches past its given parameters to read shared state directly, like calling into a singleton or pulling a ViewModel it wasn't explicitly handed, instead of receiving everything it needs as parameters and emitting everything it produces as callbacks; this works short-term but breaks the ability to preview or test the composable in isolation and hides where state actually changes. A concrete example: a supposedly stateless list item composable that calls viewModel.onItemClicked() directly instead of exposing an onClick lambda, quietly coupling that item to one specific ViewModel and making it impossible to reuse or unit test without the whole screen's ViewModel.

Two features on the same screen need to share transient state that shouldn't belong in the persisted UiState, like a multi-step form draft that shouldn't survive process death. Walk me through how you'd design that without breaking single-source-of-truth.

What a strong answer covers: The state should still live in exactly one place, typically the ViewModel or a dedicated form-state holder scoped to it, exposed as its own StateFlow separate from the screen's primary UiState, rather than being tracked independently in local remember blocks in two different composables, which creates two sources of truth that can drift. The nuance a weak answer misses: 'doesn't belong in persisted state' is about SavedStateHandle and process-death survival, not about which class owns the data; you can have transient, non-survivable state that still has a single owner and flows down to both features as parameters, instead of each composable keeping its own copy and syncing via callbacks.

Lesson, quiz and flashcards for MVVM & Unidirectional Data Flow →

MVI Pattern

Single immutable state per screen, intents in, a reducer producing new state.

Walk me through the full loop, from a button tap to a re-rendered screen, in an MVI-based screen.

What a strong answer covers: The View dispatches a single Intent object through one entry point, typically a processIntent function on the ViewModel; that intent is either reduced synchronously against the current State to produce a new State, or if it requires async work, it triggers a side effect that eventually produces a Result, which is then fed back through the reducer alongside the current State to produce the next State. The nuance: there's exactly one path from intent to state change, funneled through the same reducer, so a weak answer describing the ViewModel mutating state directly from a callback, bypassing the intent and reducer pipeline, has actually built MVVM with extra ceremony, not MVI.

What's the honest cost of MVI on a real team, and what convinces you it's worth paying versus staying with plain MVVM or UDF?

What a strong answer covers: The cost is real boilerplate: an Intent sealed class, a Result or partial-state type, and a reducer function for every screen, even trivial ones, plus a steeper onboarding curve for engineers used to calling a ViewModel function straight from the UI. It earns its keep on screens with genuinely complex, interleaved state, multiple async sources updating overlapping fields, where the reducer's purity and the ability to log and replay every intent-to-state transition materially speeds up debugging. A weak answer applies MVI uniformly across an app regardless of screen complexity, exactly the needless-ceremony trap interviewers are probing for.

A reducer needs to trigger an async operation as a result of an intent, like loading more data. Walk me through how you keep the reducer pure and testable while still making that side effect happen.

What a strong answer covers: The reducer itself stays a pure (State, Intent) to State function that never launches coroutines; an intent like LoadMore is handled by a separate effect handler, often in the ViewModel's intent-processing coroutine, that performs the actual suspend call and then feeds the outcome back in as a Result-type intent, such as DataLoaded(items) or LoadFailed(error), which the pure reducer consumes like any other intent. The nuance a weak answer misses: the reducer never knows a network call happened, it only ever sees State plus a plain data Result, which is exactly what makes reducers trivially unit-testable without mocking coroutines, and what makes replay-based debugging valid, since replaying intents reproduces the same states.

Lesson, quiz and flashcards for MVI Pattern →

Clean Architecture & Use Cases

Layering apps into presentation, domain, and data with use cases enforcing the dependency rule.

Walk me through how you'd decide whether a ViewModel should call a Repository directly or go through a UseCase.

What a strong answer covers: If the ViewModel needs exactly one repository call with no extra logic, mapping, or combination with other data sources, calling the repository directly is fine, and a UseCase would just be a pass-through wrapper adding a file and indirection with no real payoff. A UseCase earns its place when there's actual logic to encapsulate, combining two repositories, applying a business rule, or when the same operation is genuinely reused across multiple ViewModels, since duplicating that logic per ViewModel is the real problem a UseCase solves. A weak answer treats 'always use a UseCase' or 'never use one' as a fixed rule instead of a case-by-case judgment call.

Give a concrete example of the dependency rule paying off, a change you could make in the data layer without touching domain or UI, because of that boundary.

What a strong answer covers: A strong example is swapping a repository's underlying data source, like moving from a REST endpoint to GraphQL, or adding a local Room cache in front of a network call, as long as the repository still implements the same domain-defined interface and returns the same domain model; nothing in the domain layer or ViewModel needs to change because the domain only depends on the interface, not the implementation, via dependency inversion. The nuance a weak answer misses is naming what makes this possible: the domain module has no compile-time dependency on the data module at all, dependency injection wires the concrete implementation in at runtime, so this is enforced by module boundaries, not just convention.

Where have you seen Clean Architecture over-applied on a real Android team, and how would you push back on it in review without coming across as someone who doesn't care about architecture?

What a strong answer covers: Common over-application: a UseCase per repository method that does nothing but call through with zero extra logic, separate domain models that are field-for-field identical to the DTO or entity with a mapper adding no value, or splitting a genuinely simple two-screen feature into presentation, domain, and data Gradle modules when one module would compile and test just as well. The constructive pushback argues from each layer's stated purpose, asks what the UseCase, mapping, or module boundary actually lets you do that you couldn't do without it, names the real cost, extra files, extra indirection when debugging, slower onboarding, and proposes collapsing it, while being explicit that the objection is to paying the cost where the benefits, testability, swappable implementations, reuse, aren't actually being exercised.

Lesson, quiz and flashcards for Clean Architecture & Use Cases →

Repository & Data Layer

How repositories become the single source of truth abstracting network, cache, and database.

Walk me through how you'd design a repository for a feature that needs both a fast local cache and a network source, and why the merging and conflict logic belongs there rather than in the ViewModel.

What a strong answer covers: A strong answer describes the repository as the single entry point that composes multiple data sources, exposing one unified Flow that reflects the cache and refreshes it from the network in the background, so callers never see the sources directly. Conflict resolution, cache invalidation, and retry policy live in the repository because they're business rules about data, not UI state, and keeping them out of the ViewModel keeps it testable and reusable across screens; the common miss is putting Retrofit or Room calls directly in a ViewModel.

How do you unit test a repository that depends on both a Room DAO and a Retrofit service, without spinning up a real database or making real network calls?

What a strong answer covers: You define the DAO and API service behind interfaces and inject fakes or in-memory implementations that return canned data, then drive the repository with runTest and a TestDispatcher so coroutines execute deterministically. A common miss is defaulting to an actual in-memory Room database for every test, which is slower and couples the test to SQL behavior when a plain fake list-backed implementation of the DAO interface is usually enough to verify the repository's own logic.

When would you introduce a UseCase or Interactor layer between your ViewModel and repository instead of having the ViewModel call the repository directly, and what's the trade-off?

What a strong answer covers: A UseCase earns its keep when business logic is shared across multiple ViewModels or features, or when it composes calls across more than one repository into a single meaningful operation; it isolates that logic for reuse and testing independent of any screen. The trade-off is extra indirection and boilerplate for a simple one-line delegation, and the trap is cargo-culting a UseCase for every trivial repository call in the name of 'Clean Architecture' when the ViewModel calling the repository directly would be simpler and just as testable.

Lesson, quiz and flashcards for Repository & Data Layer →

ViewModel & UI State

How ViewModel holds UI state across config changes and exposes it safely.

Walk me through how you'd structure a ViewModel's exposed state when a screen has several independent pieces of state, like a list, a loading flag, and a selected filter, versus exposing one combined UiState. What are the trade-offs?

What a strong answer covers: A single immutable UiState data class is generally preferred because it guarantees the UI always renders a consistent, atomic snapshot and lets you exhaustively branch on it, whereas several independent StateFlows can momentarily disagree with each other and force the UI to reconcile partial updates itself. The trade-off is that a combined state class can trigger broader recomposition than necessary; the trap is exposing many separate mutable flows for convenience and ending up with inconsistent state combinations that are hard to test.

How do you unit test a ViewModel that exposes a StateFlow and internally launches coroutines in viewModelScope?

What a strong answer covers: You swap Dispatchers.Main for a TestDispatcher via Dispatchers.setMain in setup (and reset it in teardown), drive the test with runTest so virtual time advances coroutines deterministically, and use something like Turbine to collect and assert the sequence of StateFlow emissions rather than just the final value. A common miss is forgetting to advance or drain the dispatcher before asserting, or never resetting the main dispatcher, which leaves tests flaky or leaking state into later tests.

When would you scope a ViewModel to a navigation graph instead of a single screen, and what problems can that introduce?

What a strong answer covers: A nav-graph-scoped ViewModel makes sense when several screens in one flow, like a multi-step checkout, need to share transient state that shouldn't outlive the flow but also shouldn't be re-created on every step. The risk is that its lifetime is now tied to the whole graph rather than a screen, so if a user jumps out of the flow via deep link or back-stack manipulation, the state can go stale or leak until the graph itself is popped, and it silently couples otherwise-independent screens to a shared instance they must agree exists.

Lesson, quiz and flashcards for ViewModel & UI State →

Lifecycle, LiveData & repeatOnLifecycle

Lifecycle-aware data observation: LiveData, LifecycleOwner, repeatOnLifecycle, and Flow tradeoffs.

Walk me through what actually happens under the hood when repeatOnLifecycle(STARTED) restarts flow collection as a user backgrounds and foregrounds the screen rapidly. Is there a risk of losing emissions?

What a strong answer covers: repeatOnLifecycle cancels the collecting coroutine on STOP and launches a brand-new one on the next START, so the collector itself is torn down and rebuilt, not paused. Whether emissions are lost depends on the upstream: a StateFlow or a Flow with a replay cache re-delivers its latest value or replay buffer to the new collector, but a plain cold Flow or a SharedFlow with no replay simply drops whatever it emitted while stopped, so the trap is assuming repeatOnLifecycle is loss-free for any arbitrary Flow rather than only ones with conflated or replayed semantics.

In a legacy codebase that still uses LiveData extensively, when would you actually recommend keeping LiveData rather than migrating to StateFlow?

What a strong answer covers: Keep LiveData where the UI is still View-based with many existing observe() call sites and simple map/switchMap bindings, since LiveData's built-in lifecycle awareness avoids having to introduce repeatOnLifecycle boilerplate throughout, and its guaranteed main-thread delivery matches what that code already assumes. Push for StateFlow when the team is moving to Compose or needs Flow operators and coroutine-based testing; the trap is treating the migration as mandatory 'modernization' and rewriting a stable, working View-based screen purely for its own sake.

A screen needs to collect three different flows from a ViewModel simultaneously inside repeatOnLifecycle. Walk me through how you'd structure that, and what mistake would silently break it.

What a strong answer covers: You launch a separate coroutine with launch { } for each flow's collect call inside the repeatOnLifecycle block, so all three run concurrently under the same lifecycle-scoped parent job. The common mistake is writing the collect calls sequentially in one coroutine, since flow.collect suspends indefinitely, so only the first flow is ever actually observed and the other two silently never run, with no exception thrown to indicate the bug.

Lesson, quiz and flashcards for Lifecycle, LiveData & repeatOnLifecycle →

Paging 3

Loading large lists incrementally with PagingSource, Pager, RemoteMediator, and Compose.

When would you reach for Paging 3 versus just a simple 'load next page on scroll' manual implementation backed by a MutableList? What does Paging 3 actually buy you?

What a strong answer covers: Paging 3 buys memory-bounded windowed loading, built-in de-duplication of in-flight requests, structured LoadState for loading and error UI with retry, placeholders, cachedIn to survive config changes, and RemoteMediator for wiring an offline cache to the network. A hand-rolled 'load more' approach is fine for a small, simple list, but it tends to reimplement prefetch distance, cancellation, and error handling poorly; the trap is reaching for full Paging 3 machinery for a tiny static list that never needs any of that.

A user taps 'like' on an item in a paged list. Walk me through how you'd update that single item's state in the UI without triggering a full page reload or losing scroll position.

What a strong answer covers: Because PagingData is a stream of immutable snapshots, you generally don't mutate an item in place; the idiomatic fix is to make the local database the source of truth for that flag, update the row there, and let the Room-backed PagingSource's invalidation mechanism re-emit the affected page automatically, or alternatively apply a .map transform on the PagingData flow to overlay UI-only state without touching the DB. The trap is calling refresh() after every like tap, which reloads from the start and visibly jumps the user's scroll position.

How do you test a PagingSource or RemoteMediator in isolation, without standing up a real Room database or network layer?

What a strong answer covers: You call pagingSource.load() directly with hand-built LoadParams against a fake data provider (or use the androidx.paging:paging-testing library's TestPager) and assert the returned LoadResult.Page contents and keys, and similarly invoke a RemoteMediator's load() with fake API and DAO fakes to assert the returned MediatorResult. The trap is trying to test through the full Pager and Flow<PagingData> collection pipeline, which is slow, indirect, and harder to pin down than exercising load() directly.

Lesson, quiz and flashcards for Paging 3 →

Hilt Basics

Drills Hilt setup, injection annotations, modules, qualifiers, ViewModels, and the Dagger it generates.

Walk me through what you'd do when Hilt gives you a compile-time error saying it can't find a binding for a type. What's your debugging process?

What a strong answer covers: You check whether the type has an @Inject constructor or a matching @Provides/@Binds method in a module, confirm that module's @InstallIn component actually matches or is an ancestor of where the type is being injected, and check for a missing @Qualifier if multiple bindings of the same type exist in scope. The trap is assuming Hilt 'should just know' how to build something, when it is strictly a compile-time graph that requires every binding to be explicitly declared and correctly scoped.

You have a class that needs both Hilt-provided dependencies and a value only known at runtime, like a userId passed into a screen. How do you handle that with Hilt?

What a strong answer covers: You use assisted injection with @AssistedInject on the constructor and an @AssistedFactory interface, so Dagger supplies every graph dependency automatically and the generated factory only takes the runtime parameter, rather than manually constructing the class or building a bespoke ViewModelProvider.Factory. The trap is trying to smuggle the runtime value into the graph as if it were a dependency, or reaching for a full custom factory when assisted injection is the purpose-built, simpler tool.

When would you deliberately avoid Hilt for a module in your app, even though the rest of the app uses it?

What a strong answer covers: Avoid Hilt in a shared Kotlin Multiplatform module or a plain Kotlin library with no Android dependency, since Hilt is Android-only and pulls in Android Gradle Plugin tooling that such a module shouldn't depend on; use manual constructor injection or a lighter runtime DI there instead. Also consider skipping it for a small feature module meant to be published and reused outside this app, where forcing every consumer into your Hilt component graph is too heavy a constraint; the trap is wiring every module through Hilt reflexively even when it fights the module's actual constraints.

Lesson, quiz and flashcards for Hilt Basics →

Hilt Scopes & Components

Drills Hilt's component hierarchy, scope annotations, lifecycle mapping, and scoping pitfalls.

In your own words, what's the actual purpose of scoping a binding in Hilt, is it primarily a performance optimization, or something else? Walk me through a bug that happens when someone scopes something for the wrong reason.

What a strong answer covers: Scoping is primarily about instance identity and shared, consistent state within a bounded lifetime, not a performance shortcut. A classic bug is scoping a class to @Singleton purely to 'avoid recreating it,' which then leaks an Activity Context, holds stale references, or lets unrelated screens accidentally observe each other's mutable state; the fix is to scope only when a shared instance across a specific lifetime is genuinely required, and otherwise leave the binding unscoped so each consumer gets its own fresh, isolated instance.

Two ViewModels for different screens in the same flow both need to see the same in-flight form data. Walk me through which Hilt scope you'd use and why plain @ViewModelScoped won't work.

What a strong answer covers: @ViewModelScoped gives each individual ViewModel instance its own fresh copy, so two different ViewModels never actually share the same object even though both are 'ViewModel scoped.' To share state across ViewModels in one flow, you scope the shared holder to @ActivityRetainedScoped (or hoist it into a nav-graph-scoped ViewModel), since ActivityRetainedComponent survives configuration changes and is the parent that each screen's ViewModelComponent hangs off; the trap is assuming ViewModel scope means 'shared across ViewModels' when it actually means one-per-ViewModel.

Walk me through what's actually happening in Dagger's generated component hierarchy when you get an error about a binding being installed in the wrong component, say a ViewModelScoped binding that code injected via ActivityComponent can't see. What's the underlying rule?

What a strong answer covers: Hilt's components form a strict parent-to-child tree, so a child component can see everything its ancestors provide, but a parent can never reach into a descendant's bindings. ViewModelComponent hangs off ActivityRetainedComponent in parallel to ActivityComponent rather than being its child, so a binding scoped to ViewModelComponent is invisible from ActivityComponent; the fix is moving the binding up to a component that is genuinely an ancestor of every site that needs it, not just assuming 'it's close enough in the hierarchy.'

Lesson, quiz and flashcards for Hilt Scopes & Components →

Dagger Fundamentals & Manual DI

Dagger's compile-time graph, core annotations, and hand-rolled DI beneath Hilt.

Walk me through why you'd choose Dagger over just doing manual constructor injection with an AppContainer for a real production app, and where manual DI is actually still the better choice.

What a strong answer covers: Dagger buys compile-time validated dependency graphs that catch missing or duplicate bindings at build time, auto-generated wiring for a deep object graph, and scoped subcomponents for structured lifetimes, all of which matter once the graph grows past what one person can wire by hand reliably. Manual DI with an AppContainer is still the better choice for a small app, a plain library, or KMP-shared code, where the annotation-processing overhead and Android coupling aren't worth it; the trap is reaching for Dagger reflexively on a tiny app just because it's the convention elsewhere.

You're building a checkout flow made of three screens that share some in-flight state, and that state should be created when the flow starts and completely discarded when it ends. How would you model that with subcomponents rather than just using @Singleton?

What a strong answer covers: You give the flow its own scoped subcomponent with its own scope annotation, created explicitly when the flow starts and released when it completes, so the shared state's lifetime is tied exactly to the flow rather than the app. Using @Singleton for this is the common shortcut and mistake, since it leaks that state for the entire app lifetime, leaving stale checkout data around long after the user leaves and never resetting between separate checkout attempts.

Dagger throws a duplicate binding error at compile time for a type provided by two different modules installed in the same component. Walk me through how you'd actually resolve that, and what the right long-term fix is versus a quick hack.

What a strong answer covers: The quick, often-wrong hack is to delete or comment out one of the @Provides or @Binds methods until it compiles. The real fix is recognizing that two implementations legitimately need to coexist and disambiguating them with a custom @Qualifier applied at both the binding and the injection site, or, if only one should exist in that component at all, moving one module to a different subcomponent where the conflicting binding isn't installed; the trap is picking whichever binding happens to compile after deletion without checking whether both were actually needed by different consumers.

Lesson, quiz and flashcards for Dagger Fundamentals & Manual DI →

DATA

Retrofit & REST

Drills wiring Retrofit, REST verbs and annotations, converters, suspend error handling, and resilient retries.

Walk me through how you'd design the return type of your data-source methods that call Retrofit: do you let HttpException and IOException propagate, return Response<T>, or something else? What do you actually do and why?

What a strong answer covers: A strong answer wraps the call in a try/catch at the data-source boundary, catching IOException for connectivity failures and HttpException for non-2xx responses, and translates both into a sealed Result type with typed error cases the ViewModel can render directly, rather than leaking Retrofit-specific exception types up into the ViewModel or UI. The trap is returning a raw Response<T> everywhere or letting exceptions propagate uncaught and calling that 'error handling,' which couples UI-adjacent code to a specific networking library and makes it harder to test.

How do you test a class that calls a Retrofit service, without hitting a real network or fully mocking the Retrofit interface with Mockito?

What a strong answer covers: You spin up OkHttp's MockWebServer, enqueue canned responses including error codes, malformed JSON, and slow responses, and point a real Retrofit instance's baseUrl at it, so the actual converter and OkHttpClient pipeline run end-to-end against your code. This catches real serialization and error-handling bugs that mocking the Retrofit-generated interface directly would hide, since a Mockito mock only verifies your code's reaction to a hand-crafted return value rather than the real wire format and converter behavior.

Walk me through the trade-offs between Moshi and kotlinx.serialization as your Retrofit converter for a Kotlin-first app. When would you actually pick one over the other?

What a strong answer covers: kotlinx.serialization is compile-time and reflection-free via KSP, integrates natively if the data layer needs to be shared across Kotlin Multiplatform, and enforces strict serializable classes at compile time. Moshi, used with its codegen rather than reflection, has a longer track record for polymorphic and custom adapters and eases Java interop in mixed codebases or when parsing loosely-typed legacy JSON; the trap is defaulting to Gson out of habit, since its reflection-based approach is slower and gives weaker compile-time safety than either alternative.

Lesson, quiz and flashcards for Retrofit & REST →

OkHttp & Interceptors

Configuring OkHttp and using interceptors for auth, logging, caching, and TLS.

When would you reach for an application interceptor instead of a network interceptor, and can you think of a case where picking the wrong one causes a real bug?

What a strong answer covers: Application interceptors see the request/response exactly once per call, before redirects or cache, so they're right for cross-cutting concerns like adding auth headers or logging what the app actually asked for; network interceptors see every physical hop including redirects and retries, so they're right for observing what actually went over the wire, like measuring real bytes transferred or logging Content-Encoding. A common bug is putting cache-inspection logic in an application interceptor and being confused when it never fires on a cache hit, since application interceptors are skipped entirely when OkHttp serves a response straight from the Cache.

OkHttp can automatically retry a request on connection failure when retryOnConnectionFailure is true. What's dangerous about that for a POST, and how would you guard against it?

What a strong answer covers: OkHttp only retries when it believes the request was never actually transmitted, but that guarantee gets murkier with connection reuse and certain failure timings, so a non-idempotent POST (like creating an order) can occasionally be sent twice with the client only aware of one attempt. The real fix isn't disabling the retry, it's making the operation idempotent server-side with a client-generated idempotency key, so a duplicate delivery is a no-op rather than a duplicate charge or duplicate row.

Fifty screens in your app fire off requests to the same slow, expensive endpoint within a few hundred milliseconds of each other. How would you design request de-duplication so that turns into one network call, not fifty?

What a strong answer covers: OkHttp doesn't coalesce identical in-flight requests for you, so you need a layer above it, typically a map keyed by request signature holding a shared Deferred or Flow that late callers attach to instead of issuing a new call, torn down once the result lands. The trap is getting cancellation right: one caller navigating away and cancelling its coroutine must not cancel the underlying call for the other 49 still waiting on it.

Lesson, quiz and flashcards for OkHttp & Interceptors →

JSON Serialization

Mapping JSON to Kotlin types with kotlinx.serialization, Moshi, and Gson.

Is there ever still a good reason to reach for Moshi over kotlinx.serialization on a new Android feature today?

What a strong answer covers: kotlinx.serialization is now the default for new Kotlin and KMP code because it's compiler-plugin-driven, ships no reflection, and shares code cleanly across platforms, but Moshi can still make sense in a codebase that's heavily Retrofit-and-Java-interop centric, has existing custom JsonAdapters it doesn't want to rewrite, or needs Moshi-specific tooling. A weak answer just says 'always use kotlinx.serialization'; a strong one recognizes migration cost and existing investment are legitimate reasons to stay put, not just inertia.

How do you design your serialized models so an API that evolves over time, new enum values, renamed fields, a field going from optional to required, doesn't crash old versions of the app already out in the field?

What a strong answer covers: New enum values need a fallback branch (an UNKNOWN case with a custom serializer, or catching the SerializationException around decoding) rather than letting decode blow up on an unrecognized constant; fields should generally be added as nullable-with-default so old response shapes still decode; and ignoreUnknownKeys should be on by default so servers can add fields without breaking old clients. The trap is assuming the server team will always coordinate releases with the app team, when in practice the app is running old code against a newer API indefinitely.

Reflection-based serializers like Gson carry real production costs beyond 'it's less type-safe.' Walk me through what those costs actually look like at runtime and at build time, and why compile-time codegen avoids them.

What a strong answer covers: Reflection walks class metadata at runtime to find fields and constructors, which adds measurable per-object overhead and startup cost compared to a generated serializer calling known getters/constructors directly, and it also silently defeats R8/Proguard unless every serialized class is explicitly kept, which either bloats the keep rules or causes crashes in release builds that don't reproduce in debug. Compile-time codegen (kotlinx.serialization's plugin, Moshi's KSP) generates the (de)serialization code at build time, so there's no runtime reflection cost and no hidden Proguard keep-rule dependency.

Lesson, quiz and flashcards for JSON Serialization →

Image Loading (Coil & Glide)

Loading, caching, and decoding images asynchronously in Compose and RecyclerView lists.

Is there ever a good reason to reach for Glide over Coil on a new feature in an otherwise Compose-and-coroutines Android app?

What a strong answer covers: Glide is more mature around things Coil handles less natively out of the box, like animated GIF/WebP playback tuning, thumbnail() request chaining, and integrating with legacy View-based custom targets (e.g. drawing into a notification RemoteViews or a widget), so a codebase with heavy legacy View usage or unusual target types may get less friction from Glide. The weak answer is 'always Coil because it's Compose-first'; the strong answer recognizes library choice should follow what the surrounding code and use case actually need, not just the newer library's marketing.

Your image-heavy feed runs fine on a flagship but goes janky and eventually gets OOM-killed on a low-RAM device. Walk me through how you'd diagnose and fix it.

What a strong answer covers: First check whether requested images are sized to their target views rather than full resolution, since that's the single biggest memory lever; then check whether the memory cache size is appropriate for the device (both Coil and Glide can scale cache size off available memory, and you may need a smaller cap on low-memory devices) and whether the app responds to onTrimMemory/ComponentCallbacks2 by trimming the image cache under memory pressure. A subtler cause worth naming is hardware bitmaps: they're cheap for the Java heap since pixel data lives outside it, but that's not free everywhere, some transformations or software rendering fall back and copy the bitmap into normal memory, spiking usage unexpectedly.

You want the hero image on the next screen to already be loaded by the time the user navigates there, so the transition feels instant. How would you implement that prefetch, and how do you stop it from competing with what's currently on screen?

What a strong answer covers: You'd issue a load through the shared ImageLoader/RequestManager ahead of navigation (Coil's ImageLoader.enqueue with a memory-cache-only or disk-preload request, or Glide's preload()) so the bytes and decoded bitmap are ready by the time the destination screen binds its AsyncImage. The nuance is priority: prefetch requests should run at lower priority than on-screen requests so they don't steal bandwidth or decode threads from what the user is actually looking at right now, and they should be cancellable if the user never navigates there.

Lesson, quiz and flashcards for Image Loading (Coil & Glide) →

Auth Tokens & Idempotency

The full token lifecycle, single-flight refresh under concurrency, idempotency, and its security implications.

How would you unit-test a single-flight token refresh implementation to prove that 20 concurrent callers only trigger one network refresh call?

What a strong answer covers: You'd fake the refresh network call with something you can count invocations on and optionally suspend indefinitely until released, then launch 20 coroutines against the accessor concurrently (using a test dispatcher/runTest so they're all genuinely in-flight together rather than serialized), release the fake refresh, and assert both that the mock was invoked exactly once and that all 20 callers received the same resulting token. The trap is writing a test where the coroutines don't actually overlap because the test dispatcher runs them sequentially, which would pass even on a broken implementation that fires N refreshes.

Your app runs a widget or a separate :service process alongside the main app process, and both need a valid access token. How does an in-memory single-flight refresh strategy break down here, and what would you do instead?

What a strong answer covers: An in-memory Mutex/Deferred only coordinates coroutines within one process's memory space, so a separate process has its own copy of the token manager and can independently kick off a refresh at the same time, defeating single-flight and potentially racing against rotation if the server invalidates the old refresh token on use. The fix is moving coordination to something shared across processes, like a ContentProvider, a file lock, or funnelling all refresh through a single foreground process (often the main app) that other processes request tokens from via IPC/Binder rather than each holding their own refresh logic.

A security review flags that the access token sits in memory as a plain Kotlin String for the life of the app, and Strings aren't securely wiped from memory. How seriously do you take that, and what would you actually change, if anything?

What a strong answer covers: It's a real but low-severity finding for most apps: an access token is short-lived, scoped, and revocable, so the practical risk is a rooted device or a memory-dump attack in the narrow window before expiry, which is a much smaller attack surface than the refresh token's storage. A strong answer prioritizes accordingly, hardening refresh-token storage (Keystore-backed, never plaintext) and rotation over trying to zero out access-token Strings in memory, and pushes back on treating both tokens as equally sensitive when they're not.

Lesson, quiz and flashcards for Auth Tokens & Idempotency →

Room Database

Tests Room's entities, DAOs, queries, migrations, type converters, relations, and observable Flow queries.

How do you test Room DAOs, and what are the trade-offs between an in-memory database instance and mocking the DAO entirely?

What a strong answer covers: An in-memory Room database (Room.inMemoryDatabaseBuilder) run as an instrumented or Robolectric test exercises the real generated SQL, catching migration and query bugs a mocked DAO never would, at the cost of being slower and needing a device/emulator or Robolectric's SQLite shim. Mocking the DAO is faster and fine for testing a ViewModel or repository's orchestration logic, but it can't tell you whether your actual @Query annotations compile to correct SQL, which is exactly the class of bug Room's compile-time validation doesn't catch for runtime query correctness.

A screen observing a Room Flow<List<Item>> re-emits every time any row in that table changes, even completely unrelated ones. Why does this happen, and what performance problems does it cause at scale?

What a strong answer covers: Room's Flow invalidation tracker watches tables, not rows, so any write to a table invalidates every live query against that table and re-runs it, meaning a chat app's message list can re-query and re-diff a thousand rows because one unrelated row's read-status flag flipped. At scale this shows up as redundant query execution, unnecessary list diffing/recomposition, and battery drain from queries firing far more often than the UI actually changed; mitigations include splitting hot and cold columns into separate tables, or debouncing/distinctUntilChanged on the downstream Flow.

You have a screen listing orders, each with its line items loaded via @Relation. Walk me through the N+1 risk here, and why @Transaction doesn't actually fix the performance problem.

What a strong answer covers: Room's generated code for a @Relation POJO typically issues one query for the parent table and then a separate query per parent (or a batched IN-clause query) for the child rows, so a naive implementation can run one query per order to fetch its line items, which is classic N+1 and gets slow fast with hundreds of orders. @Transaction only guarantees the parent and child queries execute atomically and see a consistent snapshot, it says nothing about how many queries run; fixing the actual performance problem means either relying on Room's batched IN-clause fetch for the relation or hand-writing a JOIN query and mapping the flattened result yourself for large datasets.

Lesson, quiz and flashcards for Room Database →

DataStore & Preferences

Modern key-value persistence: DataStore vs SharedPreferences, commit/apply, encryption, and migration.

When would you choose Proto DataStore over Preferences DataStore for a real feature, and what's the actual cost of that choice?

What a strong answer covers: Proto DataStore earns its keep when you have structured, evolving data with real type safety needs, like a settings object with nested fields, because the schema is compile-time checked and you get real objects instead of stringly-typed keys. The cost is real: you write a .proto schema, generate code, write a Serializer for encode/decode, and now schema evolution has to follow protobuf's field-numbering rules, which is meaningfully more setup than Preferences DataStore's just-add-a-key model, so it's overkill for a handful of simple flags.

Walk me through how you'd migrate a legacy app's years of accumulated SharedPreferences into DataStore without losing user settings or racing with in-flight reads/writes during the cutover.

What a strong answer covers: You'd configure the DataStore builder with a SharedPreferencesMigration pointing at the old prefs file, which Jetpack runs automatically and exactly once before the DataStore's data is first accessed, copying over the values and letting you optionally exclude keys you don't want migrated. The race to watch for is any code path still reading the old SharedPreferences directly during the transition window, since DataStore's migration only fires when DataStore itself is first touched, so you generally want to route all reads/writes through DataStore immediately and stop touching the old SharedPreferences instance in parallel.

DataStore's edit { } model is built around applying an atomic transform to the current preferences. What bugs show up when a team ignores that and reads a value, mutates some external variable, then writes it back separately?

What a strong answer covers: Reading outside the transform and writing back later reintroduces the exact lost-update race DataStore's transactional edit{} was built to prevent: two concurrent writers can both read the old value, apply their own change, and the second write silently clobbers the first one's update. The fix is doing the read-modify-write entirely inside the edit{} lambda, since DataStore serializes those transforms, guaranteeing each one sees the latest committed state rather than a stale snapshot captured earlier.

Lesson, quiz and flashcards for DataStore & Preferences →

Offline-First Architecture

Building apps that read and write without network, syncing reliably when connectivity returns.

What does making the local Room database the single source of truth actually buy you architecturally, compared to a simpler design where the ViewModel decides whether to show cached or network data?

What a strong answer covers: With Room as the single source of truth, the UI just observes one Flow and never has to reason about which source it's looking at, network results get written into Room and the UI updates as a side effect of that write, so there's one code path for 'data changed' instead of two (network-arrived vs cache-loaded) that the ViewModel would otherwise have to reconcile and keep in sync. The ViewModel-decides approach tends to grow subtle bugs where the UI shows stale cached data after a successful network fetch because the ViewModel forgot to merge the two paths correctly, or double-renders during the handoff.

How do you handle a queued offline write that depends on another queued write that hasn't synced yet, like adding a comment to a post that itself hasn't reached the server?

What a strong answer covers: The sync worker needs to process the queue in dependency order, not just FIFO by timestamp, typically by giving each queued operation a reference to the client-generated ID of anything it depends on and having the worker defer an operation until its dependency has a confirmed server ID; the local UI can still show the comment optimistically against the client-side temp ID. The trap is naively retrying the dependent write first, getting a foreign-key rejection from the server, and either dropping the write or retrying forever without ever fixing the ordering.

Design question: a user comes back online after two weeks offline with thousands of pending queued writes. Walk me through how you'd replay that queue without overwhelming the server, and how you'd surface partial or failed sync progress to the user.

What a strong answer covers: You'd batch and throttle the replay, WorkManager naturally serializes a single sync worker so you're not firing thousands of parallel requests, and you'd cap batch size per request where the API supports bulk endpoints, backing off on 429/5xx rather than hammering the server. For visibility, each queued item should carry its own status (pending/syncing/failed/synced) that the UI can reflect, ideally as a lightweight sync-progress indicator or a per-item error state that lets the user retry or discard the individual failures rather than presenting sync as one opaque all-or-nothing operation.

Lesson, quiz and flashcards for Offline-First Architecture →

TESTING

Unit Testing & MockK

Writing fast, isolated JVM unit tests with JUnit, MockK, fakes, and dependency injection.

How do you decide what's actually worth unit testing in a ViewModel versus what you'd skip? Walk me through your mental checklist.

What a strong answer covers: Worth testing: business logic, state transformations, branching conditions, edge cases like empty lists or error states, and anything where a regression would silently ship a wrong answer; skip trivial pass-through getters, framework wiring that's just calling a well-tested library method, and UI layout details that belong in a different kind of test. A strong answer also flags that if a ViewModel is hard to unit test because it reaches directly into Android framework classes or does too much in one function, that's itself a design smell worth fixing rather than working around with heavier mocking.

You inherit a test suite that mocks everything, including simple data classes and pure functions, and tests break constantly whenever an unrelated field gets renamed. What's actually gone wrong, and how would you fix the suite's design?

What a strong answer covers: This is classic over-mocking: tests are asserting on implementation details and interaction patterns instead of observable behavior, so they're brittle to refactors that don't change actual behavior, which defeats the point of a safety net. The fix is reserving mocks/verify for real collaborators with side effects (network, database, other objects you don't own), using real objects or hand-written fakes for value types and pure logic, and asserting on outputs and state rather than on exactly which methods got called in what order.

Walk me through how you'd unit-test a use case that combines two upstream Flows with combine and has time-based logic like a debounce. What tools do you reach for, and what's actually hard about testing this correctly?

What a strong answer covers: You'd run it under runTest with a TestDispatcher so virtual time can be advanced deterministically, and typically reach for Turbine to collect emissions from the combined Flow and assert on the sequence, using advanceTimeBy or advanceUntilIdle to move past the debounce window without real delays. The hard part is that combine re-emits whenever either upstream emits, so tests need to control emission timing precisely (emit from one source, advance time, emit from the other) to actually exercise the debounce and race conditions, rather than emitting everything up front and getting a misleadingly clean pass.

Lesson, quiz and flashcards for Unit Testing & MockK →

Testing Coroutines & Flow

Deterministically testing suspend functions, virtual time, dispatcher injection, and Flow emissions.

When would writing a coroutine test actually still require a real dispatcher or real wall-clock time, rather than relying on runTest's virtual time? Give a concrete example.

What a strong answer covers: Virtual time only fast-forwards coroutines scheduled through the shared TestCoroutineScheduler; work that crosses into a genuinely separate scheduler, such as a third-party SDK's own executor, a real network client's IO threads, or an Android Handler you don't control, still runs on wall-clock time and won't be advanced by advanceUntilIdle. The common misconception is assuming runTest speeds up all async work in the test; in practice you either wrap that boundary so it goes through injected coroutines sharing the test scheduler, or accept a real, short timeout for that specific interaction.

You have a Flow built with debounce(300) feeding a search UI. How do you test that debounce behavior deterministically without waiting 300 real milliseconds?

What a strong answer covers: Drive the test with runTest's virtual time and advance the TestCoroutineScheduler explicitly, for example advanceTimeBy(299) to assert nothing has emitted yet and then past 300 to assert exactly one emission, collecting with something like Turbine so you can assert ordering precisely. The trap is reaching for a shorter debounce value or a real delay just to make the test fast; virtual time makes that unnecessary and keeps the test both fast and faithful to production timing.

Walk me through how you'd test a suspend function that retries on failure with exponential backoff, three attempts with delay(1000), delay(2000), delay(4000) between them.

What a strong answer covers: Use a fake or mock that fails the first N calls and then succeeds, run it inside runTest, and use advanceUntilIdle (or step through with advanceTimeBy at each expected boundary if you also need to assert the specific intervals) so all the backoff waits collapse to virtual time instead of real seconds, then assert the call count and, if relevant, that no attempt happened before its threshold. Testing this with real delays would make the suite slow and risks flakiness under CI load if the timing is tight; virtual time gives you exact, deterministic control over when each retry fires.

Lesson, quiz and flashcards for Testing Coroutines & Flow →

Compose & Espresso UI Testing

Writing instrumented UI tests for Compose semantics and View-based screens with Espresso.

Walk me through your strategy for keeping a large instrumented UI test suite reliable in CI, beyond what automatic synchronization gives you for free.

What a strong answer covers: Automatic synchronization only waits for the UI thread and message queue (or the Compose clock) to go idle, so it won't wait for background async work you don't control, meaning you still need IdlingResources or injected fakes with controlled dispatchers for network/database calls. Beyond that, reliability comes from running on consistent emulator images and API levels, disabling animations, isolating test data so tests don't interfere with each other, and treating any flaky test as a bug to fix rather than something to retry away.

When would you reach for Espresso over Compose testing APIs, or vice versa, on a screen that's mid-migration from Views to Compose?

What a strong answer covers: Use createAndroidComposeRule to host the real Activity when the screen mixes both; onView still targets legacy chrome like a Fragment-hosted toolbar or a Dialog outside the Compose subtree, while onNode targets the Compose-rendered content, and both engines can run in the same instrumentation session since Compose testing interoperates with Espresso's idling mechanism. The mistake is assuming you must fully migrate a screen's tests before you can meaningfully test it; you can test the hybrid screen as it actually ships.

A screen shows a spinner, fetches data asynchronously, then renders a list. What's your approach to synchronizing a test with that async work, and what breaks if you get it wrong?

What a strong answer covers: The reliable approach is to inject a fake or test repository with a controllable dispatcher so the fetch completes deterministically before you assert, the same pattern used for ViewModel unit tests; for work you genuinely don't control, register an IdlingResource around the async call, or use composeTestRule.waitUntil to poll for the final state within a timeout as a last resort. Skipping this and relying on default settling gives you intermittent flaky failures, because the test framework only waits for the UI/Compose clock to go idle, not for background work to finish, and can also produce false passes where assertions race the data arriving.

Lesson, quiz and flashcards for Compose & Espresso UI Testing →

Testing Strategy & Pyramid

How to balance unit, integration, and UI tests and what each layer should verify.

How do you decide, for a new feature, which parts get unit tests, which get integration tests, and which get UI or end-to-end tests?

What a strong answer covers: Prioritize by risk and cost: business logic and edge cases go into fast unit tests since they're cheap to write and run, boundaries between real components (a repository against a real local database, or a use case composing several collaborators) get a smaller number of integration tests to verify the contract actually holds, and slow, flaky-prone UI or end-to-end tests are reserved for a handful of critical golden paths. The mistake is testing everything at every layer, which burns CI time without adding proportional confidence.

Your team has 80% code coverage but keeps shipping regressions. How would you investigate why, rather than just writing more tests?

What a strong answer covers: Look at what the covered lines are actually asserting; a lot of coverage often comes from tests that just exercise a code path without asserting meaningful output values, or from trivial getters and happy-path-only tests that never hit edge cases. Also check whether the pyramid is inverted, with heavy UI test coverage and thin coverage of the business logic layer where real bugs live, and target fixes at the layer a recent regression would actually have been caught at instead of chasing the coverage number itself.

When is it actually the right call to skip automated tests for a piece of code, and how would you defend that in a design doc?

What a strong answer covers: Trivial glue code with no branching, throwaway or soon-to-be-removed feature-flagged code, and properties already guaranteed by the type system or a linter are reasonable candidates to skip. The defense isn't just 'it's simple'; it's naming the alternative safety net you're relying on instead, such as code review, a staged rollout, or production monitoring and alerting, so the decision reads as a deliberate trade-off rather than an omission.

Lesson, quiz and flashcards for Testing Strategy & Pyramid →

BUILD & TOOLING

Gradle Basics

Drills Gradle build files, dependency configurations, plugins, version catalogs, and build caching.

Walk me through what happens, step by step, running ./gradlew assembleDebug for the first time versus running it again with no changes. Where does the time go, and why is the second run faster?

What a strong answer covers: The first run has to evaluate every build script in the configuration phase, resolve the full dependency graph including downloading anything not cached, then execute the task graph with cold up-to-date checks. On the second run with no changes, the configuration cache can skip re-running the build scripts entirely if their inputs are unchanged, and the build cache or task-level up-to-date checks let most tasks short-circuit rather than redo real work, so time shifts almost entirely into cheap up-to-date verification.

You've got a large multi-module app and builds have gotten slow. Walk me through how you'd diagnose the bottleneck before reaching for a fix.

What a strong answer covers: Start with a build scan or --profile to see where time actually goes, per task and per module, rather than guessing; check whether the configuration phase itself is slow, which can happen even for a single-module build if too many modules get configured eagerly. Also look for tasks that aren't cache-hitting due to non-deterministic inputs, and check whether overly broad api dependencies are forcing unnecessary downstream recompilation; jumping straight to 'add more modules' or 'enable parallel' without profiling first is the common mistake.

Explain why using api instead of implementation for a dependency you don't strictly need to expose can quietly slow down incremental builds, even though the app still compiles fine either way.

What a strong answer covers: api puts that dependency on the compile classpath of every module that transitively depends on you, so Gradle has to consider all of those downstream modules potentially invalidated when that dependency's ABI changes, whereas implementation hides it and confines recompilation to your own module. Over-using api effectively removes the compile-avoidance boundary between modules, so a change in a leaf dependency can trigger a much larger recompilation cascade than the code actually warrants, even though nothing downstream directly referenced that type.

Lesson, quiz and flashcards for Gradle Basics →

Build Variants & Signing

Drills build types, flavors, the variant matrix, source sets, and Play App Signing keys.

Walk me through why a team would introduce product flavors instead of just using a buildConfigField flag or a runtime check to differentiate a free versus paid version of an app.

What a strong answer covers: Flavors give you compile-time separation via distinct source sets, so paid-only code and assets never even ship inside the free APK rather than being gated behind a runtime check that could be bypassed or accidentally left exposed, and separate applicationIds let both variants install side by side and be released and rolled back independently on the Play Store. The trade-off is a bigger variant matrix and more CI time, so it's worth it mainly when the difference is substantial rather than cosmetic.

When would you actually reach for a new flavor dimension versus just adding a plain flavor or a buildConfigField flag? What's the real cost of adding another dimension?

What a strong answer covers: Add a dimension only when that axis is genuinely orthogonal and needs to multiply against your existing flavors, like a tier dimension that shouldn't imply anything about an environment dimension; the real cost is combinatorial, since every new dimension multiplies the whole variant matrix and CI build and test time by its flavor count, and dimension order also affects source set merge priority. If the difference doesn't need separate code or resources or an independently releasable APK, a simple flag is cheaper.

For the paidRelease variant, the same string resource is defined with different values in the main source set, the paid flavor source set, and the release build type source set. Walk me through which one wins and how you'd debug it if the wrong value shipped.

What a strong answer covers: Android merges from lowest to highest specificity: main loses to the flavor source set, which loses to the build type source set, which loses to a variant-specific source set like paidRelease if one exists; so here release beats paid unless a paidRelease override is also present, in which case that wins outright. The common mistake is assuming a flavor always beats the build type; to debug it you'd inspect the merged resources output for that variant under build/intermediates rather than guess from the source files alone.

Lesson, quiz and flashcards for Build Variants & Signing →

R8, Shrinking & App Size

Shrinking, obfuscating, and optimizing apps with R8 and cutting download size with bundles.

Walk me through what you'd do the first time you enable minifyEnabled on a release build and it crashes with a ClassNotFoundException or NoSuchMethodError that never happens in debug.

What a strong answer covers: First confirm it's an R8-only failure by comparing against an unminified release build, then identify whether the missing class or member is reached only via reflection or serialization, such as a Gson model class, or is referenced only from XML, native code, or JNI where R8 can't see the usage statically. Add the narrowest possible keep rule scoped to that class or member rather than disabling minification, then rerun the release build and exercise that exact path to confirm the fix.

Why would a senior engineer push back on the instinct to add a broad -keep class com.example.** { *; } to make a shrinking crash go away, instead of a narrower rule?

What a strong answer covers: A broad keep rule defeats shrinking and obfuscation for that entire package, bloating the APK and leaving code needlessly easy to reverse-engineer, and it tends to mask the real root cause so future additions to that package silently stay unshrunk without anyone noticing. The better fix is keeping only the specific classes or members actually accessed reflectively, like the exact model classes Gson deserializes, not everything in their package.

How would you validate, before shipping, that your R8 keep rules are correct and complete, rather than just 'it didn't crash in my manual testing'?

What a strong answer covers: Run your automated test suite, ideally instrumented tests exercising reflection-heavy paths like deserialization and DI graph construction, against the actual release-minified build rather than debug, since R8 behavior in full mode can diverge in ways that only show up in that artifact. Use R8's diagnostics such as -whyareyoukeeping to confirm specific classes and members survive shrinking as intended, because a green manual smoke test doesn't cover rare paths like error handling or feature-flagged code that production traffic will eventually hit.

Lesson, quiz and flashcards for R8, Shrinking & App Size →

Modularization

Splitting an Android codebase into loosely coupled Gradle modules with enforced boundaries.

Walk me through how you'd decide whether a growing single-module app is actually ready to be split into multiple Gradle modules, versus just needing better internal package structure.

What a strong answer covers: Modularization is worth the Gradle and DI wiring overhead when there's real pain: incremental builds are slow because everything recompiles together, multiple teams need clear ownership boundaries, or you have a genuine need for Play Feature Delivery. If the codebase is small or single-team and the actual problem is disorganization, tightening internal visibility and package boundaries within one module solves it without the multi-module overhead of extra build files and cross-boundary DI wiring.

Two features need similar but not identical UI, say a details screen for two different entity types. How do you share that without creating a feature-to-feature dependency, and when would you decide it's not worth extracting a shared module?

What a strong answer covers: Extract the common UI into a shared, entity-agnostic module driven by an interface or shared data model that both features depend on downward, rather than one feature depending on the other's concrete types. If the similarity is superficial, same layout but diverging business rules, a small amount of duplication is usually the better trade, since a premature shared abstraction across features tends to cause more coupling pain than the duplication it was meant to avoid.

You've fully modularized into 40-plus modules with clean api and implementation boundaries everywhere. What real costs have you likely introduced, and how do you know if you've over-modularized?

What a strong answer covers: Symptoms include configuration time growing despite build and compile caching, since evaluating dozens of build.gradle.kts files has fixed overhead per module, plus a proliferation of tiny modules each requiring their own DI wiring that adds boilerplate without meaningfully improving build parallelism or ownership clarity. You know you've overshot when profiled build and CI times don't actually beat a coarser-grained baseline, or when module boundaries don't map to real team boundaries and developers spend more time navigating interfaces than they save in build time.

Lesson, quiz and flashcards for Modularization →

CI/CD, Signing & Release

Automating Android builds, signing keys, and staged Play Store releases.

Walk me through the full pipeline you'd consider well designed, from a developer merging a PR to a build showing up on a tester's phone.

What a strong answer covers: A PR triggers CI for lint, unit tests, and a debug build; merging to main triggers a release pipeline that bumps the version code, builds a signed AAB using secrets pulled from a secrets manager rather than committed to the repo, and uploads it via Fastlane's supply action or the Play Publishing API to an internal or closed testing track. A well-designed pipeline also gates promotion between tracks on manual approval or automated signals like crash-free rate, rather than auto-promoting straight to production.

Your CI pipeline is fully green, tests pass, the AAB is correctly signed, but Play's own review flags or rejects the upload anyway. What does that tell you about the limits of CI signal, and how would you catch this earlier?

What a strong answer covers: CI can only verify that code compiles, tests pass, and the artifact is well formed and signed; it can't replicate Play's server-side policy and content review, like permissions justification or target API compliance, so a green pipeline is necessary but not sufficient for a successful release. Catch it earlier by routing through closed or internal testing tracks first, which go through a lighter review, and by watching Play Console's pre-launch report and policy status proactively instead of treating CI green as ship-ready.

Two teams want to auto-deploy straight to the production Play track from CI with no human in the loop. What would make you comfortable approving that, and what safety nets would you insist on first?

What a strong answer covers: Insist on an automatic staged rollout that ramps by percentage gated on real crash-free and ANR-rate thresholds pulled from Play vitals or Crashlytics, auto-halting if a threshold is breached, plus a fast, reliable kill switch such as remote config or feature flags, since Play has no true rollback, only a new higher version code. Also require the release-signed, minified artifact itself to run through the full automated test suite, not just a debug build, because without those nets an unattended broken release has no automatic detection path before it reaches everyone.

Lesson, quiz and flashcards for CI/CD, Signing & Release →

Studio, Profilers & Firebase

Profiling, leak detection, baseline profiles, and Firebase tooling for diagnosing and shipping Android apps.

Walk me through your process when a user reports 'the app is laggy' with no repro steps. How do you narrow down whether it's a rendering, memory, or I/O problem before you even open a specific profiler?

What a strong answer covers: A strong answer separates symptom categories before opening a profiler: when is it slow (cold start vs mid-scroll vs after backgrounding), what devices/OS versions are affected, and check Android Vitals or Crashlytics/Firebase Performance dashboards for ANR and slow-frame rates before assuming a single root cause. It should recognize that jank, ANR, and OOM/leak symptoms point to different capture types (system trace vs CPU profiler vs Memory Profiler), and that guessing which tool to open without narrowing the symptom first wastes profiling sessions.

LeakCanary flags a leak in a screen you don't own. How do you decide whether it's a real production risk versus noise, and how do you triage it without blocking the whole team's workflow?

What a strong answer covers: Should distinguish a genuine leaked Activity/Fragment held past its lifecycle with a large retained heap and a real reference path, from a short-lived object that's collected shortly after being flagged, by reading the leak trace's retained size and reference chain rather than treating every LeakCanary alert as a blocker. A strong answer also notes LeakCanary runs in debug builds and shouldn't gate CI or release builds directly, so the fix gets filed and owned as a normal bug rather than treated as an emergency that stalls the pipeline.

Baseline profiles need to be regenerated as the app changes. What's your strategy for keeping them fresh in a CI/CD pipeline, and what actually happens to startup performance if you let one go stale?

What a strong answer covers: Should describe generating profiles via Macrobenchmark's BaselineProfileRule as part of the release pipeline or a scheduled job so the profile tracks real critical user journeys as code changes, since a stale profile only covers old code paths and quietly stops helping. A strong answer notes the failure mode is graceful, not catastrophic: ART just falls back to JIT-compiling the uncovered hot paths, so startup and early-scroll jank regress silently, which is exactly why staleness goes unnoticed without ongoing measurement.

Lesson, quiz and flashcards for Studio, Profilers & Firebase →

PERFORMANCE

ANR & Memory Management

Keeping the main thread free and references scoped to avoid ANRs, leaks, and OOM.

Walk me through how you'd debug an ANR that only reproduces on a specific low-end device model in production, when you can't repro it locally.

What a strong answer covers: A strong answer leans on ApplicationExitInfo (getHistoricalProcessExitReasons) to pull the ANR trace from the actual affected process after the fact, plus Play Console's ANR reports and Android Vitals, since local repro on a low-end device is unlikely. Should reason about device-specific culprits: slower storage/I/O turning a disk read that's fine on a flagship into a main-thread block past 5s, fewer cores worsening lock contention, or lower memory triggering more aggressive GC pauses that add up to the timeout.

When would you reach for a WeakReference instead of just fixing the underlying lifecycle or ownership issue? What's the risk of using WeakReferences as a band-aid for leak-prone code?

What a strong answer covers: A WeakReference fits caches or observer lists where holding the referent slightly longer than needed is acceptable and being cleared unpredictably by the GC is fine, but it's not a substitute for correct lifecycle ownership because the object can be collected at any GC pass, not deterministically when the screen is destroyed, leading to non-deterministic crashes or silently dropped callbacks. A strong answer flags that reaching for WeakReference to 'fix' a leak without understanding why the strong reference existed is a smell; the right move is usually breaking the reference in onDestroy/onCleared or restructuring ownership.

Your memory profiler shows a sawtooth pattern with a rising floor over time, not a hard leak. What's actually happening, and how do you distinguish 'this is just GC doing its job' from 'this is a slow leak'?

What a strong answer covers: A sawtooth with a flat floor between GC cycles is normal allocation and collection churn; a floor that trends upward across multiple GC cycles, even after forcing a GC, means objects are being retained that shouldn't be, i.e. an actual leak. A strong answer describes confirming this by repeating a navigate-in/navigate-out cycle on a screen while watching whether retained heap after GC returns to baseline, and pulling a heap dump or using LeakCanary to identify what's pinning the growing objects if it doesn't.

Lesson, quiz and flashcards for ANR & Memory Management →

App Startup Time

Cold/warm/hot starts, pre-first-frame work, and how to measure and speed up launch.

Walk me through your approach to reducing cold-start time on an app where Application.onCreate() has ballooned to include a dozen SDK initializations.

What a strong answer covers: A strong answer triages by measuring which initializers are actually on the critical path to first frame, via Perfetto/systrace, versus which can be deferred, then moves non-essential SDK init off the synchronous Application.onCreate path using lazy initialization, App Startup Initializer dependency ordering, or deferring work to after first frame/reportFullyDrawn on a background dispatcher. Should call out that not every SDK is safe to defer, a crash reporter needs to be live early, so the strategy is prioritization by risk and impact, not blanket deferral.

When is TTID actually a misleading metric for user-perceived startup performance, and why might a team optimizing for TTID make the app feel worse to use?

What a strong answer covers: TTID is satisfied the moment the first frame is drawn, which can happen while the screen shows a mostly-empty layout, skeleton, or placeholder, so a team can hit a great TTID number while the actual content the user came for loads afterward and the app still feels slow. A strong answer names TTFD, signalled via reportFullyDrawn(), as the metric that reflects when content is actually usable, and warns that gaming TTID by rendering a blank frame early is a classic Goodhart's-law failure.

You've added lazy initialization and baseline profiles, and TTID hasn't moved much, but users still say the app 'feels slow to open'. What are you missing?

What a strong answer covers: Points to things outside pure launch-to-first-frame timing: a splash screen that doesn't transition smoothly, a jarring layout shift once real content replaces a placeholder, network-bound content loading after first frame with no loading state, or reportFullyDrawn() simply never being called so TTFD and Vitals data don't reflect reality at all. A strong answer stresses startup is a perceived-performance problem as much as a raw-timing one, so profiling numbers alone aren't enough without watching an actual user session.

Lesson, quiz and flashcards for App Startup Time →

Jank & Rendering

How to keep frames under budget and diagnose dropped-frame jank on Android.

Walk me through how you'd diagnose jank in a RecyclerView list that only shows up on mid-tier devices, not on your flagship test device.

What a strong answer covers: A strong answer profiles on an actual mid-tier unit with Perfetto/systrace rather than trusting flagship numbers, since lower core count, slower storage, and less cache expose main-thread work, like item binding, image decode, or complex layouts, that a flagship's headroom hides. Should mention concrete fixes: flattening the item view hierarchy, offloading bitmap decode off the main thread, and using DiffUtil with stable IDs to cut unnecessary rebinds, then validating with FrameTimingMetric on a representative low-end device going forward.

When does overdraw actually matter for jank versus just being visual noise in the debug overlay? What's the real-world scenario where fixing it moves the needle?

What a strong answer covers: Overdraw matters when the GPU becomes the bottleneck, typically on large fill areas like stacked backgrounds or translucent overlays on lower-end GPUs or high pixel-density screens with limited fill-rate; on a flagship with headroom, moderate overdraw is often invisible in frame times. A strong answer gives a concrete diagnostic: check whether RenderThread/GPU time is the long pole in a systrace before chasing the overlay's red zones, since fixing overdraw when the main thread is the actual bottleneck wastes effort.

You've optimized a Compose screen's recomposition count to near zero but users still report jank while scrolling. What else could be the bottleneck, and how would you isolate it?

What a strong answer covers: Should point beyond composition to layout and draw, and to work happening outside Compose entirely: expensive measure/layout passes from nested weights or intrinsic measurements, large image decode or bitmap allocation on the main thread, RenderThread/GPU-bound overdraw, or synchronous I/O or GC pauses unrelated to recomposition count. A strong answer stresses recomposition count is only one input to frame time, and the next step is a Perfetto trace to see which pipeline phase, measure, layout, draw, or RenderThread, is actually consuming the frame budget.

Lesson, quiz and flashcards for Jank & Rendering →

Battery & Power

How Doze, standby buckets, and WorkManager constraints govern deferred background work to save battery.

Walk me through how you'd decide whether a piece of background work belongs in WorkManager, a foreground service, or FCM push.

What a strong answer covers: A strong answer frames it as three questions: is the work deferrable, meaning it can wait for a maintenance window or a network/charging constraint, versus time-critical; does the user need to see it's happening, which implies a foreground service for ongoing visible work the OS shouldn't defer; and does it originate server-side needing to reach the device promptly, which points to FCM, high-priority for time-sensitive delivery. WorkManager owns deferrable constrained background work, foreground service owns ongoing user-visible work, and FCM owns server-initiated events.

When would you reach for a foreground service over WorkManager, even though it's more invasive to the user with a persistent notification? What's the trade-off you're weighing?

What a strong answer covers: Reach for a foreground service when work is ongoing and immediate rather than a deferrable one-off task, like live location tracking, audio playback, or an active call, where WorkManager's deferred or batched execution model would introduce unacceptable latency or get killed by Doze. The trade-off is user trust and perceived battery drain: a persistent notification is intrusive and can itself trigger battery-optimization complaints, so it should only be used when work genuinely needs to survive backgrounding in real time, not as a workaround to dodge WorkManager constraints.

Your app's uninstall and battery-complaint rate ties back to a background sync feature. Walk me through how you'd investigate and fix it without just telling users to disable battery optimization.

What a strong answer covers: A strong answer starts with Battery Historian or on-device battery stats to identify what's actually keeping the device awake, wake locks, frequent wakeups, or radio usage from polling, and checks whether sync uses exact alarms or frequent unconstrained WorkManager runs instead of batched, constrained jobs. The fix targets reducing wake frequency, adding constraints like battery-not-low and unmetered network, replacing polling with FCM push, and batching work, rather than reflexively asking users to disable battery optimization, which just masks the underlying inefficiency.

Lesson, quiz and flashcards for Battery & Power →

SECURITY

Network Security

Securing app traffic with HTTPS, TLS, network security config, and safe certificate pinning.

Walk me through the actual attack a MITM performs against an app that does plain HTTPS with no pinning. What does the attacker need, and why doesn't HTTPS alone stop them?

What a strong answer covers: A strong answer walks through the attacker needing a certificate trusted by the device's trust store, most commonly by getting a malicious or enterprise CA installed, or exploiting a compromised or misissued public CA, then positioning on the network path to intercept and re-encrypt traffic with that cert. Standard TLS validation only checks the chain leads to some trusted root plus hostname match, so if a rogue CA is trusted, the client accepts the MITM's certificate transparently; pinning defends against exactly this scenario, which plain HTTPS's chain-of-trust model doesn't stop on its own.

When would you actually recommend certificate pinning for a production app, versus when is it more risk than it's worth? What's the failure mode that makes teams rip pinning back out?

What a strong answer covers: Pinning fits high-value targets, banking apps, apps handling sensitive PII, or apps operating where state-level MITM risk is real, where the operational cost of maintaining pins is justified; for most consumer apps, standard TLS with a solid CA chain is sufficient and pinning's rotation risk outweighs the benefit. A strong answer names the classic failure: teams pin without backup pins or an expiration and kill-switch plan, a cert rotates, and the app locks itself out of its own backend for every installed user until a new release ships.

A teammate wants to store a third-party API key in the app, arguing R8 obfuscation will protect it so you can skip building a token-issuing endpoint. Walk me through how you'd talk them out of it and what you'd build instead.

What a strong answer covers: A strong answer explains R8 only renames symbols and doesn't encrypt strings or block decompilation or runtime inspection; anyone can pull the APK, decompile it, or hook the running process to extract the key regardless of obfuscation, so any secret embedded client-side is effectively public. The right fix is a backend proxy that holds the real key server-side and issues short-lived, scoped tokens, or performs the third-party call itself, so the client never possesses a credential worth stealing.

Lesson, quiz and flashcards for Network Security →

Data Storage Security

Encrypting data at rest with Keystore-backed keys, EncryptedSharedPreferences, and scoped storage.

Walk me through how you'd decide what actually needs to be encrypted at rest in an app versus what's fine in plain SharedPreferences.

What a strong answer covers: A strong answer draws the line at sensitivity and blast radius: auth tokens, passwords, PII, payment details, and anything granting account access or violating privacy on a rooted or stolen device must be Keystore-backed or encrypted, while purely cosmetic app state like a theme preference or onboarding-seen flag is fine in plain SharedPreferences since encrypting it adds overhead with no real security benefit. Should also flag that plaintext SharedPreferences is readable by anyone with root or a backup extraction, so the decision hinges on what a device compromise or lost-phone scenario would expose.

When would you reach for a StrongBox-backed key over a TEE-backed key, given the trade-offs? What's a scenario where insisting on StrongBox actually hurts you?

What a strong answer covers: StrongBox is a separate dedicated secure element, more isolated from the main CPU and OS and resistant to a wider class of physical and side-channel attacks, but it's slower and not available on all devices, so requiring it unconditionally locks out part of the device fleet without a fallback. A strong answer notes that insisting on StrongBox for something like everyday token storage on a mid-range-heavy user base means either building fallback compatibility logic or breaking the feature for those users, when a TEE-backed key is already a strong, near-universal baseline for most threat models.

A security reviewer flags that your app's Keystore-backed encryption doesn't protect against a rooted device with a live debugger attached to your process. How do you respond, given the key material never leaves hardware?

What a strong answer covers: A strong answer is honest about the limit: Keystore protects key material from extraction since it never leaves hardware, but on a rooted device with a debugger attached, an attacker can still invoke the app's own decrypt operations while it runs and read whatever plaintext the app produces, so they get the decrypted output on demand without ever getting the key. It should frame Keystore as raising the bar, no bulk key exfiltration, no offline brute force, rather than making data safe on a fully compromised device, and mention layers like root detection or minimizing what's decrypted into memory at once as reducing, not eliminating, that exposure.

Lesson, quiz and flashcards for Data Storage Security →

App Integrity & Injection

Proving genuine app/device with Play Integrity and blocking injection via parameterised queries.

Walk me through how you'd design the backend flow for verifying a Play Integrity token end to end. What has to happen server-side, and what's the wrong way teams often build this?

What a strong answer covers: A strong answer describes the client requesting a token bound to a nonce or request hash tied to the specific action being protected, sending the opaque token to the backend, and the backend calling Google's verification endpoint to get the decrypted verdict, then checking appRecognitionVerdict, deviceIntegrity, and account details before trusting the request; the token must never be decrypted or trusted client-side since a compromised client could fabricate a positive verdict. Should call out the common mistake: checking the verdict on-device, or failing to bind the nonce to the specific request, which makes the token replayable across different actions.

When would you actually invest in root or tamper detection for an app, given it's not a real security guarantee? What's a legitimate reason to add it, and what's the trap of over-relying on it?

What a strong answer covers: A legitimate reason is raising attacker cost and feeding a broader risk signal for business needs like anti-cheat, content protection, or fraud scoring, not as a hard security boundary, since any client-side check can eventually be patched out or hooked by a motivated attacker using tools like Frida or a custom ROM. The trap is treating a positive tamper-detection result as proof of safety and skipping server-side authorization, when the right architecture is defense-in-depth: root and tamper signals as one input among several, with security-critical decisions enforced server-side regardless.

A teammate wants to add a WebView feature that loads a mix of trusted first-party and untrusted third-party content in the same view, with a JavaScript bridge for native calls. Walk me through the risks and how you'd architect it safely.

What a strong answer covers: A strong answer flags that mixing trusted and untrusted content in one WebView with a shared JavaScript bridge is dangerous because any script running there, including a compromised ad, iframe, or injected third-party script, can call the bridge's exposed methods, extending the bridge's attack surface to every piece of content ever loaded. The safer architecture separates the WebViews: trusted content gets the bridge with JavaScript restricted to allowlisted first-party origins, untrusted content gets a separate WebView with no bridge and JavaScript disabled if possible, and every bridge method validates its inputs as if they came from an attacker.

Lesson, quiz and flashcards for App Integrity & Injection →

ADVANCED

Kotlin Multiplatform (KMP)

Sharing business logic, networking, and data across Android and iOS while choosing native or shared UI.

Walk me through how you'd introduce KMP into an app that currently has two fully separate Android and iOS codebases, without freezing feature work on either platform.

What a strong answer covers: A strong answer starts small: pick a stateless, low-risk slice like validation logic or a networking client to prove out the shared module, CI, and publishing pipeline before touching business-critical code, and keeps both native implementations running in parallel until the shared version is trusted. It also names that the real friction is organizational as much as technical, getting iOS engineers comfortable with Kotlin tooling and Xcode integration, and flags that trying to convert everything at once is the classic way these migrations stall.

When would you choose not to share the ViewModel or presentation layer in commonMain, even though KMP supports it, and keep it native on each platform instead?

What a strong answer covers: Sharing ViewModels works cleanly when both platforms want the same screen flow and state shape; if iOS and Android diverge in navigation pattern or interaction model, forcing a shared ViewModel creates a lowest-common-denominator abstraction that leaks through the UI. It should also note that SwiftUI's state and lifecycle model doesn't map 1:1 onto Android's ViewModel, so a shared ViewModel typically needs a thin native wrapper on iOS (an ObservableObject bridging a StateFlow), and many teams deliberately stop at sharing logic below the ViewModel to avoid that plumbing.

You've shipped a KMP module that's core to both apps, and a critical bug shows up only on iOS in the shared Kotlin/Native code. Walk me through how you'd debug it.

What a strong answer covers: A strong answer reproduces the failure with a fast unit test in commonTest or iosTest before touching the full app, then checks whether it's an interop issue such as threading or Kotlin GC interaction with Objective-C ARC, or an expect/actual mismatch that only fires on the iOS actual. It should also flag that iOS crash reports won't have readable Kotlin stack traces unless the KMP framework's debug symbols are set up for symbolication, and that debugging via LLDB in Xcode against the compiled framework is a heavier workflow than Android Studio's, which is a trap engineers new to KMP often underestimate.

Lesson, quiz and flashcards for Kotlin Multiplatform (KMP) →

Bluetooth, Sensors & Auto

Tests BLE GATT, the Android sensor framework, and Android Automotive OS versus Android Auto.

Walk me through the lifecycle of a BLE connection in a real app, from scanning through disconnection, and where things typically go wrong in production.

What a strong answer covers: A strong answer covers that BluetoothGattCallback methods land on a binder thread rather than the main thread, that gatt.close() must always be called on disconnect or the app leaks a system GATT client slot (the classic exhaustion bug after repeated connect attempts without cleanup), and that Android's BLE stack does not queue concurrent operations, so reads and writes must be serialized, waiting for each callback before issuing the next. It should also mention requesting a larger MTU for bigger payloads. The trap is treating GATT calls like fire-and-forget async network calls the way you would with Retrofit.

Why does battery life so often become a problem in apps using continuous sensors, like accelerometer-based motion detection, and what design choices actually move the needle?

What a strong answer covers: A strong answer names batching via registerListener's maxReportLatencyUs so the hardware FIFO buffer holds samples and wakes the CPU less often, preferring a low-power composite or hardware trigger sensor such as the step detector or significant motion sensor over raw accelerometer plus a custom algorithm, and unregistering promptly in onPause. It should also mention that Doze and background sensor restrictions on newer Android versions change what's even possible off-screen. The nuance a weak answer misses is that lowering the sample rate alone barely helps; the real lever is how often the CPU actually wakes up.

A product wants one app that runs both as a phone-projected Android Auto experience and natively on an Android Automotive OS head unit. What are the real architectural constraints, beyond just using the Car App Library?

What a strong answer covers: A strong answer explains that AAOS is a full standalone Android OS running directly on car hardware with no phone required, while Android Auto only projects UI from a connected phone, so an AAOS build must work without assuming a phone's auth session, connectivity, or data layer is present. The Car App Library templates handle the driver-distraction UI constraint, but the deeper design work is making the data and auth layers phone-independent, and testing across OEM head unit implementations that vary in behavior. The trap is treating AAOS as 'Android Auto but native' instead of a genuinely separate deployment target.

Lesson, quiz and flashcards for Bluetooth, Sensors & Auto →

Mobile System Design

Designing an Android feature end-to-end: layers, single source of truth, offline, pagination, and testing.

For a feature like a photo gallery with thousands of images, walk me through the trade-offs between loading everything into memory, paginating from a local database, and paginating straight from the network. When would you pick each?

What a strong answer covers: A strong answer rules out loading everything into memory past a few hundred items due to OOM and jank risk, and distinguishes database-backed paging, right when users scroll back and revisit content and you want instant offline re-render, from pure network pagination, which avoids storage and dedup cost for huge catalogs users rarely revisit. It should tie the choice to concrete signals like revisit frequency and offline requirements rather than defaulting to one pattern out of habit.

Mid-interview, the interviewer pushes back on your design and asks 'what if this needs to scale to ten times the traffic' or 'what if the network is really flaky.' Walk me through how you'd adapt your design live.

What a strong answer covers: A strong answer names specific pressure points the new constraint stresses, such as moving from offset to true cursor pagination, adding retry with backoff and idempotency keys to writes, or introducing a caching layer, and explicitly frames it as revising one component rather than starting over. The trap a weaker candidate falls into is redesigning from scratch, which signals they didn't understand why the original design held up in the first place.

Two engineers disagree about whether a feature needs a Domain layer of use cases, or whether the Repository should talk directly to the ViewModel. How do you actually decide, and what's the cost of getting it wrong either way?

What a strong answer covers: A strong answer ties the decision to whether business logic is reused across multiple ViewModels or is complex enough to warrant testing independently of Android framework classes; a single screen with simple pass-through logic often doesn't need the extra layer. It should name the cost of skipping it when needed, duplicated logic and hard-to-test ViewModels, against the cost of adding it when unneeded, extra indirection and boilerplate per screen, rather than asserting a blanket rule.

Lesson, quiz and flashcards for Mobile System Design →

Design: Offline-First Feed

Designing an offline-first feed with Room as source of truth, RemoteMediator paging, and WorkManager sync.

A social feed shows new posts appearing at the top in real time. Walk me through what actually breaks about naive offset-based pagination in that scenario.

What a strong answer covers: A strong answer explains that offset pagination assumes a stable ordering; when new items are inserted at the top, every subsequent page's offset shifts, causing items to be skipped or duplicated as the user scrolls, known as page drift. It should connect this to why RemoteMediator with a RemoteKeys table decouples the client's paging position from raw offsets by tracking stable keys per page, so insertions at the top don't corrupt pages already loaded further down.

Your team's feed screen currently fetches directly from the network with no local cache. You're asked to make it offline-first without a full rewrite. Walk me through the migration path.

What a strong answer covers: A strong answer sequences the change: first introduce Room as the source of truth by writing network responses into it and having the UI observe a Flow from Room instead of the raw network response, the network-bound resource pattern, and only after that read path is stable and tested, layer in RemoteMediator and RemoteKeys for paging, then WorkManager sync. The trap is trying to introduce Paging 3, RemoteMediator, and background sync all in one change, which makes it hard to review, test, and roll back independently.

In this feed, a 'like' can be edited offline and a caption can also be edited by its author elsewhere. Would you apply the same conflict-resolution strategy to both, and why?

What a strong answer covers: A strong answer distinguishes commutative, idempotent operations like a like toggle, which are safe to resolve with simple last-write-wins or by re-deriving from server-side counts, from non-commutative edits like caption text, where last-write-wins can silently discard a user's real edit and server-authoritative resolution with explicit conflict surfacing is safer. The nuance a weak answer misses is that picking one conflict strategy for the whole feed ignores that different data types tolerate silently losing a write very differently.

Lesson, quiz and flashcards for Design: Offline-First Feed →

Design: Real-Time Chat

Designing an Android chat app: real-time transport, local store, receipts, offline queue, and scale.

Compare a persistent WebSocket connection against periodic short-polling for a chat app's foreground transport. Where does polling actually still make sense despite WebSocket being the obvious choice?

What a strong answer covers: A strong answer concedes WebSocket wins on latency and bidirectional push, but names real costs: maintaining a persistent connection burns battery and radio wake time, and some carrier or corporate proxy setups silently kill long-lived sockets. It should note polling can be fine for low-volume, check-occasionally chat, and that any WebSocket implementation still needs a poll-like reconnect-and-catch-up fetch as a fallback anyway, so the two aren't as opposed as they first appear.

A user is logged into the same chat account on their phone and a tablet at once. Walk me through what has to change in your design to keep both devices consistent for read state, history, and typing indicators.

What a strong answer covers: A strong answer recognizes that read state can no longer be assumed single-session; the server needs to track last-read-sequence per device or account and push a reconciliation event so an action on one device propagates to the other, meaning local Room storage becomes a per-device cache rather than the sole source of truth for social state. It should note typing indicators are usually fine to keep ephemeral and per-device since they aren't persisted. The trap is a design that assumed single-session read receipts and silently breaks with a second device.

Where would you actually put end-to-end encryption in this chat design, and what does it break that you'd already designed for, like search, notification previews, and multi-device sync?

What a strong answer covers: A strong answer explains that E2EE means the server can no longer read message content, so server-side search must move to on-device indexing per client, push notifications can only carry a wake signal rather than message text since FCM payloads would otherwise be plaintext, and multi-device sync becomes materially harder because each device needs its own key material and a session-establishment or re-encryption mechanism for history. The signal is recognizing E2EE constrains search, notifications, and sync simultaneously, so it has to be decided early rather than bolted on later.

Lesson, quiz and flashcards for Design: Real-Time Chat →

BEHAVIORAL

Behavioural & the STAR Method

Structuring behavioural answers with STAR while showing ownership, collaboration, and engineering judgement.

Tell me about a time you had to influence a decision without direct authority over the people involved, maybe a PM, a backend team, or another squad's lead. Walk me through how you built the case.

What a strong answer covers: A strong answer uses STAR with clear scope, naming who was involved and what was actually at stake, and shows they led with data and framed the ask around shared goals rather than personal preference, closing with a concrete result such as the decision changing or a documented trade-off both sides accepted. The trap is a story that's really about pushing an opinion through by being persistent rather than by building a coalition or using evidence.

Describe a project where you underestimated the complexity and it slipped. What did you actually change about how you scope or estimate work afterward?

What a strong answer covers: A strong answer names the specific miss, such as not accounting for a third-party API's edge cases or underestimating a migration's blast radius, is honest about the consequence without deflecting blame, and describes a durable process change, like spiking unknowns before committing to an estimate, rather than a vague claim of better communication. The trap is a 'lesson learned' so generic it wouldn't actually prevent the same slip recurring.

Tell me about the largest-scope technical decision you've owned end to end, where you set the direction rather than executed someone else's plan. What trade-off did you accept, and how did you know it was right?

What a strong answer covers: A strong answer for a senior candidate names real scope, how many engineers, screens, or systems were affected, states the trade-off explicitly, such as short-term velocity for long-term maintainability, and describes validating the decision afterward against concrete signals like adoption, bug rate, or ship cadence rather than just asserting it worked. The trap is a story where the 'decision' was actually just implementing someone else's spec, with no independent judgment or after-the-fact check on the outcome.

Lesson, quiz and flashcards for Behavioural & the STAR Method →

The Senior Signal

What separates senior from mid-level: ownership, trade-offs, migrations, performance, and production maturity.

Walk me through how you'd decide whether a growing app actually needs to be split into multiple Gradle modules, versus staying single-module a while longer.

What a strong answer covers: A strong answer ties the decision to pain already being felt, slow incremental builds, unclear ownership causing merge conflicts, or a genuine need for isolated feature delivery, rather than modularizing preemptively because it's considered best practice. It should note that premature modularization has real costs, more boilerplate, harder navigation, slower initial setup, that aren't justified until team size or build time crosses an actual threshold. The trap is treating modularization as an unconditional good instead of a trade-off with a break-even point.

You inherit a team that ships fast but has no staged rollouts, no feature flags, and minimal crash monitoring. Where do you start, and how do you get buy-in without just mandating process?

What a strong answer covers: A strong answer sequences the highest-leverage, lowest-friction change first, usually crash monitoring since you can't improve what you can't see, then staged rollouts since they're nearly free once monitoring exists, and only then feature flags for genuinely risky work. It should describe getting buy-in by tying each addition to a concrete incident the team already felt rather than presenting it as process for its own sake. The trap is proposing all the maturity practices at once, which reads as dogma rather than sequencing judgment.

Two senior engineers on your team disagree about whether a risky architecture change, like adopting a new DI framework or restructuring navigation, is worth doing right now. How do you resolve that, and what do you do if you're the one who's wrong?

What a strong answer covers: A strong answer names the actual constraints, timeline, team bandwidth, blast radius, reversibility, and proposes a small reversible experiment with an explicit re-evaluation point and named signals to check the decision against, rather than a big-bang commitment. On being wrong, they describe genuinely changing course when evidence contradicts their position and communicating the reversal to the team as a normal part of engineering. The trap is a candidate who describes winning the disagreement rather than describing a mechanism for finding out who was actually right.

Lesson, quiz and flashcards for The Senior Signal →