Question bank

Every question, in one place

236 questions across 7 topics. Filter, search, and expand for full answers. The Core view keeps Junior and Mid questions together; Senior is an optional deep dive.

Explain the coroutine dispatchers: Main, IO, Default, Unconfined. When do you use each?
Junior Coroutines & Flow #coroutines#dispatchers#threading

A dispatcher decides which thread(s) a coroutine runs on.

  • Dispatchers.Main - the Android UI thread. Use for touching views/Compose state. Main.immediate runs synchronously if you’re already on Main, avoiding an extra re-dispatch.
  • Dispatchers.IO - an elastic dispatcher tuned for blocking I/O: legacy network calls, file reads, and blocking database APIs. Its default parallelism limit is at least 64 or the number of CPU cores (whichever is larger), and it shares threads with Default.
  • Dispatchers.Default - a pool sized to the number of CPU cores, for CPU-bound work: parsing, sorting, JSON, image processing.
  • Dispatchers.Unconfined - starts in the calling thread and resumes in whatever thread the suspending function used. Rarely used in app code; mainly for specific library/testing cases.
suspend fun loadAndProcess(): Result = withContext(Dispatchers.IO) {
    val raw = legacyBlockingApi.download()      // blocking I/O → IO pool
    withContext(Dispatchers.Default) {
        parseAndSort(raw)                       // CPU-heavy → Default
    }
}

Key points interviewers probe:

  • IO vs Default is the most-asked distinction: IO for waiting (blocking calls), Default for computing. They actually share threads, but IO permits many more concurrent blocking ops.
  • Main-safe suspending APIs such as Retrofit’s suspend support and Room’s suspend queries already keep blocking work off Main; don’t add an IO hop by reflex. Check the API contract.
  • withContext(Dispatchers.IO) is preferred over launch(Dispatchers.IO) for “do this blocking thing and give me the result.”
  • limitedParallelism(n) carves a bounded view out of a dispatcher to cap concurrency (e.g. one network host).
Explain the scope functions: let, run, with, apply, also. How do you choose?
Junior Kotlin Language #kotlin#scope-functions#stdlib

They all execute a block on an object; they differ in how you reference the object (it vs this) and what they return (the object vs the lambda result).

FunctionReceiverReturnsTypical use
letitlambda resultnull-checks, transform a value
runthislambda resultrun a block + return a result
withthislambda resultgroup calls on one object (not an extension)
applythisthe objectconfigure/build an object
alsoitthe objectside effects (logging, validation)
// let - operate on a nullable, transform
val len = name?.let { it.trim().length } ?: 0

// apply - configure and return the same object
val paint = Paint().apply {
    color = Color.RED
    isAntiAlias = true
}

// also - side effect, pass through
val user = repo.load().also { Log.d("TAG", "loaded $it") }

How to choose (the mental model interviewers like):

  • Need the result of the block? → let / run / with.
  • Need the object back (chaining/config)? → apply / also.
  • Referencing members a lot? → this-receivers (run/with/apply) read cleaner.
  • Want an explicit name for clarity? → it-receivers (let/also).

apply for building, also for side effects, let for null-safe transforms are the three you’ll reach for most.

How are coroutines different from threads? Why are they called 'lightweight'?
Junior Coroutines & Flow #coroutines#threads#fundamentals

A thread is an OS-level construct with a large fixed stack (~1MB+ on the JVM). Creating thousands is expensive in memory and context-switching. A coroutine is a language-level construct - essentially a resumable computation - that runs on top of threads.

The key differences:

  • Cheap. You can launch hundreds of thousands of coroutines; they’re objects, not OS threads. Many coroutines share a small pool of threads.
  • Suspend, don’t block. A coroutine waiting on I/O suspends and frees its thread for other coroutines. A blocked thread sits idle holding its stack.
  • Structured. Coroutines form parent/child scopes with automatic cancellation and error propagation - threads have none of that.
  • Cooperative scheduling at suspension points, vs. preemptive OS thread scheduling.
// 100k coroutines - fine. 100k threads - OutOfMemoryError.
repeat(100_000) {
    launch { delay(1000); print(".") }
}

coroutines don’t replace threads - they multiplex work onto threads efficiently. “Lightweight” means the cost is a small state-machine object plus a continuation, not an OS thread.

Interview clincher: “Suspension releases the underlying thread; blocking holds it. That’s why a handful of Dispatchers.IO threads can serve thousands of concurrent suspended network calls.”

How does Kotlin's null safety work, and what does !! actually do?
Junior Kotlin Language #kotlin#null-safety

Kotlin encodes nullability in the type system. String can never be null; String? can. The compiler forces you to handle the nullable case before you can dereference it, which eliminates most NullPointerExceptions at compile time.

The main tools:

  • Safe call ?. - returns null instead of throwing if the receiver is null: user?.name.
  • Elvis ?: - supply a fallback: user?.name ?: "Guest".
  • Smart casts - after a != null check, the compiler treats the variable as non-null inside that block.
  • !! (not-null assertion) - tells the compiler “trust me, this isn’t null.” If it is, it throws an NPE. It’s an escape hatch that throws away the guarantee you came for.
val length = name?.length ?: 0   // safe
val forcedLength = name!!.length // throws if name is null

Practical guidance: !! is a code smell - reserve it for genuine impossibilities, and prefer ?., ?:, requireNotNull() (which throws a meaningful message), or restructuring so the value can’t be null. Also mention platform types (String!) from Java interop: the compiler can’t verify them, so annotate Java APIs or null-check at the boundary.

List vs Sequence - what's the performance difference?
Junior Kotlin Language #kotlin#collections#sequence#performance

The headline difference is eager vs lazy evaluation, but Sequence is not automatically faster.

  • On a List, each operation (map, filter, …) is processed fully and creates a new intermediate list before the next operation runs. It’s horizontal: do all the maps, then all the filters.
  • On a Sequence, elements flow through the whole chain one at a time, lazily, with no intermediate collections. It’s vertical: each element goes through map → filter → … until a terminal operation pulls it.
// List: builds a full mapped list of a million items, then filters it
val r1 = (1..1_000_000).map { it * 2 }.filter { it % 3 == 0 }.first()

// Sequence: pulls elements until first match - barely any work
val r2 = (1..1_000_000).asSequence().map { it * 2 }.filter { it % 3 == 0 }.first()

Use ordinary collection operations for small inputs or a single transformation: they are simple and often faster because sequences add iterator/lambda overhead. Reach for Sequence when you have a large input, several intermediate operations, or a short-circuiting terminal operation such as first, take, or any. Measure hot paths instead of treating laziness as a universal optimization.

When sequences win: large collections, multiple chained operations, or short-circuiting terminals (first, take, find) - you avoid allocating big intermediate lists and can stop early.

When lists win: small collections or a single operation. Sequences add per-element overhead (an iterator hop per stage), so for small data the simpler List is actually faster.

val vs var vs const val - what's the difference?
Junior Kotlin Language #kotlin#basics#immutability
  • var - a mutable (reassignable) variable.
  • val - a read-only reference. You can’t reassign it, but the object it points to may still be mutable (val list = mutableListOf(1) lets you list.add(2)).
  • const val - a compile-time constant. It’s inlined at the call site and must be a top-level or object/companion object member with a primitive or String value.
const val API_VERSION = "v1"        // compile-time, inlined
val createdAt = System.currentTimeMillis()  // runtime, just read-only
var counter = 0                      // mutable

Important distinction: val is about the reference being immutable, not deep immutability. const is resolved by the compiler, so it can’t hold anything computed at runtime.

Prefer val by default - it makes code easier to reason about and is required for things like smart casts on properties.

What are extension functions, and how are they resolved?
Junior Kotlin Language #kotlin#extensions#dispatch

An extension function doesn’t actually modify the class. The compiler turns it into a static method that takes the receiver as its first argument. So this:

fun String.shout() = uppercase() + "!"
"hi".shout()

compiles to roughly StringExtKt.shout("hi").

The crucial consequence: extensions are dispatched statically, by the declared type, not the runtime type. There’s no virtual dispatch / polymorphism.

open class A
class B : A()
fun A.name() = "A"
fun B.name() = "B"

val x: A = B()
println(x.name())   // "A"  - uses the static type A, not B

Other things to know:

  • A member function always wins over an extension with the same signature.
  • Extensions can’t access private/protected members of the receiver - they’re just outside static functions.
  • They’re great for keeping APIs focused and adding utilities to types you don’t own (Context, View, Flow), which is why Android codebases lean on them heavily.

Interview trap: the polymorphism question above. If you say it prints “B”, that’s the classic miss.

What are higher-order functions and function types in Kotlin?
Junior Kotlin Language #kotlin#lambdas#functional

A higher-order function takes a function as a parameter and/or returns one. Functions are first-class values, with types like (Int) -> String or (T) -> Unit.

fun <T> List<T>.customFilter(predicate: (T) -> Boolean): List<T> {
    val result = mutableListOf<T>()
    for (item in this) if (predicate(item)) result.add(item)
    return result
}

val evens = listOf(1, 2, 3, 4).customFilter { it % 2 == 0 }

Worth knowing:

  • Trailing lambda syntax - if the last parameter is a function, you can move the lambda outside the parentheses: customFilter { it > 0 }.
  • it is the implicit name for a single-parameter lambda.
  • Function references - pass an existing function with ::: list.filter(::isValid).
  • A lambda is compiled to a Function object (allocation) unless the function is inline.

This is the backbone of the Kotlin stdlib (map, filter, forEach) and of idiomatic APIs like Compose and coroutine builders.

What are viewModelScope and lifecycleScope? When is each cancelled?
Junior Coroutines & Flow #coroutines#scopes#lifecycle#android

These are pre-built CoroutineScopes tied to Android lifecycles, so your coroutines are cancelled automatically.

  • viewModelScope - an extension on ViewModel. Cancelled in onCleared(), i.e. when the ViewModel is destroyed for good (the screen is finished, not just rotated). Uses Dispatchers.Main.immediate + a SupervisorJob. This is where most app coroutines live, since the ViewModel survives configuration changes.
  • lifecycleScope - an extension on a LifecycleOwner (Activity/Fragment). Cancelled when the lifecycle reaches DESTROYED. Use sparingly - for UI-only work that genuinely must follow the view, not the data.
class FeedViewModel : ViewModel() {
    fun refresh() = viewModelScope.launch {     // cancelled in onCleared()
        _state.value = repo.load()
    }
}

Why this matters: before these existed, you manually cancelled jobs in onDestroy/onCleared - easy to forget, causing leaks and callbacks firing on dead screens. Structured concurrency + lifecycle scopes make that automatic.

Gotchas:

  • Don’t run data work in lifecycleScope - on rotation the Activity is destroyed and the work is cancelled and restarted. Put it in the ViewModel.
  • GlobalScope is not lifecycle-aware - coroutines launched there outlive everything and leak. Avoid it.
  • For collecting flows in the UI, pair lifecycleScope with repeatOnLifecycle(STARTED) so collection pauses in the background.
What does a data class generate for you, and what are its limitations?
Junior Kotlin Language #kotlin#data-class

For the properties declared in the primary constructor, the compiler generates:

  • equals() / hashCode() - structural equality based on those properties
  • toString() - readable, e.g. User(id=1, name=Ada)
  • componentN() - enables destructuring (val (id, name) = user)
  • copy() - create a modified clone
data class User(val id: Int, val name: String)

val a = User(1, "Ada")
val b = a.copy(name = "Grace")   // User(id=1, name=Grace)
val (id, name) = b               // destructuring

Limitations / gotchas:

  • Only primary-constructor properties count toward equals/hashCode/toString. A property declared in the body is ignored by them.
  • A data class can’t be abstract, open, sealed, or inner.
  • The primary constructor needs at least one parameter, and they must all be val/var.
  • copy() does a shallow copy - nested mutable objects are shared.
data class Team(val members: MutableList<String>)
val first = Team(mutableListOf("Ada"))
val second = first.copy()
second.members += "Grace"
println(first.members) // [Ada, Grace]; both copies share the list

Common follow-up: “Two data classes with the same fields - are they equal?” No. equals also checks the runtime type, so different classes are never equal even with identical fields.

What is a companion object? Is it the same as Java's static?
Junior Kotlin Language #kotlin#companion-object#static

Kotlin has no static. A companion object is a single object tied to a class that lets you call members on the class name:

class User private constructor(val id: Int) {
    companion object {
        const val TABLE = "users"
        fun create(id: Int) = User(id)   // factory
    }
}

User.create(1)      // looks static
User.TABLE

But it’s not the same as static - it’s a real object instance (User.Companion). That means it can:

  • implement interfaces and extend classes,
  • be passed as a value,
  • have extension functions.

Implications interviewers probe:

  • Members are not truly static on the JVM unless you add @JvmStatic (useful for Java callers) - otherwise Java sees User.Companion.create(...).
  • const val and @JvmField do compile to genuine static fields.
  • There’s one companion object per class, and it’s initialized when the class is first loaded - so it’s a handy place for factories and constants, but heavy work there delays class loading.

Common follow-up: “How do you make a singleton?” Use a top-level object Foo { }, not a companion - the companion belongs to a class, a top-level object stands alone.

What is a suspend function, and how does suspension actually work under the hood?
Junior Coroutines & Flow #coroutines#suspend#internals

A suspend function is one that can pause without blocking the thread and resume later. The keyword is a contract: it can only be called from another suspend function or a coroutine.

Under the hood - Continuation Passing Style (CPS): the compiler rewrites a suspend function to take an extra hidden parameter, a Continuation (a callback for “what to do when I resume”). The function body is transformed into a state machine: each suspension point is a state, and local variables are saved on the continuation object.

suspend fun loadUser(): User {
    val token = auth()      // suspension point 1
    val user = api(token)   // suspension point 2
    return user
}

Conceptually compiles to something like a switch over a label:

  • State 0: call auth(continuation), save label = 1, return COROUTINE_SUSPENDED.
  • When auth completes, it invokes the continuation → re-enters at state 1, and so on.

Why this matters:

  • Suspension frees the thread to do other work - that’s how thousands of coroutines run on a small pool. A blocked thread sits idle; a suspended coroutine doesn’t hold a thread.
  • suspend alone doesn’t move work off the main thread - you still need withContext(Dispatchers.IO) for blocking work. suspend means “can suspend,” not “runs in the background.”
  • There’s no magic threading; it’s compiler-generated callbacks that look like sequential code.
What's the difference between launch and async in coroutines?
Junior Coroutines & Flow #coroutines#concurrency

Both are coroutine builders, but they differ in what they return and how you use the result.

  • launch starts a coroutine and returns a Job. Use it for work whose outcome is completion rather than a returned value. It is still owned by its scope. Callers should be able to cancel it or observe failure, so “fire and forget” is a misleading mental model.
  • async returns a Deferred<T>, a Job that also carries a result. You call .await() to get the value. Use it for concurrent work you need to combine.
// Run two network calls concurrently, then combine.
suspend fun loadDashboard() = coroutineScope {
    val user = async { api.getUser() }
    val feed = async { api.getFeed() }
    Dashboard(user.await(), feed.await())
}

Interview trap: calling async { ... }.await() immediately, one after another, runs them sequentially - you’ve lost the concurrency. Start all the async blocks first, then await them.

Exception nuance: async stores its failure in the Deferred, so await() rethrows it. But if that async is a regular child, its failure also cancels its parent immediately. Waiting to call await() does not prevent structured-concurrency propagation. A root launch reports an uncaught failure immediately; a root async exposes it through await().

When should an API return a suspend value, a Flow, or a Sequence?
Junior Coroutines & Flow #coroutines#flow#suspend#sequence#api-design

Choose based on how many values arrive and whether producing them may suspend.

API shapeValuesCan suspend between values?Typical use
suspend fun load(): Userone resultyesone network/database operation
fun observe(): Flow<User>zero to many over timeyesdatabase updates, UI state, events
fun parse(): Sequence<Row>many, pulled synchronouslynolazy in-memory or blocking iteration
suspend fun user(id: Long): User = api.fetchUser(id) // one eventual answer

fun observeUser(id: Long): Flow<User> =
    dao.observeUser(id)                              // updates over time

A suspend function does not imply a background thread; it returns one result and may suspend while obtaining it. A cold Flow is also lazy, but collection can receive multiple values and is cancelled with the collecting coroutine. A Sequence is lazy but synchronous. Its iterator cannot call suspending APIs.

Interview trap: do not return Flow merely to wrap one network response. A suspend function is clearer unless the operation genuinely emits progress, retries as values, or later updates.

When would you use StateFlow over LiveData?
Junior Coroutines & Flow #flow#state#lifecycle

StateFlow and LiveData are both observable, lifecycle-friendly state holders, but StateFlow is the modern default in a coroutine-first codebase.

LiveDataStateFlow
Always has a valueNo; it may be unsetYes (requires initial value)
Lifecycle-awareBuilt inVia repeatOnLifecycle / collectAsStateWithLifecycle
OperatorsFew (map, switchMap)Full Flow operator set
ThreadingMain-thread boundAny dispatcher
Pure Kotlin (testable, multiplatform)No (Android dep)Yes

Choose StateFlow when you want a single source of truth that’s always set, you need Flow operators (combine, debounce, flatMapLatest), or you’re in a shared/KMP module with no Android dependency. Collect it with lifecycle awareness (repeatOnLifecycle in Views or collectAsStateWithLifecycle in Compose).

private val _state = MutableStateFlow(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()

The catch: StateFlow isn’t lifecycle-aware on its own. Collect it safely so you don’t waste work while the UI is in the background:

// Compose
val state by viewModel.state.collectAsStateWithLifecycle()

// Views
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.state.collect { render(it) }
    }
}

Reach for SharedFlow instead for one-off events (navigation, snackbars) where you don’t want a “current value” replayed on rotation.

Why are Kotlin classes final by default? How do open, abstract, and interfaces differ?
Junior Kotlin Language #kotlin#classes#inheritance#open

Kotlin classes and members are final by default: they cannot be inherited or overridden unless you explicitly allow it. This nudges code toward composition and makes extension points deliberate.

open class Vehicle {
    open fun move() = "moving"
    fun stop() = "stopped" // final; cannot be overridden
}

class Bike : Vehicle() {
    override fun move() = "pedalling"
}
  • open class: may be instantiated and subclassed. Only open members may be overridden.
  • abstract class: cannot be instantiated; may hold constructor state, implemented methods, and abstract members. Abstract members are implicitly open.
  • interface: defines a capability or contract. It can contain default method bodies and properties without backing fields, and a class may implement several interfaces.

Use an abstract class when related implementations need shared state or construction. Use interfaces for roles that unrelated types can implement. Prefer composition when you only want to reuse behavior because inheritance creates tighter coupling.

Common follow-up: an override member is open by default. Mark it final override when subclasses must not replace it again.

Explain the Builder pattern. Is it still needed in Kotlin?
Junior Architecture & Patterns #design-patterns#builder#kotlin

The Builder pattern constructs a complex object step by step, avoiding telescoping constructors (many overloads) and making optional parameters readable.

// Java: classic builder
Notification n = new NotificationCompat.Builder(context, channelId)
    .setContentTitle("Hi")
    .setContentText("Body")
    .setSmallIcon(R.drawable.ic)
    .setAutoCancel(true)
    .build();

Where it appears in Android: NotificationCompat.Builder, AlertDialog.Builder, Retrofit.Builder, OkHttpClient.Builder, Room.databaseBuilder, WorkRequest.Builder, Intent (chained putExtra). These predate Kotlin or come from Java APIs.

Is it still needed in Kotlin? Often not - Kotlin’s default and named arguments replace most builders:

data class RequestConfig(
    val url: String,
    val timeout: Long = 30_000,
    val retries: Int = 3,
    val headers: Map<String, String> = emptyMap(),
)
RequestConfig(url = "...", retries = 5)   // no builder needed

For more builder-like ergonomics, Kotlin uses:

  • apply { } to configure an object fluently.
  • Type-safe DSL builders - a lambda with receiver (buildString { }, Modifier chains, Gradle Kotlin DSL) - the idiomatic Kotlin “builder.”

When a builder still earns its place in Kotlin:

  • Java interop - your API is consumed from Java (no default args there).
  • Step-by-step validation or enforcing a build order / required-before-optional sequencing.
  • Mirroring an established API style for familiarity.
Explain the Facade pattern and how it relates to the Repository.
Junior Architecture & Patterns #design-patterns#facade#repository

A Facade provides a simple, unified interface over a complex subsystem, hiding its internal parts from callers.

// Facade over several subsystems
class MediaFacade(
    private val downloader: Downloader,
    private val decoder: Decoder,
    private val cache: MediaCache,
) {
    suspend fun play(url: String) {          // one simple call...
        val bytes = cache.get(url) ?: downloader.fetch(url).also { cache.put(url, it) }
        val media = decoder.decode(bytes)    // ...hides downloader + decoder + cache
        player.start(media)
    }
}

The caller uses play(url) and never touches the downloader, decoder, or cache directly.

Why use it:

  • Simplifies usage - clients deal with one entry point instead of orchestrating many classes.
  • Decouples clients from subsystem internals - you can restructure the subsystem without breaking callers.
  • Reduces coupling and centralizes a workflow.

How it relates to the Repository: a Repository is essentially a Facade over data sources - it hides the network client, database, cache, and the coordination logic behind a clean API (observeUser()), so the ViewModel doesn’t know whether data came from Room or Retrofit. Many Android “manager”/“controller” classes are facades too.

Other Android examples: a SessionManager wrapping token storage + refresh + auth headers; an AnalyticsFacade over multiple analytics SDKs; Retrofit itself is a facade over OkHttp + converters + call adapters.

Caution: a facade can grow into a God object if it accumulates too many responsibilities - keep it focused on simplifying access, not doing everything.

Explain the Observer pattern and where it appears in Android.
Junior Architecture & Patterns #design-patterns#observer#reactive

The Observer pattern defines a one-to-many dependency: a subject maintains a list of observers and notifies them automatically when its state changes. It decouples the producer of data from its consumers.

// The essence: subscribe, get notified on change
interface Observer<T> { fun onChanged(value: T) }
class Subject<T>(initial: T) {
    private val observers = mutableListOf<Observer<T>>()
    var value: T = initial
        set(v) { field = v; observers.forEach { it.onChanged(v) } }
    fun observe(o: Observer<T>) { observers += o }
}

Where it’s everywhere in Android:

  • LiveData - observe and get lifecycle-aware updates.
  • Flow / StateFlow / SharedFlow - the coroutine-based reactive streams; collect is observing.
  • Compose state - reading a State subscribes the composable; writes notify readers (recomposition).
  • RecyclerView.AdapterDataObserver, click listeners, ViewTreeObserver, LifecycleObserver.
  • RxJava Observable/Observer - the pattern in its named form.

Why it matters architecturally: it’s the backbone of reactive, UDF apps - the UI observes state from the ViewModel and updates automatically, instead of the ViewModel reaching into the UI. This inverts the dependency (UI depends on data, not vice versa).

Trade-offs to mention:

  • Lifecycle leaks - observers not unregistered (or not lifecycle-aware) leak or update dead UI. LiveData/repeatOnLifecycle solve this.
  • Notification storms / ordering - too many fine-grained updates can cause churn (hence distinctUntilChanged, conflation, derivedStateOf).
Explicit vs implicit Intents, and how do intent filters work?
Junior Android Fundamentals #intents#components

An Intent is a messaging object to request an action from a component (start an Activity/Service, deliver a broadcast).

Explicit intent - names the exact target component. Used within your app.

startActivity(Intent(this, DetailActivity::class.java).putExtra("id", 42))

Implicit intent - describes an action, and the system finds a component (often in another app) that can handle it via intent filters.

startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")))
startActivity(Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"; putExtra(Intent.EXTRA_TEXT, "Hi")
})

Intent filters (in the manifest) declare what implicit intents a component accepts, matched on action, category, and data (scheme/host/mimeType):

<activity android:name=".ShareActivity">
    <intent-filter>
        <action android:name="android.intent.action.SEND"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <data android:mimeType="text/plain"/>
    </intent-filter>
</activity>

What to remember:

  • Always verify an implicit intent resolves (resolveActivity / wrap in try-catch) or no app may handle it.
  • Modern Android requires <queries> in the manifest (package visibility) to query/launch other apps’ intents on API 30+.
  • Deep links / App Links are implicit ACTION_VIEW intents with a <data> URL filter; verified App Links open your app directly without a chooser.
  • Extras pass data via putExtra/getXxxExtra; complex objects need Parcelable.
How do default and named arguments work, and how do they replace the builder pattern?
Junior Kotlin Language #kotlin#functions#default-args

Default arguments let a parameter have a fallback, so callers can omit it. Named arguments let callers pass parameters by name in any order, which makes calls readable and lets you skip optional ones in the middle.

fun showSnackbar(
    message: String,
    duration: Int = LENGTH_SHORT,
    actionLabel: String? = null,
    onAction: (() -> Unit)? = null,
) { /* ... */ }

// Call only what you need, by name:
showSnackbar("Saved")
showSnackbar("Undo delete", actionLabel = "Undo", onAction = { restore() })

Together they replace most builder patterns and telescoping overloads in Kotlin - no Builder class, no five overloaded constructors. One function with defaults covers it.

Interop gotchas:

  • Java callers don’t see Kotlin defaults. Add @JvmOverloads to generate overloads for them - essential when writing a custom View whose constructors Java/XML inflation calls.
  • Named arguments don’t work when calling Java methods (the parameter names aren’t reliably in the bytecode).
How do runtime permissions work, and what are the modern best practices?
Junior Android Fundamentals #permissions#security

Since Android 6 (Marshmallow), dangerous permissions (location, camera, contacts, microphone) must be requested at runtime, not just declared in the manifest. Normal permissions (internet, vibrate) are granted at install.

The flow with the modern Activity Result API:

val launcher = registerForActivityResult(RequestPermission()) { granted ->
    if (granted) startCamera() else showRationaleOrSettings()
}

when {
    checkSelfPermission(CAMERA) == PERMISSION_GRANTED -> startCamera()
    shouldShowRequestPermissionRationale(CAMERA) -> showRationale { launcher.launch(CAMERA) }
    else -> launcher.launch(CAMERA)
}

Key behaviors & best practices:

  • Request in context, just-in-time - ask for the camera permission when the user taps “take photo,” not at app launch. Show rationale if the user previously denied.
  • shouldShowRequestPermissionRationale returns true after one denial; if the user selects “Don’t ask again” (or denies twice on Android 11+), the system auto-denies and you must guide them to Settings.
  • Location tiers - ACCESS_COARSE/FINE, and background location (ACCESS_BACKGROUND_LOCATION) must be requested separately and is heavily scrutinized.
  • One-time & approximate location (Android 10/12+) - users can grant “only this time” or coarse-only; handle partial grants.
  • New granular media permissions (Android 13+): READ_MEDIA_IMAGES/VIDEO/AUDIO replace READ_EXTERNAL_STORAGE; Android 14 adds partial photo access (selected photos).
  • Don’t over-ask - Play flags apps that request sensitive permissions without justification; use scoped storage, the Photo Picker, and CameraX’s system UI to avoid needing some permissions at all.
How do vararg and the spread operator work?
Junior Kotlin Language #kotlin#functions#vararg

vararg lets a function accept a variable number of arguments; inside the function the parameter is an Array.

fun sum(vararg numbers: Int): Int = numbers.sum()
sum(1, 2, 3)        // pass any count
sum()               // or none

To pass an existing array where a vararg is expected, use the spread operator *, which unpacks the array into individual arguments:

val arr = intArrayOf(1, 2, 3)
sum(*arr)                       // spread
sum(0, *arr, 4)                 // can mix with other args

Points to know:

  • A function can have only one vararg parameter. If it’s not the last one, later parameters must be passed by name.
  • For reference types it’s an Array<out T>; for primitives use the specialized arrays (IntArray) to avoid boxing.
  • Spread copies the array’s references into the call, so it’s a shallow pass.

Real use: listOf(vararg elements: T), arrayOf(...), and forwarding args: fun log(vararg args: Any) = print(format(*args)).

How do you use @Preview effectively, and what makes a composable preview-friendly?
Junior Jetpack Compose #compose#preview#tooling

@Preview renders a composable in Android Studio without running the app or a device - fast iteration on UI.

@Preview(showBackground = true)
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Large font", fontScale = 1.5f)
@Composable
private fun ProfileCardPreview() {
    AppTheme {
        ProfileCard(user = User("Ada", "ada@x.com"))
    }
}

Useful features:

  • Multiple previews on one function (light/dark, font scales, locales, device sizes) - or a @PreviewParameter provider to render several data states (loading/empty/error/loaded) at once.
  • Custom annotation classes (@PreviewLightDark, or your own multi-preview annotation) to apply a standard set everywhere.
  • Interactive Preview and Live Edit for clicking through and editing without rebuilds.

What makes a composable preview-friendly (the real point):

  • It must be stateless / driven by parameters - a composable that fetches data from a ViewModel or reads a real repository can’t preview cleanly. Hoist state and pass plain data.
  • Wrap previews in your theme so colors/typography render correctly.
  • Pass fake/sample data rather than real dependencies.

This is a strong argument for the stateless composable + state hoisting pattern: previewability falls out of it for free. A screen split into a stateful “screen” (wires the ViewModel) and a stateless “content” (pure params) lets you preview the content in every state.

Bonus: previews double as screenshot tests (Paparazzi/Roborazzi) to catch visual regressions in CI without a device.

How does Android's resource system and configuration qualifiers work?
Junior Android Fundamentals #resources#configuration#localization

Android picks the best-matching resource for the current device configuration at runtime, using qualified resource directories. You provide alternatives; the system selects.

res/
├── values/strings.xml            # default
├── values-es/strings.xml         # Spanish
├── values-night/colors.xml       # dark mode
├── drawable-hdpi/ic.png          # density buckets
├── drawable-xxhdpi/ic.png
├── layout/activity_main.xml      # default layout
├── layout-sw600dp/activity_main.xml   # tablets (smallest width ≥ 600dp)
└── mipmap-xxhdpi/ic_launcher.png # launcher icons

Common qualifiers (in precedence order): locale (-es, -fr), layout direction (-ldrtl), smallest width (-sw600dp), screen width/orientation (-w820dp, -land), night mode (-night), density (-hdpi/-xxhdpi), and API level (-v29).

Why it matters:

  • Localization - translate by adding values-<lang> folders; never hardcode strings (use @string/... and getString()).
  • Dark mode - values-night / -night resources are auto-selected; no code branching.
  • Density independence - provide density buckets (or a single vector drawable that scales) so images look crisp on all screens; use dp for layout and sp for text.
  • Responsive layouts - -sw600dp/-w600dp for tablets and foldables.
  • API-specific - -v29 for resources only valid on newer APIs.

What to remember:

  • The system falls back to the default (values/) when no qualified match exists.
  • Use vector drawables to avoid shipping many density PNGs.
  • Access in code via R.string.x, R.drawable.y; the qualifier resolution is automatic.
  • A configuration change (rotation, locale, dark mode) re-resolves resources - which is why the Activity recreates.
How does Kotlin handle exceptions differently from Java?
Junior Kotlin Language #kotlin#exceptions#error-handling

The headline difference: Kotlin has no checked exceptions. Every exception is unchecked, so you’re never forced to try/catch or declare throws. This removes Java’s boilerplate but means the compiler won’t remind you an API can fail - you have to know.

// No "throws IOException" needed; caller isn't forced to handle it
fun readConfig(): String = File("config").readText()

Other points:

  • try is an expression - it returns a value:
    val n = try { input.toInt() } catch (e: NumberFormatException) { 0 }
  • @Throws - annotate a function so Java callers see the checked exception (needed for interop, e.g. a function Java code must catch).
  • runCatching wraps a block in a Result<T>, turning exceptions into values for functional handling:
    val result = runCatching { api.fetch() }
        .map { it.body }
        .getOrElse { fallback }
  • Nothing is the type of throw, which is why it slots into any expression (val x = a ?: throw ...).

Caution interviewers like to hear: don’t catch broad Exception around coroutine code - it can swallow CancellationException and break structured cancellation. Rethrow it, or catch specific types.

How does theming work in Compose?
Junior Jetpack Compose #compose#theming#material

MaterialTheme provides three systems down the tree via CompositionLocals: colors, typography, and shapes.

@Composable
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
    val colors = when {
        // Material 3 dynamic color (Android 12+): derive from the wallpaper
        dynamicColorAvailable() && darkTheme -> dynamicDarkColorScheme(LocalContext.current)
        dynamicColorAvailable() -> dynamicLightColorScheme(LocalContext.current)
        darkTheme -> DarkColors
        else -> LightColors
    }
    MaterialTheme(colorScheme = colors, typography = AppTypography, content = content)
}

Reading the theme anywhere inside:

Text("Hi", color = MaterialTheme.colorScheme.primary,
     style = MaterialTheme.typography.titleMedium)

Key points:

  • Single source of truth - define colors/type/shapes once; components read from MaterialTheme.*. Don’t hardcode colors in composables.
  • Dark mode is just a different ColorScheme. isSystemInDarkTheme() follows the system; toggling is swapping the scheme - the whole tree recomposes with new colors.
  • Dynamic color (Material You) derives a scheme from the user’s wallpaper on Android 12+. Provide static fallbacks for older versions.
  • Custom design systems - wrap or replace MaterialTheme with your own CompositionLocalProviders (custom spacing, brand colors) and expose them via a Theme object.
  • Theme values flow through staticCompositionLocalOf, so reads are cheap but changing the theme recomposes the provided subtree.
How should you implement a splash screen on modern Android?
Junior Android Fundamentals #splash-screen#startup

Use the androidx.core:core-splashscreen library / the SplashScreen API (standardized in Android 12), not a dedicated splash Activity.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        val splash = installSplashScreen()    // BEFORE super.onCreate / setContentView
        super.onCreate(savedInstanceState)

        // Keep the splash visible until data is ready
        splash.setKeepOnScreenCondition { viewModel.isLoading.value }
    }
}

Configure the icon/background via a theme:

<style name="Theme.App.Starting" parent="Theme.SplashScreen">
    <item name="windowSplashScreenBackground">@color/brand</item>
    <item name="windowSplashScreenAnimatedIcon">@drawable/logo</item>
    <item name="postSplashScreenTheme">@style/Theme.App</item>
</style>

Why not a splash Activity (the old anti-pattern):

  • It adds an extra Activity and transition → slower startup, the opposite of the goal.
  • A fake fixed-duration splash (postDelayed) wastes the user’s time.
  • The system already shows a launch window; a separate Activity just delays content.

Best practices:

  • The splash should cover actual startup work, not an artificial timer. Use setKeepOnScreenCondition to hold it only while genuinely loading critical data.
  • Keep it brief - if startup is slow, fix startup (lazy init, Baseline Profiles), don’t pad it with a splash.
  • Provide an animated icon + brand background through the theme; it integrates with the system launch animation seamlessly.
  • On pre-12 devices the library backports the same behavior.
LazyColumn vs Column, and why provide keys to items?
Junior Jetpack Compose #compose#lazycolumn#performance#keys

Column composes and lays out all its children immediately, whether or not they’re on screen - fine for a handful of items, disastrous for a long/unbounded list. LazyColumn only composes the items currently visible (plus a small buffer) and recycles them as you scroll - the Compose equivalent of RecyclerView.

LazyColumn {
    items(users, key = { it.id }) { user ->
        UserRow(user)
    }
}

Why key = { it.id } matters:

  • By default, Lazy lists identify items by their position/index. If the list reorders, inserts, or removes items, Compose can mismatch state to the wrong item.
  • A stable key ties each item’s identity (and its remembered state, animations, scroll position) to the data, not the index. So when items move, Compose moves the existing composition instead of recomposing everything.
  • Without keys, deleting the first item makes every item below shift index, causing unnecessary recomposition and losing per-item state (e.g. an expanded row collapses, a half-typed field clears).

Other LazyColumn tools:

  • contentType - hint the type of each item so Compose reuses compositions of the same type more efficiently in heterogeneous lists.
  • rememberLazyListState() - observe/control scroll (with derivedStateOf for things like “show scroll-to-top”).
  • Don’t nest a vertically-scrolling LazyColumn inside a vertically-scrolling parent without a bounded height - it can crash or measure infinitely.
remember vs rememberSaveable - what's the difference?
Junior Jetpack Compose #compose#state

Both cache a value across recompositions, but they survive different events.

  • remember keeps a value across recompositions. It’s lost on configuration change (rotation) or process death, because the composition is recreated.
  • rememberSaveable also persists across configuration changes and process death by writing into the saved-instance-state Bundle.
// Survives recomposition only
var query by remember { mutableStateOf("") }

// Also survives rotation / process death
var query by rememberSaveable { mutableStateOf("") }

Use rememberSaveable for UI state the user would be annoyed to lose - text fields, scroll position, expanded/collapsed flags. Use remember for things that are cheap to recreate or derived from other state.

Gotcha: rememberSaveable can only store types that go in a Bundle (primitives, Parcelable, etc.). For a custom type, provide a Saver.

Show some practical uses of the Elvis operator beyond a simple default.
Junior Kotlin Language #kotlin#null-safety#elvis

The Elvis operator ?: returns its left side if non-null, otherwise the right side. Its power comes from the right side being able to be any expression - including return and throw (both have type Nothing).

// 1. Default value
val name = user?.name ?: "Guest"

// 2. Early return - "guard clause"
fun process(input: String?) {
    val text = input ?: return            // bail out if null
    println(text.length)                  // text is non-null here
}

// 3. Fail fast with a meaningful message
val config = loadConfig() ?: throw IllegalStateException("config missing")

// 4. Chained fallbacks
val displayName = nickname ?: fullName ?: email ?: "Anonymous"

// 5. Default for a whole expression
val count = map[key]?.size ?: 0

Why interviewers like #2 and #3: after val x = nullable ?: return, the compiler smart-casts x to non-null for the rest of the function - cleaner than nesting everything inside ?.let { } or an if (x != null) block.

View Binding vs Data Binding vs findViewById - what's the difference?
Junior Android Fundamentals #viewbinding#databinding#views

Three ways to reference views in the View system, increasingly capable:

findViewById - the original: look up a view by id at runtime.

  • Problems: not null-safe (returns a view that might be wrong/absent → crash), not type-safe (casts), and verbose.

View Binding - generates a binding class per layout with typed, non-null references to all id’d views.

val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.titleText.text = "Hi"     // typed, non-null, no findViewById
  • Benefits: null-safe and type-safe, near-zero overhead, minimal setup. The recommended replacement for findViewById.

Data Binding - a superset that also supports binding expressions in XML, linking layouts directly to data/observables.

<TextView android:text="@{viewModel.title}" />
<Button android:onClick="@{() -> viewModel.submit()}" />
  • Benefits: two-way binding, observable data in layouts, binding adapters.
  • Costs: slower builds (annotation processing), logic-in-XML can be hard to debug, and steeper complexity. Largely superseded by Compose for new code; many teams prefer View Binding + observing state in code over Data Binding.

How to choose:

  • New View-based code → View Binding (simple, safe, fast).
  • Legacy projects already using Data Binding → keep it, but it’s not recommended for new adoption.
  • New UI → Compose sidesteps all three.

Note: View Binding ≠ Data Binding - View Binding only generates references (no XML expressions), which is exactly why it’s faster and simpler.

Walk through the Activity lifecycle and what happens on rotation.
Junior Android Fundamentals #lifecycle#activity

Think of the Activity lifecycle as three questions: Does the Activity exist? Is it visible? Can the user interact with it? The main callbacks are:

  • onCreate - create this Activity instance and set up its UI.
  • onStart - activity becomes visible.
  • onResume - activity is in the foreground and interactive.
  • onPause - losing focus (a dialog, another activity in front). Keep this fast.
  • onStop - no longer visible.
  • onDestroy - being torn down (finished or recreated).

On rotation, Android normally destroys the current Activity instance and creates a new one. You will usually see a sequence like this:

onPause → onStop → onDestroy
→ onCreate → onStart → onResume

The exact timing of state-saving callbacks can vary, so do not write logic that depends on one precise callback order. The important point is that Activity fields belong to the old instance and are lost.

  • ViewModel keeps screen data across configuration changes.
  • onSaveInstanceState, SavedStateHandle, or rememberSaveable keep small pieces of restorable UI state, such as a selected tab or search query.

Common follow-up: Process death also removes the ViewModel because the whole app process is gone. Restore the minimum state needed to rebuild the screen and load durable data again from a database or network source.

What are coupling and cohesion, and why do they matter?
Junior Architecture & Patterns #design-principles#coupling#cohesion

Two measures of code quality that good architecture optimizes in opposite directions: low coupling, high cohesion.

Coupling - how much one module depends on another. Low (loose) coupling is the goal: modules interact through small, stable interfaces, so a change in one doesn’t ripple into many others.

  • Tightly coupled: a ViewModel directly instantiating RetrofitClient and RoomDatabase - changing either breaks the ViewModel.
  • Loosely coupled: the ViewModel depends on a Repository interface, injected. Swap the implementation freely.

Cohesion - how focused a module is; how strongly its parts relate to a single purpose. High cohesion is the goal: a class does one well-defined job.

  • Low cohesion: a Utils class with networking, date formatting, and bitmap helpers thrown together.
  • High cohesion: a DateFormatter that only formats dates; a FeedRepository that only handles feed data.

Why they matter:

  • Maintainability - loosely coupled, highly cohesive code is easier to change: edits stay local, and each class is easy to understand.
  • Testability - low coupling lets you inject fakes; high cohesion means small, focused units to test.
  • Reusability - focused modules are reusable; tangled ones aren’t.

How Android practices achieve them:

  • DI + interfaces → low coupling (depend on abstractions).
  • Single Responsibility / layering → high cohesion (each class/layer one job).
  • Modularization → enforces boundaries (low coupling between features).
  • UDF → the UI depends on state, not on the ViewModel’s internals.

These two are the why behind SOLID, Clean Architecture, and DI - interviewers like seeing you connect the principle to the practice.

What are Kotlin's visibility modifiers? What does internal mean?
Junior Kotlin Language #kotlin#visibility#modules

Four modifiers, with public as the default:

  • public (default) - visible everywhere.
  • private - visible only within the file (top-level) or the enclosing class.
  • protected - visible in the class and its subclasses (not top-level).
  • internal - visible everywhere in the same module.

The interesting one is internal, which Java doesn’t have. A module is a set of files compiled together - a Gradle module/source set, a Maven project, an IntelliJ module. internal is the backbone of modularization: a library module can expose a small public API while keeping implementation classes internal so other modules physically can’t depend on them.

internal class HttpClientImpl   // usable across this module, invisible outside it
class FeatureApi {
    private val client = HttpClientImpl()
}

Notes:

  • Kotlin has no package-private; internal (module) is the nearest equivalent and is broader than Java’s package scope.
  • internal names are mangled in the bytecode, which is why Java callers shouldn’t rely on them.
  • There’s no default “open” - classes/members are final unless marked open.
What are terminal operators on a Flow, and why does nothing happen without one?
Junior Coroutines & Flow #flow#terminal-operators#cold

A Flow is cold - the chain of intermediate operators (map, filter, onEach…) just describes work. Nothing runs until a terminal operator starts collection. Terminal operators are suspend functions (they need a coroutine).

val pipeline = flow { emit(1); emit(2) }.map { it * 10 }
// Nothing has run yet - pipeline is just a recipe.

pipeline.collect { println(it) }   // NOW it runs: 10, 20

Common terminal operators:

  • collect { } - the fundamental one; process every value.
  • toList() / toSet() - gather into a collection.
  • first() / firstOrNull() - take the first value (and cancel upstream).
  • single() - expect exactly one value.
  • reduce / fold - accumulate to a single result.
  • count() - count emissions.
  • launchIn(scope) - collect in a given scope without a lambda (usually after onEach).
flow.onEach { render(it) }.launchIn(viewModelScope)   // fire-and-collect

Why this matters: the classic bug is building a flow with onEach/map and wondering why the side effects never fire - there’s no terminal operator, so collection never starts. “Cold flows do nothing until collected” is the point being tested.

What are the different types of Context, and when do you use each?
Junior Android Fundamentals #context#memory-leaks

Context is the handle to app/system resources and services. The main flavors:

  • Application context - tied to the app’s lifetime. Get it via applicationContext / getApplication(). Use for things that must outlive any single screen: singletons, databases, WorkManager, DataStore, app-wide managers.
  • Activity context - tied to one Activity’s lifetime. Carries theme/config. Use for UI work: inflating layouts, starting activities, showing dialogs, theming.
  • Service / BroadcastReceiver context - scoped to those components.

The golden rule - match the context’s lifetime to the object that holds it:

// ✅ singleton holds app context - same lifetime, no leak
class Analytics(context: Context) {
    private val appContext = context.applicationContext
}

// ❌ singleton (or static/ViewModel) holding an Activity context → leaks the Activity
object Cache { lateinit var ctx: Context }   // if assigned an Activity, it leaks

Why it matters: holding an Activity context in something longer-lived (a static field, singleton, ViewModel, or a long-running thread) prevents the Activity from being garbage-collected after it’s destroyed - a classic memory leak.

Things that need a specific context:

  • Dialogs / theming / inflation → need an Activity (or themed) context; the app context lacks the right theme and can crash/misrender.
  • Toasts, system services, resources → app context is fine.
What are the main ways to create a Flow?
Junior Coroutines & Flow #flow#builders

The common Flow builders:

// 1. flow { } - the general builder; call emit() inside
val f1 = flow {
    emit(1)
    delay(100)
    emit(2)
}

// 2. flowOf(...) - fixed set of values
val f2 = flowOf("a", "b", "c")

// 3. asFlow() - from a collection, range, or sequence
val f3 = (1..5).asFlow()
val f4 = listOf("x", "y").asFlow()

// 4. channelFlow { } / callbackFlow { } - emit from other contexts/callbacks
val f5 = callbackFlow {
    val l = listener { trySend(it) }
    register(l); awaitClose { unregister(l) }
}

// 5. MutableStateFlow / MutableSharedFlow - hot flows you push into
val state = MutableStateFlow(0)

How to pick:

  • flow { } - most cases; sequential emission, context-preserving (use flowOn to switch dispatchers).
  • flowOf / asFlow - wrap existing values/collections.
  • channelFlow / callbackFlow - when you must emit from a callback or multiple coroutines/threads (a plain flow { } forbids cross-context emission).
  • StateFlow / SharedFlow - hot, shared, observable app state/events.

Note: flow { }, flowOf, and asFlow are cold - the block runs per collector, only when collected. StateFlow/SharedFlow are hot.

What can the when expression do beyond a switch statement?
Junior Kotlin Language #kotlin#when#control-flow

when is far more capable than Java’s switch. As an expression it returns a value, and it can branch on conditions, not just constants.

// As an expression with ranges, types, and multiple values
val label = when (score) {
    in 90..100 -> "A"
    in 70..89  -> "B"
    50, 51, 52 -> "borderline"
    else       -> "F"
}

// Type checks with smart cast
when (x) {
    is String -> x.length
    is List<*> -> x.size
    else -> 0
}

// No subject: acts like an if/else-if chain
when {
    user == null   -> showLogin()
    user.isAdmin   -> showAdmin()
    else           -> showHome()
}

Key abilities to mention:

  • Exhaustiveness - when used as an expression on a sealed type or enum, the compiler requires all cases (no else needed), and errors if you miss one later.
  • Smart casts inside is branches.
  • Ranges and collections with in.
  • Capturing the subject: when (val r = compute()) { ... }.

Interview note: prefer when as an expression returning a value over mutating a variable in branches - it’s more idiomatic and the exhaustiveness check protects you.

What do apply and let return?
Junior Kotlin Language #kotlin#output-based#scope-functions
val a = StringBuilder("x").apply { append("y") }
val b = StringBuilder("x").let { it.append("y") }
val c = StringBuilder("x").let { it.append("y"); "done" }

println(a)
println(b)
println(c)

Output:

xy
xy
done

Why:

  • apply returns the receiver object (the StringBuilder). So a is the builder → "xy".
  • let returns the lambda result. For b, the last expression is it.append("y"), and StringBuilder.append returns the same StringBuilder - so b is also the builder → "xy".
  • For c, the lambda’s last expression is the string "done", so let returns "done".

The takeaway: apply/also always give you the object back; let/run/with give you whatever the block’s last line evaluates to. Here b only prints "xy" because append happens to return the builder - change the last line and let returns that instead.

What does runBlocking do, and when should (and shouldn't) you use it?
Junior Coroutines & Flow #coroutines#runBlocking#testing

runBlocking is a bridge from regular blocking code into the coroutine world. It starts a coroutine and blocks the current thread until that coroutine and all its children complete.

fun main() = runBlocking {   // blocks main until done
    val data = repository.load()
    println(data)
}

Where it’s appropriate:

  • main() functions and simple scripts.
  • Tests - though runTest is now preferred for coroutine tests (it skips delays and controls virtual time).
  • Bridging a suspend function into a legacy blocking API you must implement.

Where it’s dangerous:

  • Never on the main/UI thread in an app - it blocks the thread, defeating the entire point of coroutines and risking ANRs. This is the #1 misuse interviewers watch for.
  • Inside another coroutine - you’d block a pool thread instead of suspending. Use withContext/coroutineScope instead.

Contrast with coroutineScope: both wait for children, but coroutineScope suspends (releases the thread) while runBlocking blocks (holds the thread). Inside coroutine code you want coroutineScope; only use runBlocking to enter coroutine code from a non-suspending context.

What is a ViewModel, how does it survive configuration changes, and what should it not hold?
Junior Android Fundamentals #viewmodel#architecture#lifecycle

A ViewModel holds and manages UI-related state and survives configuration changes, so data and in-flight work aren’t lost on rotation.

How it survives: the ViewModel is stored in a ViewModelStore owned by the Activity/Fragment/NavBackStackEntry. On a configuration change, the Activity is recreated but its ViewModelStore is retained (via onRetainNonConfigurationInstance internally) and handed to the new instance. So you get the same ViewModel back. It’s cleared (onCleared()) only when the owner is permanently gone (finished, popped) - not on rotation.

class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
    private val _state = MutableStateFlow(FeedUiState())
    val state = _state.asStateFlow()
    // viewModelScope cancelled in onCleared()
}

What a ViewModel must NOT hold:

  • Context of an Activity, Views, Fragments, or anything view-bound - these outlive a config change while the ViewModel persists, so holding them leaks the old Activity. If you need a context, use AndroidViewModel’s application context.
  • It shouldn’t reach into the UI; it exposes state the UI observes (one-way).

Key points:

  • It does not survive process death - pair with SavedStateHandle for state that must.
  • viewModelScope ties coroutines to the ViewModel lifecycle (cancelled in onCleared).
  • Scope it correctly: viewModels() (Activity/Fragment), activityViewModels() (share across fragments), or hiltViewModel() (per nav destination).
  • Construct it with a factory (or Hilt) to inject dependencies.
What is declarative UI, and how is Compose different from the View system?
Junior Jetpack Compose #compose#fundamentals#declarative

Declarative means you describe what the UI should look like for a given state, and the framework figures out how to update the screen. You don’t hold view references and mutate them; you re-describe the UI when state changes.

// Declarative (Compose): describe UI as a function of state
@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
    Button(onClick = onIncrement) { Text("Count: $count") }
}

vs the imperative View system, where you fetch a widget and mutate it:

button.text = "Count: $count"   // you manually keep the view in sync

Key differences:

  • No XML / no findViewById - UI is Kotlin functions.
  • State-driven - when state changes, Compose recomposes (re-invokes the affected composables) instead of you manually updating views.
  • No view hierarchy inflation - composables don’t map 1:1 to View objects; Compose maintains its own tree and renders directly.
  • Single source of truth - the UI can’t drift out of sync with state because it’s derived from state.

A useful mental model: UI = f(state). Instead of imperatively poking widgets when data changes, you make the UI a pure function of state and let recomposition handle updates.

What is dependency injection, and why use it on Android?
Junior Architecture & Patterns #dependency-injection#testability

Dependency injection (DI) means a class receives its dependencies from outside rather than creating them itself. “Inversion of control” - something else (a framework or the caller) is responsible for constructing and wiring objects.

// ❌ Without DI: class creates and is coupled to concrete dependencies
class UserViewModel {
    private val repo = UserRepository(RetrofitClient.create(), AppDatabase.dao())
}

// ✅ With DI: dependencies injected; class depends on abstractions
class UserViewModel(private val repo: UserRepository)

Why it matters:

  • Testability - inject a fake/mock repository in tests instead of hitting the real network/DB. This is the #1 reason.
  • Decoupling - a class depends on an interface, not a concrete implementation; swap implementations (debug vs prod, different backends) without changing the class.
  • Single responsibility - classes focus on using dependencies, not constructing them (and their dependencies, and their dependencies…).
  • Lifecycle & scoping - a DI framework can provide singletons, per-Activity, or per-ViewModel instances correctly.

On Android specifically:

  • Manual DI works but becomes painful as the graph grows (constructing a ViewModel might require a repo, which needs an API, a DB, an OkHttp client, …).
  • Hilt (built on Dagger) generates this wiring at compile time - type-safe, no reflection - and integrates with Android components (Activity, ViewModel, WorkManager).
  • Koin is a lighter, runtime service locator-style alternative (simpler, but resolution errors surface at runtime).

Forms of DI: constructor injection (preferred - explicit, testable), field injection (for framework-created objects like Activities), and method injection.

What is state hoisting, and what makes a composable stateless?
Junior Jetpack Compose #compose#state-hoisting#state

State hoisting is moving state up out of a composable to its caller, so the composable becomes stateless - it receives the value and a callback to change it, rather than owning the state.

The pattern is value down, events up:

// Stateless: owns no state, fully driven by parameters
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
    TextField(value = query, onValueChange = onQueryChange)
}

// Stateful caller owns the state
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
    val query by viewModel.query.collectAsStateWithLifecycle()
    SearchBar(query = query, onQueryChange = viewModel::onQueryChange)
}

Why hoist:

  • Reusable - a stateless composable works anywhere; the caller decides where state lives.
  • Testable - you can render it with any value, no internal state to set up.
  • Single source of truth - state lives in one place (ViewModel or a parent), avoiding divergent copies.
  • Controllable - the parent can intercept, transform, or share the state.

Stateful vs stateless:

  • Stateful composables hold their own remember { mutableStateOf(...) } - convenient for self-contained widgets where the caller doesn’t care about the state.
  • Stateless ones take state as parameters - preferred for anything shared, tested, or driven by a ViewModel.

A common design is to offer both: a stateful overload (with a default rememberX state) that delegates to a stateless one - exactly how Compose’s own Scaffold/rememberScaffoldState are structured.

What is the Application class, and what should (and shouldn't) you do in it?
Junior Android Fundamentals #application#startup#lifecycle

The Application object is a singleton created before any Activity/Service, living for the whole process lifetime. It’s the global entry point and the holder of application-wide state.

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        // app-wide, must-happen-early init
    }
}

Register it in the manifest (<application android:name=".MyApp">).

Legitimate uses:

  • Critical, early initialization - crash reporting, DI graph (Hilt generates Application integration), logging.
  • A safe source of application context for app-lifetime objects.
  • ProcessLifecycleOwner / registerActivityLifecycleCallbacks for app-wide foreground/background awareness.

What NOT to do (the important part - onCreate runs on every cold start and blocks the first frame):

  • No heavy/synchronous work - network, disk, big SDK init on the main thread here directly slows cold start and risks ANR. Lazy-init non-critical SDKs (or use the App Startup library) instead.
  • Don’t store mutable global state as a substitute for proper architecture - it’s not a dumping ground for “global variables.”
  • Don’t assume it survives process death with in-memory state - the process can be recreated; persist what must survive.

What to remember:

  • It’s a singleton tied to the process - and there can be multiple processes (android:process), each with its own Application instance.
  • Keep onCreate lean; defer everything you can - startup time is a real metric (Android vitals).
  • Prefer Hilt/DI and the App Startup library over manual init soup.
What is the difference between local and instrumented tests on Android?
Junior Testing & Quality #testing#junit#instrumentation

Local tests run on the JVM on your development machine. They are fast and work well for business logic, mappers, reducers, and ViewModels whose Android dependencies have been kept behind interfaces.

Instrumented tests run on an Android device or emulator. Use them when the behavior depends on the framework, such as navigation, permissions, resources, Room integration, or a complete UI flow. They provide more realism but are slower and need more setup.

Robolectric sits between the two: it runs Android-like behavior on the JVM. It can be useful, but it is not a replacement for every device test.

A good default is to keep most tests local, add integration tests at important boundaries, and reserve device tests for behavior that genuinely requires Android.

What is the Repository pattern, and what problem does it solve?
Junior Architecture & Patterns #repository#data-layer#abstraction

A Repository mediates between the rest of the app and the data sources (network, database, cache, DataStore). It exposes a clean, domain-oriented API and hides where the data comes from.

class UserRepository(
    private val api: UserApi,
    private val dao: UserDao,
) {
    // The caller doesn't know or care this comes from cache + network
    fun observeUser(id: String): Flow<User> = dao.observe(id)
        .onStart { refreshFromNetwork(id) }

    private suspend fun refreshFromNetwork(id: String) {
        runCatching { api.fetch(id) }.onSuccess { dao.upsert(it.toEntity()) }
    }
}

What it solves:

  • Single source of truth - the repository decides the caching/refresh policy (e.g. DB as source of truth, network refreshes it). Callers just observe.
  • Abstraction - ViewModels depend on the repository, not on Retrofit or Room. Swapping the network library or adding a cache doesn’t ripple into the UI.
  • Testability - fake the repository (or its data sources) in ViewModel tests.
  • Centralized logic - retry, mapping DTO→domain, combining sources, and offline behavior live in one place, not scattered across ViewModels.

Design choices:

  • Repositories typically expose domain models, mapping from DTOs (network) and entities (DB) at the boundary.
  • Define the repository as an interface in the domain layer; implement it in the data layer (dependency inversion) so the domain doesn’t depend on data details.
  • One repository per data type/feature (UserRepository, FeedRepository), not one giant “DataRepository.”
  • Keep business logic out of the repository - it does data orchestration; complex rules belong in use cases.
What should an Android app's testing strategy look like?
Junior Testing & Quality #testing#test-pyramid#quality

The test pyramid guides where to invest: many fast tests at the bottom, few slow ones at the top.

        /\        UI / E2E tests  (few - slow, brittle, on-device)
       /  \       Espresso / Compose UI tests, full flows
      /----\      Integration tests (some)
     /      \     Room DAO, repository + fakes, navigation
    /--------\    Unit tests (many - fast, JVM)
   /__________\   ViewModels, use cases, mappers, pure logic

Unit tests (the base, most of your tests):

  • Run on the JVM (no device) → fast, run on every change.
  • Target pure logic: ViewModels, use cases, mappers, formatters, repositories (with fake data sources).
  • Use runTest for coroutines, inject dispatchers, Turbine for flows.

Integration tests (middle):

  • Verify components together: Room DAOs against an in-memory DB, a repository with real DB + fake network, navigation graphs.
  • Some run on JVM (Robolectric) or instrumented.

UI / End-to-end (top, few):

  • Espresso (Views) / Compose UI tests / UI Automator drive real screens and flows.
  • Slow and flakier, so cover critical user journeys (login, checkout), not every screen.

What makes the app testable (the real point):

  • Architecture enables testing - DI + interfaces let you inject fakes; UDF makes ViewModels pure functions of input you can assert on; separating layers keeps logic Android-free.
  • Inject dispatchers and clocks so time/threading is controllable.
  • Prefer fakes over heavy mocking, and test behavior, not implementation.

Other tools: screenshot tests (Paparazzi/Roborazzi) for visual regression, Macrobenchmark for performance, and Play Pre-launch reports for device coverage.

What's the difference between an APK and an Android App Bundle (AAB)?
Junior Android Fundamentals #aab#apk#distribution
  • APK - the installable package that lands on a device. It contains all code and resources for every density, ABI, and language.
  • AAB (Android App Bundle) - a publishing format (.aab) you upload to Play. It’s not installed directly; Play uses it to generate and serve optimized APKs per device via Play Feature/Dynamic Delivery.

The win - smaller downloads. With an AAB, Play’s split APKs ship only what a given device needs:

  • Density splits - only that device’s drawable density.
  • ABI splits - only that device’s CPU architecture (arm64 vs x86).
  • Language splits - only the user’s languages.

So a user doesn’t download xxhdpi assets, French strings, and x86 libraries they’ll never use. AAB is required for new apps on Google Play (since Aug 2021).

Related capabilities AAB enables:

  • Dynamic feature modules - download features on demand (Play Feature Delivery), shrinking the base install.
  • Play Asset Delivery - stream large game assets.
  • Play App Signing - Google holds the signing key and re-signs the generated APKs (a consequence to understand: you upload with an upload key, Play signs with the app key).

What to remember:

  • AAB ≠ APK: AAB is for upload/distribution; APK is for install.
  • You can still build a universal APK from a bundle (bundletool) for sideloading/testing.
  • It reduces app size without code changes - the splits are automatic.
collect vs collectLatest, and what do flatMapLatest / mapLatest do?
Mid Coroutines & Flow #flow#collectLatest#flatMapLatest

The *Latest variants cancel the in-progress work when a new value arrives.

collect processes every emission to completion, sequentially. If processing is slow, emissions queue up.

collectLatest starts processing each value, but if a new value arrives before the current block finishes, it cancels the current block and restarts with the new value.

flow {
    emit("A"); delay(10); emit("B")
}.collectLatest { value ->
    println("start $value")
    delay(100)                 // slow work
    println("done $value")     // only reached for the LAST value
}
// Output: start A, start B, done B   (A's work was cancelled by B)

flatMapLatest / mapLatest apply the same idea to transformations - cancel the previous inner flow/computation when upstream emits again. This is the common search-as-you-type pattern:

queryFlow
    .debounce(300)
    .distinctUntilChanged()
    .flatMapLatest { q -> repo.search(q) }   // cancels the stale search
    .collect { render(it) }

When to use which:

  • Only the latest value matters (UI state, search results) → collectLatest / flatMapLatest.
  • Every value must be processed (analytics events, a write queue) → plain collect (with buffer if needed).

Gotcha: with collectLatest, cancellation means the slow block’s later lines may never run - don’t rely on it for must-complete side effects.

Explain generics variance: in, out, and star projection.
Mid Kotlin Language #kotlin#generics#variance

Start with a practical rule: if a generic type only produces values, mark it out. If it only consumes values, mark it in.

These rules are called variance. Without in or out, a generic type is invariant: Box<String> cannot be used as Box<Any>, even though String is a subtype of Any.

out means producer. The type is returned, not accepted as input. This is why Kotlin’s read-only List is List<out T>.

interface Producer<out T> { fun produce(): T }
val p: Producer<Any> = object : Producer<String> { ... }  // OK

Kotlin’s read-only List<out E> is covariant - that’s why List<String> is usable as List<Any>.

in means consumer. The type is accepted as input, not returned. A Comparator<Any> can compare strings, so it can be used where a Comparator<String> is required.

interface Consumer<in T> { fun consume(item: T) }
val c: Consumer<String> = object : Consumer<Any> { ... }  // OK

Mnemonic: PECS / “in–consumer, out–producer.”

Star projection <*> - used when you don’t know or care about the argument: a Box<*> is a Box of some type. You can read values as the upper bound (Any?) but can’t safely write (except null), because the real type is unknown.

fun printAll(box: Box<*>) { println(box.get()) }  // get is fine, set isn't

in/out at the declaration site is declaration-site variance; specifying it at a usage point (Array<out T>) is use-site variance (Kotlin’s equivalent of Java wildcards).

Explain structured concurrency. Why does it matter on Android?
Mid Coroutines & Flow #coroutines#scopes#cancellation

Structured concurrency means every coroutine runs inside a scope, and a scope doesn’t finish until all the coroutines it launched have finished. Coroutines form a parent–child tree.

This gives you three guarantees:

  1. No leaks. A coroutine can’t outlive its scope. When the scope is cancelled, all children are cancelled.
  2. Cancellation propagates. Cancelling a parent cancels its children; a failing child (by default) cancels its siblings and parent.
  3. Errors aren’t lost. Exceptions surface to the scope rather than vanishing on some detached thread.

Why it matters on Android: viewModelScope is cancelled in onCleared(), and lifecycleScope follows the lifecycle. Tie your coroutines to these and work is automatically cancelled when the user leaves - no manual teardown, no callbacks firing on a dead screen.

class FeedViewModel : ViewModel() {
    fun refresh() = viewModelScope.launch {
        val items = repo.loadFeed()   // cancelled automatically if the
        _state.value = State.Loaded(items) // ViewModel is cleared mid-flight
    }
}

Follow-up to be ready for: use supervisorScope (or a SupervisorJob) when you don’t want one child’s failure to cancel its siblings - e.g. loading several independent widgets where one failing shouldn’t blank the rest.

How do exceptions behave in launch and async coroutines?
Mid Coroutines & Flow #coroutines#error-handling#exceptions

The behavior depends on the builder.

launch - an uncaught exception propagates immediately up the Job hierarchy. A root launch reports it to a CoroutineExceptionHandler (or the thread’s uncaught-exception handler). A child delegates handling to its parent.

async - stores the exception and rethrows it from await(). A CoroutineExceptionHandler does not consume a root async failure because the caller is expected to observe the Deferred.

val handler = CoroutineExceptionHandler { _, e -> Log.e("TAG", "caught $e") }

// Root launch → handler reports it
scope.launch(handler) { throw IOException() }

// async → must catch at await()
val deferred = scope.async { throw IOException() }
try { deferred.await() } catch (e: IOException) { /* handle */ }

The interview-grade nuance: in a normal coroutineScope, a child created with async still cancels its parent as soon as it fails. Catching only await() from inside that already-cancelled scope is often too late. Use supervisorScope when children should fail independently, and then handle each await() result.

Things that trip people up:

  • try/catch around launch { } doesn’t work - the builder returns immediately; the exception happens later, inside the coroutine. Put the try/catch inside the coroutine, or use a handler.
  • A CoroutineExceptionHandler is a last-resort reporter for an uncaught root failure, not a recovery mechanism. It cannot make the failed coroutine continue.
  • CancellationException is special - it’s not treated as a failure and doesn’t trigger the handler.
  • With a regular Job, one child’s exception cancels siblings; with SupervisorJob/supervisorScope, children fail independently and each needs its own handling.
How does coroutine cancellation work? Why is it 'cooperative'?
Mid Coroutines & Flow #coroutines#cancellation

Cancellation is cooperative: cancelling a coroutine sets its Job to a cancelling state, but the coroutine only actually stops when it checks for cancellation. If your code never checks, it keeps running.

Suspending functions from kotlinx.coroutines (delay, withContext, yield, etc.) check automatically and throw CancellationException when cancelled. But a tight CPU loop won’t:

// ❌ Ignores cancellation - runs to completion even after cancel()
launch {
    while (i < 1_000_000) { heavyStep(i++) }
}

// ✅ Cooperates
launch {
    while (i < 1_000_000) {
        ensureActive()        // throws if cancelled
        heavyStep(i++)
    }
}

Ways to cooperate:

  • ensureActive() - throws CancellationException if cancelled.
  • isActive - check the flag yourself (while (isActive) { }).
  • yield() - checks for cancellation and lets other coroutines run.
  • Call any cancellable suspend function (delay, etc.).

Critical rules:

  • CancellationException is normal - don’t swallow it. A blanket try { } catch (e: Exception) { } will eat it and break cancellation. Catch specific exceptions, or rethrow CancellationException.
  • To run cleanup that itself suspends (closing a resource), use withContext(NonCancellable) { } - the coroutine is already cancelling, so normal suspension would immediately throw.
  • finally blocks run on cancellation, so they’re the place for non-suspending cleanup.
Job vs SupervisorJob, and coroutineScope vs supervisorScope?
Mid Coroutines & Flow #coroutines#job#supervision#error-handling

The difference is how a child’s failure affects its siblings and parent.

Regular Job - failure propagates both ways: a failing child cancels its parent, which cancels all the other children. One failure tears down the whole scope.

SupervisorJob - failure propagates downward only: a child failing does not cancel its siblings or the parent. Each child fails independently.

// Regular: if one fails, both are cancelled
coroutineScope {
    launch { loadProfile() }   // if this throws...
    launch { loadFeed() }      // ...this gets cancelled too
}

// Supervisor: independent children
supervisorScope {
    launch { loadProfile() }   // can fail alone
    launch { loadFeed() }      // keeps running regardless
}

coroutineScope { } uses a regular Job; supervisorScope { } uses a SupervisorJob. Same relationship as CoroutineScope(Job()) vs CoroutineScope(SupervisorJob()).

When to use supervision: a screen loading several independent widgets where one failing shouldn’t blank the others; a viewModelScope-style scope where one failed operation shouldn’t kill all future ones.

Two gotchas interviewers love:

  1. With SupervisorJob, each child needs its own exception handling - a CoroutineExceptionHandler must be installed on the child launch, not just the scope, because the failure doesn’t propagate up to the scope’s handler in the same way.
  2. Putting SupervisorJob() inside a child launch(SupervisorJob()) does not make its children supervised - supervision comes from the scope’s Job, and passing a Job to a builder breaks the parent link. Use supervisorScope { } instead.
lateinit vs lazy - what's the difference and when do you use each?
Mid Kotlin Language #kotlin#lateinit#lazy#initialization

Both defer initialization, but they’re for different situations.

lateinit var

  • A var you promise to set before first use. No initial value.
  • Only for non-null, non-primitive types (var x: Int won’t work).
  • Accessing it before assignment throws UninitializedPropertyException.
  • You can reassign it and check ::x.isInitialized.
  • Use when something injects/sets the value later - Dagger fields, onCreate views/binding, test setup.
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
    binding = ActivityMainBinding.inflate(layoutInflater)
}

by lazy

  • A val computed once, on first access, then cached.
  • Thread-safe by default (LazyThreadSafetyMode.SYNCHRONIZED); you can relax it.
  • Use for expensive, read-only values you might not even need.
val database by lazy { Room.databaseBuilder(...).build() }

Quick decision: mutable + set-externally-later → lateinit; read-only + compute-on-demand → lazy. And remember lateinit can’t be used with primitives or nullable types, while lazy can hold anything.

Sealed class vs enum vs abstract class - when do you use each?
Mid Kotlin Language #kotlin#sealed-class#enum#state

All three model a restricted set of types, but at different levels.

  • enum - a fixed set of singleton instances, each the same type. Use it for a closed set of constants (Direction.NORTH). Every entry is one object; they can’t carry per-instance varying state across many instances.
  • sealed class / sealed interface - a restricted hierarchy of subclasses known at compile time, but each subtype can have its own properties and multiple instances. Perfect for modeling UI state or results.
  • abstract class - an open hierarchy; subclasses can be defined anywhere, including other modules. Use when you don’t need exhaustiveness and want open extension.
sealed interface UiState {
    data object Loading : UiState
    data class Success(val items: List<Item>) : UiState
    data class Error(val message: String) : UiState
}

The big win for sealed is exhaustive when - the compiler knows all subtypes, so you don’t need an else and it errors if you add a case and forget to handle it:

when (state) {
    UiState.Loading    -> showSpinner()
    is UiState.Success -> render(state.items)
    is UiState.Error   -> showError(state.message)
}  // no else needed

Rule of thumb: closed set of plain constants → enum; closed set of variants that carry different datasealed; open extension → abstract.

StateFlow vs SharedFlow - how do you choose, and how do you model one-time events?
Mid Coroutines & Flow #flow#stateflow#sharedflow#events

Use StateFlow for state and SharedFlow for broadcasts or events.

StateFlow always has a current value. A new collector immediately receives that value, which makes it a natural fit for a screen’s loading, content, and error state.

private val _state = MutableStateFlow(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
_state.value = UiState.Loaded(items)   // synchronous, has a current value

SharedFlow does not need to hold one current value. You can configure whether it replays old values and how it buffers new ones. That makes it useful when several collectors need the same stream of events.

private val _events = MutableSharedFlow<Event>()   // replay = 0 by default
val events: SharedFlow<Event> = _events.asSharedFlow()
suspend fun navigate() = _events.emit(Event.GoToDetail)

Choosing:

  • State that the screen renders (loading/content/error) → StateFlow.
  • Transient broadcasts for active collectors (analytics ticks, refresh signals, a snackbar that may be dropped while the UI is absent) → SharedFlow with replay = 0.

Why not put consumable events directly in StateFlow? It retains the last value and replays it, so a naïve snackbar or navigation value may run again after recreation. SharedFlow(replay = 0) avoids replay, but it can drop an event when there is no collector. If delivery must survive the UI stopping, model the outcome as durable UI state (often the best option) or use an explicit queued-event design. State vs event is a delivery-semantics decision, not just a type choice.

Optional details:

  • MutableStateFlow.value updates are conflated - fast intermediate values can be skipped; a rapidly emitting StateFlow won’t deliver every value, only the latest.
  • Equality matters: StateFlow skips emissions that are equals to the current - using a data class for state means copy()-ing is what makes it emit.
  • SharedFlow.emit suspends if the buffer is full; tryEmit doesn’t.
What are inline functions, and what do noinline and crossinline do?
Mid Kotlin Language #kotlin#inline#performance#lambdas

inline tells the compiler to copy the function body - and its lambda arguments - into the call site instead of creating a function object for each lambda. For higher-order functions this removes the per-call lambda allocation and the extra invoke() call.

inline fun measure(block: () -> Unit) {
    val start = System.nanoTime()
    block()                       // body inlined, no Function object created
    Log.d("perf", "${System.nanoTime() - start}ns")
}

Two extra benefits unlocked by inlining:

  • Non-local returns - a return inside the lambda can return from the enclosing function.
  • reified type parameters - the real type is available at runtime (covered separately).

The modifiers:

  • noinline - opt a specific lambda out of inlining (e.g. you need to store it in a variable or pass it on as an object).
  • crossinline - keep the lambda inlined but forbid non-local returns, needed when the lambda is called from another execution context (like inside a Runnable/another lambda).
inline fun run(crossinline body: () -> Unit) {
    val r = Runnable { body() }   // crossinline required here
    r.run()
}

When NOT to inline: large function bodies (inlining bloats bytecode at every call site) or functions with no lambda parameters (little benefit). Use it for small higher-order utilities.

What are the uses of the object keyword in Kotlin?
Mid Kotlin Language #kotlin#object#singleton

object creates a class and its single instance at once. It has three uses:

1. Singleton (object declaration)

object Analytics {
    fun track(event: String) { /* ... */ }
}
Analytics.track("open")   // thread-safe, lazily created on first access

2. Companion object - a singleton tied to a class, called via the class name (factories, constants).

3. Object expression (anonymous object) - Kotlin’s answer to anonymous classes:

view.setOnClickListener(object : View.OnClickListener {
    override fun onClick(v: View?) { /* ... */ }
})

// or an ad-hoc object holding state
val point = object {
    val x = 1
    val y = 2
}

Things to know:

  • An object declaration is initialized lazily and thread-safely on first use.
  • Unlike a class, you can’t have a constructor (it takes no parameters).
  • An anonymous object’s type is only visible locally - if returned from a public function it’s seen as its supertype.

Android note: an object singleton holding a Context is a classic memory leak - store applicationContext, never an Activity.

What is a reified type parameter and why do you need inline for it?
Mid Kotlin Language #kotlin#generics#reified#inline

On the JVM generics are erased - at runtime List<String> and List<Int> are both just List, and a normal generic function can’t ask T::class or do is T. A reified type parameter keeps the concrete type available at runtime.

It only works with inline functions: because the function is inlined at the call site, the compiler substitutes the real type there, so the type information survives.

inline fun <reified T> Gson.fromJson(json: String): T =
    fromJson(json, T::class.java)

inline fun <reified T> List<*>.filterIsType(): List<T> =
    filterIsInstance<T>()        // uses `is T` under the hood

// Android: a clean startActivity helper
inline fun <reified T : Activity> Context.start() =
    startActivity(Intent(this, T::class.java))

context.start<DetailActivity>()

Why it matters: it removes the need to pass Class<T> parameters around (fromJson(json, Foo::class.java) becomes fromJson<Foo>(json)), which is why Gson/Moshi extensions, DI lookups, and intent builders use it everywhere.

Limitation to mention: because it relies on inlining, a reified type can’t be used from Java, and you can’t call it where T is itself a non-reified generic.

What is the difference between a cold Flow and a hot Flow?
Mid Coroutines & Flow #flow#cold-hot#stateflow#sharedflow

Cold flow (flow { }, flowOf, Room/Retrofit flows): the producer block runs per collector, starting only when collected. Two collectors get two independent executions from the start. No collector = no work.

val numbers = flow {
    println("start")        // runs each time someone collects
    emit(1); emit(2)
}

Hot flow (StateFlow, SharedFlow): emits regardless of collectors, and all collectors share the same stream. Late collectors miss past emissions (except replay/the current value).

Cold (Flow)Hot (StateFlow / SharedFlow)
Starts whencollectedexists independently
Per-collector executionyesshared
Has a current valuenoStateFlow: yes / SharedFlow: optional replay
Use forone-shot data, transformationsobservable app state, events

StateFlow = hot, always has one current value, conflated, deduplicated (distinctUntilChanged built in). Great for UI state.

SharedFlow = hot, configurable replay and buffer, no “current value” requirement. Great for one-off events (navigation, snackbars) where you don’t want replay on rotation.

Bridging them: convert a cold flow to hot with stateIn / shareIn, so an upstream (e.g. a DB query) runs once and is shared across collectors instead of re-running per subscriber.

When should you use == or === in Kotlin?
Mid Kotlin Language #kotlin#output-based#equality
val a: Int? = 127
val b: Int? = 127
val c: Int? = 128
val d: Int? = 128

println(a == b)    // ?
println(a === b)   // ?
println(c == d)    // ?
println(c === d)   // ?

Output:

true
true
true
false

Why:

  • == calls equals()structural equality. All four comparisons by value are true.
  • === is referential equality (same object).
  • The Int? types are boxed (Integer). The JVM caches boxed integers in the range −128..127, so a and b point to the same cached object → a === b is true. 128 is outside the cache, so c and d are different boxed objects → c === d is false.

never use === to compare values - it’s an implementation detail of boxing. Use == for value equality. (With non-nullable Int, there’s no boxing and this trap disappears - it only shows up because the types are nullable, forcing boxing.)

Which Kotlin collection operations do you use most often?
Mid Kotlin Language #kotlin#collections#functional

These are bread-and-butter and come up constantly:

val nums = listOf(1, 2, 3, 4, 5)

nums.map { it * 2 }              // [2,4,6,8,10] - transform each
nums.filter { it % 2 == 0 }     // [2,4]        - keep matching
nums.reduce { acc, n -> acc + n } // 15         - combine, seed = first element
nums.fold(100) { acc, n -> acc + n } // 115     - combine with explicit seed

val words = listOf("apple", "avocado", "banana")
words.groupBy { it.first() }    // {a=[apple, avocado], b=[banana]}
words.associate { it to it.length } // {apple=5, avocado=7, banana=6}
words.associateBy { it.first() } // {a=avocado, b=banana} (last wins per key)
words.partition { it.length > 5 } // Pair([avocado, banana], [apple])

listOf(listOf(1,2), listOf(3)).flatMap { it } // [1,2,3] - map then flatten

Distinctions interviewers probe:

  • fold vs reduce - reduce starts from the first element and throws on an empty list; fold takes an explicit initial accumulator (and can change the result type).
  • associate vs associateBy vs groupBy - associate builds key→value pairs you specify; associateBy keys by a selector (one value per key, last wins); groupBy keys to a list of all matching values.
  • map vs flatMap - flatMap is for when each element produces a collection you want flattened into one.
  • mapNotNull / filterNotNull - transform-and-drop-nulls in one pass.

For big chains, prepend .asSequence() to avoid intermediate lists.

Why collect a Flow with repeatOnLifecycle? What problem does it solve?
Mid Coroutines & Flow #flow#lifecycle#repeatOnLifecycle#android

The problem: a plain lifecycleScope.launch { flow.collect { } } keeps collecting even when the app is in the background. The UI isn’t visible, but the flow still does work and holds references - wasted CPU/battery and a potential crash if you touch views.

repeatOnLifecycle(STATE) suspends, and runs its block only while the lifecycle is at least in that state, cancelling it when the lifecycle drops below and restarting it when it comes back.

class MyFragment : Fragment() {
    override fun onViewCreated(view: View, b: Bundle?) {
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { render(it) }   // collected only while STARTED
            }
        }
    }
}

What happens: when the app goes to the background (below STARTED), the collection coroutine is cancelled (unsubscribing from the flow); when it returns to the foreground, the block restarts and re-collects. For a StateFlow, you immediately get the current value back.

Equivalents / related:

  • flowWithLifecycle(lifecycle, STARTED) - operator form for a single flow.
  • Compose: collectAsStateWithLifecycle() does the same lifecycle-aware collection automatically - prefer it over collectAsState().

Common bug it prevents: using LATEST/launchWhenStarted (deprecated) only paused the coroutine but kept the flow subscription alive upstream - repeatOnLifecycle actually cancels it, which (combined with WhileSubscribed upstream) lets the producer stop too.

Why is GlobalScope considered an anti-pattern?
Mid Coroutines & Flow #coroutines#globalscope#structured-concurrency

GlobalScope launches coroutines that live for the entire application lifetime and belong to no parent. That breaks structured concurrency and causes real problems:

  • Leaks - the coroutine isn’t tied to any lifecycle, so it keeps running after the screen (and the objects it references) is gone. A GlobalScope.launch capturing a ViewModel or Context leaks it.
  • No cancellation - nothing cancels it. You can’t stop it when the user navigates away; it runs to completion regardless.
  • Orphaned errors - exceptions don’t propagate to any parent scope, so failures can vanish or crash unexpectedly.
  • Hard to test - there’s no scope to control or wait on in tests.
// ❌ leaks, never cancelled, error goes nowhere useful
GlobalScope.launch { repo.sync() }

// ✅ tied to the ViewModel lifecycle
viewModelScope.launch { repo.sync() }

Use a lifecycle-bound scope instead: viewModelScope, lifecycleScope, or an application-scoped CoroutineScope you create and inject (with a SupervisorJob) for genuinely app-lifetime work (e.g. a sync that must outlive a screen).

@Singleton
class AppScope @Inject constructor() :
    CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default)

The rare legit case: truly application-lifetime, fire-and-forget work with no lifecycle - but even then, an injected app scope is preferable because it’s testable and controllable. GlobalScope is marked @DelicateApi for exactly these reasons.

withContext vs launch vs async - when do you use each?
Mid Coroutines & Flow #coroutines#builders#withContext

They all run code in a coroutine, but serve different purposes.

  • withContext(ctx) { } - a suspend function that runs a block in a different context (usually a different dispatcher) and returns its result. It does not start a concurrent coroutine - it suspends the current one until the block finishes. Use it to switch threads for a piece of work.
  • launch { } - starts a new concurrent coroutine, returns a Job, no result. Fire-and-forget.
  • async { } - starts a new concurrent coroutine, returns a Deferred<T> you await(). Use for concurrent work you’ll combine.
// withContext: switch to IO, get the result back, sequential
suspend fun load() = withContext(Dispatchers.IO) { api.fetch() }

// async: two things at once
suspend fun loadBoth() = coroutineScope {
    val a = async { api.fetchA() }
    val b = async { api.fetchB() }
    a.await() to b.await()
}

The common mistake: using async { }.await() immediately to switch threads:

val data = async(Dispatchers.IO) { fetch() }.await()  // ❌ pointless
val data = withContext(Dispatchers.IO) { fetch() }    // ✅ clearer, cheaper

If you’re going to await right away, you want withContext - async is only worth it when you start multiple and await them later.

Rule of thumb: one result, switch context → withContext; concurrent work to combine → async; side-effect with no result → launch.

combine vs zip for Flows - what's the difference?
Mid Coroutines & Flow #flow#combine#zip#operators

Both merge multiple flows, but emit on different triggers.

zip pairs emissions one-to-one, in lockstep. It waits until both flows have a new value, then emits a pair. It completes when either flow completes. Use it to pair up corresponding items.

combine emits whenever any flow emits, using that flow’s newest value plus the latest value of the others. It needs every flow to have emitted at least once before the first emission.

val a = flowOf(1, 2, 3)
val b = flowOf("x", "y")

a.zip(b) { n, s -> "$n$s" }       // [1x, 2y]  - pairs, stops at shorter
a.combine(b) { n, s -> "$n$s" }   // e.g. [3x, 3y] or [1x,2x,3x,3y]… - latest of each

When to use which:

  • combine - building UI state from several independent sources: combine(user, settings, network) { ... }. Any source changing should recompute the result with the latest of the others. This is the common one in apps.
  • zip - genuinely paired streams where item N of one corresponds to item N of the other (e.g. requests with their responses).

Gotchas:

  • combine’s output count is non-deterministic - it depends on timing. Don’t assume a fixed number of emissions.
  • combine won’t emit until every input has emitted once, so give each source an initial value (a StateFlow always has one, which is why it pairs well with combine).
Compare pagination strategies for a mobile client. Why cursor over offset?
Mid Mobile System Design #system-design#pagination#api-design

Pagination loads a large list in chunks. The main strategies:

Offset/limit (page-based) - ?offset=40&limit=20 (or ?page=3).

  • ✅ Simple, can jump to arbitrary pages, shows total count.
  • Breaks on inserts/deletes - if items are added at the top while you scroll, offset 40 now points at a shifted position → duplicates or skipped items.
  • ❌ Slow on large datasets (DB OFFSET scans rows).

Cursor/keyset-based - ?after=<cursor>&limit=20, where the cursor encodes the last item’s stable position (e.g. createdAt + id).

  • Stable under inserts/deletes - you ask for “items after this specific item,” so shifting doesn’t cause dupes/gaps.
  • ✅ Efficient (WHERE id < cursor LIMIT n uses an index, no offset scan).
  • ❌ No random page access, harder to show a total count or “page 5.”

Why cursor wins for feeds: social/chat/activity feeds change constantly at the head. Cursor pagination is the standard because it’s consistent during live updates - exactly the mobile reality.

Mobile client implementation (Paging 3):

  • PagingSource loads pages by cursor; RemoteMediator writes pages into Room for offline-first paging.
  • Prefetch distance - load the next page before the user hits the end (smooth scroll).
  • Placeholders for not-yet-loaded items; dedup by stable id; expose load states (loading/error/retry).
  • cachedIn(scope) to survive config changes.

Other approaches: keyset with timestamp for chat history (before=<seq>), bidirectional paging (load older and newer), and infinite scroll vs explicit “load more” as UX choices.

Trade-offs to name: cursor’s consistency vs loss of random-access/total-count; prefetch distance (smoothness vs memory/data); page size (fewer requests vs larger payloads).

Compare the side-effect APIs: LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope.
Mid Jetpack Compose #compose#side-effects

A side effect is anything that escapes the scope of a composable (network call, listener registration, logging). Composition can run often and unpredictably, so you must contain side effects in the right API:

LaunchedEffect(keys) - run a suspend block scoped to composition; cancelled on leave, restarted on key change. For coroutine work driven by the composition.

DisposableEffect(keys) - for effects that need cleanup. Register in the block, clean up in onDispose. Re-runs (dispose + re-setup) on key change.

DisposableEffect(lifecycleOwner) {
    val observer = LifecycleEventObserver { _, e -> /* ... */ }
    lifecycleOwner.lifecycle.addObserver(observer)
    onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}

SideEffect { } - runs after every successful recomposition. Use to publish Compose state to a non-Compose object (e.g. update an analytics property or a third-party controller).

rememberCoroutineScope() - returns a CoroutineScope tied to the composition that you launch from event callbacks (not during composition):

val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { snackbarHost.showSnackbar("Hi") } }) { ... }

How to choose:

  • Suspend work when entering / on key change → LaunchedEffect.
  • Need teardown (listeners, callbacks) → DisposableEffect.
  • Launch a coroutine in response to a user eventrememberCoroutineScope.
  • Sync Compose state to a non-Compose API every recomposition → SideEffect.

The rule: never call viewModel.load() or launch coroutines directly in a composable body - it’d fire on every recomposition. Use these APIs to scope effects correctly.

DataStore vs SharedPreferences - why migrate, and what are the differences?
Mid Android Fundamentals #datastore#sharedpreferences#persistence

SharedPreferences is the old key-value store; DataStore (Jetpack) is its modern replacement, designed to fix SharedPreferences’ flaws.

SharedPreferences problems:

  • apply() is async but commit() does synchronous disk I/O on the calling thread - easy to block the main thread (and a known ANR source).
  • Loads the entire file into memory on first access, synchronously - can cause jank at startup.
  • No error signaling, no transactional safety, no first-class async API.
  • getString etc. can return on the main thread after blocking.

DataStore advantages:

  • Fully async and safe - built on coroutines and Flow. Reads are a Flow; writes are suspend. No main-thread I/O.
  • Transactional writes with strong consistency, and it surfaces errors (e.g. IOException) through the Flow.
  • Two flavors:
    • Preferences DataStore - untyped key-value (drop-in for SharedPreferences use cases).
    • Proto DataStore - typed schema via protobuf, with type safety.
val EXAMPLE_KEY = booleanPreferencesKey("dark_mode")

val darkMode: Flow<Boolean> = context.dataStore.data
    .map { it[EXAMPLE_KEY] ?: false }

suspend fun setDarkMode(on: Boolean) {
    context.dataStore.edit { it[EXAMPLE_KEY] = on }
}

When to use which:

  • DataStore for new code - settings, flags, small typed config.
  • SharedPreferences only for legacy code or trivial cases; DataStore even provides a migration (SharedPreferencesMigration).
  • For structured/relational data, neither - use Room.
Design a chat / messaging app (like WhatsApp) - the client side.
Mid Mobile System Design #system-design#chat#realtime#offline

Start with the user experience: messages should appear immediately, work through brief network loss, stay in the right order, and show whether they were sent or read. Then design the client one concern at a time.

Requirements: 1:1 (and group) messaging, real-time delivery, sent/delivered/read receipts, offline send & receive, message history, media.

Real-time transport: use a WebSocket, a connection that lets the client and server send messages at any time, while the app is in the foreground. Use FCM push notifications when it is backgrounded. If the connection drops, reconnect gradually instead of retrying in a tight loop.

Local data: let the UI observe messages from Room. Network responses update Room, and the UI updates from the database. This keeps one place responsible for the visible message history and makes offline reading straightforward.

messages(id, chatId, senderId, body, status, createdAt, serverSeq)
status: SENDING | SENT | DELIVERED | READ | FAILED

Sending a message (optimistic):

  1. Insert into Room with a client-generated UUID and status = SENDING → UI shows it instantly.
  2. Send over the socket (or queue if offline).
  3. When the server acknowledges the message, change it to SENT and store the server ID. An acknowledgement simply means the server confirmed receipt.
  • A WorkManager job (or outbox) drains queued messages when connectivity returns.

Receiving & ordering:

  • The server assigns a monotonic sequence per chat (serverSeq); the client orders by it, not by device time (clocks drift).
  • Reusing the client UUID makes a retry safe: the server can recognize the same message instead of creating a duplicate. This is called idempotency.
  • Gap detection - if you receive seq 5 then 8, fetch the missing 6–7 (sync by “last seen seq”).

Receipts: delivered = stored on device; read = user opened the chat. Send these back over the socket; update local status.

Media: upload to blob storage, send a reference/URL in the message (not the bytes); thumbnails first, lazy full download; resumable chunked upload for large files.

Other concerns: pagination of history (cursor by serverSeq, load older on scroll up), E2E encryption (keys in Keystore) if required, typing/presence via lightweight socket events, notification dedup between FCM and socket.

Trade-offs to name: WebSocket battery cost vs real-timeness (drop socket in background, use FCM), optimistic UI vs consistency, ordering by server sequence vs device time.

Design a news / article reader app with offline reading.
Mid Mobile System Design #system-design#offline#caching#sync

Requirements: browse a feed of articles, read full content, read offline, sync read/bookmark state, images, periodic refresh.

Offline-first data layer (the centerpiece):

  • Room is the single source of truth. The UI observes Room Flows, so the feed and saved articles render instantly and work offline.
  • Schema: articles(id, title, summary, body, imageUrl, publishedAt, isRead, isBookmarked, cachedAt).
  • Network fetches write into Room; the UI never reads the network directly.

Sync strategy:

  • Cache-then-network - show cached feed immediately, refresh in background, update.
  • Background refresh - WorkManager periodic job (constraints: unmetered + maybe charging) pulls latest headlines so content is fresh when the user opens the app, even offline.
  • Delta sync with a timestamp/cursor to fetch only new articles.
  • Prefetch full article bodies + images for the top N feed items (and bookmarked ones) so they’re readable offline - on Wi-Fi to save data.

Read & bookmark state:

  • Stored locally (instant), synced to the server (delta). Optimistic updates; reconcile on sync.

Pagination: cursor-based, load older on scroll, RemoteMediator to page from Room.

Images: Coil with disk cache; prefetch thumbnails with the feed and the hero image for prefetched articles; downsample to view size.

UX: “saved for offline” indicator, last-updated time, pull-to-refresh, graceful offline banner.

Other concerns: cache eviction (cap stored articles / TTL cleanup of old cached bodies to bound storage), content formatting (sanitized HTML/markdown rendering), analytics (reads, dwell time, batched).

Trade-offs to name: how much to prefetch for offline (readability vs storage/data), refresh frequency (freshness vs battery/data), cache retention (offline availability vs storage), eager body prefetch vs on-demand.

Design a search / typeahead (autocomplete) feature.
Mid Mobile System Design #system-design#search#flow#debounce

Requirements: suggestions as the user types, fast, tolerant of slow networks, no wasted requests, no stale results.

The client pipeline (this is also a coroutines/Flow question):

queryFlow
    .debounce(300)                 // wait for a typing pause
    .filter { it.length >= 2 }     // skip tiny queries
    .distinctUntilChanged()        // skip duplicate queries
    .flatMapLatest { q ->          // cancel the previous in-flight search
        searchRepository.search(q)
            .onStart { emit(Loading) }
            .catch { emit(Error) }
    }
    .collect { render(it) }

Why each operator:

  • debounce - don’t fire on every keystroke; one request per typing pause. Saves network/battery.
  • distinctUntilChanged - type then backspace to the same text → no repeat search.
  • flatMapLatest - cancel the stale search when a newer query arrives. Fixes the classic race: a slow response for “ja” must not overwrite results for “java”.

Caching & performance:

  • Cache recent query results (LRU) so re-typing a query is instant and offline-tolerant.
  • Local index for some sources - recent searches, contacts, on-device data via a Room FTS table → instant local suggestions merged with remote.
  • Prefetch / warm popular queries.

Ranking & UX:

  • Merge local (recent/history) + remote suggestions; rank by relevance/recency.
  • Highlight the matched substring; show recent searches when the box is empty.
  • Debounce-tuned for feel (200–400ms); show a subtle loading state, not a blocking spinner.

Backend-ish considerations (mention briefly): server-side prefix index (trie/Elasticsearch) - but the client focus is debounce, cancellation, caching, and merging local+remote.

Trade-offs to name: debounce delay (responsiveness vs request count), min query length, local vs remote suggestions (instant/offline vs coverage), cache size, prefetch popular queries (instant vs wasted work).

Design authentication and token refresh for a mobile app.
Mid Mobile System Design #system-design#auth#security#networking

The model: OAuth2/OIDC issues a short-lived access token (minutes–hours) and a long-lived refresh token (days–months). The access token authorizes API calls; the refresh token gets a new access token when it expires.

Login flow:

  • OAuth2 with PKCE (Authorization Code + PKCE) for first-party and social login - avoids embedding secrets in the app.
  • Store tokens securely - encrypted via Android Keystore (EncryptedSharedPreferences / encrypted DataStore). Never plain prefs.

Transparent refresh (the key client design):

  • Use OkHttp’s Authenticator, which fires automatically on a 401: refresh the token and retry the original request - invisible to the rest of the app.
class TokenAuthenticator(private val store: TokenStore, private val api: AuthApi) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        val newToken = runBlocking { refreshOnce() } ?: return null  // give up → log out
        return response.request.newBuilder()
            .header("Authorization", "Bearer $newToken").build()
    }
}
  • Serialize concurrent refreshes - if 5 requests 401 at once, only one refresh should run (a Mutex); the others wait and reuse the new token. Otherwise you fire 5 refreshes and may invalidate each other.
  • An Interceptor attaches the current access token to every request.

Edge cases to handle:

  • Refresh token expired/revoked → force logout, clear tokens, send to login.
  • Refresh token rotation - many servers issue a new refresh token each refresh; store the latest, handle reuse-detection (a replayed old token = possible theft → invalidate session).
  • Clock skew - refresh slightly before expiry (proactive) or rely on 401 (reactive); proactive avoids a failed request.
  • Logout - revoke server-side, clear local tokens, clear caches, cancel the device push token.
  • Multiple accounts - token store keyed by account.

Security: Keystore-backed storage, HTTPS + cert pinning, biometric gate for sensitive apps, no tokens in logs.

Trade-offs to name: access-token lifetime (security vs refresh frequency), proactive vs reactive refresh (extra check vs a failed request), refresh-token rotation (security vs complexity).

Design the push notification system for a mobile client (FCM).
Mid Mobile System Design #system-design#fcm#notifications#push

Flow overview: App registers with FCM → gets a device token → sends it to your backend → backend sends messages to FCM addressed by token → FCM delivers to the device → your app shows a notification or syncs.

Client responsibilities:

1. Token management

  • On onNewToken, upload the token to your backend (associated with the user/device). Tokens rotate (reinstall, restore, refresh) - always sync the latest.
  • Remove/invalidate tokens on logout so the next user doesn’t get the previous user’s pushes.

2. Message types (the key design choice):

  • Notification messages - FCM displays them automatically when backgrounded; limited control.
  • Data messages - delivered to your onMessageReceived (foreground; background with caveats), giving you full control to build the notification or trigger a sync.
  • Best practice: use data messages so you control rendering and can act (sync), and treat the push as a signal - for important data, fetch the source of truth rather than trusting the payload (which is size-limited and not guaranteed ordered).

3. Displaying & handling

  • Build with NotificationCompat on the right channel (user-controlled importance); attach an immutable PendingIntent with a deep link to the relevant screen.
  • Request POST_NOTIFICATIONS runtime permission (Android 13+).
  • Deduplicate with the socket/in-app path (don’t double-notify), and collapse related notifications (group + summary, or collapseKey).

4. Reliability & priority

  • High-priority messages can wake the app from Doze for time-sensitive pushes (use sparingly - abuse gets throttled).
  • FCM delivery is best-effort, not guaranteed/instant/ordered - design for missed/late pushes (sync on next open).
  • WorkManager to do any heavy work the push triggers (don’t do it in onMessageReceived, which has a ~10s budget).

Trade-offs to name: data vs notification messages (control vs simplicity), high-priority (timeliness vs battery/throttling), push-as-signal vs push-as-payload (reliability vs latency).

Explain Activity launch modes and the related intent flags.
Mid Android Fundamentals #activity#launch-modes#tasks

Launch modes control how an Activity instance relates to the task back stack. Set them in the manifest (android:launchMode) or via intent flags.

  • standard (default) - a new instance every time it’s launched, even if one already exists. Can have multiple copies in the stack.
  • singleTop - if an instance is already at the top of the stack, reuse it and deliver the intent to onNewIntent() instead of creating a new one. If it’s not on top, a new instance is created.
  • singleTask - at most one instance in the task. If it exists, it’s brought to the front and everything above it is cleared (onNewIntent is called). Common for an app’s entry/root activity.
  • singleInstance - like singleTask, but the activity is the only one in its task - nothing else can be added to that task. Rare (e.g. a launcher or a separate-window screen).

Equivalent intent flags (set at launch time, no manifest change):

  • FLAG_ACTIVITY_NEW_TASK - start in a new/ existing task.
  • FLAG_ACTIVITY_SINGLE_TOP - like singleTop for this launch.
  • FLAG_ACTIVITY_CLEAR_TOP - if the activity exists in the stack, clear everything above it.
  • FLAG_ACTIVITY_CLEAR_TASK (with NEW_TASK) - wipe the task and start fresh (e.g. after logout).

onNewIntent() is the callback you must handle when an existing instance is reused - the new Intent arrives there, not in onCreate. Forgetting it means you process the old intent’s data.

Practical uses: singleTask/CLEAR_TOP for “go home” buttons and notification taps that shouldn’t stack duplicates; singleTop for a search activity re-launched with a new query; CLEAR_TASK + NEW_TASK to reset the stack on logout.

Explain build types, product flavors, and build variants in Gradle.
Mid Android Fundamentals #gradle#build#flavors

Three related concepts let you produce multiple versions of an app from one codebase:

  • Build types - how the app is built. Default debug and release; differ in signing, minifyEnabled, debuggable, applicationIdSuffix, etc. You can add custom ones (e.g. staging).
  • Product flavors - what the app is. Different variants like free/paid, or dev/prod (different API endpoints, app names, feature sets). Grouped by flavor dimensions.
  • Build variant = build type × flavor. With flavors free/paid and types debug/release you get four: freeDebug, freeRelease, paidDebug, paidRelease.
android {
    flavorDimensions += "tier"
    productFlavors {
        create("free")  { dimension = "tier"; applicationIdSuffix = ".free" }
        create("paid")  { dimension = "tier" }
    }
    buildTypes {
        getByName("release") { isMinifyEnabled = true; signingConfig = ... }
        create("staging")    { initWith(getByName("debug")); applicationIdSuffix = ".staging" }
    }
}

What you control per variant:

  • applicationId/suffix - so debug/staging/free can install alongside release (different package names).
  • buildConfigField and resValue - inject constants (API base URL, feature flags) and resources per variant.
  • Source sets - src/free/, src/debug/ directories override/add code and resources for that variant.
  • Signing configs, ProGuard rules, manifest placeholders.

Common real-world use: dev/prod flavors pointing at different backends, a staging build type for QA, and applicationIdSuffix so testers keep prod + staging installed simultaneously.

Explain Clean Architecture on Android. What are the layers and the dependency rule?
Mid Architecture & Patterns #clean-architecture#layers#separation

Clean Architecture separates code by responsibility. The useful part is not the diagram or the number of layers. It is keeping business rules independent from Android UI and storage details.

On Android this typically maps to three layers (Google’s recommended architecture):

  • Data layer - repositories and their data sources (network, database, cache). Owns how data is fetched/stored. Exposes data to the domain/UI.
  • Domain layer (optional) - pure business logic: use cases and domain models. No Android dependencies - plain Kotlin, fully testable. Defines repository interfaces.
  • UI (presentation) layer - ViewModels + Compose/Views. Holds UI state, reacts to user input, observes data.
UI  ──depends on──▶  Domain  ◀──depends on──  Data
(ViewModel)          (UseCase,                (Repository impl,
                      interfaces)              network, db)

The dependency rule in practice: a domain layer can define a UserRepository interface and the data layer can implement it. Business logic then knows it can load a user, but does not know whether the data came from Room, Retrofit, or a fake used in a test.

Why teams use it:

  • Testability - domain logic is pure Kotlin, tested without Android.
  • Replaceability - change a data source without rewriting the UI.
  • Separation of concerns - each layer has one reason to change.

Keep it practical:

  • The domain layer is optional - for simple screens, ViewModel → Repository is fine; add use cases when business logic is reused across ViewModels or gets complex.
  • Don’t over-engineer: mapping models across three layers and a use case per call can be overkill for a CRUD app. Match the architecture to the app’s complexity.
  • Separate models can protect layers from each other’s changes, but mapping every small object through four representations is not automatically better.
Explain Handler, Looper, and MessageQueue. How does the main thread work?
Mid Android Fundamentals #threading#handler#looper#main-thread

This trio is the message-passing machinery behind Android’s main thread.

  • MessageQueue - a queue of Message/Runnable tasks to be processed, ordered by time.
  • Looper - an infinite loop bound to a thread that pulls messages off the queue and dispatches them, one at a time. One Looper per thread (Looper.prepare() + Looper.loop()).
  • Handler - the interface to post messages/runnables onto a Looper’s queue and to handle them when they’re dispatched. A Handler is bound to the Looper of the thread that created it (or one you pass).
val mainHandler = Handler(Looper.getMainLooper())
mainHandler.post { textView.text = "Done" }        // run on main thread
mainHandler.postDelayed({ /* ... */ }, 1000)        // schedule for later

The main (UI) thread is a thread running a Looper. The framework calls Looper.loop() for you; every lifecycle callback, touch event, and View.invalidate is a message dispatched through the main MessageQueue. That’s why:

  • All UI updates must happen on the main thread - it’s the single thread draining that queue.
  • Blocking the main thread (heavy work in a message) stalls the queue → no frames drawn → ANR.
  • You “go back to the UI thread” by posting to the main Handler (or, in coroutines, Dispatchers.Main).

Where it still matters today: even though you use coroutines now, Dispatchers.Main is built on the main Looper, and HandlerThread (a thread with its own Looper) backs some libraries (e.g. camera/sensor callbacks). Understanding it explains why runOnUiThread, View.post, and Dispatchers.Main exist.

Explain LaunchedEffect. Why do its keys matter?
Mid Jetpack Compose #compose#side-effects#LaunchedEffect

LaunchedEffect runs a suspend block tied to the composition. It launches a coroutine when the composable enters the composition and cancels it when the composable leaves - perfect for one-off or ongoing async work driven by Compose.

LaunchedEffect(Unit) {
    viewModel.loadData()          // runs once when entering composition
}

The keys are the crucial part. LaunchedEffect(key1, key2, …) restarts the coroutine (cancels the old, starts a new) whenever any key changes. If keys don’t change across recompositions, the same coroutine keeps running.

LaunchedEffect(userId) {
    user = repository.loadUser(userId)   // reloads whenever userId changes
}
  • LaunchedEffect(Unit) or LaunchedEffect(true) - run once for the composable’s lifetime (key never changes). Good for “fire on first appearance.”
  • LaunchedEffect(key) - re-run when key changes. The classic bug is using Unit when you need it to re-run on a parameter change (stale data), or capturing a value that’s stale because it isn’t a key.

Output-based trap:

LaunchedEffect(Unit) {
    delay(2000)
    println(count)     // captures count at first composition - may be STALE
}

If count should be current, either add it as a key, or wrap it in rememberUpdatedState.

Explain the Adapter and Decorator patterns with Android examples.
Mid Architecture & Patterns #design-patterns#adapter#decorator

Two structural patterns that are easy to confuse.

Adapter - converts one interface into another the client expects. It wraps an incompatible type to make it usable.

// Adapt a domain list to what RecyclerView expects
class UserAdapter(val users: List<User>) : RecyclerView.Adapter<UserVH>() { ... }

Android examples: RecyclerView.Adapter (the name says it - adapts data to view-holders), PagerAdapter, wrapping a third-party SDK’s interface behind your own (AnalyticsClient interface adapting Firebase/Amplitude), or a Retrofit CallAdapter. Use it to make incompatible interfaces work together, especially to wrap libraries you don’t control behind your own abstraction (an anti-corruption layer).

Decorator - adds behavior to an object dynamically by wrapping it in another object with the same interface, without changing the original.

interface DataSource { suspend fun load(key: String): String }

class CachingDataSource(private val wrapped: DataSource) : DataSource {
    private val cache = mutableMapOf<String, String>()
    override suspend fun load(key: String) =
        cache.getOrPut(key) { wrapped.load(key) }   // adds caching, same interface
}

class LoggingDataSource(private val wrapped: DataSource) : DataSource {
    override suspend fun load(key: String): String {
        Log.d("DS", "load $key"); return wrapped.load(key)
    }
}

Android examples: OkHttp Interceptors (each wraps the chain, adding logging/auth/caching), ContextWrapper (and ContextThemeWrapper), input stream wrappers (BufferedInputStream). You can stack decorators (Logging(Caching(real))) to compose behavior.

The distinction:

  • Adapter = change the interface (make B usable as A).
  • Decorator = same interface, add responsibilities (wrap to enhance).
Explain the Factory pattern and where you use it on Android.
Mid Architecture & Patterns #design-patterns#factory

A Factory centralizes object creation, hiding the construction logic and the concrete type behind a method. Callers ask the factory for an object instead of calling a constructor directly.

// Factory method: decide the concrete type from input
object PaymentProcessorFactory {
    fun create(type: PaymentType): PaymentProcessor = when (type) {
        PaymentType.CARD   -> CardProcessor()
        PaymentType.UPI    -> UpiProcessor()
        PaymentType.WALLET -> WalletProcessor()
    }
}

Why use it:

  • Encapsulates creation - complex/conditional construction lives in one place, not scattered across call sites.
  • Decouples callers from concrete classes (they depend on the PaymentProcessor interface).
  • Open/Closed - add a new type by extending the factory, not editing every caller.

Where it appears in Android:

  • ViewModelProvider.Factory - the common example. ViewModels need constructor args (a repository), but the framework creates them, so you provide a factory that knows how to build it:
class FeedVMFactory(private val repo: FeedRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(c: Class<T>): T = FeedViewModel(repo) as T
}

(Hilt’s @HiltViewModel generates this for you.)

  • Fragment.instantiate / newInstance pattern, RecyclerView.ViewHolder creation in onCreateViewHolder, LayoutInflater.Factory, Retrofit/OkHttp builders internally, and DI @Provides methods are factories.

Variants: Factory Method (a method returns a type), Abstract Factory (a family of related objects), and DI frameworks are essentially generalized factories.

Explain the SOLID principles with Android examples.
Mid Architecture & Patterns #solid#design-principles#clean-code

Five object-oriented design principles for maintainable code:

S - Single Responsibility. A class should have one reason to change.

Android: a ViewModel manages UI state; it shouldn’t also parse JSON or do networking. A God-Activity doing UI + networking + persistence violates this.

O - Open/Closed. Open for extension, closed for modification.

Android: add a new RecyclerView view type or a new PaymentMethod by adding a class, not editing a giant when everywhere. A sealed hierarchy + polymorphism extends behavior without rewriting existing code.

L - Liskov Substitution. Subtypes must be usable wherever the base type is, without breaking expectations.

Android: a FakeRepository must honor the Repository contract so it can replace the real one in tests. A subclass that throws on a method the base supports breaks LSP.

I - Interface Segregation. Prefer small, focused interfaces over fat ones.

Android: don’t force a class to implement a 10-method Callback; split into OnClick, OnLongClick. Clients depend only on what they use.

D - Dependency Inversion. Depend on abstractions, not concretions; high-level modules shouldn’t depend on low-level details.

Android: the ViewModel depends on a UserRepository interface, not RetrofitUserRepository. This is exactly what DI (Hilt) and Clean Architecture’s “domain defines interfaces, data implements them” enforce.

Why this matters: SOLID underpins why we use repositories, interfaces, DI, and layered architecture. The strongest answers tie each principle to a concrete Android decision (the examples above), not just recite definitions.

Explain the Strategy pattern with an Android example.
Mid Architecture & Patterns #design-patterns#strategy

The Strategy pattern defines a family of interchangeable algorithms behind a common interface, so you can swap behavior at runtime without changing the code that uses it.

fun interface SortStrategy {
    fun sort(items: List<Post>): List<Post>
}

val byDate    = SortStrategy { it.sortedByDescending(Post::date) }
val byPopular = SortStrategy { it.sortedByDescending(Post::likes) }

class FeedViewModel(private var strategy: SortStrategy = byDate) {
    fun setStrategy(s: SortStrategy) { strategy = s }
    fun display(posts: List<Post>) = strategy.sort(posts)   // behavior swappable
}

Why use it:

  • Open/Closed - add a new strategy (a new sort/validation/formatting rule) without touching existing code or growing a giant when.
  • Runtime flexibility - switch algorithms based on user choice, config, or A/B flags.
  • Testable - each strategy is isolated and unit-testable.

Where it shows up in Android:

  • RecyclerView.LayoutManager - LinearLayoutManager / GridLayoutManager are interchangeable layout strategies.
  • Interpolator (animations), ItemAnimator, DiffUtil.ItemCallback.
  • Image-loading, caching, or retry policies injected into a repository.
  • Validation strategies, sort/filter options, payment processors, ad providers behind an interface.
  • In Kotlin it’s often just a function type / fun interface passed in - a lightweight strategy without ceremony.

Relation to DI: injecting different implementations of an interface is the Strategy pattern applied via dependency injection (debug vs prod logger, fake vs real repo).

Explain Unit, Nothing, and Any. How do they differ?
Mid Kotlin Language #kotlin#type-system

These sit at the edges of Kotlin’s type hierarchy.

Any - the root of all non-nullable types (like Java’s Object). Everything is an Any; Any? is the absolute top including null. It declares equals, hashCode, toString.

Unit - the type of functions that return “nothing meaningful,” equivalent to void, except Unit is a real type with a single value (Unit). That matters because generics need an actual type: Callback<Unit> works, Callback<void> couldn’t.

Nothing - the bottom type: it has no instances and is a subtype of every type. A function returning Nothing never returns normally - it always throws or loops forever.

fun fail(msg: String): Nothing = throw IllegalStateException(msg)

val name = user.name ?: fail("no name")  // compiler knows name is non-null after

Because Nothing is a subtype of everything, the compiler uses it for control flow: throw and TODO() have type Nothing, so they fit into any expression. emptyList() returns List<Nothing>, which is assignable to List<anything>.

Summary: Any = top (every value), Nothing = bottom (no value), Unit = “returns, but no useful value.”

Fakes vs mocks vs stubs - which should you prefer and why?
Mid Testing & Quality #testing#fakes#mocks

All three are test doubles that stand in for real dependencies, but they differ:

  • Stub - returns canned answers to calls (whenever(repo.get()).thenReturn(data)). No real behavior.
  • Mock - a stub that also verifies interactions (“was save() called once with X?”). Created with frameworks like MockK/Mockito.
  • Fake - a real, working lightweight implementation of the interface (e.g. an in-memory repository backed by a MutableList/MutableStateFlow).
// Fake: a real, simple implementation
class FakeUserRepository : UserRepository {
    private val users = MutableStateFlow<List<User>>(emptyList())
    override fun observeUsers() = users.asStateFlow()
    override suspend fun add(user: User) { users.update { it + user } }
}

Prefer fakes (Google’s guidance) because:

  • They test behavior, not implementation - you assert on the resulting state, not on “which methods were called.” Mocks couple tests to internal call sequences, so refactors break tests even when behavior is unchanged (“brittle tests”).
  • A fake supports realistic flows (add then observe → emits the new list), which is exactly what Flow-based code needs. Mocking a Flow’s emissions over time is painful and error-prone.
  • Fakes are reusable across many tests and read clearly.

When mocks are still useful:

  • Verifying an interaction is the requirement - e.g. “analytics track() was called,” “the repository’s sync() was invoked.” There’s no state to assert, so verifying the call is legitimate.
  • Simulating errors/edge cases that are awkward to build into a fake (a specific exception on the 3rd call).
  • Quick isolation of a dependency you don’t want to implement.

Anti-pattern interviewers watch for: mock-heavy tests that mirror the implementation line-by-line - they pass even when the code is wrong and break on every refactor.

Hilt/Dagger vs Koin - what's the trade-off?
Mid Architecture & Patterns #hilt#dagger#koin#dependency-injection

The core distinction: Dagger/Hilt resolve the graph at compile time; Koin resolves it at runtime.

Dagger / Hilt - compile-time, code-generated DI.

  • Type-safe - missing/duplicate bindings fail the build, not in production.
  • No reflection → fast at runtime, good for large graphs.
  • ✅ Hilt adds Android lifecycle components/scopes out of the box.
  • Steeper learning curve, more annotations, and build-time cost (annotation processing / KSP).
  • ❌ Cryptic Dagger error messages.

Koin - runtime service locator (a DSL that registers and resolves dependencies).

  • Simple and Kotlin-idiomatic - a readable DSL, minimal boilerplate, no codegen, fast builds.
  • ✅ Easy to learn; great for small/medium apps and KMP.
  • Errors surface at runtime - a missing dependency crashes when first requested, not at compile time.
  • ❌ Resolution has some runtime overhead (historically reflection-ish; improved over versions), and less compile-time safety.

How to choose (the balanced interview answer):

  • Large, multi-module, performance-sensitive apps / teams that value compile-time safetyHilt (Google’s recommended default on Android).
  • Smaller apps, rapid prototyping, KMP, or teams prioritizing simplicity and build speedKoin.

Note: Koin is technically closer to a service locator than “true” DI, and that distinction (compile-time safety vs runtime flexibility) is the real heart of the question - not which is “better.”

How do navigation, arguments, and ViewModels work in Compose?
Mid Jetpack Compose #compose#navigation

Navigation-Compose models the app as a NavHost with composable destinations addressed by routes, driven by a NavController.

val navController = rememberNavController()
NavHost(navController, startDestination = "feed") {
    composable("feed") {
        FeedScreen(onItemClick = { id -> navController.navigate("detail/$id") })
    }
    composable(
        "detail/{itemId}",
        arguments = listOf(navArgument("itemId") { type = NavType.StringType }),
    ) { backStackEntry ->
        val id = backStackEntry.arguments?.getString("itemId")!!
        DetailScreen(id)
    }
}

Key points:

  • Routes are strings (older API) or type-safe classes/objects with Kotlin Serialization (newer Navigation 2.8+ type-safe routes) - prefer type-safe to avoid stringly-typed bugs.
  • Arguments are declared with navArgument and read from the backStackEntry. Pass IDs, not whole objects - large/complex args don’t belong in the back stack.
  • ViewModel scoping - hiltViewModel() inside a composable {} scopes the ViewModel to that NavBackStackEntry, so it survives recomposition and config changes but is cleared when you pop the destination.
  • Nested graphs group related destinations; a ViewModel scoped to a nested graph (hiltViewModel(graphEntry)) can be shared across screens in a flow (e.g. a multi-step checkout).
  • Results between screens - use the previous entry’s SavedStateHandle, or share a graph-scoped ViewModel, rather than passing data forward and back through routes.

Common gotchas: don’t pass non-trivial objects as nav args (serialize an ID instead); use popUpTo/launchSingleTop to control the back stack; and remember each destination’s ViewModel lifecycle is tied to its back stack entry.

How do notifications work on modern Android?
Mid Android Fundamentals #notifications#channels

Posting a notification requires a few things on modern Android:

1. A notification channel (Android 8+, mandatory). Every notification belongs to a channel; the user controls importance, sound, vibration, and can mute a channel - you can’t override their choice. Create channels once (e.g. in Application.onCreate).

val channel = NotificationChannel(
    "messages", "Messages", NotificationManager.IMPORTANCE_HIGH,
)
notificationManager.createNotificationChannel(channel)

2. Build and post:

val n = NotificationCompat.Builder(context, "messages")
    .setSmallIcon(R.drawable.ic_msg)
    .setContentTitle("New message")
    .setContentText(body)
    .setContentIntent(pendingIntent)        // tap action (PendingIntent)
    .setAutoCancel(true)
    .build()
NotificationManagerCompat.from(context).notify(id, n)

3. Runtime permission (Android 13+). POST_NOTIFICATIONS is now a runtime permission - request it like any dangerous permission; without it, your notifications are silently dropped.

What to remember:

  • Importance is set on the channel, not the notification, and the user has final say. IMPORTANCE_HIGH = heads-up; LOW/MIN = quiet.
  • Channel groups, and you can’t change a channel’s importance after creation (user owns it).
  • PendingIntent powers tap and action buttons - use FLAG_IMMUTABLE (except direct-reply, which needs MUTABLE).
  • Rich features: styles (BigTextStyle, MessagingStyle, MediaStyle), actions, direct reply (RemoteInput), progress, grouping/summary, and foreground service notifications.
  • NotificationCompat for backward compatibility.
How do you add a timeout to a coroutine? withTimeout vs withTimeoutOrNull.
Mid Coroutines & Flow #coroutines#timeout#cancellation

Two builders cap how long a block may run:

  • withTimeout(ms) - throws TimeoutCancellationException if the block doesn’t finish in time.
  • withTimeoutOrNull(ms) - returns null instead of throwing on timeout.
// Throws on timeout - handle with try/catch
val data = try {
    withTimeout(5_000) { api.fetch() }
} catch (e: TimeoutCancellationException) {
    fallback()
}

// Returns null on timeout - clean for "best effort"
val data = withTimeoutOrNull(5_000) { api.fetch() } ?: fallback()

How it works: on timeout the block is cancelled (cooperatively - same rules as normal cancellation). So the timed work must reach a suspension point or check isActive, or the timeout won’t fire until it does.

Gotchas interviewers like:

  • TimeoutCancellationException is a subclass of CancellationException. If you cancel inside the block and catch broadly, you can accidentally swallow it - and a blanket catch (e: Exception) around withTimeout will catch the timeout but also risks eating real cancellation.
  • The timeout cancels the block, but cleanup in finally still runs. If cleanup suspends, wrap it in withContext(NonCancellable).
  • For a non-cancelling timeout (let the work finish but stop waiting), race it with select/a separate delay instead.
How do you approach a mobile system design interview?
Mid Mobile System Design #system-design#framework#interview

Drive the conversation with a structured framework - interviewers grade your process and trade-off reasoning, not a memorized answer. Mobile system design is client-focused: don’t draw a backend; design the app.

A repeatable structure (~45 min):

1. Clarify requirements (5 min). Don’t jump in. Pin down:

  • Functional - what features? (feed: scroll, like, post? offline?)
  • Non-functional - offline support, real-time, scale, target devices/OS versions, battery/data constraints.
  • Scope - “Should I focus on the feed rendering and data layer?” Narrow it.

2. Define the API / data contract (5 min). The endpoints the client calls, request/response shapes, pagination style (cursor), and real-time mechanism (WebSocket/FCM/poll). This frames everything downstream.

3. High-level architecture (10 min). Layered client design:

  • UI (Compose/Views + ViewModel/UDF)
  • Domain (use cases, if needed)
  • Data (repository, single source of truth, local DB + network + cache)
  • Draw the data flow: UI ↔ ViewModel ↔ Repository ↔ {Room, Network}.

4. Deep-dive the hard parts (15 min). Pick the spicy bits and go deep:

  • Caching & offline - DB as source of truth, freshness policy.
  • Pagination - cursor-based, prefetch.
  • Sync & conflicts - optimistic updates, reconciliation.
  • Images/media - downsampling, prefetch, cancellation.
  • Real-time - WebSocket vs FCM vs polling.

5. Trade-offs & wrap-up (5–10 min). Name the tensions explicitly: memory vs smoothness, freshness vs data usage, consistency vs latency, battery vs real-timeness. Mention failure modes, error handling, and what you’d measure.

Cross-cutting concerns to weave in: offline behavior, error/retry, security (token storage), performance (jank, startup), battery/data, testing, observability.

What separates a strong candidate: naming the trade-off out loud (“longer cache TTL saves data but risks staleness - I’d…”), handling failure cases, and connecting choices to constraints (flaky network, limited battery).

How do you design an app that works offline?
Mid Architecture & Patterns #offline-first#caching#single-source-of-truth#repository

The core principle: the local database is the single source of truth. The UI always reads from the database; the network only updates the database. The app works offline by default, and network is an enhancement.

UI ──observes──▶ Room (source of truth) ◀──writes── Repository ◀──fetches── Network

The classic flow (NetworkBoundResource pattern):

  1. UI observes a Room Flow → shows cached data immediately (even offline).
  2. Repository decides whether to refresh (stale? forced?).
  3. If refreshing, fetch from network → write into Room.
  4. Room emits the new data → UI updates automatically. The network result never goes straight to the UI.
fun observeArticles(): Flow<List<Article>> = flow {
    emitAll(dao.observeArticles())           // 1. always from DB
}.onStart {
    runCatching { val fresh = api.getArticles(); dao.upsertAll(fresh.map { it.toEntity() }) }
        .onFailure { /* offline: UI still has cached data */ }   // 2-4
}

Key design decisions interviewers probe:

  • Source of truth = DB, not the network response. This is what makes it consistent and offline-capable.
  • Freshness policy - cache-then-network, TTL-based invalidation, or pull-to-refresh forcing a fetch.
  • Writes / sync - queue local mutations (likes, edits) with a status flag, do optimistic UI, and sync to the server when online (often via WorkManager with a network constraint); reconcile conflicts (last-write-wins, version vectors, or server authority).
  • Pagination - Paging 3 + RemoteMediator implements offline-first paging: pages are written to Room, the UI pages from Room.
  • Conflict resolution and partial failure handling are the senior-level details.

Why it’s better than fetch-on-demand: instant loads from cache, resilience to flaky networks, consistent UI, and less redundant fetching.

How do you design an app to handle poor or intermittent connectivity?
Mid Mobile System Design #system-design#offline#networking#resilience

Treat the network as unreliable by default - this is the defining constraint of mobile vs web. Design so the app stays usable on a flaky train-Wi-Fi connection.

Offline-first foundation:

  • Local DB (Room) as the single source of truth. The UI reads from the DB, so it always has data to show - network is an enhancement, not a requirement.
  • Optimistic UI - apply user actions locally immediately (mark PENDING), sync in the background; reconcile on success/failure.

Queue writes, sync later:

  • An outbox of pending mutations persisted in the DB.
  • Drain it with WorkManager (network constraint) when connectivity returns - guaranteed, survives app kill/reboot.
  • Make syncs idempotent (client-generated IDs) so retries don’t duplicate.

Smart networking:

  • Retry with exponential backoff + jitter for transient failures; cap attempts.
  • Timeouts tuned for mobile (don’t hang forever); distinguish “slow” from “failed.”
  • Request dedup / coalescing; cancel on screen leave.
  • Conditional requests (ETag) and delta sync to minimize data over weak links.
  • Detect connectivity with NetworkCallback (and quality, not just connected - captive portals/validated capability).

UX for degraded states:

  • Show cached content immediately; subtle “offline” / “last updated X” indicators.
  • Don’t block the UI on the network; never show a blank screen because a request is pending.
  • Clear retry affordances; pause-and-resume for transfers.
  • Graceful partial failures (one widget fails, the rest render).

Resilience details:

  • Handle mid-request drops (resume via Range/resumable uploads).
  • Data Saver / metered awareness - defer heavy syncs to Wi-Fi.
  • Avoid thundering-herd reconnects (jittered backoff).

Trade-offs to name: optimistic UI (responsiveness vs reconciling failures), aggressive retry (success vs battery/data), sync frequency (freshness vs cost), cache staleness vs availability.

How do you design observability for a production mobile app? (crashes, ANRs, performance, logs)
Mid Mobile System Design #system-design#observability#monitoring#quality

You can’t fix what you can’t see, and you don’t control users’ devices - so observability is essential.

Crash & error reporting:

  • Crashlytics / Sentry / Bugsnag - capture crashes with stack traces, breadcrumbs, device/OS/app-version, and custom keys (user state, feature flags).
  • Upload R8 mapping.txt so obfuscated stacks are deobfuscated - without it, production traces are unreadable.
  • Log non-fatal exceptions (caught errors) to spot issues that don’t crash but degrade UX.

Stability metrics:

  • Crash-free users/sessions rate - the headline quality KPI.
  • ANR rate - track via Android vitals (Play Console) and tooling; ANRs hurt ranking and retention.

Performance monitoring:

  • Startup time (cold/warm), frame rendering / jank (JankStats, FrameMetrics), network latency, screen-load times.
  • Firebase Performance / custom traces for key flows (reportFullyDrawn, custom spans).
  • Macrobenchmark in CI to catch regressions before release; Baseline Profiles to improve.

Analytics & business events - funnels, feature adoption, drop-off (batched pipeline; see analytics design).

Logging:

  • Structured, leveled logging; strip verbose logs in release (no PII, no tokens). Remote log collection for diagnosing reported issues.
  • Correlation IDs to tie client requests to backend logs.

Release safety:

  • Staged rollouts (1% → 100%) watching crash/ANR/vitals; halt/rollback on regression.
  • Remote kill switch (feature flags) to disable a broken feature without a release.
  • Pre-launch reports (Play) and device labs for coverage.
  • Alerting on crash-rate spikes and ANR thresholds.

Privacy: consent, no PII in logs/analytics, respect opt-out and platform policies.

Trade-offs to name: logging verbosity (diagnosability vs noise/PII/size), sampling performance traces (cost vs fidelity), rollout speed (velocity vs risk).

How do you design the local database schema for a mobile client?
Mid Mobile System Design #system-design#database#room#schema

The local DB (Room) is usually the single source of truth, so the schema should serve offline reads, sync, and fast queries - not mirror the backend blindly.

Principles:

  • Model for your screens’ queries, not the API shape. Denormalize where it makes reads fast; normalize where data is shared/updated independently.
  • Stable primary keys - use server IDs when available, or client-generated UUIDs for offline-created entities (so they exist before sync).
  • Sync metadata on each table - fields like updatedAt, syncStatus (SYNCED/PENDING/CONFLICT), isDeleted (soft delete / tombstone), version. These power delta sync and conflict detection.
  • Relations - @Relation/foreign keys for one-to-many (a chat → messages); index foreign keys and common query columns.
  • Indexing - add indices on columns you filter/sort by (chatId, createdAt); don’t over-index (write cost).

Example (chat):

chats(id PK, title, lastMessageAt, unreadCount)
messages(id PK, chatId FK→chats, body, status, serverSeq, createdAt, syncStatus)
  index(chatId, serverSeq)        -- ordered history queries

Key decisions interviewers probe:

  • Soft delete vs hard delete - soft (isDeleted) so deletions can sync; clean up tombstones later.
  • Migrations - version the schema; provide Migration objects (never ship destructive migration to prod).
  • Normalization vs denormalization - denormalize a lastMessage onto chats for a fast list query vs joining every time (read speed vs write/consistency cost).
  • Large blobs - store files on disk, keep a path/URI in the DB (don’t put images/videos in SQLite).
  • Pagination - keyset-friendly columns (serverSeq/createdAt) for cursor paging; works with Paging 3 PagingSource.
  • Observability - Flow-returning queries so the UI updates reactively.

Performance: wrap bulk writes in transactions, use @Upsert, avoid main-thread queries (Room enforces this), and FTS tables for search.

Trade-offs to name: denormalization (read speed vs update complexity/consistency), indexing (read speed vs write cost & size), soft delete (sync correctness vs cleanup), storing derived fields (fast reads vs keeping them in sync).

How do you diagnose and fix Compose performance problems?
Mid Jetpack Compose #compose#performance#recomposition

Approach it as measure → find the cause → fix, not guesswork.

Measure first:

  • Layout Inspector shows recomposition counts per composable - find what recomposes too often.
  • Compose compiler metrics report which composables are skippable/restartable and which parameters are unstable.
  • System Trace / Macrobenchmark for jank and frame timing.
  • Always profile a release build - debug Compose is much slower and misleading.

Common causes & fixes:

  1. Unstable parameters → composable can’t skip. Fix with ImmutableList, @Immutable/@Stable, or stable state classes.
  2. Reading state too high / too early → wide recomposition scope. Read state as low in the tree and as late in the phases as possible. Defer reads to lambda modifiers: Modifier.offset { }, graphicsLayer { }, drawBehind { } - these read during layout/draw, skipping composition.
  3. New lambda/object allocations each recomposition → break skipping. remember expensive objects; method references and stable lambdas help (strong skipping remembers lambdas).
  4. Rapidly-changing state read directly (scroll offset) → wrap with derivedStateOf so readers update only on meaningful changes.
  5. Work in composition → no heavy computation, sorting, or I/O in a composable body; remember(key) { } it or move it to the ViewModel.
  6. Missing keys in lazy lists → wasted recomposition on reorder; add key = { it.id }.

Ship-time wins:

  • Baseline Profiles - precompile hot paths (including Compose) so the first runs are AOT-compiled, cutting startup and scroll jank significantly.
  • Strong skipping mode (Kotlin 2.x) reduces manual stability work.
How do you do custom drawing in Compose? (Canvas, drawBehind, drawWithCache)
Mid Jetpack Compose #compose#canvas#drawing

Compose drawing happens in the draw phase via a DrawScope, which gives you drawLine, drawCircle, drawPath, drawRect, drawImage, etc. - all in pixels (.toPx() from dp).

Canvas composable - a dedicated drawing surface:

Canvas(Modifier.size(200.dp)) {
    drawCircle(color = Color.Red, radius = size.minDimension / 2)
    drawLine(Color.Black, start = Offset.Zero, end = Offset(size.width, size.height))
}

Modifier.drawBehind { } - draw behind a composable’s content (e.g. a custom background):

Text("Hi", Modifier.drawBehind { drawRoundRect(Color.Yellow, cornerRadius = CornerRadius(8f)) })

Modifier.drawWithContent { } - control ordering relative to content (drawContent() places the children’s drawing; draw before/after it). Great for overlays, scrims, masks.

Modifier.drawWithCache { } - cache expensive draw objects (paths, brushes, shaders) so they’re not recreated every draw frame:

Modifier.drawWithCache {
    val path = buildExpensivePath(size)   // computed only when size changes
    onDrawBehind { drawPath(path, Color.Blue) }
}

Performance notes:

  • Drawing is in the draw phase, so reading state inside a draw lambda (drawBehind { }) skips composition/layout - cheap for animations like progress bars.
  • Use drawWithCache for anything costly to build (Paths, gradients) so it’s rebuilt only when inputs change, not every frame.
  • For very heavy/continuous graphics, graphicsLayer (and RenderEffect) offload work to the GPU.
How do you handle gestures and touch input in Compose?
Mid Jetpack Compose #compose#gestures#input

Compose offers gesture handling at two levels.

High-level modifiers for common cases:

Modifier
    .clickable { onClick() }
    .combinedClickable(onClick = {}, onLongClick = {})
    .draggable(state = rememberDraggableState { delta -> offset += delta },
               orientation = Orientation.Horizontal)
    .scrollable(...)
    .swipeable(...) // or anchoredDraggable in newer APIs
    .transformable(...) // pinch/zoom/rotate

Low-level pointerInput for custom gestures - gives raw pointer events and coroutine-based detectors:

Modifier.pointerInput(Unit) {
    detectTapGestures(
        onTap = { pos -> /* ... */ },
        onDoubleTap = { /* ... */ },
        onLongPress = { /* ... */ },
    )
}

Modifier.pointerInput(Unit) {
    detectDragGestures { change, dragAmount ->
        change.consume()
        offset += dragAmount
    }
}

Key points:

  • pointerInput(key) restarts the gesture coroutine when the key changes - pass relevant state as the key (a common bug is pointerInput(Unit) capturing stale state).
  • Consume events (change.consume()) to stop them propagating to parents and avoid conflicting gestures.
  • Built-in detectors: detectTapGestures, detectDragGestures, detectTransformGestures, awaitPointerEventScope { awaitFirstDown() ... } for fully custom flows.
  • For accessibility, prefer the semantic modifiers (clickable adds roles/handlers) over raw pointerInput where possible, or add Modifier.semantics.
How do you handle large bitmaps without running out of memory?
Mid Android Fundamentals #bitmap#memory#performance

Bitmaps are the #1 cause of OutOfMemoryError because they’re huge in memory: a bitmap’s RAM ≈ width × height × bytesPerPixel. A 4000×3000 photo at ARGB_8888 (4 bytes/px) is ~48 MB - regardless of the file’s compressed size on disk.

Techniques:

1. Downsample when decoding - never decode full-res for a thumbnail. Use inSampleSize to load a scaled version:

val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, opts)              // read dimensions only
opts.inSampleSize = calculateInSampleSize(opts, reqWidth, reqHeight)
opts.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeFile(path, opts) // decode scaled

2. Pick the right config - RGB_565 (2 bytes/px) halves memory vs ARGB_8888 when you don’t need alpha; HARDWARE bitmaps keep pixels in GPU memory.

3. Use an image library - Coil (Compose-native) or Glide handle all of this: automatic downsampling to the target view/size, memory + disk LRU caches, bitmap pooling/reuse, request cancellation when a view is recycled, and lifecycle awareness. In practice you almost never hand-decode.

AsyncImage(model = url, contentDescription = null, modifier = Modifier.size(96.dp))

4. Other practices:

  • Decode off the main thread (coroutines) to avoid jank/ANR.
  • In lists, cancel loads for recycled items and size to the actual view dimensions.
  • Bound caches; don’t hold strong references to bitmaps you no longer show.
  • For very large images, consider BitmapRegionDecoder (tiles) for pan/zoom.
How do you handle window insets and edge-to-edge in Compose?
Mid Jetpack Compose #compose#insets#edge-to-edge

Insets are the system-reserved areas - status bar, navigation bar, IME (keyboard), display cutout. With edge-to-edge (default on Android 15+ when targeting SDK 35), your content draws behind the system bars, so you must apply insets so nothing important is hidden.

// Enable edge-to-edge in the Activity
enableEdgeToEdge()

// Apply insets as padding in Compose
Column(
    Modifier
        .fillMaxSize()
        .windowInsetsPadding(WindowInsets.systemBars)   // pad for status+nav bars
) { ... }

Common tools:

  • Modifier.windowInsetsPadding(WindowInsets.systemBars) / .statusBarsPadding() / .navigationBarsPadding() - pad content out of the system bars.
  • Modifier.imePadding() - pad for the keyboard so inputs aren’t covered; Modifier.safeDrawingPadding() covers all of the above.
  • Scaffold automatically supplies contentPadding accounting for its bars - apply it to the content.
  • WindowInsets.ime / navigationBarsPadding for fine control; .consumeWindowInsets() to avoid double-applying in nested layouts.

What to remember:

  • You typically want backgrounds to extend behind the bars (immersive look) but pad interactive/text content so it’s not under them - apply insets at the content level, not the root background.
  • Handle the IME with imePadding() (and adjustResize-style behavior is automatic in Compose).
  • Test with gesture nav, 3-button nav, and a display cutout.
How do you implement a Singleton in Kotlin, and what are the pitfalls?
Mid Architecture & Patterns #design-patterns#singleton#kotlin

A Singleton ensures a class has one instance with a global access point. In Kotlin it’s trivial - object gives you a thread-safe, lazily-initialized singleton:

object AnalyticsTracker {
    fun track(event: String) { /* ... */ }
}
AnalyticsTracker.track("open")   // single instance, created on first use

The compiler handles thread-safe lazy init - no double-checked-locking boilerplate like Java.

Common pitfalls:

  1. Holding a Context/View leaks it. An object lives for the whole process. If it stores an Activity context, that Activity can never be GC’d. Store applicationContext only, or don’t hold context at all.
    object Bad { lateinit var ctx: Context }   // if assigned an Activity → permanent leak
  2. Global mutable state - singletons holding mutable state create hidden coupling, make code hard to test (shared state bleeds across tests), and cause race conditions if not synchronized.
  3. Testability - a hard-coded object dependency can’t be swapped for a fake. This is the big one: prefer DI with @Singleton scope over a manual object, so the single instance is provided and replaceable in tests.
  4. Initialization order / parameters - an object can’t take constructor parameters; if it needs config, you end up with an init(context) method and ordering hazards.

The recommended approach: use a normal class and let Hilt/Dagger provide it as @Singleton. You get one instance and testability/injectability - the benefits without the global-state/leak downsides.

How do you implement infinite scroll / pagination in Compose?
Mid Jetpack Compose #compose#paging#lazycolumn

Two approaches.

1. Paging 3 (recommended for real data):

// ViewModel
val items: Flow<PagingData<Item>> = Pager(PagingConfig(pageSize = 20)) {
    ItemPagingSource(api)
}.flow.cachedIn(viewModelScope)

// UI
val lazyItems = viewModel.items.collectAsLazyPagingItems()
LazyColumn {
    items(lazyItems.itemCount, key = lazyItems.itemKey { it.id }) { index ->
        lazyItems[index]?.let { ItemRow(it) }
    }
    // footer based on load state
    when (lazyItems.loadState.append) {
        is LoadState.Loading -> item { LoadingRow() }
        is LoadState.Error -> item { RetryRow { lazyItems.retry() } }
        else -> {}
    }
}

Paging 3 handles page requests, caching, retries, placeholders, and exposes loadState. collectAsLazyPagingItems() integrates it with LazyColumn. With a RemoteMediator + Room it becomes offline-first (DB is the source of truth).

2. Manual “load more” with scroll detection (simple lists):

val state = rememberLazyListState()
val shouldLoadMore by remember {
    derivedStateOf {
        val last = state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
        last >= items.size - 5            // near the end
    }
}
LaunchedEffect(shouldLoadMore) {
    if (shouldLoadMore) viewModel.loadNextPage()
}

Note the derivedStateOf - visibleItemsInfo changes every scroll frame, but shouldLoadMore only flips occasionally, so this avoids recomposing on every frame.

When to use which: Paging 3 for production lists from network/DB (it solves caching, retry, dedup, placeholders). Manual approach for small or fully-in-memory lists where Paging is overkill.

How do you make a Compose screen accessible?
Mid Jetpack Compose #compose#accessibility#semantics

Compose builds a semantics tree parallel to the UI tree - it’s what TalkBack reads and what UI tests query. Much of it is automatic; you fill the gaps.

// Icons/images need a contentDescription (or null if purely decorative)
Icon(Icons.Default.Favorite, contentDescription = "Add to favorites")
Image(painter, contentDescription = null)   // decorative → skipped by TalkBack

// Add or override semantics
Modifier.semantics {
    contentDescription = "Profile photo of $name"
    role = Role.Button
    stateDescription = if (selected) "Selected" else "Not selected"
}

// Merge children into one announcement (e.g. a whole card read as one node)
Modifier.semantics(mergeDescendants = true) { }
Row(Modifier.clickable {}.semantics(mergeDescendants = true)) {
    Icon(...); Text("Settings")   // announced together, not separately
}

Key points:

  • contentDescription on Icon/Image is required for non-decorative graphics; pass null for decorative ones so they’re ignored.
  • mergeDescendants groups child semantics into a single focusable node - important so a card isn’t read as five separate items. clickable/toggleable merge automatically.
  • role, stateDescription, onClick label make custom controls understandable to assistive tech.
  • Touch targets should be ≥ 48dp (Modifier.minimumInteractiveComponentSize() / sizeIn).
  • testTag is also part of semantics (used by tests; excluded from accessibility by default).
  • Respect font scaling - use sp for text and avoid fixed heights that clip scaled text; honor dark mode and contrast.
How do you manage TextField state in Compose, and what is the recomposition concern?
Mid Jetpack Compose #compose#textfield#state

A TextField is stateless - it shows the value you give it and reports edits via onValueChange. You own the state (state hoisting):

var text by rememberSaveable { mutableStateOf("") }
TextField(value = text, onValueChange = { text = it })

rememberSaveable keeps the text across rotation/process death.

The recomposition concern: every keystroke updates the state, which recomposes the TextField (and anything reading text). For most screens that’s fine. Problems appear when:

  • onValueChange routes through a ViewModel + StateFlow round-trip - the extra hop can cause lag or cursor jumps if updates are async or debounced incorrectly. Keep the text state close to the field (often local rememberSaveable), and send only derived actions (search query) to the ViewModel via debounce.
  • Heavy work runs in onValueChange - keep it light; do validation/search reactively (e.g. snapshotFlow { text }.debounce(300)).

The modern API - TextFieldState (Compose Foundation BasicTextField with state-based API) manages text, selection, and composition more robustly than the value/callback pair, avoiding a class of cursor/state desync bugs:

val state = rememberTextFieldState()
BasicTextField(state = state)
// read with state.text

Key points:

  • Hoist text with rememberSaveable for config-change survival.
  • Don’t introduce async latency between keystroke and displayed value - it causes janky typing/cursor jumps.
  • Prefer the newer TextFieldState API for complex inputs; it handles selection/IME edge cases.
  • Use KeyboardOptions/KeyboardActions for input type and IME actions, and visualTransformation for masking (passwords, currency).
How do you minimize battery and data usage in a mobile app?
Mid Mobile System Design #system-design#battery#performance#data

Battery and data are first-class constraints in mobile design. The biggest drains are the radio (network), GPS, wakelocks, and the screen/CPU.

Network (the #1 lever - the radio is expensive):

  • Batch and coalesce requests - waking the radio repeatedly costs more than one larger transfer (the radio stays in a high-power state for seconds after each use - the “tail energy” problem).
  • Defer non-urgent work to WorkManager with constraints (charging, unmetered Wi-Fi) so it runs in efficient windows.
  • Cache aggressively; use ETags/delta sync to avoid redundant downloads.
  • Compress payloads; fetch only needed fields.

Location:

  • Lower priority/accuracy and interval to the minimum the feature needs (BALANCED_POWER vs HIGH_ACCURACY); use geofencing/activity recognition instead of constant polling; stop updates when not needed.

Background work:

  • Respect Doze / App Standby - don’t fight them; use WorkManager/FCM which the system optimizes.
  • Avoid wakelocks; if unavoidable, hold them as briefly as possible.
  • No polling loops; prefer push (FCM) over periodic checks.

CPU / rendering:

  • Avoid jank and unnecessary work (efficient Compose recomposition, no work in onDraw); offload heavy compute to Dispatchers.Default.
  • Hardware-accelerated media decode.

Data-specific:

  • Data Saver / metered awareness - reduce quality, defer prefetch on cellular.
  • Prefetch on Wi-Fi/charging only; cap image/video resolution on cellular.

Measure:

  • Battery Historian, Android vitals (excessive wakeups, wakelocks, background usage), Network Profiler, JankStats/Macrobenchmark. Optimize from data, not guesses.

Trade-offs to name: batching (efficiency vs freshness/latency), location accuracy vs battery, prefetch (instant UX vs data/battery), real-time sockets vs push (timeliness vs drain).

How do you mix Compose and the View system in both directions?
Mid Jetpack Compose #compose#interop#views

Interop goes both ways and is common during migration.

Views inside Compose - AndroidView:

AndroidView(
    factory = { context -> MapView(context) },  // create once
    update = { mapView -> mapView.setZoom(zoom) }, // re-run on state change
    modifier = Modifier.fillMaxSize(),
)
  • factory runs once to create the View.
  • update runs on initial composition and whenever a state it reads changes - bridge Compose state into the View here.
  • Use it for things Compose lacks or that are expensive to reimplement: MapView, WebView, AdView, custom legacy views, SurfaceView.
  • AndroidViewBinding wraps a whole XML layout via ViewBinding.

Compose inside Views - ComposeView:

// In XML or code, add a ComposeView, then:
composeView.setContent {
    MyComposable()
}
  • Lets you adopt Compose screen-by-screen or widget-by-widget inside a View-based app.
  • Set the right ViewCompositionStrategy (e.g. DisposeOnViewTreeLifecycleDestroyed) so the composition is disposed with the host’s lifecycle - especially in RecyclerView rows or Fragments.

Gotchas:

  • AndroidView’s update is where you push state; don’t recreate the View in factory on recomposition.
  • Watch lifecycle/disposal for ComposeView in lists and Fragments to avoid leaks.
  • Theming/CompositionLocal don’t automatically cross the boundary - pass values explicitly.
How do you model UI state well? (single state object vs multiple flows, sealed vs data class)
Mid Architecture & Patterns #state#ui-state#sealed-class

Two common approaches, each with a place:

1. Single immutable data class of nullable/boolean fields - flexible; can represent overlapping conditions (loading while showing stale content).

data class FeedUiState(
    val isLoading: Boolean = false,
    val items: List<Post> = emptyList(),
    val errorMessage: String? = null,
    val isRefreshing: Boolean = false,
)

2. Sealed hierarchy of mutually-exclusive states - clearer when the screen is in exactly one state at a time, with exhaustive when.

sealed interface FeedUiState {
    data object Loading : FeedUiState
    data class Success(val items: List<Post>, val refreshing: Boolean) : FeedUiState
    data class Error(val message: String) : FeedUiState
}

How to choose:

  • States are truly exclusive (can’t be loading and error at once) → sealed. Forces you to handle every case.
  • States overlap (refreshing while content is visible, partial errors) → a single data class with flags is more honest than contorting a sealed hierarchy.
  • A common hybrid: a data class whose fields include a sealed content: ContentState.

Principles regardless of shape:

  • Immutable - expose a single StateFlow<UiState>; update with copy() / update {}. Never let the UI mutate it.
  • Single source of truth - one state object the UI renders, not five separate StateFlows that can drift out of sync.
  • Derive, don’t duplicate - compute showEmptyState from existing fields rather than storing a redundant flag that can desync.
  • Separate one-off events (navigation, snackbars) from state so they don’t replay on rotation.
How do you retry a failing Flow with exponential backoff?
Mid Coroutines & Flow #flow#retry#practical#error-handling

Use retryWhen (or retry) to re-subscribe to the upstream when it throws, with a delay between attempts.

fun <T> Flow<T>.retryWithBackoff(
    maxAttempts: Int = 3,
    initialDelay: Long = 500,
    factor: Double = 2.0,
): Flow<T> = retryWhen { cause, attempt ->
    if (attempt >= maxAttempts || cause !is IOException) {
        false                                   // stop retrying → error propagates
    } else {
        val delayMs = (initialDelay * factor.pow(attempt.toInt())).toLong()
        delay(delayMs)                          // 500ms, 1s, 2s, ...
        true                                    // retry
    }
}

repository.observe()
    .retryWithBackoff()
    .catch { emit(fallback) }                   // give up gracefully after retries
    .collect { render(it) }

Key points:

  • retry(n) { predicate } - simpler: retry up to n times while the predicate is true.
  • retryWhen { cause, attempt -> Boolean } - full control: inspect the exception type and attempt index, delay() for backoff, return true to retry / false to give up.
  • Only retry transient failures. Check the cause - retry IOException/timeouts, but not a 4xx auth error or a CancellationException (never retry cancellation).
  • Pair with catch as a final fallback so the UI shows an error state after retries are exhausted.
  • Add jitter (a small random offset) in production to avoid thundering-herd retries.
How do you run multiple independent suspend calls in parallel and combine the results?
Mid Coroutines & Flow #coroutines#async#practical#parallel

Use async inside a coroutineScope to start each piece concurrently, then await them:

suspend fun loadDashboard(): Dashboard = coroutineScope {
    val profile = async { api.profile() }
    val feed    = async { api.feed() }
    val notifs  = async { api.notifications() }
    Dashboard(profile.await(), feed.await(), notifs.await())
}

All three calls run at the same time, so total latency ≈ the slowest one, not the sum.

Why coroutineScope? It provides structured concurrency: if any child fails, the others are cancelled and the exception propagates out of loadDashboard. It also waits for all children before returning. Never use GlobalScope.async here.

Common mistakes:

  • Accidentally sequential - async { a() }.await() then async { b() }.await() runs them one after another. Start all the asyncs first, then await.
  • Wanting independent failures - if one call failing should not cancel the others, use supervisorScope and handle each await() in its own try/catch.

For a dynamic list of inputs, map then await all:

suspend fun loadAll(ids: List<Int>): List<Item> = coroutineScope {
    ids.map { id -> async { api.item(id) } }.awaitAll()
}
How do you test Compose UI?
Mid Testing & Quality #compose#testing

Compose tests use a semantics tree (the same accessibility tree screen readers use), not view IDs. You find nodes, assert on them, and perform actions through a ComposeTestRule.

class CounterTest {
    @get:Rule val rule = createComposeRule()

    @Test fun increments() {
        rule.setContent { Counter() }

        rule.onNodeWithText("Count: 0").assertIsDisplayed()
        rule.onNodeWithContentDescription("Increment").performClick()
        rule.onNodeWithText("Count: 1").assertExists()
    }
}

The pieces:

  • Finders - onNodeWithText, onNodeWithTag (Modifier.testTag("...")), onNodeWithContentDescription, onAllNodes.
  • Assertions - assertIsDisplayed, assertExists, assertIsEnabled, assertTextEquals.
  • Actions - performClick, performTextInput, performScrollTo, performTouchInput.
  • createComposeRule for pure Compose; createAndroidComposeRule<Activity>() when you need a real Activity/host.

Synchronization: the test framework auto-syncs with recomposition and Compose-driven animations/coroutines - waitForIdle() happens implicitly between actions, so you rarely sleep. For non-Compose async, use waitUntil { }. You can disable auto-advance and control the clock with mainClock for animation tests.

Good practices:

  • Add testTag for elements without stable text.
  • Test stateless composables by passing state directly - easy because they’re pure functions of inputs.
  • @Preview + screenshot testing (Paparazzi / Roborazzi) catches visual regressions without a device.
How do you unit-test a ViewModel?
Mid Testing & Quality #testing#viewmodel#coroutines

A ViewModel is testable precisely because it’s a function of injected dependencies and input events → emitted state. Inject a fake repository, drive events, assert on the emitted UiState.

class FeedViewModelTest {
    private val dispatcher = StandardTestDispatcher()

    @Before fun setup() { Dispatchers.setMain(dispatcher) }   // for viewModelScope
    @After  fun tearDown() { Dispatchers.resetMain() }

    @Test fun `loads feed successfully`() = runTest {
        val repo = FakeFeedRepository(items = listOf(post1, post2))
        val vm = FeedViewModel(repo)

        vm.state.test {                        // Turbine
            assertEquals(FeedUiState(loading = true), awaitItem())
            val loaded = awaitItem()
            assertEquals(listOf(post1, post2), loaded.items)
            assertFalse(loaded.loading)
        }
    }

    @Test fun `shows error when repo fails`() = runTest {
        val vm = FeedViewModel(FakeFeedRepository(error = IOException()))
        vm.refresh()
        advanceUntilIdle()
        assertNotNull(vm.state.value.errorMessage)
    }
}

The essentials:

  • Dispatchers.setMain(testDispatcher) in setup - viewModelScope runs on Dispatchers.Main, which doesn’t exist in unit tests; replace it. Reset in teardown.
  • runTest gives a virtual clock (delays skipped) and advanceUntilIdle() to flush coroutines.
  • Inject dispatchers into the ViewModel/repo rather than hardcoding Dispatchers.IO, so tests are deterministic.
  • Fake, don’t hit real I/O - a FakeRepository returning canned data/errors. Prefer fakes over mocking frameworks for state.
  • Assert on emitted state with Turbine (.test { awaitItem() }) or by collecting into a list.
  • Use InstantTaskExecutorRule if testing LiveData.

What to test: initial state, success path, error/empty paths, that events produce the right state transitions, and that one-off events are emitted.

How do you use prefetching and predictive loading to make an app feel instant?
Mid Mobile System Design #system-design#prefetch#performance#ux

Prefetching loads data/media before the user asks, so the next screen or item appears instantly. The art is predicting accurately without wasting data/battery.

Where to prefetch:

  • List scrolling - load the next page before the user reaches the end (prefetch distance), so scrolling never stalls. Paging 3’s prefetchDistance does this.
  • Images/media - preload images for items just below the fold; in Stories/feeds, prefetch the next item’s media.
  • Likely next screen - when a feed loads, prefetch detail data for the top items the user is likely to tap.
  • Predictable navigation - on a product list, prefetch the first detail; on a wizard, prefetch the next step.
  • App open - warm caches / refresh feed in the background (WorkManager) so content is ready on launch.

Making predictions smart:

  • Use scroll direction & velocity to decide how far ahead to fetch.
  • Heuristics / ML signals - recently viewed, popularity, user patterns.
  • Cancel prefetches that become irrelevant (user scrolled past / navigated away) to reclaim bandwidth.

Guardrails (so prefetch doesn’t backfire):

  • Respect network type - prefetch aggressively on Wi-Fi/charging, conservatively or not at all on metered/Data Saver.
  • Bound concurrency & memory - too much prefetch causes OOM, jank, and cache thrash.
  • Prioritize visible content over prefetch (don’t starve the current screen’s requests).
  • Low priority requests so prefetch yields to user-initiated ones.

Trade-offs to name (this is the crux): instant UX vs wasted data/battery/memory. Over-prefetching a feed the user abandons burns their data plan; under-prefetching causes loading spinners. Tune prefetch depth to confidence in the prediction and the cost of being wrong, and gate it on network/battery.

How does delegation with the by keyword work in Kotlin?
Mid Kotlin Language #kotlin#delegation#by

by lets one object hand off work to another, with compiler-generated plumbing. Two flavors:

1. Class delegation - implement an interface by forwarding to an instance, instead of inheritance.

interface Repo { fun load(): String }
class NetworkRepo : Repo { override fun load() = "net" }

// CachingRepo implements Repo by delegating to `delegate`,
// overriding only what it needs.
class CachingRepo(delegate: Repo) : Repo by delegate {
    override fun load() = cache ?: super.load()  // override selectively
}

This is composition over inheritance, with no boilerplate forwarding methods.

2. Property delegation - a property’s get/set is delegated to an object that provides getValue/setValue.

val lazyValue: String by lazy { compute() }          // stdlib delegate
var name: String by Delegates.observable("") { _, old, new -> log(old, new) }
val token: String by preferences                      // custom delegate

Built-in delegates: lazy, Delegates.observable/vetoable, Delegates.notNull, and map-backed properties (val name: String by map). Compose’s by remember { mutableStateOf(...) } is property delegation too.

Write your own by implementing operator fun getValue(thisRef, property) (and setValue for var), or ReadOnlyProperty/ReadWriteProperty. Great for things like SharedPreferences-backed properties.

How does destructuring work, and what is the componentN convention?
Mid Kotlin Language #kotlin#destructuring

Destructuring unpacks an object into multiple variables. It works by calling component1(), component2(), … operator functions in order.

val (id, name) = user        // user.component1(), user.component2()
val (key, value) = mapEntry  // Map.Entry has component1/2
for ((index, item) in list.withIndex()) { /* ... */ }

data class generates componentN() for its primary-constructor properties automatically. Any class can support it by declaring them manually:

class Point(val x: Int, val y: Int) {
    operator fun component1() = x
    operator fun component2() = y
}

Gotchas worth raising:

  • Destructuring is positional, not by name - val (name, id) = user silently assigns name = user.id if you get the order wrong. This is a real bug source; reordering data-class properties can break callers.
  • Use _ to skip a component: val (_, name) = user.
  • Works in lambda parameters too: map.forEach { (k, v) -> ... }.
How does Doze mode and background execution limits affect your app?
Mid Android Fundamentals #doze#background#battery

Android has steadily tightened background execution to save battery. The major mechanisms:

Doze mode (Android 6+) - when the device is unplugged, stationary, and screen off for a while, the system enters Doze: it batches and defers background work into periodic maintenance windows. During Doze:

  • Network access is suspended for apps (except during windows).
  • Wakelocks ignored, alarms deferred (AlarmManager non-exact), jobs/syncs deferred.
  • App Standby does the same for individual unused apps.

Background service limits (Android 8+) - apps in the background can’t start background services; the system kills them shortly after the app leaves the foreground. Implicit broadcasts are mostly disallowed in the manifest.

Background location limits (Android 8/10+) - background apps get location updates only a few times per hour; background access needs a separate permission.

App Standby Buckets (Android 9+) - the system buckets apps (active / working set / frequent / rare / restricted) by usage and throttles their jobs/alarms accordingly.

How to work with the system (not fight it):

  • WorkManager for deferrable background work - it respects Doze/buckets and runs in maintenance windows.
  • FCM high-priority messages to wake the app for genuinely time-sensitive pushes (temporarily exempt from Doze).
  • setExactAndAllowWhileIdle for true alarms (calendar) - used sparingly.
  • Foreground service (with notification) for ongoing user-visible work that must run now.

What to avoid: holding wakelocks, polling, or expecting precise background timing - the OS will defer or kill it. Requesting battery-optimization exemption is heavily restricted by Play and should be a last resort.

How does Flow handle backpressure? Explain buffer, conflate, and collectLatest.
Mid Coroutines & Flow #flow#backpressure#buffer#conflate

By default a Flow is sequential: the producer waits for the collector to finish processing each value before emitting the next (suspension is the natural backpressure). When the collector is slower than the producer, you choose a strategy:

buffer(capacity) - run producer and collector concurrently. Emissions go into a buffer so the producer doesn’t wait; the collector drains it. Speeds up pipelines where both sides do real work.

flow.buffer().collect { slowProcess(it) }   // producer keeps emitting into buffer

conflate() - keep only the latest value; if the collector is busy, intermediate emissions are dropped. Equivalent to buffer(CONFLATED).

fastSensor.conflate().collect { render(it) }  // skip stale frames, render newest

collectLatest { } - like conflate, but it cancels and restarts the collector block for each new value (rather than dropping after processing starts).

How to choose:

  • Need every value, just want concurrency → buffer.
  • Only the newest value matters, dropping intermediates is fine → conflate.
  • Only the newest matters and processing the old one should be cancelled → collectLatest.

buffer also accepts an onBufferOverflow policy (SUSPEND, DROP_OLDEST, DROP_LATEST) for fine control.

How does mutableStateOf update the Compose UI?
Mid Jetpack Compose #compose#state#snapshot

mutableStateOf holds a value that Compose can observe. When a composable reads that value, Compose remembers the dependency. Changing the value then schedules that part of the UI to run again with the new state.

var count by remember { mutableStateOf(0) }  // `by` uses property delegation
Text("$count")          // reading count subscribes this Text to changes
Button(onClick = { count++ }) { Text("+") }  // writing schedules recomposition

That is enough for most Junior and Mid interviews: state is read, the value changes, and the UI that read it recomposes.

Optional detail: the snapshot system. Compose stores observable state in snapshots so a group of reads sees a consistent view of state. This supports:

  • Controlled changes - Snapshot.withMutableSnapshot { } can apply a group of changes together. Ordinary UI state should still usually be updated from the main thread.
  • Consistency - within one recomposition pass you never see half-updated state.
  • Precise observation - snapshotFlow { } can turn any state read into a Flow.

Why remember is required: mutableStateOf(0) alone creates a new state object on every recomposition, resetting it. remember { } keeps the same state object across recompositions. rememberSaveable additionally survives recreation.

Storage options: mutableStateOf (single value), mutableStateListOf / mutableStateMapOf (observable collections that trigger recomposition on add/remove), and derivedStateOf (computed). Use the observable collections rather than a plain MutableList inside state, or mutations won’t trigger recomposition.

How does operator overloading work in Kotlin?
Mid Kotlin Language #kotlin#operators

You overload an operator by defining a function with a reserved name and the operator modifier. Kotlin maps symbols to these functions:

OperatorFunction
a + ba.plus(b)
a[i]a.get(i)
a[i] = va.set(i, v)
a in bb.contains(a)
a..ba.rangeTo(b)
a == ba.equals(b)
+a / -aunaryPlus / unaryMinus
a()a.invoke()
data class Vec(val x: Int, val y: Int) {
    operator fun plus(o: Vec) = Vec(x + o.x, y + o.y)
    operator fun get(i: Int) = if (i == 0) x else y
}

val v = Vec(1, 2) + Vec(3, 4)   // Vec(4, 6)
val first = v[0]                 // 4

Things interviewers check you know:

  • The operator function name and signature are fixed - you can’t invent new symbols.
  • == always routes through equals (with a null check), and === (referential) can’t be overloaded.
  • Overloading invoke is how MutableState-like or DSL objects become “callable.”

Use it sparingly - only when the operator’s meaning is obvious (vectors, money, durations). kotlin.time.Duration (1.hours + 30.minutes) is a good example of tasteful use.

How does Paging 3 fit into an Android app's architecture?
Mid Architecture & Patterns #paging#architecture#offline-first

Paging 3 is the Jetpack solution for incrementally loading large lists, integrated across all three layers.

The pieces:

  • PagingSource - loads one page from a single source (e.g. network only). Defines how to fetch a page and the keys for next/prev.
  • RemoteMediator - coordinates network + database for offline-first paging: it fetches pages from the network and writes them into Room, while a Room-backed PagingSource serves the UI from the DB.
  • Pager - config (page size, prefetch) that produces a Flow<PagingData<T>>.
  • PagingData - a stream of paged items the UI consumes.

Layered flow (network + DB, the recommended setup):

UI (LazyColumn / PagingDataAdapter)
   ▲  Flow<PagingData>
ViewModel:  Pager(config, remoteMediator) { db.dao().pagingSource() }
                                   │ writes pages
Data:   RemoteMediator ── fetches ──▶ Network,  ── stores ──▶ Room (source of truth)
val items: Flow<PagingData<Article>> = Pager(
    config = PagingConfig(pageSize = 20),
    remoteMediator = ArticleRemoteMediator(api, db),
) { db.articleDao().pagingSource() }
    .flow
    .cachedIn(viewModelScope)     // survive config changes

What Paging handles for you: page requests on scroll, prefetch distance, deduplication, placeholders, retries, and exposing LoadState (loading/error for refresh/append/prepend) so the UI can show spinners/retry footers. UI side: collectAsLazyPagingItems() (Compose) or PagingDataAdapter + DiffUtil (Views).

Why architecturally clean:

  • Single source of truth - with RemoteMediator, the DB is the truth; the UI always pages from Room → offline-first for free.
  • cachedIn(scope) keeps paged data across recreation so scroll position/data isn’t lost on rotation.
  • Each layer keeps its role: data fetches/stores, ViewModel configures the Pager, UI renders PagingData.
How does RecyclerView work, and what does DiffUtil do?
Mid Android Fundamentals #recyclerview#views#performance

RecyclerView efficiently displays large lists by recycling a small pool of item views instead of creating one per data item. The pieces:

  • ViewHolder - caches the views for one row so you don’t findViewById repeatedly.
  • Adapter - onCreateViewHolder (inflate, called rarely) + onBindViewHolder (bind data to a recycled holder, called often). The recycling is the whole point: as you scroll, off-screen holders are rebound with new data.
  • LayoutManager - positions items (Linear, Grid, StaggeredGrid).
  • ItemAnimator, ItemDecoration - animations and dividers/spacing.

DiffUtil computes the minimal set of changes between an old and new list (using a Myers diff) so you can dispatch precise notifyItemInserted/Removed/Changed instead of notifyDataSetChanged().

class MyDiff : DiffUtil.ItemCallback<Item>() {
    override fun areItemsTheSame(a: Item, b: Item) = a.id == b.id        // same entity?
    override fun areContentsTheSame(a: Item, b: Item) = a == b           // same content?
}
class MyAdapter : ListAdapter<Item, MyVH>(MyDiff()) { ... }
adapter.submitList(newList)   // diff + granular updates, with animations

Why it matters:

  • notifyDataSetChanged() rebinds everything and kills animations/scroll position - wasteful.
  • DiffUtil gives smooth animations and only rebinds changed rows.
  • areItemsTheSame = same identity (by id); areContentsTheSame = same data (drives the “changed” animation). Getting these wrong causes flicker or missed updates.
  • ListAdapter wraps DiffUtil and runs it on a background thread via AsyncListDiffer - the recommended adapter base class.

Compose parallel: LazyColumn is the Compose equivalent; its items(key = {}) plays the role of DiffUtil’s identity matching.

How is process death different from a configuration change?
Mid Android Fundamentals #lifecycle#process-death#savedstate#viewmodel

Two different ways your UI state can be destroyed - and they need different tools.

Configuration change (rotation, locale, dark mode, multi-window): the system destroys and recreates the Activity immediately, but the process stays alive. So in-memory objects that survive recreation are intact.

  • Handled by ViewModel - it survives config changes (it’s retained across the recreate), so your data and in-flight coroutines aren’t lost.

Process death (system reclaims your app’s memory while it’s in the background): the entire process is killed. The ViewModel, static fields, singletons - everything in memory is gone. When the user returns, the OS recreates the Activity (and process) and expects you to restore the prior UI state.

  • Handled by saved instance state - onSaveInstanceState(Bundle) / rememberSaveable / SavedStateHandle. This is the only state that survives process death, because it’s serialized to disk by the system.
class SearchViewModel(private val handle: SavedStateHandle) : ViewModel() {
    // Survives BOTH config change AND process death
    val query: StateFlow<String> = handle.getStateFlow("query", "")
    fun setQuery(q: String) { handle["query"] = q }
}
Config changeProcess death
Processsurviveskilled
ViewModelsurviveslost
SavedStateHandle / Bundlesurvivessurvives

Rules:

  • Put screen data and ongoing work in the ViewModel (handles config changes for free).
  • Put small, essential UI state (a query, scroll position, selected tab) in SavedStateHandle/rememberSaveable so it survives process death too.
  • Keep saved state small - the Bundle is for identifiers and UI state, not large data. Re-fetch big data from a repository on restore.
  • Test it with the “Don’t keep activities” developer option or adb shell am kill.
How should a ViewModel represent UI state and one-time events?
Mid Architecture & Patterns #events#state#sharedflow#udf

The problem: state is persistent and re-emitted (a StateFlow replays its current value on rotation), but events like “navigate to detail” or “show snackbar” should happen exactly once. Putting an event in StateFlow causes it to re-fire on configuration change (navigation loops, duplicate snackbars).

The common approaches:

1. SharedFlow / Channel with replay = 0 - events are delivered once to active collectors, not replayed.

private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()   // each event consumed once

fun onSave() = viewModelScope.launch {
    repo.save(); _events.send(UiEvent.NavigateBack)
}

A Channel guarantees each event goes to a single consumer and suspends if no one’s collecting (events buffer rather than drop) - often preferred over SharedFlow(replay=0) which can drop events emitted with no active collector.

2. State-based events (the modern recommendation from some Google guidance) - model the event as state that the UI consumes and tells the ViewModel to clear:

data class UiState(val navigateToId: String? = null)
// UI: LaunchedEffect(state.navigateToId) { id -> navigate(id); vm.consumedNavigation() }

This keeps a single source of truth and is process-death safe, at the cost of a “consume” callback.

The collection side matters: collect events with lifecycle awareness (repeatOnLifecycle(STARTED) / collectAsStateWithLifecycle) so an event isn’t delivered to a backgrounded UI and lost.

What to avoid:

  • SingleLiveEvent / “event wrapper” hacks - historically used, now discouraged (fragile, doesn’t compose well).
  • Putting transient events in StateFlow - they replay on rotation.

Important nuance: there’s genuine debate here. Channel/SharedFlow(replay=0) is the pragmatic, widely-used answer; the “events as state you consume” approach is more UDF-pure and process-death safe. Be able to argue both.

How should Compose collect state from a ViewModel?
Mid Jetpack Compose #compose#state#lifecycle#viewmodel

You convert the flow into Compose State so reads trigger recomposition:

@Composable
fun FeedScreen(viewModel: FeedViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    FeedContent(state)
}

collectAsState() vs collectAsStateWithLifecycle():

  • collectAsState() collects the flow as long as the composable is in composition - including when the app is in the background. It keeps the upstream active and doing work even when the screen isn’t visible.
  • collectAsStateWithLifecycle() (from lifecycle-runtime-compose) collects only while the lifecycle is at least STARTED, using repeatOnLifecycle under the hood. It stops collecting in the background and resumes in the foreground.

Why the lifecycle-aware one is the recommended default on Android:

  • Avoids wasted work, CPU, and battery while backgrounded.
  • Lets the upstream (stateIn(WhileSubscribed(5000))) actually stop, since collection is properly cancelled.
  • Prevents updates to UI state that isn’t visible.

Notes:

  • collectAsState() is still appropriate for non-Android / multiplatform Compose where there’s no lifecycle.
  • Pass an explicit lifecycle/minActiveState if you need a state other than STARTED.
  • It pairs naturally with the stateIn(..., WhileSubscribed(5000), ...) upstream pattern in the ViewModel.
How should errors move through an app's layers?
Mid Architecture & Patterns #error-handling#result#sealed-class

Rather than letting exceptions leak everywhere, model expected failures as values that flow through the layers and end as UI state.

A domain Result wrapper (your own sealed type or Kotlin’s Result):

sealed interface DataResult<out T> {
    data class Success<T>(val data: T) : DataResult<T>
    data class Failure(val error: AppError) : DataResult<Nothing>
}

sealed interface AppError {
    data object Network : AppError
    data object Unauthorized : AppError
    data class Unknown(val cause: Throwable) : AppError
}

Repository converts exceptions → typed results at the boundary:

suspend fun getUser(id: String): DataResult<User> = try {
    DataResult.Success(api.getUser(id).toDomain())
} catch (e: IOException) { DataResult.Failure(AppError.Network) }
  catch (e: HttpException) {
      DataResult.Failure(if (e.code() == 401) AppError.Unauthorized else AppError.Unknown(e))
  }

ViewModel maps the result into UI state:

when (val r = getUser(id)) {
    is DataResult.Success -> _state.update { it.copy(user = r.data) }
    is DataResult.Failure -> _state.update { it.copy(error = r.error.toMessage()) }
}

Principles:

  • Distinguish expected vs unexpected failures. Expected (network down, validation, 404) → modeled as Result/sealed errors and shown to the user. Unexpected (programming bugs) → let them crash/report; don’t swallow.
  • Translate at the boundary - convert framework exceptions (IOException, HttpException, SQLException) into domain errors in the data layer so upper layers don’t depend on Retrofit/Room types.
  • Exhaustive handling - a sealed AppError forces the UI to handle each case (retry, re-login, generic message).
  • For Flow, surface errors via a result-emitting flow or the catch operator mapping to an error state - never an unhandled throw in collect.
  • Never catch CancellationException in a blanket catch - rethrow it.
How would you architect feature flags / remote config?
Mid Architecture & Patterns #feature-flags#remote-config#architecture

Feature flags let you toggle features without shipping a new build - for gradual rollouts, A/B tests, kill switches, and per-segment targeting.

Architecture - wrap the source behind your own abstraction:

interface FeatureFlags {
    fun isEnabled(flag: Flag): Boolean
    fun <T> value(flag: Flag, default: T): T
}

enum class Flag(val key: String, val default: Boolean) {
    NEW_CHECKOUT("new_checkout", false),
    DARK_MODE_V2("dark_mode_v2", false),
}

Implement it over Firebase Remote Config (or LaunchDarkly, Statsig, your own backend). The rest of the app depends on the FeatureFlags interface, not the vendor SDK.

Why the abstraction matters:

  • Decoupling / swappability - switch providers without touching feature code (anti-corruption layer).
  • Testability - inject a fake FeatureFlags to test both branches.
  • Type safety - an enum/sealed set of flags beats scattered magic strings.

Design considerations:

  • Fetch & cache - remote config is fetched async and cached locally; provide sensible defaults so the app works on first launch / offline. Don’t block startup on a fetch.
  • Consistency within a session - usually snapshot values at app start / screen entry so a flag doesn’t flip mid-flow; apply new values on next launch.
  • Kill switch - flags let you disable a broken feature server-side without a release - pair with a forced refresh for emergencies.
  • Clean up stale flags - old flags rot; track and remove them.
  • A/B testing - flags carry experiment assignments; log exposure to analytics for analysis.
  • Layering - the flag check usually lives in the domain/data layer (or a config module), surfaced to the UI via state, not scattered if checks everywhere.
How would you design the networking layer of an Android app?
Mid Mobile System Design #system-design#networking#retrofit#okhttp

A robust networking layer is built on Retrofit + OkHttp + a serializer, with cross-cutting concerns handled by interceptors.

The stack:

  • Retrofit - type-safe API interface (suspend fun getUser(): User), turns HTTP into Kotlin functions.
  • OkHttp - the HTTP client: connection pooling, timeouts, disk cache, interceptors.
  • Serializer - kotlinx.serialization or Moshi (codegen, no reflection → R8-friendly).

Interceptors do the cross-cutting work (chain-of-responsibility / decorator pattern):

OkHttpClient.Builder()
    .addInterceptor(AuthInterceptor(tokenProvider))      // add auth header
    .addInterceptor(HttpLoggingInterceptor())            // logging (debug only)
    .addInterceptor(RetryInterceptor())                  // retry transient failures
    .addNetworkInterceptor(CacheControlInterceptor())    // tune caching
    .authenticator(TokenAuthenticator(refresher))        // 401 → refresh token & retry
    .certificatePinner(pinner)                            // pin certs
    .connectTimeout(15, SECONDS).build()

Key concerns to cover:

  • Auth & token refresh - an Authenticator transparently refreshes the access token on 401 and retries; serialize concurrent refreshes (mutex) so you refresh once.
  • Error handling - map HTTP/IOException/timeouts to typed domain results at the repository boundary; expose retry/error to the UI.
  • Retries & backoff - exponential backoff with jitter for transient failures; don’t retry non-idempotent writes blindly; consider a circuit breaker for a failing host.
  • Caching - OkHttp disk cache + Cache-Control/ETag; offline-first via Room.
  • Request dedup / coalescing - collapse identical in-flight requests; cancel on screen leave (coroutine cancellation cancels the call).
  • Security - certificate pinning, HTTPS only, no secrets in code, secure token storage (Keystore/EncryptedSharedPreferences).
  • Observability - logging (debug), metrics, and correlation IDs.
  • Threading - Retrofit suspend functions run on a background dispatcher; cancellation via structured concurrency.

REST vs GraphQL - Retrofit for REST; Apollo for GraphQL (one query fetches exactly what the screen needs, reducing over/under-fetching). Mention based on the API.

How would you test a repository that combines a network API and Room?
Mid Testing & Quality #testing#repository#room#fake

Test the repository as a unit by giving it controlled dependencies: a fake API, a fake or in-memory data source, and a test dispatcher. Verify behavior rather than implementation details.

Useful cases include:

  • Cached data is returned while a refresh is in progress.
  • A successful response is saved and then observed from the database.
  • A network failure preserves usable cached data and exposes the right error.
  • Repeated refreshes do not create duplicate rows.
  • Cancellation stops work instead of being converted into a normal failure.

Add a smaller Room integration test with an in-memory database when queries, transactions, migrations, or conflict rules are important. There is usually no value in mocking every DAO call and then asserting that each mock was invoked. That only repeats the implementation in the test.

Implement search-as-you-type with Flow. Which operators do you use and why?
Mid Coroutines & Flow #flow#practical#debounce#flatMapLatest

A clean search pipeline chains a few Flow operators, each solving a specific problem:

val results: StateFlow<SearchState> = queryFlow
    .debounce(300)                  // 1. wait for typing to pause
    .filter { it.length >= 2 }      // 2. ignore tiny queries
    .distinctUntilChanged()         // 3. skip duplicate queries
    .flatMapLatest { query ->       // 4. cancel the previous search
        repository.search(query)
            .map { SearchState.Results(it) }
            .onStart { emit(SearchState.Loading) }
            .catch { emit(SearchState.Error(it.message)) }
    }
    .stateIn(viewModelScope, WhileSubscribed(5000), SearchState.Idle)

Why each operator:

  1. debounce(300) - don’t fire a request on every keystroke; wait until the user pauses. Saves network calls.
  2. filter - skip 0–1 character queries that aren’t worth searching.
  3. distinctUntilChanged - if the debounced query equals the last one (e.g. type then backspace), don’t repeat the search.
  4. flatMapLatest - when a new query comes in, cancel the in-flight search for the old one. This prevents the classic race where a slow response for “ja” arrives after “java” and overwrites the correct results.

onStart / catch model loading and error states inside the per-query inner flow.

This question tests whether you understand the race condition flatMapLatest solves - that’s the senior-level insight interviewers are listening for, not just naming debounce.

In what order do launched coroutines run when they call delay?
Mid Coroutines & Flow #coroutines#output-based#concurrency
fun main() = runBlocking {
    println("1")
    launch {
        println("2")
        delay(100)
        println("3")
    }
    launch {
        println("4")
        delay(50)
        println("5")
    }
    println("6")
}

Output:

1
6
2
4
5
3

Why, step by step:

  1. println("1") runs.
  2. The first launch schedules a coroutine but doesn’t run it yet (it’s dispatched); execution continues.
  3. The second launch likewise schedules.
  4. println("6") runs - we’re still in the runBlocking body, which hasn’t suspended.
  5. Now the body finishes its synchronous part; the launched coroutines run. First coroutine prints 2, hits delay(100) and suspends. Second prints 4, hits delay(50) and suspends.
  6. After ~50ms the second resumes → 5. After ~100ms the first resumes → 3.

Key teaching points:

  • launch doesn’t run its body immediately - it dispatches it. The current coroutine keeps going until it suspends or completes, which is why 6 prints before 2.
  • delay is non-blocking suspension, so both coroutines wait concurrently; the 50ms one finishes first (5 before 3).
  • runBlocking keeps the main thread alive until all child coroutines complete.
Is Kotlin's List truly immutable? Read-only vs immutable collections.
Mid Kotlin Language #kotlin#collections#immutability

No - List is read-only, not immutable. The List interface simply doesn’t expose mutating methods like add/remove; it doesn’t guarantee the underlying data can’t change.

Two ways that bites you:

1. The same object can be referenced as both types.

val mutable = mutableListOf(1, 2, 3)
val readOnly: List<Int> = mutable   // same backing object
mutable.add(4)
println(readOnly)   // [1, 2, 3, 4]  - it changed under you

2. A List can be cast back (it’s often an ArrayList at runtime).

So List protects your code from calling mutators, but it’s not a deep immutability guarantee.

For real immutability, use the kotlinx.collections.immutable library: ImmutableList / persistentListOf(). These genuinely can’t be mutated and are also recognized as stable by the Compose compiler, which helps skip recomposition.

val items: ImmutableList<Item> = persistentListOf(a, b, c)
MVC vs MVP vs MVVM - how did Android presentation patterns evolve?
Mid Architecture & Patterns #mvp#mvvm#mvc#presentation

All separate UI from logic; they differ in how the logic talks to the view.

MVC (Model-View-Controller) - on Android, the Activity/Fragment often ended up as both View and Controller (“Massive View Controller”). Poor separation; hard to test because logic was tangled with framework classes.

MVP (Model-View-Presenter):

  • The View (Activity/Fragment) implements a View interface and is passive.
  • The Presenter holds the logic, calls back into the view through that interface (view.showLoading(), view.showError()).
  • ✅ Testable (mock the view interface), clear separation.
  • Boilerplate - a View interface with many methods per screen; the Presenter holds a reference to the view, so you must detach it (onDestroy) to avoid leaks; doesn’t survive config changes by itself.

MVVM (Model-View-ViewModel):

  • The ViewModel exposes observable state (StateFlow/LiveData); it does not reference the view.
  • The View observes state and renders it (reactive, UDF).
  • ✅ No view reference → no leak, survives config changes (Jetpack ViewModel), less boilerplate, works naturally with Compose/data binding.
  • ✅ The current recommended pattern (often refined into MVI).

The key shift: MVP pushes to the view via an interface (imperative, two-way coupling); MVVM has the view pull/observe state (reactive, one-way). MVVM’s lack of a view reference is what fixes MVP’s leak and lifecycle pain.

MVP:  Presenter ──calls──▶ View (interface)      [imperative push]
MVVM: View ──observes──▶ ViewModel (state)        [reactive pull / UDF]
MVVM vs MVI - when would you pick one over the other?
Mid Architecture & Patterns #architecture#mvi#mvvm#state

Both put a state holder between UI and data; they differ in how state changes flow.

MVVM - the ViewModel exposes several observable properties; the UI observes them and calls methods to mutate them. Simple and familiar, but state can become fragmented across multiple LiveData/StateFlow fields that can drift out of sync.

MVI - there’s a single immutable UiState, and the UI sends intents/events that the ViewModel reduces into the next state. Strictly unidirectional: Intent → reduce → new State → render.

data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null,
)

fun onIntent(intent: Intent) = when (intent) {
    is Intent.Load -> reduce { copy(isLoading = true) }
    // ...
}

Pick MVI when: the screen has complex, interdependent state; you want every UI state reproducible from a single object (great for testing and time-travel debugging); or a team needs a strict, predictable pattern.

Pick MVVM when: the screen is simple - MVI’s boilerplate (intents, reducers, single state) isn’t worth it for a form with two fields.

Senior-level nuance to raise: they’re not opposites. A clean “MVVM with a single immutable StateFlow<UiState> and event functions” is effectively MVI-lite. What interviewers actually care about is unidirectional data flow and a single source of truth, not the acronym. Also mention how you model one-off events (navigation, toasts) separately from state so they don’t replay on rotation.

Parcelable vs Serializable on Android - why is Parcelable preferred?
Mid Android Fundamentals #parcelable#serializable#performance

Both let you pass objects between components (in Intent extras / Bundle), but they work very differently.

  • Serializable - Java’s reflection-based marker interface. Easy (just implements Serializable), but slow: it uses reflection and creates lots of temporary objects/garbage, hurting performance and GC.
  • Parcelable - Android’s IPC-optimized serialization. You define how to flatten/restore the object explicitly, so it’s much faster (no reflection) - the right choice for Android.

The pain point Parcelable used to have was boilerplate (writeToParcel, CREATOR, describeContents). Kotlin removes it with @Parcelize:

@Parcelize
data class User(val id: Int, val name: String) : Parcelable
// that's it - writeToParcel/CREATOR are generated

What to remember:

  • Prefer Parcelable (@Parcelize) for anything passed via Intents/Bundles - it’s faster and the platform standard.
  • Parcel is for in-memory IPC / transient transport, not persistence - never write a Parcel to disk or rely on its format across versions.
  • There’s a Binder transaction size limit (~1MB for TransactionTooLargeException) - don’t pass large objects/bitmaps through Intents; pass an ID and load the data, or use a shared repository.
  • For passing data between navigation destinations, pass IDs, not big Parcelables.
Show the idiomatic way to expose state and handle events from a ViewModel with Flow.
Mid Coroutines & Flow #coroutines#flow#practical#viewmodel#udf

The standard pattern: a private mutable state holder exposed as a public read-only flow, with one-off events on a separate SharedFlow.

class FeedViewModel(private val repo: FeedRepository) : ViewModel() {

    // State: private mutable, public read-only
    private val _state = MutableStateFlow(FeedUiState())
    val state: StateFlow<FeedUiState> = _state.asStateFlow()

    // One-off events: SharedFlow with replay = 0 (don't replay on rotation)
    private val _events = MutableSharedFlow<FeedEvent>()
    val events: SharedFlow<FeedEvent> = _events.asSharedFlow()

    init {
        repo.observeFeed()
            .onStart { _state.update { it.copy(loading = true) } }
            .onEach { items -> _state.update { it.copy(loading = false, items = items) } }
            .catch { _state.update { it.copy(loading = false, error = it.message) } }
            .launchIn(viewModelScope)
    }

    fun onItemClick(id: String) = viewModelScope.launch {
        _events.emit(FeedEvent.OpenDetail(id))   // navigation = event, not state
    }
}

Why each choice:

  • asStateFlow() / asSharedFlow() expose read-only views so the UI can’t mutate state - enforcing unidirectional data flow.
  • _state.update { it.copy(...) } is atomic and works on immutable data class state.
  • State vs event split - render-able state goes in StateFlow (survives rotation, has a current value); transient actions like navigation/snackbars go in SharedFlow(replay = 0) so they fire once and don’t replay on configuration change.

The UI collects state with collectAsStateWithLifecycle() (Compose) or repeatOnLifecycle (Views), and collects events to trigger navigation/toasts.

Walk me through how you'd structure a new feature end to end.
Mid Architecture & Patterns #architecture#practical#design

A common open-ended interview question. Structure the answer by layers + data flow, and mention testing and trade-offs. Example: a “Saved articles” feature.

1. Data layer

  • Models: ArticleDto (network), ArticleEntity (Room), Article (domain) with mappers.
  • Data sources: ArticleApi (Retrofit), ArticleDao (Room).
  • Repository: ArticleRepository interface (domain) + impl (data). Exposes observeSaved(): Flow<List<Article>> from Room (single source of truth), with network refresh writing into Room (offline-first).

2. Domain layer (if warranted)

  • ToggleSaveArticleUseCase, GetSavedArticlesUseCase - only if logic is reused/complex; otherwise the ViewModel calls the repository directly.

3. UI layer

  • SavedViewModel exposes StateFlow<SavedUiState> (immutable state: loading/items/error) via stateIn(WhileSubscribed(5000)); handles events (onToggleSave); emits one-off events (snackbar) on a Channel.
  • SavedScreen (Compose) collects state with collectAsStateWithLifecycle(), renders, sends events up (UDF).

4. Wiring

  • Hilt provides the API, DAO, repository (@Binds interface→impl), scoped appropriately (@Singleton for DB/network, @HiltViewModel for the VM).
  • Navigation destination/route; nav args via SavedStateHandle.

5. Cross-cutting

  • Error handling → typed results mapped to UI state.
  • Testing → unit-test the ViewModel (fake repo + test dispatcher), DAO (in-memory Room), mappers; a Compose UI test for the critical flow.
  • Paging if the list is large (Paging 3 + RemoteMediator).

Then state the trade-offs: “I’d skip the domain layer and separate models if it’s simple, and add them if logic is shared or the API is messy - matching the architecture to the feature’s complexity.”

SavedScreen ──events──▶ SavedViewModel ──▶ UseCase(opt) ──▶ Repository
   ▲ state                                                    │
   └──────────────── StateFlow<UiState> ◀── Room (SoT) ◀── Network

Why this answer lands: it shows you think in layers, UDF, single source of truth, DI, and testing, and that you apply judgment about how much architecture the feature actually needs.

Walk through the Fragment lifecycle. Why is the View lifecycle separate?
Mid Android Fundamentals #fragments#lifecycle

A Fragment has two lifecycles - the fragment instance and its view - and the gap between them is the source of most fragment bugs.

Fragment callbacks: onAttachonCreateonCreateViewonViewCreatedonStartonResume → … → onPauseonStoponDestroyViewonDestroyonDetach.

The key insight: onCreateView/onDestroyView can run multiple times while the fragment instance stays alive. When a fragment goes on the back stack, its view is destroyed (onDestroyView) but the fragment object survives. Coming back, onCreateView runs again - a new view.

Consequences interviewers probe:

  • Use viewLifecycleOwner, not this (the fragment), when observing LiveData/flows in a fragment. Observing with the fragment lifecycle in onCreateView leaks: after onDestroyView the old view is gone but the observer (tied to the longer-lived fragment) keeps firing and may touch a dead view, or you get duplicate observers when the view is recreated.
    viewModel.data.observe(viewLifecycleOwner) { render(it) }
  • Null out ViewBinding in onDestroyView (_binding = null) - the binding references the destroyed view and leaks it otherwise.
  • Collect flows in onViewCreated with viewLifecycleOwner.lifecycleScope + repeatOnLifecycle.

Why fragments at all: reusable UI chunks with their own lifecycle, used by Navigation, ViewPager, and multi-pane (tablet) layouts. Modern apps often use a single-Activity architecture with fragment (or Compose) destinations.

What are cold, warm, and hot starts, and how do you optimize app startup?
Mid Android Fundamentals #startup#performance

The three startup types, by how much already exists:

  • Cold start - the process doesn’t exist. The system creates the process, the Application object, then the first Activity. Slowest and the one you optimize.
  • Warm start - the process is alive but the Activity must be recreated (e.g. user backed out then returned). Some work is reused.
  • Hot start - the Activity is already in memory; just brought to the foreground. Fastest (mostly a redraw).

What runs at cold start (and where time goes): Application.onCreate() → Activity onCreate → first frame drawn (time-to-initial-display).

Optimizations:

  • Trim Application.onCreate - it runs on every cold start and blocks the first frame. Lazy-initialize SDKs; defer non-critical init off the critical path.
  • App Startup library - consolidate ContentProvider-based library auto-initializers into one, and initialize lazily.
  • Avoid heavy work in the first Activity’s onCreate; load data async (coroutines) and show content progressively.
  • Baseline Profiles - ship AOT-compiled profiles of startup/critical paths so the first runs aren’t interpreted/JIT’d. Big, measurable win for cold start and scroll jank.
  • Modern Splash Screen API (androidx.core.splashscreen) - a system splash you keep on screen until content is ready; avoid a separate splash Activity that adds a hop.
  • Reduce dependency graph work at startup (DI graph creation), minimize MultiDex impact on older devices, and avoid synchronous disk/network.

Measure with:

  • adb shell am start -W (reports TotalTime), Macrobenchmark (StartupTimingMetric), Perfetto/system traces, and Android vitals (startup time in production).
  • Distinguish time-to-initial-display (TTID) from time-to-full-display (TTFD) - report TTFD with reportFullyDrawn().
What are common Android architecture anti-patterns?
Mid Architecture & Patterns #anti-patterns#code-smells#clean-code

The ones interviewers love to hear you call out:

God Activity/Fragment - an Activity doing UI, networking, persistence, and business logic. Violates SRP, untestable, unmaintainable. Fix: move logic to ViewModel/use cases/repositories; keep the UI thin.

God ViewModel - a 1000-line ViewModel handling many unrelated features. Fix: split by responsibility, extract use cases.

Leaking Context/View in singletons, ViewModels, static fields, or long-running coroutines. Fix: app context only, lifecycle scoping, weak refs.

Business logic in the UI - validation, formatting, or decisions in composables/Activities. Fix: push into ViewModel/domain; keep UI a function of state.

Mutable shared state without a single source of truth - multiple components caching/mutating the same data, drifting out of sync. Fix: one owner (repository/DB), observe it.

Two-way / circular data flow - UI mutating ViewModel state directly, or ViewModel referencing the View. Fix: UDF (state down, events up); expose read-only state.

Overusing GlobalScope - unscoped coroutines that leak and aren’t cancelled. Fix: lifecycle scopes.

Event bus everywhere (EventBus, LocalBroadcastManager) - implicit, hard-to-trace global messaging. Fix: explicit Flow/callbacks, scoped state.

Over-engineering - three model layers + a use case per trivial call + five modules for a tiny app. Fix: match architecture to complexity; YAGNI.

Stringly-typed everything - string keys for navigation/args, magic strings. Fix: type-safe routes, sealed types, constants.

Mock-heavy tests mirroring implementation - brittle, break on refactor. Fix: prefer fakes, test behavior.

The meta-point: most anti-patterns are violations of separation of concerns, single source of truth, UDF, or lifecycle correctness - or the opposite sin, over-engineering. Naming the underlying principle is what impresses.

What are common memory leaks on Android, and how do you detect them?
Mid Android Fundamentals #memory-leaks#performance

A memory leak on Android usually means a long-lived object holds a reference to a short-lived one (often an Activity/Fragment/View), preventing GC after it’s destroyed.

The usual culprits:

  • Inner classes / anonymous listeners / Handlers holding an implicit reference to the outer Activity, posted with a delay that outlives the Activity. (A Handler.postDelayed of 60s pins the Activity.)
  • Static fields / singletons / objects holding a Context, View, or callback. Use application context for app-lifetime objects.
  • ViewModel holding a View/Activity context - survives config change, leaks the old Activity.
  • Listeners/observers not unregistered - BroadcastReceiver, LocationListener, LiveData.observeForever, RxJava/Flow subscriptions, ViewTreeObserver.
  • Coroutines in the wrong scope - GlobalScope/unscoped jobs capturing UI.
  • Fragment view leaks - not nulling ViewBinding in onDestroyView, or observing with the fragment instead of viewLifecycleOwner.
  • Bitmaps / large caches not bounded or recycled.

Detection tools:

  • LeakCanary - the standard. It watches destroyed Activities/Fragments/ViewModels, triggers a heap dump when one isn’t GC’d, and shows the leak trace (the reference chain holding it). First thing to add when investigating.
  • Android Studio Memory Profiler - capture a heap dump, look for retained Activities, inspect references, force GC, and track allocations.
  • StrictMode can flag some leaks (e.g. unclosed resources).

The fix pattern: break the reference chain - use weak references or app context, unregister in the symmetric lifecycle callback, scope coroutines to a lifecycle, and null out view-bound fields on destroy.

What are infix functions and tailrec functions?
Mid Kotlin Language #kotlin#infix#tailrec

Infix functions can be called without the dot and parentheses, reading like an operator. Requirements: marked infix, a member or extension, exactly one parameter (no default, no vararg).

infix fun Int.times(str: String) = str.repeat(this)
3 times "ab"        // "ababab"  (same as 3.times("ab"))

You already use stdlib infix functions: to ("key" to 1 builds a Pair), until, downTo, step, and/or, shl.

tailrec functions - when a recursive function’s recursive call is the very last operation (tail position), tailrec lets the compiler rewrite it into a loop, avoiding stack growth and StackOverflowError.

tailrec fun factorial(n: Long, acc: Long = 1): Long =
    if (n <= 1) acc else factorial(n - 1, acc * n)   // tail call → compiled to a loop

The catch: the recursive call must be the last action - return 1 + factorial(...) is not tail-recursive (the addition happens after), and the compiler warns. The accumulator-parameter trick (as above) is the usual way to make a function tail-recursive.

What are smart casts, and when do they fail to apply?
Mid Kotlin Language #kotlin#smart-cast#null-safety

A smart cast is the compiler automatically casting a value after you’ve checked its type or nullability, so you don’t write an explicit cast:

fun describe(x: Any) {
    if (x is String) {
        println(x.length)   // x smart-cast to String here
    }
}

It works after is checks, != null checks, and on the matched branch of a when.

When it fails - the classic interview point: the compiler only smart-casts if it can guarantee the value didn’t change between the check and the use. So it fails for:

  • var properties (especially of another class / open): they could be modified by another thread or an overridden getter between check and use.
  • Custom getters: a val with a custom getter could return a different value each call.
  • Properties from another module / mutable var globals.
class Holder { var name: String? = null }
fun f(h: Holder) {
    if (h.name != null) {
        // println(h.name.length)  // ERROR: smart cast impossible (mutable var)
    }
}

Fixes: copy to a local val first (val n = h.name; if (n != null) n.length), or use ?.let { }. Local vals and immutable properties smart-cast cleanly.

What are the main animation APIs in Compose, and how do you pick one?
Mid Jetpack Compose #compose#animation

Compose animations are state-driven - you animate toward a target value and Compose interpolates.

High-level / value animations:

  • animate*AsState - animate a single value to a target. The simplest: animateColorAsState, animateDpAsState, animateFloatAsState.
    val size by animateDpAsState(if (expanded) 200.dp else 100.dp)
    Box(Modifier.size(size))
  • updateTransition - coordinate multiple values that change together based on one state, staying in sync.
  • AnimatedVisibility - animate a composable entering/leaving (enter/exit transitions: fade, slide, expand).
  • AnimatedContent - animate the swap between different content for different states.
  • Crossfade - fade between two layouts.
  • animateContentSize() - a modifier that animates size changes automatically.

Low-level / fine control:

  • Animatable - coroutine-driven, gives full control (e.g. fling, gesture-following, snapTo/animateTo), and is interruptible.
  • rememberInfiniteTransition - looping animations (pulsing, loading shimmer).

AnimationSpec customizes the how: tween(durationMillis, easing), spring(dampingRatio, stiffness), keyframes, repeatable.

How to choose:

  • One value to a target → animate*AsState.
  • Several values from one state → updateTransition.
  • Show/hide → AnimatedVisibility; swap content → AnimatedContent.
  • Gesture-following / manual control → Animatable.
  • Continuous loop → rememberInfiniteTransition.
What are the most important ways to secure a mobile app?
Mid Mobile System Design #system-design#security#auth

Security on the client spans storage, transport, and code.

Credential / token storage:

  • Never store tokens in plain SharedPreferences or in code.
  • Use the Android Keystore - hardware-backed keys that can’t be extracted - to encrypt secrets, or EncryptedSharedPreferences / encrypted DataStore (Jetpack Security) which use Keystore under the hood.
  • Prefer short-lived access tokens + a refresh token; store the refresh token securely; rotate on use.
  • For high-security apps, gate access behind BiometricPrompt.

Transport security:

  • HTTPS/TLS only; block cleartext (android:usesCleartextTraffic="false", network security config).
  • Certificate pinning (OkHttp CertificatePinner or network-security-config) to defeat MITM with rogue CAs - but plan for rotation (pin backups; a wrong pin can brick the app).

Data at rest:

  • Encrypt sensitive local data (SQLCipher for Room, EncryptedFile). App-private storage by default; never sensitive data on shared storage.
  • Clear caches/tokens on logout.

Code & runtime hardening:

  • R8/ProGuard obfuscation (raises the bar, not a guarantee).
  • No secrets in the APK - API keys in an APK are extractable; keep secrets server-side, use short-lived/scoped tokens, and a backend proxy for sensitive 3rd-party calls.
  • Root/tamper detection, Play Integrity API for high-value apps.
  • Validate inputs; beware insecure deep links / exported components / intent redirection (PendingIntent immutability).

Authentication:

  • OAuth2 / OIDC with PKCE for the auth flow; tokens via the secure storage above.
  • Transparent token refresh (OkHttp Authenticator on 401), serialized to refresh once.

Common mobile vulns (OWASP Mobile): insecure data storage, weak transport security, hardcoded secrets, insecure IPC/deep links, insufficient cryptography.

Trade-offs to name: cert pinning (MITM protection vs rotation/ops risk), encryption (security vs minor perf), root detection (security vs false positives/UX), strictness vs developer/QA friction.

What are the three phases of Jetpack Compose?
Mid Jetpack Compose #compose#phases#performance

Compose renders a frame in three phases, in order:

  1. Composition - what to show. Compose runs your @Composable functions to build/update the UI tree (the description of widgets). This is where recomposition happens.
  2. Layout - where to put it. Each node is measured and placed: the tree is measured top-down, then children are placed. This is the measure/place pass.
  3. Drawing - how it looks. Each node draws itself onto the canvas.
State change → Composition → Layout → Drawing → frame on screen

Why this matters for performance: a state change doesn’t always need all three phases. If you can defer a state read to a later phase, you skip the earlier ones:

// ❌ reads scroll offset in composition → recomposes every scroll frame
Box(Modifier.offset(x = scrollState.value.dp))

// ✅ reads it in the layout phase via a lambda → skips composition
Box(Modifier.offset { IntOffset(scrollState.value, 0) })

The lambda version of offset/graphicsLayer/drawBehind reads the value during layout/draw, not composition - so a changing offset re-runs only layout/draw, not your composable. This deferred-read technique is a core Compose performance pattern.

What are the types of Services? Started vs bound vs foreground, and the modern alternatives.
Mid Android Fundamentals #services#background#components

A Service runs without a UI. Three usage patterns:

Started service - launched with startService/startForegroundService, runs until it stops itself (stopSelf) or is stopped. For ongoing work not tied to a UI.

Bound service - components bindService to get a client-server interface (IBinder) and call into it. Lives while clients are bound; great for in-process APIs (e.g. a media playback controller).

Foreground service - a started service that shows a persistent notification and is far less likely to be killed. Required for user-visible ongoing work (music, navigation, active location, calls). On Android 14+ you must declare a foregroundServiceType and have a matching permission/justification.

val notification = buildNotification()
startForeground(ID, notification, FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)

The big modern caveat - background limits. Since Android 8 (Oreo), apps can’t freely run background services; the system kills them. So:

  • Deferrable, guaranteed background work (sync, upload, periodic jobs) → WorkManager, not a Service.
  • In-app async work while the app is alive → coroutines (viewModelScope), not a Service.
  • Services are now reserved for genuinely immediate, ongoing, user-aware work - and then almost always foreground services.

Other points:

  • A Service runs on the main thread by default - you must offload work to a background thread/coroutine yourself (it’s not automatically backgrounded).
  • onStartCommand return value (START_STICKY etc.) controls restart behavior after the system kills it.
What caching strategies and layers would you use in a mobile client?
Mid Mobile System Design #system-design#caching#performance#offline

Caching is the backbone of a fast, offline-capable mobile app. Design it in layers with an explicit invalidation policy.

Cache layers (fastest → most durable):

  • Memory - LruCache / StateFlow in repositories; fastest, lost on process death, size-bounded. Hot data within a session.
  • Disk / database - Room (structured, queryable, observable), DataStore (key-value), files. Survives restarts; the basis of offline-first (DB = single source of truth).
  • HTTP cache - OkHttp’s disk cache honoring Cache-Control/ETag/Last-Modified.
  • Media cache - Coil/Glide memory + disk LRU for images.

Read strategies:

  • Cache-then-network - render cached data instantly, refresh in the background, update UI. Best feed UX.
  • Cache-aside - check cache; on miss fetch and populate.
  • Network-first, cache-fallback - freshness-critical data with offline resilience.
  • Stale-while-revalidate - serve stale immediately, revalidate in background.

Invalidation (the hard part):

  • TTL / expiry - store a timestamp, refetch when stale.
  • ETag / conditional requests - server returns 304 Not Modified → no payload, saves data/battery.
  • Event-based - invalidate on a known mutation or a push signal.
  • Manual - pull-to-refresh.

Mobile-specific considerations:

  • Single source of truth - write network results to the DB; the UI observes the DB, so caches don’t drift across the app.
  • Bounded eviction - LruCache sizes, Room cleanup jobs; respect device storage limits.
  • Battery/data awareness - longer TTLs and conditional requests reduce radio usage; prefetch on Wi-Fi.
  • Security - don’t cache sensitive data unencrypted; clear caches on logout.

Trade-offs to name: freshness vs data/battery cost vs consistency - e.g. a long TTL saves bandwidth but risks staleness; cache-then-network shows possibly-stale content for a moment to gain instant load.

What caching strategies would you use in an Android app?
Mid Architecture & Patterns #caching#performance#data-layer

Caching is layered; pick per data type and freshness need.

Cache tiers (fastest → most durable):

  • In-memory - a MutableStateFlow/LruCache in a repository or singleton. Fastest, lost on process death, bounded by size. Good for hot data within a session.
  • Disk / database - Room (structured), DataStore (key-value), files. Survives process death; the basis of offline-first (DB as single source of truth).
  • HTTP cache - OkHttp’s disk cache honoring Cache-Control/ETag for network responses.
  • Image cache - Coil/Glide’s memory + disk LRU.

Read strategies:

  • Cache-then-network - show cached data instantly, fetch in background, update. Best UX for feeds.
  • Cache-aside (lazy) - check cache; on miss, fetch and populate.
  • Network-first with cache fallback - fresh when possible, cache when offline.
  • Read-through - the cache layer fetches on miss transparently.

Invalidation (the hard part - “two hard things in CS”):

  • TTL / expiry - store a timestamp; refetch when stale.
  • ETag / Last-Modified - conditional requests; server returns 304 Not Modified to save bandwidth.
  • Event/push-based - invalidate on a known mutation (user edited data) or a server push.
  • Manual - pull-to-refresh forces a fetch.

Decisions to make:

  • Single source of truth - write network results into the DB and have the UI observe the DB, rather than caching in multiple places that drift.
  • Eviction - bound caches (LruCache, Room cleanup) so they don’t grow unbounded.
  • Consistency vs freshness vs cost - name the trade-off: a longer TTL saves data/battery but risks staleness.
  • Stale-while-revalidate - serve stale immediately, refresh in the background.
What causes an ANR, and how do you prevent and diagnose one?
Mid Android Fundamentals #anr#performance#main-thread

An ANR (Application Not Responding) happens when the main thread is blocked too long and can’t process input or draw. The system thresholds:

  • ~5 seconds - input event (touch/key) not handled.
  • ~10 seconds - BroadcastReceiver.onReceive not finished (foreground).
  • Service / ContentProvider timeouts and (Android 11+) onStartForeground not called in time.

Common causes:

  • Heavy work on the main thread - network, disk/database I/O, big JSON parsing, bitmap decoding.
  • Blocking calls - Thread.sleep, synchronous network, runBlocking on Main, a lock held by a slow thread.
  • Deadlocks between the main thread and a background lock.
  • Doing too much in lifecycle callbacks or onReceive.
  • A janky main thread under load (binder calls, too many/large frames).

Prevention:

  • Move all I/O and CPU work off Main - coroutines with Dispatchers.IO/Default, WorkManager for background.
  • Keep frame work under 16ms (60fps); avoid synchronous work in onCreate/onBind/onReceive.
  • Use StrictMode in debug to catch accidental disk/network on the main thread.
  • Use goAsync() or hand off in receivers; don’t block.

Diagnosis:

  • /data/anr/traces.txt (or the bug report) shows the main-thread stack at the moment of the ANR - read it to find what was blocking.
  • Play Console → Android vitals aggregates ANR rate in production with stacks.
  • Perfetto / systrace / Macrobenchmark and the CPU profiler to find main-thread stalls.
What changes when a coroutine uses delay instead of Thread.sleep?
Mid Coroutines & Flow #coroutines#output-based#delay#blocking
fun main() = runBlocking {
    val time = measureTimeMillis {
        val a = launch { delay(500); println("A") }
        val b = launch { delay(500); println("B") }
    }
    println("Done in ~${time}ms")
}

This prints A, B, and “Done in ~0ms” - wait, why 0? Because measureTimeMillis only measures the time to launch the two coroutines (which return immediately); runBlocking then waits for them after the block. The two delay(500)s overlap, so the program finishes in ~500ms total.

Now swap delay for Thread.sleep on a single-threaded dispatcher:

runBlocking {                     // single thread
    launch { Thread.sleep(500); println("A") }
    launch { Thread.sleep(500); println("B") }
}                                  // takes ~1000ms - they run sequentially!

Why: delay is a suspending function - it releases the thread, so both coroutines wait concurrently (~500ms total). Thread.sleep blocks the thread; on a single-threaded dispatcher the second coroutine can’t even start until the first unblocks, so the sleeps run back-to-back (~1000ms).

never use Thread.sleep (or any blocking call) inside a coroutine without moving it to an appropriate dispatcher - it blocks a pooled thread, kills concurrency, and on Main causes ANRs. Use delay for waiting, withContext(Dispatchers.IO) for unavoidable blocking work.

What do onStart, onEach, onCompletion, and onEmpty do on a Flow?
Mid Coroutines & Flow #flow#operators#lifecycle

These operators hook into a flow’s lifecycle without changing its values - useful for loading states, logging, and cleanup.

repository.observe()
    .onStart { emit(UiState.Loading) }       // before the first upstream value
    .onEach { log("emitted $it") }            // for each value, as it passes
    .onCompletion { cause ->                  // when the flow finishes (or fails)
        if (cause != null) log("failed: $cause") else log("done")
    }
    .catch { emit(UiState.Error) }
    .collect { render(it) }
  • onStart { } - runs before collection begins; can emit values (great for an initial Loading state).
  • onEach { } - a side effect per value; returns the value unchanged. Pairs with launchIn to collect without a collect block.
  • onCompletion { cause -> } - runs when the flow terminates for any reason: normal completion (cause == null), error (cause != null), or cancellation. Use it for cleanup or final logging. Unlike catch, it does not swallow the exception - it just observes it.
  • onEmpty { } - runs if the flow completed without emitting anything; can emit a default.

onCompletion vs finally: onCompletion is the declarative, flow-aware way to run teardown and sees the terminal cause, including downstream cancellation - clearer than wrapping collect in try/finally.

// Collect without a trailing lambda:
flow.onEach { render(it) }.launchIn(viewModelScope)
What does R8 do? (shrinking, obfuscation, optimization) and what are keep rules?
Mid Android Fundamentals #r8#proguard#build

R8 is the default code shrinker/optimizer (it replaced ProGuard, reading the same proguard-rules.pro config). Enabled with minifyEnabled true on release builds, it does four things:

  1. Shrinking (tree-shaking) - removes unused classes, methods, and fields. Smaller APK.
  2. Optimization - inlining, removing dead branches, merging classes, simplifying code.
  3. Obfuscation - renames classes/methods to short meaningless names (a, b) - smaller and harder to reverse-engineer.
  4. Resource shrinking (shrinkResources true) - drops unused resources.
buildTypes {
    release {
        isMinifyEnabled = true
        isShrinkResources = true
        proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
    }
}

The catch - reflection breaks under R8. R8 does static analysis; code accessed only via reflection, JNI, or by name (Gson/Moshi models, deserialized classes, reflective DI, Class.forName) looks “unused” and gets removed or renamed. That’s what -keep rules are for:

-keep class com.app.model.** { *; }          # don't remove/rename my JSON models
-keepclassmembers class ... { @SerializedName <fields>; }
-keepattributes Signature, *Annotation*       # keep generics/annotations for reflection

What to remember:

  • Always test the release/minified build - bugs from over-aggressive removal only appear there (crashes like NoSuchMethodException, broken JSON parsing).
  • Use libraries’ consumer ProGuard rules (Retrofit/Gson/Moshi ship them) so you don’t hand-write everything.
  • Keep mapping.txt (build/outputs/mapping) - upload it to Play to de-obfuscate crash stack traces; without it, production crashes are unreadable.
  • Prefer codegen (Moshi/kotlinx.serialization) over reflection to minimize keep rules.
What does the key() composable do, and why is identity important in Compose?
Mid Jetpack Compose #compose#key#state#identity

Compose identifies each composable by its position in the source/call tree (positional memoization). That’s how it knows which remembered state belongs to which call. The key() composable lets you override that identity with a value you control.

@Composable
fun Names(names: List<Name>) {
    Column {
        for (name in names) {
            key(name.id) {                 // tie identity to id, not position
                NameRow(name)              // its remembered state follows the data
            }
        }
    }
}

Why it matters: without key, if the list reorders or you insert/remove an item, the position of each NameRow changes, so Compose mismatches remembered state, animations, and focus to the wrong items. Wrapping in key(name.id) ties identity to the data, so Compose moves existing state with the item instead of recreating it.

Where you see it:

  • Manual loops (for inside Column) - wrap each iteration in key().
  • LazyColumn/LazyRow - the items(list, key = { it.id }) parameter does the same thing for lazy lists.

Symptoms of missing keys:

  • A row’s expanded/collapsed state or half-typed text jumps to the wrong item after a reorder/delete.
  • Item animations glitch.
  • Unnecessary recomposition because state can’t be matched.
What is a backing field, and when is one generated? (the `field` keyword)
Mid Kotlin Language #kotlin#properties#backing-field

A Kotlin property is really a getter (and setter), not a raw field. A backing field - referenced as field inside the accessor - is the actual storage, and the compiler generates it only when needed: when you use the default accessor, or you reference field in a custom one.

var counter: Int = 0
    set(value) {
        if (value >= 0) field = value   // `field` = the backing field
    }

// Computed property: NO backing field - just a getter
val isEmpty: Boolean
    get() = size == 0

Key points:

  • Using field avoids infinite recursion. Writing set(value) { counter = value } would call the setter again forever - field = value writes storage directly.
  • A property with only a custom getter and no field reference stores nothing - it’s computed each call.
  • A common pattern is the private mutable / public read-only pair (no custom field needed):
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state   // expose read-only view
What is a BroadcastReceiver, and what changed with background restrictions?
Mid Android Fundamentals #broadcast-receiver#components#background

A BroadcastReceiver responds to system-wide or app broadcast events (connectivity change, boot completed, battery low, or your own custom broadcasts).

Two ways to register:

  • Manifest-declared (static) - listens even when the app isn’t running. Since Android 8, most implicit system broadcasts can no longer be declared in the manifest (to curb apps waking up constantly). A few exceptions remain (e.g. BOOT_COMPLETED, LOCKED_BOOT_COMPLETED).
  • Context-registered (dynamic) - registerReceiver() in code; only active while your component is alive. You must unregisterReceiver() (e.g. in onStop/onDestroy) or you leak.
val receiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) { /* handle */ }
}
// API 33+: must specify exported flag
registerReceiver(receiver, IntentFilter(ACTION), RECEIVER_NOT_EXPORTED)

Key constraints interviewers probe:

  • onReceive runs on the main thread and must return quickly (~10s limit) - no heavy work. Hand off long tasks to WorkManager or a goAsync() + coroutine, not a raw thread.
  • Android 8+ background limits - prefer WorkManager/JobScheduler over receivers for background reactions; manifest receivers for implicit broadcasts are mostly disallowed.
  • Security - declare exported correctly (required flag on API 33+), use permissions on sensitive broadcasts, and prefer LocalBroadcastManager is deprecated → use a SharedFlow/observer pattern for in-app events instead of broadcasts.

Modern guidance: for in-app eventing use Flows; for reacting to system conditions (network, charging) prefer WorkManager constraints; reserve receivers for the few cases that genuinely need them (e.g. BOOT_COMPLETED to reschedule work).

What is a ContentProvider and when do you actually need one?
Mid Android Fundamentals #content-provider#components#ipc

A ContentProvider exposes structured data across app boundaries behind a content:// URI, with a CRUD interface (query, insert, update, delete). It’s the standard mechanism for sharing data between apps and is the backend for system data like contacts, calendar, and MediaStore.

val cursor = contentResolver.query(
    ContactsContract.Contacts.CONTENT_URI, null, null, null, null,
)

When you actually need to build one:

  • You want other apps to read/write your data (rare for most apps).
  • You must integrate with a system feature that requires a provider: app widgets, the search framework, sync adapters, or sharing files via FileProvider (granting temporary URI permissions instead of exposing file paths).
  • A FileProvider is the common real-world case - sharing a photo/PDF with another app safely.

When you do NOT need one:

  • For your own app’s data, just use Room/DataStore/files directly. A ContentProvider adds IPC overhead and boilerplate for no benefit if no other app consumes it.

What to remember:

  • Providers run in your process but are called via Binder IPC when another app queries them.
  • The provider is initialized very early (onCreate runs before Application.onCreate finishes) - which is why libraries like WorkManager and App Startup historically used a stub ContentProvider to auto-initialize. That early-init behavior is itself a common trivia question.
  • Secure them with android:exported, permissions, and path-permissions; never expose raw file paths.
What is a functional interface in Kotlin?
Mid Kotlin Language #kotlin#fun-interface#sam#interop

A functional interface has exactly one abstract method (Single Abstract Method). Mark it fun interface and Kotlin lets you implement it with a lambda instead of an object expression - that substitution is SAM conversion.

fun interface IntPredicate {
    fun accept(i: Int): Boolean
}

// SAM conversion: lambda becomes an IntPredicate
val isEven = IntPredicate { it % 2 == 0 }
isEven.accept(4)   // true

Without fun interface you’d have to write:

val isEven = object : IntPredicate {
    override fun accept(i: Int) = i % 2 == 0
}

Key points:

  • SAM conversion works automatically for Java interfaces (Runnable, OnClickListener, Comparator) - that’s why view.setOnClickListener { } works.
  • For Kotlin interfaces it only kicks in with the fun interface keyword; otherwise the compiler prefers you use a function type ((Int) -> Boolean) directly.
  • A fun interface can have other non-abstract (default) members, just one abstract one.

When to prefer fun interface over a typealias for a function type: when you want a named type with possible default methods, nominal typing, or Java interop - a plain (Int) -> Boolean is structural and can’t carry extra members.

What is a PendingIntent, and why do the mutability flags matter?
Mid Android Fundamentals #pendingintent#security#notifications

A PendingIntent is a token that wraps an Intent plus your app’s permission to perform it, handed to another app or the system so they can execute the action as you, later. It’s used for notifications, alarms (AlarmManager), app widgets, and Service/Activity callbacks.

val pi = PendingIntent.getActivity(
    context, requestCode, intent,
    PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
notificationBuilder.setContentIntent(pi)

Why mutability is a security issue: the receiving app holds your PendingIntent and could fill in the blank fields of the wrapped Intent if it’s mutable, then have it executed with your app’s identity/permissions. A mutable PendingIntent with an unspecified component is an intent-redirection vulnerability.

The flags:

  • FLAG_IMMUTABLE - the other app can’t modify the Intent. Default choice - use it unless you have a specific reason not to. Required thinking on Android 12+ (you must explicitly pass IMMUTABLE or MUTABLE).
  • FLAG_MUTABLE - allows modification. Only when a system feature needs to fill in data - e.g. direct reply notifications (the system inserts the typed text), or Bubbles. When you do, make the Intent explicit (named component) to avoid redirection.
  • FLAG_UPDATE_CURRENT - update the extras of an existing matching PendingIntent.
  • FLAG_CANCEL_CURRENT / FLAG_NO_CREATE / FLAG_ONE_SHOT - manage lifecycle/reuse.

Equality gotcha: PendingIntents are matched by requestCode + Intent (action/data/component, not extras). Reusing the same requestCode can hand back an old one - vary the requestCode or use UPDATE_CURRENT for notifications.

What is a typealias, and how does it differ from a value class?
Mid Kotlin Language #kotlin#typealias#value-class

A typealias gives an existing type a new name. It introduces no new type - it’s a pure compile-time alias, fully interchangeable with the original.

typealias UserId = String
typealias ClickHandler = (View) -> Unit
typealias Headers = Map<String, List<String>>

fun fetch(id: UserId) { }
fetch("u123")                 // a plain String works - same type

Use it to shorten verbose generic/function types and improve readability.

The crucial contrast with value class:

typealias UserId = Stringvalue class UserId(val v: String)
New distinct type?NoYes
Type-safe (prevents mixups)?NoYes
Runtime costNoneNone (usually inlined)
typealias Email = String
typealias Name = String
fun send(to: Email, name: Name) {}
send(userName, userEmail)     // compiles! aliases don't stop the swap

So: reach for typealias purely for readability of complex types; reach for value class when you need the compiler to enforce that two same-underlying types can’t be confused.

What is a UseCase (Interactor), and when do you actually need one?
Mid Architecture & Patterns #usecase#domain-layer#clean-architecture

A UseCase (a.k.a. Interactor) encapsulates a single piece of business logic in the domain layer. It typically depends on repositories and is consumed by ViewModels. Convention: name it as a verb and expose a single invoke operator.

class GetVisibleFeedUseCase(
    private val feedRepo: FeedRepository,
    private val settingsRepo: SettingsRepository,
) {
    operator fun invoke(): Flow<List<Post>> =
        combine(feedRepo.observeFeed(), settingsRepo.blockedAuthors()) { posts, blocked ->
            posts.filterNot { it.author in blocked }   // the business rule
        }
}

// In the ViewModel:
val feed = getVisibleFeed().stateIn(...)

When you NEED one:

  • Logic reused across multiple ViewModels - put it in one place instead of duplicating.
  • Combining multiple repositories / non-trivial rules - orchestration that doesn’t belong in a repository (which does data access) or a ViewModel (which does UI state).
  • Complex domains where you want pure, independently testable business logic with no Android deps.

When you DON’T (the pragmatic point interviewers reward):

  • Pass-through use cases that just call repository.getX() add a layer for no value - boilerplate. If a ViewModel can call the repository directly with no extra logic, skip the use case.
  • Simple CRUD apps rarely need a full domain layer.

Design conventions:

  • One use case = one responsibility (single public method, often operator fun invoke).
  • No Android dependencies - pure Kotlin, trivially unit-tested.
  • Inject the dispatcher if it does CPU work (withContext(defaultDispatcher)), keeping it off the main thread and testable.
What is a value class (inline class) and when would you use it?
Mid Kotlin Language #kotlin#value-class#performance

A value class (formerly inline class) wraps a single value to give it a distinct type, but the compiler inlines the underlying value at runtime - so you get type safety with (usually) no allocation overhead.

@JvmInline
value class UserId(val value: String)
@JvmInline
value class Email(val value: String)

fun fetch(id: UserId) { /* ... */ }

fetch(UserId("u123"))   // type-safe
// fetch(Email("a@b.c")) // compile error - can't mix them up

At runtime UserId is represented as a plain String wherever possible - no wrapper object is created.

Why use it:

  • Prevent primitive obsession / mix-ups - a function taking UserId, Email, and Meters can’t have its arguments swapped, unlike three Strings.
  • Domain modeling without the cost of a real wrapper class.

Rules & caveats:

  • Exactly one property in the primary constructor.
  • Can have methods and computed properties, but no init backing fields beyond that one value.
  • It gets boxed (allocated) when used as a nullable, as a generic type argument, or where its supertype is expected - so the “zero-cost” benefit isn’t guaranteed in every position.
What is CompositionLocal? When should you use it (and when not)?
Mid Jetpack Compose #compose#compositionlocal#theming

CompositionLocal provides a value implicitly down the composition tree, so deeply nested composables can read it without passing it through every parameter. It’s how MaterialTheme, LocalContext, LocalDensity, and LocalContentColor work.

val LocalSpacing = compositionLocalOf { Spacing() }

CompositionLocalProvider(LocalSpacing provides Spacing(large = 24.dp)) {
    MyScreen()   // anything inside can read LocalSpacing.current
}

@Composable
fun MyScreen() {
    val spacing = LocalSpacing.current
    Column(Modifier.padding(spacing.large)) { ... }
}

Two flavors:

  • compositionLocalOf - changing the value recomposes only composables that read it (tracked). Use for values that change.
  • staticCompositionLocalOf - not tracked; changing it recomposes the entire provided subtree. Use for values that essentially never change (more efficient reads). Theme colors that rarely change often use this.

When to use it: truly cross-cutting, ambient data many layers deep - theming, density, locale, a logged-in user’s display prefs.

When not to use it: it makes data flow implicit, which hurts readability and testability. Don’t use it for:

  • Data only one or two levels down - just pass a parameter.
  • ViewModel/business state - pass it explicitly; CompositionLocal hides dependencies and makes composables harder to preview/test.

Rule of thumb: “CompositionLocal for ambient, rarely-changing, widely-needed values (theme, density). Explicit parameters for everything else.”

What is CoroutineContext, and what are its elements?
Mid Coroutines & Flow #coroutines#context

A CoroutineContext is an indexed set of elements that defines how a coroutine behaves. It’s like a map keyed by element type, and elements combine with +.

The main elements:

  • Job - the coroutine’s lifecycle handle (cancellation, parent/child relationship).
  • CoroutineDispatcher - which thread(s) it runs on.
  • CoroutineName - a name for debugging/logging.
  • CoroutineExceptionHandler - last-resort handler for uncaught exceptions.
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob() + CoroutineName("ui"))

scope.launch(Dispatchers.IO + CoroutineName("download")) {
    // context here = parent's, with Dispatcher and Name overridden
}

How it composes (important):

  • A child coroutine inherits the parent’s context, then applies any overrides you pass to the builder.
  • The child always gets a new Job that is a child of the parent’s Job - that’s what wires up structured concurrency. (You don’t inherit the parent’s Job instance; you become its child.)
  • coroutineContext[Job], coroutineContext[CoroutineDispatcher] let you read elements.
What is CoroutineStart.LAZY, and how does a lazy async behave?
Mid Coroutines & Flow #coroutines#async#lazy

By default launch/async start immediately (dispatched right away). Passing start = CoroutineStart.LAZY makes the coroutine not start until you trigger it - via start(), join(), or (for async) await().

val deferred = async(start = CoroutineStart.LAZY) {
    expensiveComputation()
}
// ...nothing has run yet...
if (needed) {
    val result = deferred.await()   // NOW it starts and we wait
}

Use cases:

  • Defer expensive work you might not need.
  • Set up several coroutines and start them at a controlled moment.

The big gotcha: with lazy async, if you build multiple deferreds and only await them one-by-one, they run sequentially, not in parallel - each only starts at its await(). To parallelize lazy ones, explicitly start() them all first:

val a = async(start = LAZY) { taskA() }
val b = async(start = LAZY) { taskB() }
a.start(); b.start()        // kick both off concurrently
a.await(); b.await()

Other CoroutineStart values to know:

  • DEFAULT - start immediately (the normal behavior).
  • LAZY - start on demand.
  • ATOMIC - start even if cancelled before dispatch (runs to the first suspension point).
  • UNDISPATCHED - run in the current thread until the first suspension, skipping the dispatcher.
What is derivedStateOf, and when should you use it instead of a plain calculation?
Mid Jetpack Compose #compose#derivedStateOf#performance#state

derivedStateOf creates a state object whose value is computed from other state, but only notifies readers when the computed result actually changes - not every time an input changes.

Use it when a frequently-changing state feeds a rarely-changing derived value:

val listState = rememberLazyListState()

// Recomputes as you scroll, but only emits true/false transitions
val showButton by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

if (showButton) ScrollToTopButton()

Here firstVisibleItemIndex changes on every scroll frame, but showButton only flips false→true→false. Without derivedStateOf, any composable reading showButton would recompose on every scroll tick. With it, recomposition happens only when the boolean changes - a big saving.

When NOT to use it: when the output changes about as often as the input. derivedStateOf has overhead, so for val full = "$first $last" (changes whenever inputs do) a plain calculation is better - wrapping it adds cost for no benefit.

The decision rule: reach for derivedStateOf when one or more rapidly-changing states collapse into a value that changes far less often. If input-change-rate ≈ output-change-rate, just compute it directly.

Common pairing: remember { derivedStateOf { } } - the remember keeps the derived-state object across recompositions; the derivedStateOf controls when readers are notified.

What is Room, and what are its main components and benefits?
Mid Android Fundamentals #room#persistence#database

Room is Jetpack’s persistence library - an abstraction over SQLite that adds compile-time safety and coroutine/Flow support. Three core pieces:

  • @Entity - a table; each instance is a row.
  • @Dao - Data Access Object; methods annotated @Query/@Insert/@Update/@Delete define database operations.
  • @Database - ties entities + DAOs together and exposes the DB instance.
@Entity data class User(@PrimaryKey val id: Int, val name: String)

@Dao interface UserDao {
    @Query("SELECT * FROM User WHERE id = :id")
    suspend fun getUser(id: Int): User?

    @Query("SELECT * FROM User")
    fun observeAll(): Flow<List<User>>     // emits on every change

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsert(user: User)
}

@Database(entities = [User::class], version = 1)
abstract class AppDb : RoomDatabase() { abstract fun userDao(): UserDao }

Why Room over raw SQLite:

  • Compile-time SQL verification - your @Query strings are checked against the schema at build time (typos/bad columns fail the build).
  • No boilerplate - no Cursor parsing or ContentValues; rows map straight to objects.
  • Coroutines & Flow - suspend DAO methods run off the main thread; Flow return types make the DB observable, emitting whenever the data changes - the basis of “DB as single source of truth.”
  • Migrations - explicit Migration objects (or autoMigrations) version your schema safely.
  • Relations (@Relation), type converters (@TypeConverter), full-text search, and testability.

What to remember:

  • A Flow-returning query is the idiomatic single source of truth - write to Room, observe Room, UI updates automatically (pairs with Paging’s RemoteMediator for offline-first).
  • Room enforces no main-thread queries by default (would block/ANR).
  • Provide migrations; fallbackToDestructiveMigration wipes data and is for dev only.
What is SavedStateHandle, and how does it fit the architecture?
Mid Architecture & Patterns #savedstatehandle#viewmodel#state

SavedStateHandle is a key-value map injected into a ViewModel that survives both configuration changes (like the ViewModel) and process death (unlike the ViewModel). It’s the architectural answer to “small UI state that must outlive everything.”

Two main jobs:

1. Receive navigation arguments - Hilt/Navigation populate it from the back stack, so a ViewModel reads its args without the UI passing them in:

@HiltViewModel
class DetailViewModel @Inject constructor(
    handle: SavedStateHandle,
    repo: ItemRepository,
) : ViewModel() {
    private val itemId: String = handle["itemId"]!!   // nav arg
    val item = repo.observe(itemId).stateIn(...)
}

2. Persist transient UI state across process death - query text, selected tab, scroll target:

val query: StateFlow<String> = handle.getStateFlow("query", "")
fun setQuery(q: String) { handle["query"] = q }

Where it fits:

  • It bridges the gap the ViewModel can’t cover (process death). The ViewModel handles config changes; SavedStateHandle extends that to process death for the few keys that matter.
  • It replaces manual onSaveInstanceState plumbing in the Activity/Fragment - the state lives in the ViewModel where the logic is, not in the view.
  • Values must be Bundle-able (primitives, Parcelable) and kept small - it’s for identifiers and UI state, not large data (re-fetch big data from the repository on restore).

Why it’s preferred over assisted injection for nav args: Navigation already serializes args into the saved state, so Hilt can populate SavedStateHandle automatically - no custom @AssistedFactory needed.

What is scoped storage, and how do you access files and media on modern Android?
Mid Android Fundamentals #storage#scoped-storage#permissions

Scoped storage (enforced from Android 10/11) restricts an app’s broad access to shared external storage. An app can freely access its own directories but needs specific mechanisms (and often user consent) for shared files - improving privacy and removing the need for the broad READ/WRITE_EXTERNAL_STORAGE permission in most cases.

Where data goes:

  • App-specific internal storage (filesDir, cacheDir) - private, no permission, wiped on uninstall.
  • App-specific external storage (getExternalFilesDir) - private to your app, no permission needed.
  • Shared collections (Photos, Videos, Audio, Downloads) - accessed via MediaStore.

How to access shared media/files:

  • MediaStore - query/insert into the media collections. Your own media needs no permission; reading others’ media needs the granular permissions (READ_MEDIA_IMAGES/VIDEO/AUDIO on Android 13+).
  • Photo Picker (ACTION_PICK_IMAGES / PickVisualMedia) - system UI to pick images/videos with no permission at all. The recommended way to let users choose photos.
  • Storage Access Framework (SAF) - ACTION_OPEN_DOCUMENT / ACTION_CREATE_DOCUMENT for user-chosen documents in any provider (Drive, local). Returns a content:// URI you have grant to.
  • FileProvider - share your files with other apps via temporary URI permissions instead of file paths.

What to remember:

  • Raw file paths to shared storage no longer work generally - use URIs (MediaStore/SAF).
  • Prefer Photo Picker over requesting media permissions - zero permission, better UX, Play-friendly.
  • MANAGE_EXTERNAL_STORAGE (“All files access”) is heavily restricted by Play - only for genuine file-manager apps.
  • App-specific dirs need no permission and are the default for app data/caches.
What is the lifecycle of a composable?
Mid Jetpack Compose #compose#lifecycle#composition

A composable’s lifecycle is much simpler than an Activity’s - it has three events:

  1. Enters the composition - the composable is called for the first time and added to the composition tree.
  2. Recomposes - re-executed zero or more times as the state it reads changes. This can happen frequently and in any order.
  3. Leaves the composition - removed from the tree (e.g. an if becomes false, a list item scrolls off, the screen is gone).
Enter composition → Recompose* (0..n) → Leave composition

What this means in practice:

  • remember survives across recompositions but is lost when the composable leaves the composition (and re-created if it re-enters). rememberSaveable additionally survives Activity recreation.
  • Effects are scoped to this lifecycle: LaunchedEffect’s coroutine is cancelled when the composable leaves; DisposableEffect.onDispose runs on leave (or key change).
  • Recomposition is not sequential or guaranteed - composables can recompose in parallel, be skipped, or run out of order, so they must be side-effect free in their body. Never rely on execution order or mutate external state directly in composition.
  • A composable can leave and re-enter (scrolling a LazyColumn item off and back) - its remembered state resets unless hoisted or rememberSaveable/keyed.

Contrast with Views: there’s no onCreate/onDestroy per widget; “creation” is entering composition and “destruction” is leaving it. Identity is by call-site position (or key).

What is the single-Activity architecture, and why is it recommended?
Mid Android Fundamentals #architecture#navigation#fragments

Single-Activity architecture means the app has one Activity that hosts all screens as fragments (or composables), with the Navigation component managing movement between them - instead of one Activity per screen.

MainActivity
└── NavHost
    ├── FeedFragment / FeedScreen
    ├── DetailFragment / DetailScreen
    └── ProfileFragment / ProfileScreen

Why it’s recommended (Google’s guidance since ~2018, and the default with Compose):

  • Simpler, centralized navigation - one back stack managed by the Nav controller, with type-safe args, deep-link support, and animated transitions, instead of juggling Activity intents and flags.
  • Cheaper transitions - switching fragments/composables is lighter than launching Activities (no new window/task setup).
  • Easy shared UI & scoped state - shared elements, a persistent bottom nav, and graph-scoped ViewModels (share state across a flow like checkout) are natural.
  • Less manifest/lifecycle boilerplate - no per-screen Activity declarations, launch modes, or result plumbing.
  • One place for app-wide concerns (insets, theming, snackbars).

Trade-offs / when multiple Activities still make sense:

  • Genuinely separate entry points or windows (a share target, a settings screen launched by the system, picture-in-picture).
  • Modularization boundaries or legacy code where a feature is its own Activity.
  • Integrations that require an Activity (some SDKs, launchMode needs).

With Compose: the same idea - a single Activity with a NavHost of composable destinations. Multiple Activities become the exception, not the rule.

What is the slot API pattern (content lambdas) in Compose?
Mid Jetpack Compose #compose#slot-api#reusability

The slot API is the pattern of accepting @Composable lambdas as parameters, letting callers inject their own content into named “slots.” It’s how Compose builds flexible, reusable components without exploding into dozens of configuration parameters.

@Composable
fun Card(
    title: @Composable () -> Unit,
    actions: @Composable RowScope.() -> Unit = {},
    content: @Composable () -> Unit,
) {
    Column(Modifier.padding(16.dp)) {
        title()
        Spacer(Modifier.height(8.dp))
        content()
        Row { actions() }
    }
}

// Caller fills the slots with whatever it wants
Card(
    title = { Text("Profile", style = MaterialTheme.typography.titleLarge) },
    actions = { TextButton(onClick = {}) { Text("Edit") } },
) {
    ProfileBody()
}

You see this everywhere in Material: Scaffold(topBar = {}, bottomBar = {}, floatingActionButton = {}) { content }, Button(content = { }), TopAppBar(title, navigationIcon, actions).

Why it’s powerful:

  • Inversion of control - the component owns layout/behavior; the caller owns what goes inside. No boolean/enum config soup.
  • Reusable & composable - one Card serves countless use cases.
  • RowScope/ColumnScope receivers on a slot give the caller scoped modifiers (Modifier.weight, align) inside that slot.
  • Trailing-lambda ergonomics - the last content slot reads cleanly with { }.
What is Unidirectional Data Flow (UDF), and why is it the foundation of modern Android architecture?
Mid Architecture & Patterns #udf#state#mvi#architecture

Unidirectional Data Flow means state flows down and events flow up - in one direction, forming a loop:

        ┌──────────── state ────────────┐
        ▼                               │
       UI  ──── events/intents ──▶  ViewModel ──▶ (repository / use case)

                                  produces new state
  • The ViewModel owns the state (a single, immutable UiState) and exposes it as a read-only StateFlow.
  • The UI is a function of that state - it renders whatever the state says.
  • The UI sends events up (button clicks, text input) as method calls/intents; it never mutates state directly.
  • The ViewModel processes the event, produces a new immutable state, and the cycle repeats.
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()   // down (read-only)

fun onRefresh() {                                       // up (event)
    viewModelScope.launch { _state.update { it.copy(loading = true) } }
}

Why it’s foundational:

  • Single source of truth - state lives in one place; the UI can’t drift out of sync.
  • Predictable & debuggable - every UI state is reproducible from one object; you can log/replay state transitions.
  • Testable - feed events, assert on emitted states; no UI needed.
  • Thread-safe updates via immutable copy() + atomic update {}.
  • It’s the principle behind MVI, Compose (UI = f(state)), and Google’s recommended architecture - the acronym matters less than the one-directional discipline.

Related practices: model one-off events (navigation, snackbars) separately (e.g. SharedFlow) so they don’t replay on rotation; keep UiState immutable.

What Kotlin–Java interoperability issues and JVM annotations matter in Android code?
Mid Kotlin Language #kotlin#java#interop#jvm

Kotlin and Java call each other directly on Android, but their type systems and language features do not line up perfectly. Strong answers focus on the boundary:

  • Platform types such as String! come from unannotated Java. Kotlin cannot prove whether they are nullable, so validate them or improve the Java nullability annotations.
  • Kotlin default arguments are not Java overloads. @JvmOverloads generates overloads by removing trailing default parameters.
  • @JvmStatic exposes a companion/object function as a Java-style static method; @JvmField exposes a property as a field instead of getter/setter methods.
  • Java SAM interfaces work naturally with Kotlin lambdas. Kotlin function types exposed to Java become FunctionN types, which may be awkward for a Java caller.
  • Kotlin has no checked exceptions. Add @Throws(IOException::class) when Java callers should see a throws declaration.
class ImageLoader @JvmOverloads constructor(
    val cacheSize: Int = 100,
    val debug: Boolean = false,
) {
    companion object {
        @JvmField val DEFAULT_TAG = "Images"
        @JvmStatic fun create() = ImageLoader()
    }
}

Do not scatter JVM annotations everywhere. Add them when a Java caller, framework, reflection API, or generated code genuinely requires that JVM shape.

What makes an Android component lifecycle-aware?
Mid Android Fundamentals #lifecycle#jetpack#architecture

Lifecycle-aware components observe an owner’s lifecycle and react automatically, instead of you manually wiring start/stop logic into Activity/Fragment callbacks.

The pieces (from androidx.lifecycle):

  • Lifecycle - holds the current state (INITIALIZED, CREATED, STARTED, RESUMED, DESTROYED) and dispatches events (ON_CREATE, ON_START, …).
  • LifecycleOwner - anything with a Lifecycle (Activity, Fragment, viewLifecycleOwner, NavBackStackEntry, the process via ProcessLifecycleOwner).
  • LifecycleObserver - an object that observes those events; implement DefaultLifecycleObserver for clean callbacks.
class LocationTracker(private val client: LocationClient) : DefaultLifecycleObserver {
    override fun onStart(owner: LifecycleOwner) = client.start()
    override fun onStop(owner: LifecycleOwner)  = client.stop()
}

// In the Activity/Fragment:
lifecycle.addObserver(LocationTracker(client))   // auto start/stop with the lifecycle

Why it matters:

  • No leaks / no boilerplate - the component starts and stops itself with the owner; you don’t sprinkle start()/stop() across onStart/onStop and risk forgetting one.
  • Reusable & testable - the logic lives in one self-contained class, not the Activity.
  • It’s the foundation under LiveData (only updates active observers), lifecycleScope, repeatOnLifecycle, and viewModelScope.

Related:

  • ProcessLifecycleOwner observes the whole app going to foreground/background (e.g. lock the app when backgrounded).
  • Prefer DefaultLifecycleObserver over the old annotation-based @OnLifecycleEvent (deprecated).
What makes an Android test flaky, and how do you fix it?
Mid Testing & Quality #testing#reliability#concurrency#ci

A flaky test passes and fails without a relevant code change. Common causes are real time, uncontrolled dispatchers, shared state, network calls, animations, device differences, and assertions that run before the UI or background work is idle.

Start by reproducing the failure repeatedly and recording the seed, device, and logs. Then remove the uncontrolled dependency:

  • Use virtual time for coroutines instead of delay or Thread.sleep.
  • Inject clocks, dispatchers, IDs, and external services.
  • Reset databases, files, and singletons between tests.
  • Use Compose or Espresso synchronization instead of fixed waits.
  • Give each test its own data and avoid depending on execution order.

Retries may reduce CI noise, but they do not fix the test. Quarantine can be a short-term containment step only when the failure has an owner and a deadline.

What triggers recomposition in Jetpack Compose, and how do you avoid doing it too often?
Mid Jetpack Compose #compose#performance#state

Recomposition is Compose re-running a composable to update the UI. It’s triggered when a State object that the composable reads changes value. Compose tracks reads at runtime, so only composables that actually read the changed state recompose - not the whole tree.

Keeping it cheap comes down to a few habits:

  • Read state as late as possible. Pass lambdas or state down rather than values, so only the leaf that needs the value recomposes. Defer reads into Modifier.drawBehind/graphicsLayer lambdas for things like scroll offsets.
  • Hoist state so a frequently-changing value lives close to where it’s used, not at the top of the screen.
  • Use stable types. Compose skips a composable if its inputs are stable and unchanged. List is unstable; prefer ImmutableList (kotlinx.collections.immutable) or mark classes @Immutable/@Stable.
  • Provide keys in LazyColumn items so Compose can match items across changes instead of recomposing everything.
  • Don’t allocate in composition. Wrap expensive computations in remember(key) so they don’t rerun every recomposition.
// Bad: passes a value, recomposes on every count change
Header(count = count)

// Better: pass a lambda, the read happens inside Header only when needed
Header(count = { count })

How to prove it in an interview: mention the Layout Inspector’s recomposition counts and the Compose compiler metrics/strong-skipping mode - they show you measure rather than guess.

What value does a lambda capture from a for or while loop?
Mid Kotlin Language #kotlin#output-based#closures#lambdas
val actions = mutableListOf<() -> Int>()
for (i in 1..3) {
    actions.add { i }
}
println(actions.map { it() })

Output:

[1, 2, 3]

Why this surprises people: in Java, a similar loop with a mutable index would capture the same variable, and all lambdas would print the final value. Kotlin is different - in a for loop, each iteration has its own i. The lambda closes over that iteration’s value, so you get [1, 2, 3].

The contrast - capture a single mutable variable and they do share it:

var j = 0
val fns = mutableListOf<() -> Int>()
while (j < 3) { fns.add { j }; j++ }
println(fns.map { it() })   // [3, 3, 3] - all see the final j

Kotlin closures capture the variable, not a snapshot of its value. The loop case works out because for introduces a fresh val each iteration; the while case shares one mutable var, so every lambda sees its final value.

When should a mobile app retry a failed network request?
Mid Mobile System Design #system-design#resilience#retry#networking

Robust retry logic distinguishes what to retry, how to space attempts, and when to stop.

Classify the error first:

  • Transient (timeouts, IOException, 5xx, 429) → retry.
  • Permanent (4xx like 400/401/403/404, validation) → don’t retry; surface to the user or refresh auth (401).
  • CancellationException → never retry; rethrow.

Exponential backoff with jitter:

suspend fun <T> retry(maxAttempts: Int = 4, base: Long = 500, block: suspend () -> T): T {
    var attempt = 0
    while (true) {
        try { return block() }
        catch (e: IOException) {
            if (++attempt >= maxAttempts) throw e
            val delayMs = base * (1L shl (attempt - 1))        // 500, 1000, 2000…
            val jitter = Random.nextLong(0, delayMs / 2)        // avoid thundering herd
            delay(delayMs + jitter)
        }
    }
}
  • Exponential spacing avoids hammering a struggling server.
  • Jitter (randomness) prevents synchronized retries from many clients (the thundering herd).
  • Cap attempts and total time; respect a Retry-After header on 429/503.

Idempotency:

  • Only safely retry idempotent operations. For non-idempotent writes (create order), send an idempotency key so a retried request the server already processed isn’t applied twice.

Circuit breaker (for a repeatedly failing dependency):

  • After N consecutive failures, open the circuit - fail fast for a cooldown instead of retrying every call (which wastes battery and piles load on a down service).
  • After the cooldown, allow a trial request (half-open); success closes it, failure re-opens.

Surfacing to the user:

  • Map errors to typed domain results → UI state (retry button, offline banner, re-login).
  • Optimistic UI with rollback on permanent failure.

Trade-offs to name: retry count/backoff (success rate vs battery/data/latency), at-least-once + idempotency (reliability vs server complexity), circuit breaker (protecting the backend & battery vs delayed recovery), aggressive vs conservative timeouts.

When should you use WorkManager?
Mid Android Fundamentals #workmanager#background#scheduling

WorkManager is the recommended API for deferrable, guaranteed background work - tasks that must run eventually, even across app restarts and device reboots.

val work = OneTimeWorkRequestBuilder<UploadWorker>()
    .setConstraints(Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .setRequiresCharging(true)
        .build())
    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
    .build()
WorkManager.getInstance(context).enqueue(work)

What it gives you:

  • Guaranteed execution - persisted to a DB; survives app death and reboot.
  • Constraints - network, charging, battery-not-low, storage, idle.
  • Retry/backoff, periodic work, chaining (beginWith().then()), unique work, and observable status (LiveData/Flow).
  • Respects Doze and battery limits, picking the right underlying mechanism (JobScheduler, etc.).

When to use which:

NeedUse
In-app async while app is alive (load data)Coroutines (viewModelScope)
Deferrable work that must complete eventually (sync, upload, backup)WorkManager
Immediate, ongoing, user-visible task (music, navigation)Foreground Service
Exact-time alarm (calendar reminder)AlarmManager (setExactAndAllowWhileIdle)

Key distinctions:

  • WorkManager ≠ for exact timing - it’s “run when constraints are met, eventually,” not “run at exactly 9:00.” For precise alarms use AlarmManager.
  • WorkManager ≠ for immediate in-app work - if the app is in the foreground and you just need async, coroutines are simpler.
  • It supersedes the old JobScheduler/FirebaseJobDispatcher/AlarmManager+Receiver combos for most background jobs.
When would you choose Compose or the View system?
Mid Jetpack Compose #compose#views#tradeoffs

Compose advantages:

  • Less code, one language - UI in Kotlin, no XML, no findViewById/ViewBinding boilerplate.
  • State-driven - UI = f(state) eliminates manual view-syncing bugs and inconsistent UI.
  • Powerful, simpler customization - custom layouts, animations, and theming are far easier than custom Views/onDraw.
  • Reusable via slot APIs; great tooling (Previews, live edit).

Compose costs / caveats:

  • Maturity gaps - some specialized widgets and third-party SDKs still ship Views (maps, ads, some media) - handled via AndroidView interop.
  • Performance footguns - easy to cause excessive recomposition if you don’t understand stability/phases; needs Baseline Profiles to match View startup.
  • Learning curve - recomposition, state, and effects are a different mental model.
  • Min SDK / size - adds runtime; fine for most apps but a consideration for tiny ones.

When you might stick with Views:

  • A large existing View codebase - migrate incrementally (Compose in a ComposeView) rather than rewrite.
  • Heavy reliance on a View-only SDK with no Compose equivalent.
  • Team without Compose experience on a tight timeline.

The honest interview answer: Compose is Google’s recommended default for new UI in 2024+, and most shops are adopting it. But it’s not all-or-nothing - interop lets Compose and Views coexist, so the real-world answer is usually “Compose for new screens, interop for the rest,” not a big-bang rewrite.

Why can remember return stale data when an input changes?
Mid Jetpack Compose #compose#output-based#remember#keys
@Composable
fun UserCard(userId: String) {
    // Caches the FIRST userId's data forever
    val userData = remember { loadUserSummary(userId) }
    Text(userData.name)
}

The bug: remember { } with no key computes its value once and reuses it for the composable’s whole lifetime. If the parent re-renders UserCard with a different userId (same position in the tree), remember does not recompute - it keeps the original user’s data. The card shows the wrong user.

The fix - key the remember on the inputs it depends on:

val userData = remember(userId) { loadUserSummary(userId) }

Now when userId changes, remember discards the cached value and recomputes.

remember(key1, key2) recomputes whenever any key changes - exactly like LaunchedEffect’s keys. A keyless remember { } means “compute once, never again for this slot.”

Related gotchas:

  • Don’t do real I/O in remember/composition (loadUserSummary blocking is bad regardless) - use LaunchedEffect/produceState. The example simplifies to show the keying bug.
  • The same stale-closure issue hits LaunchedEffect(Unit) { ...uses userId... } - add userId as a key or use rememberUpdatedState.
Why does changing a MutableList sometimes fail to update Compose?
Mid Jetpack Compose #compose#output-based#state#collections
@Composable
fun BrokenList() {
    val items = remember { mutableStateOf(mutableListOf("a")) }
    Column {
        Button(onClick = { items.value.add("b") }) { Text("Add") }   // ❌ no update
        items.value.forEach { Text(it) }
    }
}

The bug: tapping “Add” mutates the list in place. The MutableState still holds the same list reference, so .value hasn’t “changed” by Compose’s equality check - no recomposition is scheduled, and the new item never appears.

Two correct approaches:

1. Use an observable collection - mutableStateListOf:

val items = remember { mutableStateListOf("a") }
Button(onClick = { items.add("b") }) { Text("Add") }   // ✅ add triggers recomposition
items.forEach { Text(it) }

mutableStateListOf (and mutableStateMapOf) are snapshot-aware: structural changes (add/remove/set) notify Compose.

2. Use an immutable list and replace the reference:

var items by remember { mutableStateOf(listOf("a")) }
Button(onClick = { items = items + "b" }) { Text("Add") }  // ✅ new list → new reference

Assigning a new list changes .value, so Compose detects it.

Why this happens: MutableState schedules recomposition when .value is set to a different value (by equals). Mutating the contained list doesn’t change the reference, so nothing fires. Either make the container observable (mutableStateListOf) or always assign a new immutable instance.

Bonus: the immutable approach also keeps the parameter stable for child composables - better for skipping recomposition.

Why does Modifier order change a composable's appearance?
Mid Jetpack Compose #compose#output-based#modifier
// A
Box(Modifier.padding(16.dp).background(Color.Red).size(100.dp))

// B
Box(Modifier.background(Color.Red).padding(16.dp).size(100.dp))

Result:

  • A - 16dp of transparent space, then a red 100dp box. The padding is applied before the background, so the background only covers the area inside the padding.
  • B - a red box with 16dp of red padding inside it before the content. The background is applied first, so it fills the outer bounds, and padding insets the content within the red area.

Why: Modifiers are applied in order, left to right - each one wraps the result of the previous. The order literally is the order of operations:

  • A: “add padding, then draw background inside that” → background sits inside the padded region.
  • B: “draw background, then pad” → background covers everything, padding pushes content in.

The general rule: Compose modifiers are not commutative. padding().background()background().padding(). Also:

  • size() then padding() vs padding() then size() changes whether padding is inside or outside the declared size.
  • clip() before background() clips the background; after, it doesn’t.
  • clickable() before padding() makes the padded area clickable; after, only the inner area is.

Interview takeaway: “Modifiers compose like nested wrappers in declaration order - position padding, background, clip, and clickable deliberately.”

Why should you inject coroutine dispatchers instead of hardcoding them?
Mid Architecture & Patterns #coroutines#dispatchers#testing#dependency-injection

Hardcoding Dispatchers.IO/Default couples your code to real threads, which makes it non-deterministic in tests. Injecting dispatchers makes threading configurable and testable.

The problem with hardcoding:

class Repo(private val api: Api) {
    suspend fun load() = withContext(Dispatchers.IO) { api.fetch() }  // ❌ real IO in tests
}

In tests you can’t control this - runTest’s virtual clock doesn’t govern a real Dispatchers.IO, so timing is unpredictable and tests can be flaky.

The fix - inject the dispatcher:

class Repo(
    private val api: Api,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
    suspend fun load() = withContext(ioDispatcher) { api.fetch() }
}

// Test: pass a TestDispatcher
val repo = Repo(fakeApi, UnconfinedTestDispatcher())

Provide them via DI with qualifiers so the right one is injected everywhere:

@Qualifier annotation class IoDispatcher
@Qualifier annotation class DefaultDispatcher

@Provides @IoDispatcher fun io(): CoroutineDispatcher = Dispatchers.IO

A common pattern is a DispatcherProvider interface (io, default, main) injected into repositories/use cases, with a test implementation returning a single TestDispatcher.

Benefits:

  • Deterministic tests - runTest controls the virtual clock; advanceUntilIdle() works; no flakiness.
  • Flexibility - swap dispatchers per environment without touching logic.
  • Honors structured concurrency - viewModelScope already uses Main; you only switch for blocking/CPU work, and now that switch is testable.
Why was startActivityForResult deprecated, and how does the Activity Result API work?
Mid Android Fundamentals #activity-result#lifecycle

startActivityForResult + onActivityResult had real problems:

  • Scattered logic - you launched in one place and handled the result in a giant onActivityResult when(requestCode), far from the call site.
  • Manual requestCode management - error-prone integer juggling.
  • Process-death unsafe - the callback could be lost; state was hard to preserve.
  • Tight coupling to Activity/Fragment internals.

The Activity Result API replaces it with type-safe, lifecycle-aware contracts:

// Register at construction time (not after STARTED)
private val pickImage = registerForActivityResult(
    ActivityResultContracts.GetContent()
) { uri: Uri? ->
    uri?.let { showImage(it) }      // result handled right here
}

// Launch from anywhere
button.setOnClickListener { pickImage.launch("image/*") }

Benefits:

  • Type-safe contracts - GetContent, TakePicture, RequestPermission, RequestMultiplePermissions, StartActivityForResult, or a custom ActivityResultContract with typed input/output. No requestCodes, no manual Intent parsing.
  • Result handled at the call site - the callback lives next to where you launch.
  • Lifecycle-aware & process-death safe - the registry survives recreation and re-delivers results; you must register before the lifecycle reaches STARTED (i.e. as a field / in onCreate).
  • Decoupled - works the same in Activities, Fragments, and even non-UI components via an ActivityResultRegistry.

Common contracts: runtime permissions (RequestPermission), picking content/photos, taking a picture, and StartIntentSenderForResult.

How do you convert a callback-based API into a Flow? (callbackFlow / channelFlow)
Senior Coroutines & Flow #flow#callbackflow#interop

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Use callbackFlow to bridge a listener/callback API (location updates, Firebase listeners, sensor events) into a cold Flow.

fun locationUpdates(client: FusedLocationProviderClient): Flow<Location> = callbackFlow {
    val callback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult) {
            result.lastLocation?.let { trySend(it) }   // emit into the flow
        }
    }
    client.requestLocationUpdates(request, callback, Looper.getMainLooper())

    // REQUIRED: suspend until the collector cancels, then clean up
    awaitClose { client.removeLocationUpdates(callback) }
}

The essential pieces:

  • trySend(value) (or send) emits from inside the callback. callbackFlow provides a channel, so emission is allowed from other threads/contexts (unlike a plain flow { }).
  • awaitClose { } is mandatory - it keeps the flow alive while the callback is registered and runs your teardown (unregister the listener) when the collector cancels or the flow completes. Forgetting it throws and, worse, leaks the listener.

callbackFlow vs channelFlow: both give you a channel-backed flow you can emit to from multiple contexts. callbackFlow is channelFlow specialized for the callback-bridging pattern (it expects an awaitClose). Use channelFlow when you need concurrent emission from multiple coroutines.

Why not flow { }? A plain flow { } enforces context preservation and can’t emit from a callback on another thread - callbackFlow exists precisely to handle that case safely.

What do stateIn and shareIn do, and why use SharingStarted.WhileSubscribed?
Senior Coroutines & Flow #flow#stateIn#shareIn#sharing

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

stateIn and shareIn convert a cold flow into a hot one so an expensive upstream (a DB query, a network poll) runs once and is shared across collectors, instead of restarting per subscriber.

  • stateIn → produces a StateFlow (has a current value; needs an initial value).
  • shareIn → produces a SharedFlow (configurable replay; no current-value requirement).
val uiState: StateFlow<UiState> = repository.observeData()  // cold, restarts per collector
    .map { it.toUiState() }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = UiState.Loading,
    )

The started strategy controls when the upstream is active:

  • Eagerly - starts immediately, never stops. Wastes work if no one’s listening.
  • Lazily - starts on the first collector, then stays forever.
  • WhileSubscribed(stopTimeoutMillis) - active only while there’s a subscriber, and stops stopTimeout ms after the last one leaves.

Why WhileSubscribed(5000) is the standard choice: on a configuration change the UI briefly unsubscribes and resubscribes. The 5-second grace period keeps the upstream alive across rotation (so you don’t re-query the DB or re-hit the network), but still stops it when the user actually navigates away and backgrounds the app - preventing leaks and wasted work.

What does flowOn do, and why is Flow context preservation important?
Senior Coroutines & Flow #flow#flowOn#context#threading

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

flowOn(dispatcher) changes where the part of a Flow above it runs. The collector and operators below it stay in the collector’s context.

flow { emit(readFromDisk()) }   // runs on IO
    .map { parse(it) }          // runs on IO (upstream of flowOn)
    .flowOn(Dispatchers.IO)
    .map { toUiModel(it) }      // runs on the collector's context
    .collect { render(it) }     // collector's context (e.g. Main)

For example, disk work can run on Dispatchers.IO while collect remains on the main thread to update the UI.

Why not use withContext around emit? A flow builder expects its values to be emitted from one consistent coroutine context. Moving only an emit call to another context breaks that rule. Move the upstream Flow with flowOn instead.

// ❌ throws: "Flow invariant is violated"
flow { withContext(Dispatchers.IO) { emit(load()) } }

// ✅ use flowOn instead
flow { emit(load()) }.flowOn(Dispatchers.IO)

The rule keeps threading predictable. You can read a Flow chain from bottom to top and see which part changes dispatcher.

Key points: multiple flowOns each govern the segment above them; the terminal collect runs in whatever context calls it (often Main), which is exactly what you want for updating UI.

Dalvik vs ART, and what are AOT, JIT, and baseline profiles?
Senior Android Fundamentals #art#dalvik#runtime#performance

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

The short answer is that modern Android uses ART. Dalvik is useful history, but Junior and Mid candidates should focus on why current apps use a mix of JIT, AOT, and profiles.

Dalvik was Android’s original runtime before Android 5. It compiled code as the app ran using JIT, or Just-In-Time compilation.

ART (Android Runtime) replaced it (Android 5+) and has evolved:

  • Android 5–6: full AOT - the entire app was compiled to native code at install time. Fast execution, but slow installs and large storage.
  • Android 7+ (the current hybrid): JIT + AOT + profile-guided compilation. The app runs interpreted/JIT first; ART profiles which methods are hot, and during idle/charging it AOT-compiles just those hot paths. Best of both - fast installs, and frequently-used code gets compiled over time.

The terms:

  • AOT (Ahead-Of-Time) - compile to native before running (install or build time). Fast at runtime, costs install time/space.
  • JIT (Just-In-Time) - compile while running, for hot code. No install cost, but first runs are slower (interpreted).
  • Profile-guided - collect which methods are hot, then AOT-compile those.

Baseline Profiles list important code paths such as startup and scrolling. Shipping that list lets ART compile those paths earlier instead of waiting to learn them from usage. The result can be faster first launches and smoother critical interactions. Macrobenchmark tooling can generate and verify them.

Other ART facts:

  • ART executes DEX (Dalvik Executable) bytecode - Kotlin/Java → .class.dex (via D8) → optimized by R8.
  • It has improved GC over Dalvik (concurrent, less pause).
Design a location-tracking / ride-sharing client (like Uber). What are the client concerns?
Senior Mobile System Design #system-design#location#realtime#battery

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Requirements: track the user’s location, show nearby drivers/the trip in real time, update the server with location, work with the screen off, all while not draining the battery.

Location acquisition:

  • FusedLocationProviderClient (Play Services) - fuses GPS/Wi-Fi/cell for accurate, battery-efficient location. Choose the priority by need: HIGH_ACCURACY during an active trip, BALANCED_POWER while browsing.
  • Tune update interval and smallest displacement - request the least frequency/accuracy that satisfies the use case. This is the central battery vs accuracy trade-off.
  • Geofencing / activity recognition to trigger updates only when relevant (cheaper than constant polling).

Background & foreground:

  • An active trip needs a foreground service with foregroundServiceType="location" and a persistent notification - required for background location and prevents the OS killing it.
  • Background location permission (ACCESS_BACKGROUND_LOCATION) requested separately and justified.

Real-time updates:

  • Driver locations stream to the client via WebSocket while foregrounded; FCM for trip status when backgrounded.
  • The client uploads its location on an interval - batch points and send periodically (not one request per fix) to save radio/battery; queue when offline and flush on reconnect.

Map & rendering:

  • Maps SDK with marker clustering for many drivers; interpolate/animate marker movement between updates for smoothness (don’t snap); draw the route polyline.
  • Throttle UI updates to avoid jank.

Offline & resilience:

  • Cache the last known location and trip state in Room; degrade gracefully when GPS is weak (show “searching…”).
  • Handle permission revocation, location-off, and mock-location detection.

Trade-offs to name: accuracy/frequency vs battery (the big one - HIGH_ACCURACY + 1s updates kills the battery), batching uploads (efficiency vs freshness), foreground service (survivability + permission cost vs a persistent notification), marker interpolation (smoothness vs CPU).

Design a music streaming client (like Spotify) with offline support.
Senior Mobile System Design #system-design#audio#streaming#offline#media

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Requirements: stream audio with instant playback, gapless transitions, prefetch the next track, background playback, offline downloads, lock-screen controls.

Playback engine:

  • ExoPlayer (Media3) for streaming + buffering + format support.
  • MediaSessionService (Media3) so playback runs as a foreground service that survives backgrounding, with MediaSession for lock-screen/notification/Bluetooth/Android Auto controls.
  • Gapless playback - preload and pre-buffer the next track so transitions are seamless.

Streaming & buffering:

  • Adaptive bitrate by network (lower quality on cellular, higher on Wi-Fi; user-selectable).
  • Buffer ahead a few seconds; start fast at modest quality.
  • Prefetch the next song in a playlist based on the queue (predictive loading).

Offline downloads (the key feature):

  • Download tracks (chosen quality) to app-private storage, encrypted; store metadata + download status in Room.
  • WorkManager for download jobs (Wi-Fi/charging constraints, resume via Range, retry).
  • DRM/license management with expiry (offline tracks need periodic online check-in).
  • The player checks local-first: play the downloaded file if present, else stream.

Data layer:

  • Room as source of truth for library, playlists, queue, download state → works offline.
  • Sync playlists/library across devices (delta sync); reconcile “liked”/queue changes made offline.

UX & system integration:

  • Lock-screen + notification controls, headset button handling, audio focus (pause on call/other audio), Bluetooth/Android Auto.
  • Crossfade, queue management, resume where you left off (persist position).

Other concerns: caching recently played for instant replay, battery (efficient codec, screen-off playback), analytics (play/skip/completion), scrobbling offline events to sync later.

Trade-offs to name: prefetch/buffer (instant playback & gapless vs data/battery), download quality (size vs fidelity), cache size (instant replay vs storage), Wi-Fi-only downloads (cost vs availability).

Design a resumable file upload/download manager.
Senior Mobile System Design #system-design#upload#workmanager#networking

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

This tests reliability under flaky networks: large transfers, resume after interruption, progress, and background continuation.

Requirements: upload/download large files, survive app kill & network drops, resume (not restart), show progress, retry, respect Wi-Fi/metered preferences.

Resumable transfers - the core:

  • Chunked / multipart upload - split the file into chunks (e.g. 5–10MB); upload sequentially or with bounded concurrency. Track which chunks succeeded.
  • Resumable protocol - use the server’s resumable upload API (e.g. tus, Google Resumable Uploads, or S3 multipart). The client asks “how much did you receive?” and continues from there with Content-Range.
  • Downloads - use HTTP Range requests (Range: bytes=1024-) to resume from the last byte written to disk.
  • Persist transfer state (file id, upload URL/session, bytes transferred, chunk status) in Room so it survives process death.

Background execution & reliability:

  • WorkManager with constraints (NetworkType.UNMETERED for “Wi-Fi only”, requiresCharging) - guaranteed, survives app death and reboot, retries with exponential backoff.
  • A foreground service (or setForeground expedited work) for large active transfers so the OS doesn’t kill them and the user sees progress.
  • Queue + dedup; cap concurrency to avoid saturating the radio.

Progress & UX:

  • Emit progress via WorkManager setProgress / a Flow → notification + in-app UI.
  • Optimistic UI - show the item as “uploading”; mark complete/failed on result.
  • Pause/resume/cancel controls; retry failed.

Other concerns:

  • Integrity - checksum (MD5/SHA) per chunk and whole file to detect corruption.
  • Battery/data - defer to Wi-Fi/charging when possible; respect Data Saver.
  • Failure handling - distinguish transient (retry) vs permanent (auth, file gone) errors; expire stale sessions.
  • Security - signed upload URLs, auth headers, HTTPS.

Trade-offs to name: chunk size (more chunks = more resumable granularity but more overhead), concurrency (speed vs radio/battery), Wi-Fi-only (reliability/cost vs immediacy), foreground service (survivability vs a persistent notification).

Design a video streaming client (like YouTube/Netflix). What are the key client decisions?
Senior Mobile System Design #system-design#video#streaming#media

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

The client cares about smooth playback under variable networks, not transcoding (that’s backend).

Requirements: play video, minimal buffering, adapt to changing bandwidth, scrubbing, prefetch, maybe offline downloads.

Adaptive Bitrate Streaming (ABR) - the core concept:

  • Video is encoded server-side at multiple bitrates/resolutions, split into small segments (2–10s), described by a manifest (HLS .m3u8 or DASH .mpd).
  • The client measures available bandwidth and buffer level, then picks the segment quality for the next chunk - stepping down on a slow network to avoid stalls, up when bandwidth allows.
  • Use ExoPlayer (Media3), which implements ABR, buffering, and HLS/DASH out of the box - don’t reinvent it.

Buffering strategy:

  • Maintain a buffer ahead (e.g. 10–30s). Start playback once enough is buffered (fast start = lower initial quality, then ramp up).
  • Balance buffer size: bigger = fewer stalls but more wasted data if the user abandons; smaller = less waste but more rebuffer risk.

Performance & UX:

  • Prefetch the first segments of likely-next videos (autoplay/next-in-list).
  • Preload manifest + first segment on hover/focus for instant start.
  • Scrubbing - request the segment at the seek position (and thumbnails track for the seek bar).
  • Hardware decoding (MediaCodec) for efficiency/battery; SurfaceView for rendering.

Offline downloads: download selected quality segments to disk (ExoPlayer DownloadManager), DRM license handling, expiry; resume via Range.

Other concerns: DRM (Widevine) for protected content, CDN selection, analytics (startup time, rebuffer ratio, bitrate - key quality metrics), battery/data (Wi-Fi-only downloads, data-saver capping resolution).

Trade-offs to name: buffer size (smoothness vs wasted data), aggressive quality (sharpness vs rebuffering), prefetch (instant start vs data/battery), startup quality (fast start vs initial blurriness).

Design an analytics / event tracking pipeline for a mobile app.
Senior Mobile System Design #system-design#analytics#batching#workmanager

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Requirements: track user events reliably, don’t drop events (even offline / on crash), minimal battery/data/perf impact, no jank from logging.

The core principle: never send one network request per event. That would hammer the radio (battery), waste data, and add latency. Instead persist then batch.

Pipeline:

track(event) → enqueue to local DB → batch → upload → clear sent
  1. Capture - track(event) is fire-and-forget and fast (no main-thread work, no network). It just writes the event to a local queue.
  2. Persist - store events in Room (or a file) so they survive process death and crashes - critical for not losing data and for capturing crash-adjacent events.
  3. Batch & flush - upload events in batches when:
    • the batch reaches a size threshold (e.g. 50 events), or
    • a time interval elapses, or
    • the app goes to background, or
    • connectivity returns. Use WorkManager (network constraint, backoff) so flushes are guaranteed and battery-friendly.
  4. Acknowledge & clear - on successful upload, delete sent events. Use a batch id / idempotency so a retried upload doesn’t duplicate (server dedups).

Reliability details:

  • Offline - events accumulate locally and flush on reconnect.
  • At-least-once delivery with server-side dedup (event UUIDs) - simpler and safer than exactly-once.
  • Bounded queue - cap size / drop oldest low-priority events if the queue grows unbounded (offline for days).
  • Crash safety - because events are persisted immediately, a crash doesn’t lose the trail; flush on next launch.

Other concerns: enrich events with common context (session, app version, device) once; sampling for high-volume events; privacy/consent (don’t log PII; respect opt-out); schema/versioning of event payloads; compression of batches.

Trade-offs to name: batch size/interval (freshness of analytics vs battery/data), at-least-once + dedup (simplicity vs duplicate handling), queue cap (completeness vs storage), sampling (volume/cost vs fidelity).

Design an e-commerce checkout / payment flow.
Senior Mobile System Design #system-design#payments#reliability#security

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Checkout is about correctness, reliability, and security - you must never double-charge or lose an order.

Requirements: cart → address → payment → confirmation; handle network failures without double-charging; secure payment data; show accurate state.

The cardinal rule - idempotency:

  • Generate an idempotency key per checkout attempt (client UUID). Send it with the “place order” request.
  • If the response is lost (network drop after the server charged), the client retries with the same key; the server recognizes it and returns the existing order instead of charging again. This single mechanism prevents the classic double-charge.

State machine for the order:

CART → PLACING_ORDER → (PAYMENT_PENDING) → CONFIRMED | FAILED
  • Persist the in-progress order locally so a crash/kill mid-checkout can resume or reconcile.
  • On uncertain outcome (timeout), poll order status rather than re-submitting blindly.

Payment security:

  • Never handle raw card data - use a PCI-compliant SDK (Stripe, Braintree, Google Pay). The card is tokenized by the provider; your app/backend only sees a token, keeping you out of PCI scope.
  • Google Pay / payment sheets for a native, secure UX.
  • HTTPS + cert pinning; no card data in logs/local storage.

Reliability & UX:

  • Disable the pay button after tap and show progress to prevent duplicate taps (belt-and-suspenders with idempotency).
  • Optimistic but careful - don’t show “confirmed” until the server confirms; show “processing” for pending.
  • Validate inventory/price server-side at order time (client prices can be stale/tampered).
  • Handle 3-D Secure / OTP redirects and async payment methods (UPI, wallets) via status polling/webhook-driven push.

Other concerns: cart persistence across devices (synced), address validation, retry on transient failures (idempotent), clear error messaging (declined vs network), analytics on funnel drop-off.

Trade-offs to name: optimistic confirmation vs waiting for server (UX vs correctness - here correctness wins), polling vs push for async payment status, how long to retain in-progress order state.

Design an infinite, image-heavy feed (like Instagram). What are the key client-side decisions?
Senior Mobile System Design #system-design#pagination#caching#offline

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Drive the discussion through the layers; the interviewer wants trade-offs, not a backend diagram.

1. Data flow & pagination

  • Use cursor-based pagination, not offset - stable as new items are inserted at the top.
  • Make the database the single source of truth. The Paging 3 library + a RemoteMediator writes pages into Room; the UI only ever reads from Room. This gives you offline reads and consistent scroll position for free.

2. Caching

  • Disk cache (Room) for feed metadata, separate image cache (Coil/Glide handle memory + disk LRU) for bitmaps.
  • Define a freshness/invalidation policy: cache-then-network, with pull-to-refresh forcing a revalidation.

3. Images - usually the real bottleneck

  • Request server-resized variants per device density; never download full-res for a thumbnail.
  • Prefetch a few items ahead based on scroll velocity; cancel requests for items scrolled off-screen.
  • Decode to the target size to avoid OOM; downsample large images.

4. Networking

  • Coalesce/limit concurrent requests, retry with backoff, dedupe in-flight calls.

5. Scroll performance

  • Stable item keys, fixed/known item sizes where possible, avoid heavy work in the bind/compose path, and watch for jank with the recomposition counter or systrace.

6. Offline & resilience

  • Because Room is the source of truth, the feed renders offline. Queue writes (likes, comments) and reconcile when back online.

Call out the trade-offs explicitly: memory vs. smoothness (prefetch distance), freshness vs. data usage (cache TTL), and consistency vs. latency (optimistic UI for likes). Naming the tension is what separates a senior answer from a feature list.

Design an Instagram/WhatsApp Stories feature.
Senior Mobile System Design #system-design#media#prefetch#ui

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Stories test media prefetching, smooth transitions, and ephemeral state.

Requirements: horizontal tray of users with stories; tap to view full-screen; auto-advance through a user’s segments; swipe to next user; images + videos; seen/unseen state; expire after 24h.

Data model:

stories(userId, segments[], expiresAt)
segment(id, type=IMAGE|VIDEO, url, duration, seenAt?)
  • Room caches the tray + seen state (works offline, instant tray render).
  • Seen state persisted locally and synced to the server.

The make-or-break: prefetching for instant playback.

  • When the tray loads, prefetch the first segment of the first few users’ stories.
  • While viewing user N, prefetch user N+1’s first segment (and the next segment of the current user). Viewers expect zero load time on tap/advance.
  • Use the image library (Coil) for image prefetch and ExoPlayer preloading for video; cap concurrency and cancel prefetch for users scrolled away.

Playback & UX:

  • Full-screen pager (HorizontalPager) of users; within a user, a segment progress indicator that auto-advances on a timer (images) or on video completion.
  • Gestures: tap right/left = next/prev segment, long-press = pause, swipe down = dismiss, swipe horizontal = next user.
  • Preload the next segment’s media before the current finishes so transitions are seamless.

Media handling:

  • Images: downsample to screen size; videos: ExoPlayer with a small buffer (segments are short), hardware decode.
  • Show a subtle loading state only if prefetch missed.

Lifecycle & ephemerality:

  • Pause on background (repeatOnLifecycle); resume position.
  • Expire stories after 24h - clean up cache; don’t show expired.
  • Upload own story via resumable/chunked upload with optimistic “posting” state.

Trade-offs to name: prefetch depth (instant UX vs data/battery/memory - prefetching everyone’s stories wastes data), buffer size for video, cache retention vs storage, eager vs lazy seen-sync.

Design an offline-first notes app with sync across devices.
Senior Mobile System Design #system-design#offline#sync#conflict-resolution

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

This problem is really about sync and conflict resolution - the interviewer will push hard there.

Requirements: create/edit/delete notes offline, sync across devices, handle conflicts (edited on two devices), eventual consistency.

Local model (source of truth = local DB):

notes(id, content, updatedAt, version, syncStatus, isDeleted)
syncStatus: SYNCED | PENDING | CONFLICT
  • Client-generated IDs (UUIDs) so notes can be created offline without a server round-trip.
  • Soft delete (isDeleted) so deletions propagate (you can’t sync the absence of a row reliably).

Sync engine:

  • Delta sync - the client stores a sync token / last-sync timestamp; it pulls only changes since then and pushes its local PENDING changes. Avoids re-downloading everything.
  • Triggered on app open, on a timer, on connectivity regained (WorkManager with a network constraint), and optionally on a push (“you have changes”).
  • Optimistic UI - edits apply locally immediately (PENDING), sync in the background.

Conflict resolution (the heart of it):

  • Last-Write-Wins (LWW) - simplest: compare updatedAt/version, newest wins. Risks silent data loss.
  • Version vectors / version counter - detect that both sides changed since the common ancestor → a real conflict.
  • Field-level / 3-way merge - merge non-overlapping changes; only truly conflicting fields need resolution.
  • CRDTs - for collaborative/concurrent editing (e.g. text), conflict-free automatic merging - mention for real-time collab, but it’s heavier.
  • User-prompted - surface “keep both / pick one” when automatic merge is unsafe.

State your choice and why: “For simple notes, LWW with a version check and a ‘conflict copy’ fallback; for collaborative editing, CRDTs.”

Other concerns: idempotent sync (replaying a push is safe), tombstones with cleanup, partial sync failure handling (per-note status), and encryption at rest if sensitive.

Trade-offs to name: LWW simplicity vs data-loss risk; delta sync efficiency vs complexity; how aggressively to sync (battery/data) vs freshness.

Design the image loading and caching pipeline for an image-heavy app.
Senior Mobile System Design #system-design#images#caching#performance

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Images dominate memory and bandwidth in feed/gallery apps, so the pipeline is a frequent deep-dive. In practice you’d use Coil (Compose) or Glide - and explaining what they do is the answer.

The pipeline stages:

request → memory cache → disk cache → network → decode/downsample → display

Caching (multi-level):

  • Memory cache - LruCache of decoded bitmaps keyed by URL+size. Instant re-display; bounded by a fraction of app memory.
  • Disk cache - encoded bytes on disk (survives process death), LRU-evicted; OkHttp can also cache the HTTP response.
  • Check memory → disk → network in order; only hit the network on a miss.

Decoding & memory safety (critical):

  • Downsample to the target view/composable size - never decode a 4000×3000 image for a 100dp thumbnail (that’s ~48MB). inSampleSize/Coil’s size resolution.
  • Choose bitmap config (RGB_565 when alpha isn’t needed halves memory; hardware bitmaps keep pixels off-heap).
  • Decode off the main thread (coroutines) to avoid jank.
  • Bitmap pooling/reuse (Glide) to cut GC churn.

Scrolling performance (lists):

  • Cancel in-flight requests for items recycled/scrolled off - otherwise you waste bandwidth and may bind the wrong image.
  • Prefetch a few items ahead based on scroll direction/velocity.
  • Stable keys so the right image binds to the right item; placeholder + crossfade.

Network/quality:

  • Request server-resized variants per density/size (don’t download full-res for thumbnails).
  • Use the right format (WebP/AVIF) and Cache-Control.
  • Progressive/blur-up placeholders for perceived speed.

Other: respect Data Saver (lower quality on cellular), bound caches to storage, clear on logout if private.

Trade-offs to name: memory cache size (instant re-display vs OOM risk), prefetch distance (smoothness vs data/battery/memory), quality/resolution (sharpness vs bandwidth), downsampling (memory vs detail).

How do generic type constraints work in Kotlin?
Senior Kotlin Language #kotlin#generics#bounds

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

A type bound restricts what a type parameter can be. The default upper bound is Any? (anything, including null).

// Single upper bound: T must be Comparable<T>
fun <T : Comparable<T>> max(a: T, b: T): T = if (a > b) a else b

// Non-null bound - T can't be nullable
fun <T : Any> requireValue(x: T?): T = x ?: error("null")

For multiple bounds, use a where clause:

fun <T> copyWhenReady(source: T, dest: T)
    where T : CharSequence,
          T : Appendable {
    // T is guaranteed to be both CharSequence and Appendable
}

Points interviewers check:

  • An unbounded <T> defaults to T : Any?, so T may be nullable - bound it with : Any if you need non-null.
  • Bounds are how you call methods on a generic type: max above can use > only because T : Comparable<T>.
  • Combine with variance: class Box<out T : Number> is a covariant box constrained to numbers.
  • Don’t confuse a bound (T : Number, constrains the type) with variance (out T, constrains assignability).

Practical use: generic repositories/adapters (<T : Entity>), or a Compose AnimateAsState-style helper bounded to types it can interpolate.

How do you architect a Kotlin Multiplatform (KMP) app? What's shared and what isn't?
Senior Architecture & Patterns #kmp#multiplatform#architecture

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Kotlin Multiplatform lets you share business logic across Android, iOS (and more) while keeping UI native (or shared via Compose Multiplatform).

What’s typically shared (commonMain):

  • Data layer - repositories, networking (Ktor), local storage (SQLDelight / Room KMP), DTOs, mappers.
  • Domain layer - use cases, business rules, domain models.
  • Presentation logic - ViewModels/state holders (with libraries like Decompose, Voyager, or KMP-ViewModel) and StateFlow-based state.
  • Shared coroutines/Flow code, serialization (kotlinx.serialization).

What stays platform-specific:

  • UI - Jetpack Compose on Android, SwiftUI on iOS (or Compose Multiplatform to share UI too).
  • Platform APIs - camera, sensors, permissions, push, secure storage - accessed via the expect/actual mechanism.
// commonMain
expect class PlatformContext
expect fun httpClientEngine(): HttpClientEngine

// androidMain / iosMain provide the `actual` implementations

Key architecture decisions interviewers probe:

  • expect/actual for platform differences - declare the contract in common, implement per platform.
  • Source sets - commonMain, androidMain, iosMain; common code can’t touch Android/iOS APIs directly.
  • DI - Koin is popular for KMP (Hilt is Android-only); or constructor DI in common code.
  • How much to share - sharing the data + domain + presentation layers maximizes reuse with the least friction; sharing UI (Compose Multiplatform) is increasingly viable but more involved on iOS.
  • iOS interop - shared code is exposed to Swift via an Obj-C/Swift framework; suspend/Flow need bridging (SKIE, callbacks) for ergonomic Swift consumption.

Trade-offs: shared logic and consistency vs. tooling maturity, iOS interop friction, and a steeper build setup. Sweet spot for many teams: share logic, keep UI native.

How do you build a custom layout in Compose? Explain the measure/place model.
Senior Jetpack Compose #compose#layout#custom-layout

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Use the Layout composable (or a Modifier.layout). Compose’s layout protocol has one rule: measure children once, then place them. Constraints flow down, sizes flow up.

@Composable
fun SimpleColumn(content: @Composable () -> Unit, modifier: Modifier = Modifier) {
    Layout(content = content, modifier = modifier) { measurables, constraints ->
        // 1. Measure each child with constraints
        val placeables = measurables.map { it.measure(constraints) }

        // 2. Decide our own size
        val width = placeables.maxOf { it.width }
        val height = placeables.sumOf { it.height }

        // 3. Place children
        layout(width, height) {
            var y = 0
            placeables.forEach { p ->
                p.placeRelative(x = 0, y = y)
                y += p.height
            }
        }
    }
}

The three steps:

  1. Measure - call measurable.measure(constraints) on each child exactly once (measuring twice throws). You may tighten/loosen the constraints you pass down.
  2. Size yourself - call layout(width, height) based on children’s measured sizes.
  3. Place - inside the layout {} block, position each Placeable with placeRelative/place.

Key concepts interviewers probe:

  • Constraints = min/max width and height passed top-down. A child must size within them.
  • Single-pass measurement - Compose layout is single-pass for performance (no double measure like some View layouts), which is why measuring a child twice is an error.
  • SubcomposeLayout - for the rare case where you must measure something before composing its children (e.g. BoxWithConstraints, lazy lists). It’s more expensive; avoid unless needed.
  • Intrinsic measurements (Modifier.height(IntrinsicSize.Min)) let a parent query a child’s natural size when one-pass isn’t enough.
How do you build a custom View, and what do you need to handle?
Senior Android Fundamentals #views#custom-view

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Extend View (fully custom drawing) or an existing widget/ViewGroup (compose existing ones). A typical fully-custom view overrides three things plus constructors.

class RatingView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyle: Int = 0,
) : View(context, attrs, defStyle) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)   // allocate ONCE, not in onDraw

    init {
        // Read custom XML attributes
        context.obtainStyledAttributes(attrs, R.styleable.RatingView).use { a ->
            paint.color = a.getColor(R.styleable.RatingView_starColor, Color.YELLOW)
        }
    }

    override fun onMeasure(wSpec: Int, hSpec: Int) {
        // Resolve desired size honoring the MeasureSpec
        val size = resolveSize(desiredSize, wSpec)
        setMeasuredDimension(size, size)
    }

    override fun onDraw(canvas: Canvas) {
        canvas.drawCircle(width / 2f, height / 2f, radius, paint)
    }
}

What you must handle:

  • Constructors / @JvmOverloads - XML inflation calls the (Context, AttributeSet) constructor; missing it crashes on inflate.
  • Custom attributes - declare <declare-styleable> in attrs.xml, read via obtainStyledAttributes (and recycle it).
  • onMeasure - respect the parent’s MeasureSpec (EXACTLY/AT_MOST/UNSPECIFIED); use resolveSize. A ViewGroup also needs onLayout to place children.
  • onDraw - render with Canvas; never allocate (Paint/Path/objects) here - it runs every frame.
  • State saving - override onSaveInstanceState/onRestoreInstanceState for view state that should survive recreation.
  • Touch - onTouchEvent / gesture detectors; call invalidate() to redraw, requestLayout() if size changed.
  • Accessibility - set content descriptions / AccessibilityNodeInfo for custom controls.

What to remember:

  • Allocate paints/objects once; allocating in onDraw/onMeasure causes jank and GC churn.
  • invalidate() for redraw vs requestLayout() for size changes.
  • Prefer composing existing views or Compose over a fully custom onDraw unless you genuinely need custom rendering.
How do you choose between polling, long-polling, SSE, WebSocket, and FCM for real-time updates?
Senior Mobile System Design #system-design#realtime#websocket#fcm

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Each mechanism trades latency, battery, and complexity differently.

Short polling - client requests every N seconds.

  • ✅ Simple, stateless, works everywhere.
  • ❌ Wasteful (most polls return nothing), latency = poll interval, battery/data cost.
  • Use: low-frequency, non-urgent updates (refresh a dashboard every 30s).

Long polling - request stays open until the server has data, then the client immediately re-requests.

  • ✅ Near-real-time without persistent connections; firewall-friendly.
  • ❌ Connection churn, server holds many open requests.
  • Use: a fallback when WebSockets aren’t available.

SSE (Server-Sent Events) - a one-way server→client stream over HTTP.

  • ✅ Simple, auto-reconnect, good for server-push-only feeds (live scores, notifications).
  • ❌ One-directional; client→server still needs separate requests.

WebSocket - full-duplex persistent connection.

  • Lowest latency, bidirectional - ideal for chat, live collaboration, multiplayer.
  • ❌ Battery drain (keeps a socket alive), reconnection/backoff logic, can’t run in the background on Android - the OS kills it.
  • Use: foreground real-time interactivity.

FCM (push) - server sends a push via Google’s infrastructure.

  • ✅ Works when the app is backgrounded/killed; battery-efficient (one OS-level channel); the only way to wake a sleeping app.
  • ❌ Not guaranteed instant or ordered; payload-size limited; best as a signal (“new data, come fetch”), not the data transport.
  • Use: notifications, waking the app to sync.

A practical mobile approach: combine them by app state. Use a WebSocket while foregrounded for instant bidirectional updates, and FCM when backgrounded to wake/notify (since you can’t keep a socket open in the background). Plus reconnect-with-backoff and a sync-on-reconnect to fill gaps.

Decision factors: update frequency, latency requirement, direction (one-way vs two-way), foreground vs background, battery/data budget, and server complexity.

How do you choose the right dependency injection scope?
Senior Architecture & Patterns #dependency-injection#hilt#scoping

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

A scope controls how long a single instance lives and how widely it’s shared. With Hilt:

ScopeOne instance perUse for
@SingletonapplicationDB, Retrofit, OkHttp, app-wide repos
@ActivityRetainedScopedsurvives config changeshared across an Activity + its ViewModels
@ViewModelScopeda ViewModeluse cases/helpers tied to one screen’s VM
@ActivityScopedan ActivityActivity-bound helpers
@FragmentScopeda Fragmentfragment-bound helpers
(unscoped)every injectionstateless, cheap objects

Matching scope to lifetime is the whole game:

Over-scoping (e.g. @Singleton on everything):

  • Memory leaks / bloat - objects live forever even when only needed briefly.
  • Stale state - a singleton holding screen-specific or user-specific state persists across screens/logins when it shouldn’t (e.g. caching the wrong user’s data after re-login).
  • Hidden coupling and harder reasoning about lifecycle.

Under-scoping (unscoped where you needed sharing):

  • Multiple instances when you expected one - e.g. two ViewModels each get a different “session cache,” so they don’t share data.
  • Wasted work - recreating expensive objects (an OkHttp client) on every injection.

Guidance:

  • Expensive, stateless, app-wide (network/DB clients) → @Singleton.
  • Stateless, cheap → leave unscoped (a new instance is fine and avoids retention).
  • State tied to a lifecycle → scope to that lifecycle (@ViewModelScoped, @ActivityRetainedScoped).
  • User/session state → a custom scope or a singleton you explicitly clear on logout (otherwise it leaks the previous session).
How do you handle errors in a Flow?
Senior Coroutines & Flow #flow#error-handling#catch

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Handle flow errors with the catch operator, not a try/catch wrapped around the chain - and never try/catch inside the flow { } builder around emit.

repository.observe()
    .map { transform(it) }
    .catch { e -> emit(fallbackValue) }   // catches upstream errors
    .onEach { render(it) }
    .launchIn(viewModelScope)

Exception transparency is the design principle behind this: a flow must never catch exceptions from its downstream (the collector). A catch operator only handles exceptions from operators above it - emissions, map, the builder. An exception thrown in collect (downstream) is not caught by an upstream catch.

flow { emit(1) }
    .catch { /* will NOT catch the error below - it's downstream */ }
    .collect { throw RuntimeException() }   // propagates to the collector's scope

Why this rule exists: it keeps error handling local and predictable. An operator can only deal with failures of the work it declares above it; the collector’s own bugs surface where the collector runs.

Practical toolkit:

  • catch - recover from upstream errors (emit a fallback, log, map to an error state).
  • retry(n) / retryWhen - re-subscribe to the upstream on failure (great for flaky network flows, often with exponential backoff).
  • onCompletion { cause -> } - runs on success and failure (cause is non-null on error) - use for cleanup, not recovery.
  • For the collector’s errors, use a normal try/catch around collect, or handle them in the coroutine’s scope.

Anti-pattern: wrapping emit() in a try/catch inside flow { } - it can swallow CancellationException and breaks transparency. Use the catch operator instead.

How do you handle navigation between feature modules without coupling them?
Senior Architecture & Patterns #modularization#navigation#decoupling

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

The problem: in a multi-module app, :feature:checkout shouldn’t directly depend on :feature:profile - that creates tight coupling and dependency cycles. But they sometimes need to navigate to each other.

Solutions (least to most decoupled):

1. Route-based navigation (Navigation component). Features expose routes/deep links (strings or type-safe), and navigation goes through a shared NavController. A feature navigates by route without importing the destination feature’s classes.

navController.navigate("profile/$userId")   // no compile dep on :feature:profile

The :app module assembles all feature nav graphs. Features depend on a tiny :core:navigation contract (route constants/keys), not on each other.

2. Navigation abstraction / API modules. Define an interface in a shared module:

// :core:navigation
interface ProfileNavigator { fun openProfile(id: String) }

The :feature:profile module implements it; other features inject ProfileNavigator and call it. Implementation is wired by DI in :app. This keeps features depending on abstractions, not each other.

3. api vs impl module split. A feature exposes a small :feature:profile:api (interfaces, navigation entry points) that others depend on, while :feature:profile:impl stays private. Maximum decoupling for large codebases.

Key principles:

  • Features depend on core/abstractions, never on each other - avoids cycles and keeps build parallelism.
  • The :app module is the composition root - it knows all features and wires the graph/DI.
  • Deep links double as the inter-feature navigation contract.
  • Type-safe routes (Navigation 2.8+) reduce stringly-typed errors.
How do you handle request deduplication, coalescing, and client-side rate limiting?
Senior Mobile System Design #system-design#networking#deduplication#performance

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

All three ideas answer one simple question: how do we avoid doing the same network work too often? They save battery and data while also protecting the server.

Deduplication or coalescing: if several callers request the same resource at the same time, make one network call and share its result.

// Coalesce identical in-flight requests
private val inFlight = mutableMapOf<String, Deferred<User>>()

suspend fun getUser(id: String): User = coroutineScope {
    inFlight.getOrPut(id) {
        async { api.getUser(id) }.also { it.invokeOnCompletion { inFlight.remove(id) } }
    }.await()
}
  • Common when several composables/observers request the same resource at once (e.g. a feed refresh triggered from two places).
  • A StateFlow with shareIn/stateIn(WhileSubscribed) naturally coalesces collectors onto one upstream.

Caching: keep a recent result for a short time so repeated reads do not need another request. TTL means “time to live,” or how long that result is considered fresh.

Client-side rate limiting / throttling:

  • Debounce rapid user-triggered requests (search, button mashing).
  • Throttle high-frequency events (scroll-triggered loads) to a max rate.
  • Coalesce writes - batch rapid updates (e.g. analytics, “mark as read”) into one request.
  • Cap concurrency (a bounded dispatcher / Semaphore / OkHttp dispatcher maxRequests) so you don’t open 50 sockets at once.

Respect server rate limits:

  • Honor 429 Too Many Requests + Retry-After; back off rather than retry-storm.
  • A circuit breaker when the backend is failing.

Cancellation - cancel obsolete requests (screen left, query changed via flatMapLatest) so you don’t waste a response no one needs.

Why it matters on mobile: every redundant request costs battery (radio), data, and server load, and can trigger rate limits. Dedup + coalescing + caching collapse N requests into 1.

Trade-offs to name: dedup window/cache TTL (freshness vs request savings), throttle/debounce timing (responsiveness vs request volume), concurrency cap (throughput vs resource use), aggressive coalescing (efficiency vs slight staleness).

How do you protect shared mutable state in coroutines? Mutex vs synchronized.
Senior Coroutines & Flow #coroutines#concurrency#mutex

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Coroutines run concurrently, so shared mutable state still needs protection - but you should avoid blocking locks.

Don’t use synchronized / ReentrantLock around suspending code: they block the thread, defeating coroutines, and you can’t suspend while holding them.

Options, best-first:

1. Avoid shared state. The cleanest fix - confine state to a single coroutine, or use immutable data + StateFlow.update { } (atomic, lock-free):

_state.update { it.copy(count = it.count + 1) }   // atomic compare-and-set

2. Mutex - a coroutine-aware lock that suspends instead of blocking:

val mutex = Mutex()
suspend fun increment() = mutex.withLock { counter++ }

3. Confine to a single-threaded dispatcher - withContext(singleThreadDispatcher) or Dispatchers.Default.limitedParallelism(1) serializes access without a lock.

4. Atomics (AtomicInteger, atomicfu) for simple counters.

What to remember:

  • Mutex.withLock is the coroutine equivalent of synchronized, but it suspends - no thread blocked.
  • Mutex is not reentrant (locking it twice in the same coroutine deadlocks), unlike synchronized.
  • For UI state, prefer StateFlow.update {} over any lock - it’s atomic and idiomatic.
How do you test coroutines and flows?
Senior Testing & Quality #coroutines#testing#runTest

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

The toolkit from kotlinx-coroutines-test:

runTest { } - the entry point for coroutine tests. It runs on a virtual clock, so delay(10_000) completes instantly (time is skipped, not waited). It also auto-waits for child coroutines.

@Test
fun loadsData() = runTest {
    val vm = MyViewModel(fakeRepo)
    vm.load()
    advanceUntilIdle()                 // run all pending coroutines
    assertEquals(Expected, vm.state.value)
}

Test dispatchers:

  • StandardTestDispatcher - coroutines are queued, not run eagerly; you drive them with advanceUntilIdle() / advanceTimeBy(). Good for controlling ordering.
  • UnconfinedTestDispatcher - runs coroutines eagerly to their first suspension. Simpler when you don’t care about precise scheduling.

Injecting the dispatcher is the key design point: don’t hardcode Dispatchers.IO - inject a dispatcher so tests can swap in a test one.

class Repo(private val io: CoroutineDispatcher = Dispatchers.IO) {
    suspend fun load() = withContext(io) { /* ... */ }
}

Replacing Dispatchers.Main (for viewModelScope): in setup call Dispatchers.setMain(testDispatcher), and Dispatchers.resetMain() in teardown.

Testing flows - collect manually, or use Turbine for ergonomic assertions:

viewModel.state.test {           // Turbine
    assertEquals(Loading, awaitItem())
    assertEquals(Loaded(data), awaitItem())
    cancelAndIgnoreRemainingEvents()
}
How do you write a custom Modifier, and why prefer Modifier.Node over composed { }?
Senior Jetpack Compose #compose#modifier#performance

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

A simple custom modifier is just a chaining extension that combines existing modifiers:

fun Modifier.card() = this
    .clip(RoundedCornerShape(12.dp))
    .background(MaterialTheme.colorScheme.surface)
    .padding(16.dp)

For modifiers that need state or to participate in layout/draw, there are two approaches:

Modifier.composed { } (legacy) - lets you call composable functions (like remember) inside a modifier. The problem: it’s a factory that recomposes, doesn’t get inlined/optimized well, allocates per use, and can hurt performance.

Modifier.Node (modern, recommended) - a lower-level API where you implement a Modifier.Node and a ModifierNodeElement. It’s more efficient: nodes are long-lived, not recreated on recomposition, can directly implement DrawModifierNode, LayoutModifierNode, PointerInputModifierNode, etc., and avoid the composition overhead of composed.

fun Modifier.circleBorder(color: Color) = this then CircleBorderElement(color)

private data class CircleBorderElement(val color: Color) :
    ModifierNodeElement<CircleBorderNode>() {
    override fun create() = CircleBorderNode(color)
    override fun update(node: CircleBorderNode) { node.color = color }
}

private class CircleBorderNode(var color: Color) : DrawModifierNode, Modifier.Node() {
    override fun ContentDrawScope.draw() {
        drawContent()
        drawCircle(color, style = Stroke(2.dp.toPx()))
    }
}

Why this matters: Google migrated all built-in modifiers off composed to Modifier.Node for performance. Knowing to prefer Modifier.Node (and that composed { } is discouraged for stateful/drawing modifiers) signals you understand Compose performance at a deeper level.

Rule of thumb: plain chaining for stateless combos; Modifier.Node for anything stateful, drawing, or layout-affecting; avoid composed { } in new code.

How does a touch event move through the Android View system?
Senior Android Fundamentals #views#touch#events

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

A touch event (MotionEvent) travels down the view tree from the root and can be consumed or passed back up. Three methods govern it:

  • dispatchTouchEvent - every View/ViewGroup has it; it routes the event. The tree traversal starts here.
  • onInterceptTouchEvent (ViewGroup only) - a parent can intercept an event before it reaches a child. Return true to steal it (e.g. a scroll container deciding a drag is a scroll, not a child tap).
  • onTouchEvent - where a view actually handles the event. Return true to consume it (and receive subsequent events in the gesture).

The flow for a gesture (starting with ACTION_DOWN):

  1. Root dispatchTouchEvent → ViewGroup onInterceptTouchEvent.
  2. If the parent doesn’t intercept, it dispatches to the child under the finger; this recurses down.
  3. The deepest view’s onTouchEvent runs first. If it returns true (consumes), it becomes the target for the rest of the gesture (MOVE/UP).
  4. If a view returns false, the event bubbles up to its parent’s onTouchEvent.
  5. Crucial rule: if no view consumes the ACTION_DOWN, that view (and its descendants) won’t receive the rest of the gesture.

Key mechanisms interviewers probe:

  • requestDisallowInterceptTouchEvent(true) - a child tells parents not to intercept (e.g. a ViewPager inside a scroll view, so swipes go to the pager).
  • Once a parent intercepts, the child gets ACTION_CANCEL and stops receiving the gesture.
  • This is exactly the kind of logic behind nested scrolling conflicts (“the inner RecyclerView won’t scroll inside a ScrollView”) - solved via NestedScrollingChild/interception rules.
How does Hilt work? Explain components, scopes, modules, and bindings.
Senior Architecture & Patterns #hilt#dagger#dependency-injection

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Hilt is a DI framework built on Dagger that standardizes DI on Android with predefined components tied to Android lifecycles.

Setup: annotate the Application with @HiltAndroidApp (creates the app-level component), and inject into Android classes with @AndroidEntryPoint.

Components & scopes - Hilt generates a component hierarchy mirroring Android lifecycles; each has a scope annotation:

ComponentScopeLifetime
SingletonComponent@SingletonApplication
ActivityRetainedComponent@ActivityRetainedScopedacross config changes
ViewModelComponent@ViewModelScopeda ViewModel
ActivityComponent@ActivityScopedan Activity
FragmentComponent@FragmentScopeda Fragment

A scoped binding returns the same instance within that component’s lifetime; unscoped returns a new instance each request.

Providing dependencies:

  • Constructor injection - @Inject constructor(...); Hilt knows how to build it.
  • Modules (@Module @InstallIn(SomeComponent::class)) - for types you can’t annotate (interfaces, third-party classes):
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun provideRetrofit(): Retrofit = Retrofit.Builder()...build()
}
  • @Binds - bind an interface to its implementation efficiently:
@Binds abstract fun bindRepo(impl: UserRepositoryImpl): UserRepository

ViewModels: annotate with @HiltViewModel + @Inject constructor; retrieve with hiltViewModel() (Compose) or by viewModels().

What to remember:

  • Hilt is compile-time and type-safe (Dagger codegen) - errors surface at build time, no reflection, good performance.
  • @Qualifier disambiguates two bindings of the same type (@AuthClient vs @PublicClient OkHttp).
  • Assisted injection (@AssistedInject) for objects needing both DI-provided and runtime params.
  • Match scope to lifecycle - over-scoping (@Singleton everything) causes leaks/stale state; under-scoping recreates expensive objects.
How does the Compose compiler work? What is positional memoization and the slot table?
Senior Jetpack Compose #compose#internals#compiler

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

@Composable is not a normal function - the Compose compiler plugin transforms it. The key transformations:

  • $composer parameter - every composable gets a hidden Composer parameter threaded through. The Composer is how composition reads/writes the tree.
  • Group calls - the compiler inserts startRestartGroup/endRestartGroup (and movable/replaceable groups) so Compose can track and restart just this scope.
  • Skipping logic - it generates code to compare parameters and skip the body if they’re stable and unchanged.

The slot table is the in-memory data structure that stores composition state - the tree of groups, remembered values, and CompositionLocals. It’s a flat, gap-buffer-backed array optimized for the common case: re-running composables in the same order.

Positional memoization is the core idea: Compose identifies each composable and each remember by its position in the execution order (call site), not by a name. That’s why:

  • remember { } at the same call site returns the same stored value across recompositions.
  • Calling composables conditionally (if) is fine, but reordering them without key() can confuse identity - hence key() to give stable identity in loops.
// The compiler turns this:
@Composable fun Greeting(name: String) { Text("Hi $name") }
// into roughly:
fun Greeting(name: String, $composer: Composer, $changed: Int) {
    $composer.startRestartGroup(...)
    if ($changed and 0b1 == 0 && $composer.skipping) { $composer.skipToGroupEnd() }
    else { Text("Hi $name", $composer) }
    $composer.endRestartGroup()?.updateScope { Greeting(name, it, $changed) }
}

Why this matters: it explains why the rules exist - why composables must be side-effect-free and idempotent (they re-run), why identity is positional (slot table), and why stability enables skipping.

How does the View rendering pipeline work? (measure, layout, draw - and invalidate vs requestLayout)
Senior Android Fundamentals #views#rendering#performance

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

A View is rendered in three passes, traversing the view tree top-down:

  1. Measure (onMeasure) - each parent passes MeasureSpec (a mode + size: EXACTLY, AT_MOST, UNSPECIFIED) to children; each child reports its desired size via setMeasuredDimension. Determines how big.
  2. Layout (onLayout) - parents position children by calling child.layout(l, t, r, b). Determines where.
  3. Draw (onDraw) - each view renders itself onto a Canvas, parents before children.
requestLayout → measure → layout → draw
invalidate    → draw only

invalidate() vs requestLayout() - the key distinction:

  • invalidate() - “I need to redraw, but my size/position is unchanged.” Schedules only the draw pass for that view. Use when only appearance changes (color, text content of same size).
  • requestLayout() - “My size or position may have changed.” Triggers a full measure + layout (+ draw) pass, walking up to the root and back down. More expensive.

Using the wrong one is a classic bug: change content that affects size but only call invalidate() → the view redraws but is clipped/wrong size because it wasn’t re-measured.

Performance points:

  • Avoid deep view hierarchies - each level adds measure/layout cost (mitigated with ConstraintLayout to flatten, merge tags, ViewStub).
  • Don’t allocate in onDraw/onMeasure - they run on every frame/pass; allocate paints/objects once.
  • Overdraw - drawing the same pixel multiple times; minimize overlapping backgrounds (debug with “Show overdraw”).
  • A double measure pass (e.g. RelativeLayout, weight in LinearLayout) is costly in lists.

Compose parallel: Compose’s phases are the same idea (composition → layout → drawing), but layout is single-pass by design.

In what order are Kotlin properties and init blocks initialized?
Senior Kotlin Language #kotlin#output-based#initialization

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

class Sample {
    val a = "a".also { println("prop a") }
    init { println("init 1") }
    val b = "b".also { println("prop b") }
    init { println("init 2") }
}

fun main() { Sample() }

Output:

prop a
init 1
prop b
init 2

Why: property initializers and init blocks run in the order they’re written, top to bottom, interleaved - not “all properties, then all inits.” The constructor effectively executes them as a single sequence.

The classic trap is referencing a property declared below:

class Broken {
    init { println(x.length) }  // x not initialized yet → NullPointerException
    val x = "hi"
}

Even though x is a non-null val, at the time the init block runs it still holds its default (null), so this throws. The compiler warns you (“accessing non-initialized property”).

Lesson: declaration order is execution order. Don’t reference a property before its initializer has run.

REST vs GraphQL for a mobile client, and what API design choices matter for mobile?
Senior Mobile System Design #system-design#api-design#graphql#rest

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

REST - resource-oriented endpoints (GET /users/1, GET /users/1/posts).

  • ✅ Simple, cacheable (HTTP caching/ETags), familiar, great tooling (Retrofit).
  • Over-fetching (endpoint returns more than the screen needs) and under-fetching (N+1 round trips to assemble a screen - fetch user, then posts, then comments).

GraphQL - a single endpoint; the client queries exactly the fields it needs in one request.

  • No over/under-fetching - one round trip builds a whole screen; the client controls the shape; strongly typed (Apollo codegen).
  • ✅ Great when different screens need different slices of the same data and you want to minimize round trips on mobile networks.
  • ❌ HTTP caching is harder (usually POST to one URL - needs client-side normalized cache like Apollo’s), more server complexity, query cost/abuse concerns.

For mobile specifically, the deciding factors:

  • Round trips are expensive on high-latency mobile networks → GraphQL’s “one query per screen” is attractive; with REST, design screen-shaped/aggregated endpoints (BFF - Backend-for-Frontend) to avoid N+1.
  • Payload size matters (data cost) → fetch only needed fields (GraphQL, or REST ?fields=).

API design choices that matter for mobile regardless of REST/GraphQL:

  • Cursor-based pagination (stable under live updates).
  • Partial responses / field selection to cut payload.
  • Compression (gzip/brotli), and efficient formats (protobuf for high-volume).
  • ETags/conditional requests to save bandwidth.
  • Backward compatibility / versioning - old app versions live for months; don’t break them. Additive changes, version the API.
  • Batch endpoints and a BFF to shape responses for the client.
  • Idempotency keys for safe retries of writes.
  • Clear error contracts (codes the client can act on).
Service Locator vs Dependency Injection - what's the difference?
Senior Architecture & Patterns #dependency-injection#service-locator

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Both manage dependencies, but the direction of control differs.

Dependency Injection - dependencies are pushed in from outside (usually the constructor). The class declares what it needs and receives it; it never asks for anything.

class FeedViewModel(private val repo: FeedRepository)   // dependencies are explicit

Service Locator - the class pulls dependencies from a central registry on demand.

class FeedViewModel {
    private val repo = ServiceLocator.get<FeedRepository>()   // class asks the locator
}

Why DI is generally preferred:

  • Explicit dependencies - the constructor signature documents exactly what the class needs. A service locator hides dependencies inside the body, so you can’t tell what a class requires without reading its implementation.
  • Testability - with DI you just pass a fake in the constructor. With a locator you must configure global state before each test (and reset it after), which is brittle and order-dependent.
  • Compile-time safety - frameworks like Dagger/Hilt verify the graph at build time; a locator typically fails at runtime when a dependency is missing.
  • No hidden global state - the locator is global mutable state, with all the coupling/testing problems that implies.

Where it’s nuanced:

  • Koin is technically closer to a service locator (you call get()/by inject()), though it presents a DI-like DSL - that’s a common interview “gotcha.”
  • Service locators are simpler to set up and can be pragmatic for small apps or to bootstrap before a full DI framework.
Should the network, database, domain, and UI use separate models?
Senior Architecture & Patterns #models#mapping#clean-architecture#layers

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

In a layered architecture, the same concept (“User”) often has separate models per layer, with mappers at the boundaries:

  • DTO (network) - shape of the API response. Has serialization annotations (@SerializedName), nullable fields, server quirks.
  • Entity (database) - Room @Entity; has DB concerns (@PrimaryKey, column info, denormalization).
  • Domain model - clean Kotlin used by use cases/business logic. No framework annotations.
  • UI model - pre-formatted for display (e.g. "3h ago" instead of a timestamp, a resolved color/label).
fun UserDto.toDomain() = User(id = id, name = name ?: "Unknown")
fun User.toUi() = UserUiModel(name = name, initials = name.take(2).uppercase())

Why separate them:

  • Decoupling - a backend field rename only touches the DTO + its mapper, not the whole app. The UI doesn’t break because the API changed.
  • Each layer models its own concerns - nullability/serialization at the edge, clean types in the middle, display-ready in the UI.
  • Testability & clarity - domain logic works on clean models without server cruft.

The pragmatic counterpoint (interviewers reward this balance):

  • For a simple app, 3–4 models + mappers per entity is massive boilerplate for little gain. It’s fine to share a single model across layers when the app is small and the API maps cleanly to the UI.
  • Introduce separate models where the friction is real - e.g. when the API is messy, when one screen aggregates several sources, or when domain logic shouldn’t see serialization details. Don’t apply it dogmatically everywhere.

Where mapping lives: typically in the data layer (DTO/Entity → Domain) and presentation layer (Domain → UI), often as extension functions or dedicated Mapper classes (easy to unit-test).

What are constraints, intrinsic measurements, and BoxWithConstraints?
Senior Jetpack Compose #compose#layout#constraints

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Constraints are the min/max width and height a parent passes to a child during the layout phase. A child must choose a size within them. They flow top-down; measured sizes flow bottom-up.

Layout(content) { measurables, constraints ->
    // constraints.maxWidth, minHeight, etc.
}

The single-pass rule means a parent normally can’t know a child’s size before measuring it. Two escape hatches:

Intrinsic measurements - query a child’s “natural” size without a full measure pass. Modifier.height(IntrinsicSize.Min) makes a Row tall enough for its tallest child, etc. Used when one child’s size should depend on a sibling’s natural size (e.g. a divider matching text height). It costs an extra measurement, so use sparingly.

Row(Modifier.height(IntrinsicSize.Min)) {
    Text("Left")
    Divider(Modifier.fillMaxHeight().width(1.dp))   // matches the Row's content height
    Text("Right")
}

BoxWithConstraints - exposes the incoming constraints inside the composable so you can compose different content based on available space:

BoxWithConstraints {
    if (maxWidth < 600.dp) PhoneLayout() else TabletLayout()
}

It’s built on SubcomposeLayout (it composes children after knowing constraints), which is more expensive than a normal layout - don’t reach for it when a regular Modifier/weight approach works. Prefer it only for genuine “I must know the size before deciding what to compose” cases (responsive/adaptive layouts).

What do produceState and snapshotFlow do?
Senior Jetpack Compose #compose#side-effects#produceState#snapshotFlow

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Both bridge between Compose state and coroutines/flows, in opposite directions.

produceState - turn a coroutine/async source into Compose State. It launches a coroutine (like LaunchedEffect) and gives you a value you set over time.

@Composable
fun userState(userId: String): State<Result<User>> = produceState(
    initialValue = Result.Loading,
    key1 = userId,
) {
    value = try { Result.Success(repo.load(userId)) }
            catch (e: Exception) { Result.Error(e) }
    // optional awaitDispose { } for cleanup
}

It’s essentially remember { mutableStateOf(initial) } + LaunchedEffect combined - ideal for “load this async and expose it as state.”

snapshotFlow - the reverse: turn Compose State reads into a Flow. It observes the state read inside its block and emits when that state changes.

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .distinctUntilChanged()
        .filter { it > 10 }
        .collect { analytics.log("scrolled deep") }
}

Use it to apply Flow operators (debounce, filter, map) to Compose state, or to react to scroll/gesture state with coroutine logic.

How to choose:

  • Async data → Compose state to render: produceState.
  • Compose state → a Flow to process with operators or side effects: snapshotFlow.

Both are lifecycle-scoped to the composition and cancel when it leaves.

What does return do inside an inline lambda?
Senior Kotlin Language #kotlin#output-based#lambdas#inline

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

fun foo(): String {
    listOf(1, 2, 3).forEach {
        if (it == 2) return "early"
    }
    return "done"
}

fun bar(): String {
    listOf(1, 2, 3).forEach label@{
        if (it == 2) return@label
    }
    return "done"
}

println(foo())   // ?
println(bar())   // ?

Output:

early
done

Why:

  • forEach is an inline function, so a bare return inside its lambda is a non-local return - it returns from the enclosing function foo. When it == 2, foo returns "early" immediately.
  • In bar, return@label (a labeled return) only returns from the lambda - like continue. The loop keeps going, and bar falls through to return "done".

a plain return in an inline lambda exits the surrounding function (surprising if you expected loop-continue behavior). Use return@forEach / a label to return from the lambda only. Non-local returns are only possible because forEach is inline - try it with a non-inline higher-order function and the bare return won’t compile.

What does the transform operator do, and how do flatMapConcat, flatMapMerge, and flatMapLatest differ?
Senior Coroutines & Flow #flow#operators#flatMap#transform

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

transform { } is the general operator behind map/filter: for each upstream value you can emit zero, one, or many downstream values.

flow.transform { value ->
    emit("loading $value")
    emit(fetch(value))            // emit multiple per input
}

The flatMap* family handles the case where each value maps to another flow, and they differ in how they handle concurrency of those inner flows:

  • flatMapConcat - process inner flows sequentially: fully collect one before starting the next. Order preserved, no overlap.
  • flatMapMerge - collect inner flows concurrently (up to a concurrency limit), interleaving their emissions. Fastest, order not guaranteed.
  • flatMapLatest - when a new upstream value arrives, cancel the current inner flow and switch to the new one.
queries.flatMapLatest { q -> repo.search(q) }   // search-as-you-type (cancel stale)
ids.flatMapMerge { id -> repo.detail(id) }       // load many in parallel
events.flatMapConcat { e -> process(e) }         // strict ordering, one at a time

How to choose:

  • Need ordering, one-at-a-time → flatMapConcat.
  • Need throughput, order doesn’t matter → flatMapMerge.
  • Only the latest input matters, cancel the rest → flatMapLatest.
What happens when async fails and its result is never awaited?
Senior Coroutines & Flow #coroutines#output-based#exceptions#async

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

fun main() = runBlocking {
    val deferred = async {
        throw RuntimeException("boom")
    }
    delay(100)
    println("after delay")
    // note: we never call deferred.await()
}

Output: the program crashes - RuntimeException: boom propagates and "after delay" does not print.

Why this surprises people: they expect that because async “stores” its exception for await(), not awaiting means the exception is harmless. But here async is a child of runBlocking, whose context has a regular Job. When the child fails, structured concurrency propagates the failure to the parent, cancelling it - independent of whether you ever call await(). So the whole runBlocking fails.

The “exception is deferred to await()” rule only describes where you can catch it; it does not stop the failure from propagating up the Job hierarchy and cancelling the parent.

To actually isolate it, give the async a supervisor parent so its failure doesn’t cancel the parent:

supervisorScope {
    val d = async { throw RuntimeException("boom") }
    delay(100)
    println("after delay")     // now prints
    // exception only surfaces if/when you await d
}

Lesson: under a normal Job, an unhandled async failure still tears down the scope. Use supervisorScope/SupervisorJob for independent children, and remember await() is where you observe the exception, not what triggers propagation.

What is 'stability' in Compose, and why can an unstable parameter hurt performance?
Senior Jetpack Compose #compose#stability#performance#recomposition

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Compose skips recomposing a composable if all its parameters are stable and unchanged since last time. A type is stable if Compose can trust that:

  1. equals() is consistent, and
  2. if a public property changes, Compose is notified.

If a parameter is unstable, Compose can’t prove it’s unchanged, so it can’t skip - the composable recomposes even when nothing meaningfully changed.

What’s stable: primitives, String, function types, @Immutable/@Stable-annotated types, and data classes whose properties are all stable.

What’s unstable (common culprits):

  • List, Map, Set - the interface could be backed by a mutable implementation, so Compose treats them as unstable.
  • Classes from other modules the compiler can’t analyze (unless annotated).
  • Classes with var properties (mutable, no change notification).
// items: List<Item> is unstable → this recomposes even when items are equal
@Composable fun Feed(items: List<Item>) { ... }

// Fix 1: use a stable collection
@Composable fun Feed(items: ImmutableList<Item>) { ... }

// Fix 2: annotate the type
@Immutable data class FeedData(val items: List<Item>)

Fixes:

  • Use kotlinx.collections.immutable (ImmutableList/persistentListOf) for list params.
  • Annotate model classes with @Immutable / @Stable when you guarantee the contract.
  • Kotlin 2.x strong skipping mode relaxes this - it can skip composables with unstable params if instances are referentially equal, and remembers unstable lambdas - reducing how often you need manual fixes. Still, modeling stable state is good practice.

How to diagnose: the Compose compiler metrics report tells you which composables are skippable and which parameters are unstable.

What is a Channel, how does it differ from a Flow, and what are the channel types?
Senior Coroutines & Flow #coroutines#channels#flow

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

A Channel is a coroutine-friendly queue for passing values between coroutines - a hot, stateful primitive. One coroutine sends, another receives; each element is delivered to exactly one receiver.

val channel = Channel<Int>()
launch { for (x in 1..3) channel.send(x) ; channel.close() }
launch { for (x in channel) println(x) }   // 1 2 3

Channel vs Flow:

  • A Flow is a cold recipe - declarative, re-runs per collector, no buffering by itself.
  • A Channel is hot communication - values exist whether or not anyone reads, and each value goes to one consumer (not broadcast).
  • In practice you rarely expose a raw Channel; you wrap it in receiveAsFlow() / callbackFlow / use SharedFlow. Channels back callbackFlow and produce.

Channel types (by buffer capacity):

  • RENDEZVOUS (default, 0) - send suspends until a receive is ready. Tight handoff.
  • BUFFERED - a default-sized buffer; send only suspends when full.
  • CONFLATED - keeps only the latest; new sends overwrite the unread value, never suspend.
  • UNLIMITED - unbounded buffer; send never suspends (watch memory).

When to use a Channel directly: producer/consumer pipelines, fan-out work distribution, or one-time events where exactly-once delivery to a single consumer matters. For state or broadcast-to-many, prefer StateFlow/SharedFlow.

What is a lambda with receiver, and how does it enable Kotlin DSLs?
Senior Kotlin Language #kotlin#dsl#lambdas#receiver

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

A lambda with receiver has type T.() -> R instead of (T) -> R. Inside the lambda, this is the receiver T, so you can call its members directly without a qualifier. This is the foundation of Kotlin DSLs.

class HtmlBuilder {
    val sb = StringBuilder()
    fun p(text: String) { sb.append("<p>$text</p>") }
}

// The block is a lambda with HtmlBuilder as receiver
fun html(block: HtmlBuilder.() -> Unit): String =
    HtmlBuilder().apply(block).sb.toString()

val page = html {
    p("Hello")     // `this` is HtmlBuilder - call p() directly
    p("World")
}

This is exactly how buildString { append(...) }, Gradle Kotlin DSL, Compose Modifier chains, and apply { } work - apply is literally fun T.apply(block: T.() -> Unit): T.

Advanced point: @DslMarker annotations stop you from accidentally calling an outer receiver’s methods inside a nested block, which keeps nested DSLs (like a table inside a row) unambiguous.

What is a recomposition scope, and what is the 'donut-hole skipping' optimization?
Senior Jetpack Compose #compose#recomposition#performance

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

A recomposition scope is the smallest restartable unit Compose can re-execute - roughly, a @Composable function (and certain inline blocks). When state read inside a scope changes, Compose re-runs only that scope, not the whole tree. This is smart/partial recomposition.

Donut-hole skipping: if a composable reads state, only the part that reads it recomposes - a child that doesn’t read it can be skipped even though its parent recomposed. The state read is the “hole”; the surrounding dough is skipped.

@Composable
fun Screen() {
    var count by remember { mutableStateOf(0) }
    Column {
        ExpensiveHeader()              // does NOT read count → skipped on count change
        Text("Count: $count")         // reads count → recomposes
        Button(onClick = { count++ }) { Text("+") }
    }
}

When count changes, only the Text recomposes; ExpensiveHeader is skipped (its inputs didn’t change and it’s skippable).

Practical implications:

  • Read state as low as possible. Reading count in Screen’s body would expand the scope; reading it inside Text keeps the hole small.
  • Defer reads to lambdas/later phases (Modifier.offset { }) to avoid composition entirely.
  • A composable is only skippable if its parameters are stable (see stability) - unstable params force it to recompose even when the parent does.
  • Returning Unit and not reading changed state are what let Compose skip a scope.
What is assisted injection, and when do you need it?
Senior Architecture & Patterns #hilt#dagger#assisted-injection#dependency-injection

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Assisted injection is for objects that need both DI-provided dependencies and runtime parameters only known at the call site (an item id, a config object). DI provides some constructor args; the caller “assists” with the rest.

class DetailViewModel @AssistedInject constructor(
    private val repo: ItemRepository,      // provided by DI
    @Assisted private val itemId: String,  // provided at runtime
) : ViewModel() {

    @AssistedFactory
    interface Factory {
        fun create(itemId: String): DetailViewModel
    }
}

The @AssistedFactory interface is what you inject; you call factory.create(itemId) with the runtime value.

Why you need it: Dagger/Hilt can only provide what’s in the graph. A pure @Inject constructor can’t have a parameter the graph doesn’t know (itemId). Without assisted injection you’d resort to ugly workarounds (passing the id through a setter after creation, or a manual factory).

Common Android use cases:

  • A ViewModel that needs a runtime argument (though SavedStateHandle often covers nav args - Hilt populates it from the back stack, so prefer SavedStateHandle when the value is a navigation argument).
  • A WorkManager Worker needing injected deps + runtime WorkerParameters - Hilt’s @HiltWorker + @AssistedInject handle exactly this.
  • A presenter/use case parameterized by a runtime id or callback.
@HiltWorker
class SyncWorker @AssistedInject constructor(
    @Assisted ctx: Context,
    @Assisted params: WorkerParameters,
    private val repo: SyncRepository,        // injected
) : CoroutineWorker(ctx, params)
What is suspendCancellableCoroutine and when do you use it?
Senior Coroutines & Flow #coroutines#interop#suspendCancellableCoroutine

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

suspendCancellableCoroutine converts a single-shot callback API into a suspend function. It suspends the coroutine and hands you a Continuation to resume when the callback fires.

suspend fun FusedLocationProviderClient.awaitLocation(): Location =
    suspendCancellableCoroutine { cont ->
        val task = lastLocation
        task.addOnSuccessListener { location ->
            cont.resume(location)                 // resume with result
        }
        task.addOnFailureListener { e ->
            cont.resumeWithException(e)            // resume by throwing
        }
        // Clean up if the coroutine is cancelled while waiting
        cont.invokeOnCancellation { /* cancel the task */ }
    }

The contract:

  • Call resume(value) exactly once on success, or resumeWithException(e) on failure. Calling twice throws.
  • invokeOnCancellation { } lets you cancel the underlying operation if the coroutine is cancelled while suspended - this is why you use the Cancellable variant over plain suspendCoroutine.

suspendCancellableCoroutine vs callbackFlow:

  • suspendCancellableCoroutineone value (a single async result). Like awaiting a Task/Future.
  • callbackFlow → a stream of values from a listener over time.

Real uses: awaiting a Play Services Task, a one-time AsyncLayoutInflater, an old listener-based SDK call, or bridging Java Future/Call into suspend. Many libraries already provide await() extensions built on exactly this.

What problem does rememberUpdatedState solve?
Senior Jetpack Compose #compose#rememberUpdatedState#side-effects

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

It solves the stale-capture problem when a long-lived effect needs to always see the latest value of a parameter, but you don’t want the effect to restart when that value changes.

The classic case - a one-shot effect with a callback that might change:

@Composable
fun AutoDismiss(onTimeout: () -> Unit) {
    // Keep the latest onTimeout without restarting the timer
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    LaunchedEffect(Unit) {        // runs ONCE - keyed on Unit on purpose
        delay(5000)
        currentOnTimeout()        // calls the LATEST callback, not the first
    }
}

The dilemma without it:

  • If you put onTimeout as a LaunchedEffect key, the 5-second timer restarts every time the parent passes a new lambda - the dismiss never fires.
  • If you key on Unit and call onTimeout directly, the effect captures the first lambda - stale; later updates are ignored.

rememberUpdatedState gives you a stable holder whose .value is updated on every recomposition to the newest value, while the effect itself stays keyed on Unit (never restarts). Best of both: timer runs once, callback is always current.

When to use it: long-running effects (timers, animations, listeners started once) that reference frequently-changing parameters/callbacks you want fresh but not as restart triggers.

Why modularize an Android app, and how do you structure modules?
Senior Architecture & Patterns #modularization#gradle#scalability

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Splitting a single :app module into many Gradle modules pays off as a codebase/team grows.

Benefits:

  • Build speed - Gradle builds modules in parallel and only recompiles changed modules (incremental builds). A one-line change doesn’t rebuild the world.
  • Separation & encapsulation - a module exposes a small api surface and hides internals (internal + implementation deps), enforcing boundaries the compiler checks.
  • Team scalability - teams own modules with fewer merge conflicts.
  • Reusability - share modules across apps (e.g. a design-system module).
  • Dynamic delivery - feature modules can be downloaded on demand.

Common structures:

  • By layer (:data, :domain, :ui) - simple, but every feature touches every module → poor parallelism and ownership at scale.
  • By feature (:feature:feed, :feature:profile) - preferred for larger apps; each feature is independent and can itself be layered internally.
  • Hybrid (recommended) - feature modules + shared :core modules (:core:network, :core:database, :core:designsystem, :core:common). This is the Now in Android sample’s approach.
:app                      (wires features together, DI setup)
:feature:feed   :feature:profile   :feature:settings
:core:data   :core:domain   :core:network   :core:database   :core:designsystem

Useful design rules:

  • api vs implementation - use implementation to keep a dependency off the consuming module’s compile classpath (faster builds, real encapsulation); use api only when a type leaks into your public API.
  • Avoid cyclic dependencies - features shouldn’t depend on each other directly; route through a navigation/abstraction module or :core.
  • Feature modules depend on core, not vice versa (dependency rule).
  • Convention plugins (build-logic) to share Gradle config and avoid copy-paste.

Trade-offs: more boilerplate (Gradle files), a steeper setup, and cross-module navigation/DI wiring complexity. Worth it for medium/large apps; overkill for a tiny one.