Android interview preparation

Android Interview Questions

20 frequently asked Android interview questions. Android development is an in-demand field focused on building Kotlin-based mobile apps, working with Android SDK and Jetpack, and solving lifecycle, performance, networking, storage, testing, and release tasks. The questions cover different levels, and you can practice answering them aloud in our interview trainer.

Start an Android AI InterviewNo credit card required. 1 free session available.
Technical interview practice in EnglishA mode where non-native speakers can practice passing technical interviews.

Beginner questions

1Walk through the core Activity lifecycle callbacks from creation to destruction, and explain the differences between onPause(), onStop(), and onDestroy() regarding resource release.

An Activity transitions through six core lifecycle callbacks: onCreate(), onStart(), onResume(), onPause(), onStop(), and onDestroy(). onCreate() performs one-time initialization such as view inflation. onStart() makes the activity visible, and onResume() enters the resumed state. Window focus and lifecycle state are distinct; in modern multi-window mode several activities can be resumed. When navigating away, onPause() indicates leaving the resumed state, not merely a window-focus change, onStop() indicates it is no longer visible on screen, and onDestroy() indicates final teardown of the activity instance. Regarding resource release: - onPause(): Only pause quick, foreground-specific operations (such as pausing UI animations or lightweight camera previews) because this callback must complete quickly; in an activity transition, long work can delay the next activity's resume. - onStop(): This is the primary place to release heavier resources tied to UI visibility (such as GPS/location listeners, sensor feeds, media player playback, or network polling). Resources needed only by the visible UI should stop here; intentional background playback or tracking needs an appropriate independent owner and Android background-execution handling, and the system may terminate the background process without further notice. - onDestroy(): Used for final cleanup of the Activity instance (such as clearing local threads or references). However, critical resources should not wait until onDestroy() because Android can kill a stopped app process directly to reclaim memory without ever executing onDestroy().

class LocationActivity : AppCompatActivity() {
    private var locationClient: LocationClient? = null

    override fun onStart() {
        super.onStart()
        // Start listening when visible to the user
        locationClient?.startLocationUpdates()
    }

    override fun onStop() {
        super.onStop()
        // Release when invisible to save battery and prevent leaks on process kill
        locationClient?.stopLocationUpdates()
    }
}
Try answering this question with an AI coach

2What are the four primary Android application components, and what is the lifecycle and threading model of their default callbacks?

The four primary Android application components are Activity, Service, BroadcastReceiver, and ContentProvider. An Activity provides a user interface for screen interactions. A Service performs background processing without a dedicated UI. A BroadcastReceiver listens for and responds to system-wide or app-level broadcast announcements. A ContentProvider manages and exposes structured data to other applications or internal modules. By default, the primary lifecycle callbacks for Activity (such as onCreate, onStart, onResume), Service (such as onCreate, onStartCommand, onBind), and BroadcastReceiver (onReceive) execute synchronously on the application's main thread (UI thread). ContentProvider methods (such as onCreate) also initialize on the main thread, though query/insert operations invoked across processes run on Binder thread pool threads. Because default callbacks run on the main thread, executing heavy blocking operations like network requests or large database disk I/O directly in them blocks the UI loop and triggers an Application Not Responding (ANR) error.

class DataSyncService : Service() {
    private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // onStartCommand runs on the Main Thread; heavy work must be offloaded
        serviceScope.launch {
            performDiskAndNetworkSync()
            stopSelf(startId)
        }
        return START_NOT_STICKY
    }

    override fun onBind(intent: Intent?): IBinder? = null

    override fun onDestroy() {
        super.onDestroy()
        serviceScope.cancel()
    }

    private fun performDiskAndNetworkSync() {
        // Blocking I/O safely executed on Dispatchers.IO
    }
}
Try answering this question with an AI coach

3In Android's MVVM architecture, what are the primary responsibilities of a ViewModel compared to UI controllers like Activities and Fragments, and why is passing Context to a ViewModel discouraged?

In Android's MVVM architecture, the ViewModel is responsible for holding and managing UI-related state, orchestrating presentation logic, and surviving configuration changes (such as screen rotations). In contrast, UI controllers (Activities and Fragments) are responsible for rendering UI elements on the screen, observing state changes, handling direct user interactions (clicks, gestures), and managing Android lifecycle events. Passing an Activity Context or View reference to a ViewModel is strongly discouraged because ViewModels generally outlive the lifecycle of UI controllers across configuration changes. When an Activity is destroyed and recreated during rotation, a ViewModel holding its Context prevents the old Activity from being garbage collected, causing a memory leak. If an Android Context is strictly necessary for system-level operations, an application-scoped Context (such as via `AndroidViewModel` or injecting ApplicationContext) should be used instead.

// Bad: Storing Activity context or View reference causes leaks
class LeakyViewModel(private val activityContext: Context) : ViewModel() {
    fun showToast() { Toast.makeText(activityContext, "Hello", Toast.LENGTH_SHORT).show() }
}

// Good: ViewModel manages state; Activity observes and handles context-specific tasks
class SafeViewModel(private val repository: UserRepository) : ViewModel() {
    private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
    
    fun loadUser(id: String) {
        viewModelScope.launch {
            _uiState.value = UserUiState.Success(repository.getUser(id))
        }
    }
}
Try answering this question with an AI coach

4How do normal and dangerous Android permissions differ, and what happens when an app requests a dangerous permission at runtime?

Normal permissions pose minimal risk to user privacy or device operation and are automatically granted by the system upon app installation when declared in the AndroidManifest.xml (such as ACCESS_NETWORK_STATE). Dangerous permissions (also called runtime permissions) govern access to private user data or restricted device capabilities, such as camera, microphone, location, and contacts. When an app requests a dangerous permission at runtime, the Android system presents a system dialog prompting the user to allow or deny access. If the user grants permission, the app executes the requested feature. If the user denies permission, the app receives a denial callback and must handle it gracefully by disabling or degrading the dependent feature without crashing, and optionally displaying an educational UI rationale.

val requestPermissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
    if (isGranted) {
        accessCameraFeature()
    } else {
        showPermissionDeniedExplanation()
    }
}

fun checkAndRequestPermission() {
    when {
        ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
            accessCameraFeature()
        }
        shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
            showRationaleDialog { requestPermissionLauncher.launch(Manifest.permission.CAMERA) }
        }
        else -> {
            requestPermissionLauncher.launch(Manifest.permission.CAMERA)
        }
    }
}
Try answering this question with an AI coach

5What is the difference between debug and release Android builds, and how do build types and product flavors combine into build variants?

In Android development, build types configure packaging and runtime properties for different stages of development. Debug builds enable debugging flags (`isDebuggable = true`), use a default debug keystore, and typically disable code minification and shrinking (R8/ProGuard) for fast build iterations. Release builds disable debugging flags, enable optimizations and obfuscation, and require a secure release signing key for distribution. Product flavors represent different versions or targets of the app sharing the same core codebase, such as different environments (staging vs. production) or product editions (free vs. paid). In Gradle, build variants are formed by the Cartesian product of product flavors and build types (Build Variant = Product Flavor × Build Type). For instance, combining flavors `staging` and `production` with build types `debug` and `release` produces four build variants: `stagingDebug`, `stagingRelease`, `productionDebug`, and `productionRelease`.

android {
    flavorDimensions += "environment"
    productFlavors {
        create("staging") {
            dimension = "environment"
            applicationIdSuffix = ".staging"
        }
        create("production") {
            dimension = "environment"
        }
    }
    buildTypes {
        getByName("debug") {
            isDebuggable = true
        }
        getByName("release") {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
}
Try answering this question with an AI coach

6What is a coroutine in Kotlin, and how does suspension differ from blocking a thread at runtime in Android?

A coroutine in Kotlin is a lightweight concurrency abstraction that enables writing asynchronous, non-blocking code in a sequential manner. Multiple coroutines can run concurrently on a single thread or across thread pools without the heavy memory and context-switching overhead of OS-level threads. The essential difference between suspension and blocking is thread utilization: - Blocking (e.g., Thread.sleep() or synchronous disk/network I/O) halts the underlying thread and makes it unavailable for any other work. On Android's main thread, blocking freezes the UI and triggers Application Not Responding (ANR) errors. - Suspension (via suspend functions such as delay()) pauses the coroutine without halting the underlying thread. The coroutine captures its execution state in a Continuation and yields the thread back to the runtime so it can perform other work (like handling UI interactions). When the asynchronous operation finishes, the coroutine is resumed.

suspend fun fetchUserData(): String {
    delay(1000) // Suspends execution and yields the thread
    return "User Data"
}

fun fetchUserDataBlocking(): String {
    Thread.sleep(1000) // Blocks and locks the underlying thread
    return "User Data"
}
Try answering this question with an AI coach

7In Jetpack Compose, what is the difference between state preserved with remember versus rememberSaveable, and how does mutableStateOf drive UI updates across recompositions?

In Jetpack Compose, `remember` stores an object in memory across recompositions for as long as the composable remains in the composition tree. However, state held only with `remember` is lost during configuration changes (such as screen rotations) and system-initiated process death. In contrast, `rememberSaveable` preserves state across configuration changes and process death by automatically saving and restoring values via the Android Saved Instance State bundle (or through custom `Saver` implementations for unsupported data types). `mutableStateOf` wraps a value in an observable `MutableState` object backed by Compose's Snapshot state system. When a composable reads the value of a `MutableState` during composition, Compose records that read operation. When the value later changes, Compose automatically marks all composables that read that value as invalid and schedules them for recomposition, ensuring the UI stays synchronized with the updated state.

@Composable
fun CounterExample() {
    // Resets to 0 on screen rotation
    var localCount by remember { mutableStateOf(0) }

    // Persists across screen rotation and process death
    var persistentCount by rememberSaveable { mutableStateOf(0) }

    Column {
        Button(onClick = { localCount++; persistentCount++ }) {
            Text("Local: $localCount | Saved: $persistentCount")
        }
    }
}
Try answering this question with an AI coach

8How does Kotlin null safety help prevent crashes in Android apps, and when would you use a nullable type versus a non-null type?

Kotlin null safety helps prevent many Android crashes by making nullability part of the type system. A value declared as `String` is treated as non-null, so the compiler allows direct access like `name.length`. A value declared as `String?` may be null, so Kotlin forces you to handle that case before using it, which reduces accidental `NullPointerException`s from optional Intent extras, API responses, Bundle arguments, or Java/platform APIs. Use a non-null type when the value is required for the object or function to be valid. Use a nullable type when absence is a real expected state, such as an optional profile image URL or missing server field. Nullable values are usually handled with safe calls like `user?.name`, the Elvis operator like `user?.name ?: "Guest"`, or explicit null checks. The `!!` operator opts out of safety and throws if the value is null, so it should be rare and only used when the developer can genuinely prove the value cannot be null.

val displayName: String? = intent.getStringExtra("display_name")

val greeting = "Hello, ${displayName ?: "Guest"}"
val lengthText = displayName?.length?.toString() ?: "No name provided"

// Risky: crashes if displayName is null
val riskyLength = displayName!!.length
Try answering this question with an AI coach

9What is the mechanical difference between passing an Activity Context versus an Application Context to long-lived objects, and how does choosing the wrong one lead to a memory leak?

An Activity Context is tied directly to the lifecycle of a specific screen and UI hierarchy, whereas an Application Context is tied to the lifecycle of the entire app process. When an Activity finishes or is recreated (e.g., during a configuration change like screen rotation), its lifecycle ends and its memory is meant to be reclaimed by the Garbage Collector (GC). If you pass an Activity Context to a long-lived object—such as a singleton, a static field, or a long-running background thread—that long-lived object retains a strong reference to the Activity. Because long-lived objects act as GC roots or are reachable from GC roots, the garbage collector cannot collect the destroyed Activity. This retains not just the Activity instance itself, but also its entire attached View hierarchy, drawables, and resources, creating a substantial memory leak. In contrast, passing applicationContext is safe for long-lived components because its lifecycle is expected to match the process lifetime.

class LocationRepository private constructor(private val context: Context) {
    companion object {
        @Volatile
        private var INSTANCE: LocationRepository? = null

        fun getInstance(context: Context): LocationRepository {
            return INSTANCE ?: synchronized(this) {
                // SAFE: Using context.applicationContext prevents retaining an Activity reference
                INSTANCE ?: LocationRepository(context.applicationContext).also { INSTANCE = it }
            }
        }
    }
}
Try answering this question with an AI coach

10What is the distinction between an upload key and an app signing key when using Google Play App Signing?

Under Google Play App Signing, the upload key and the app signing key serve two distinct security roles. The upload key is retained by the developer to sign the build artifact (AAB) prior to uploading it to Google Play Console, verifying the developer's identity to Google. The app signing key is stored securely within Google's cloud infrastructure and is used by Google Play to sign the generated APKs actually delivered and installed onto end-user devices, establishing the app's permanent cryptographic identity on Android. A major operational benefit is key recovery: if a developer loses or compromises their upload key, Google Play support can reset the upload key after verifying developer identity without breaking the app's update path. Under the legacy model where developers directly held the app signing key, losing the key meant the app could never be updated again.

android {
    signingConfigs {
        create("release") {
            storeFile file("my-upload-key.jks")
            storePassword System.getenv("KEYSTORE_PASSWORD")
            keyAlias "upload-alias"
            keyPassword System.getenv("KEY_PASSWORD")
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.getByName("release")
            minifyEnabled true
        }
    }
}
Try answering this question with an AI coach

Intermediate questions

11A screen loses user-entered form data after device rotation. How would you systematically diagnose whether the issue is missing saved state, incorrect ViewModel scoping, or view rebinding?

To systematically diagnose why form data is lost after device rotation, isolate three core areas: 1. **ViewModel Scoping & Retention:** Verify that the ViewModel instance is actually retained across the configuration change rather than being re-created. Log the ViewModel's hash code (`viewModel.hashCode()`) across rotations. Ensure it is obtained via delegated properties like `by viewModels()` / `by activityViewModels()` or `ViewModelProvider(this)`, and not via direct constructor instantiation (e.g., `MyViewModel()`). 2. **Saved State & View ID Restoration:** Check if the form relied solely on Android's default view hierarchy restoration. Views must have an `android:id` attribute defined in the layout to participate in automatic view state save/restore. If using custom views or `SavedStateHandle`, verify that `onSaveInstanceState` / `SavedStateHandle` actually saves and restores the relevant fields. 3. **View Rebinding & Observer Logic:** Inspect `onViewCreated` or observer subscriptions (`StateFlow`, `LiveData`). Verify whether newly created views re-subscribe properly, or if view setup code accidentally overwrites restored text with default empty values. Additionally, check for two-way binding or `TextWatcher` loops where an empty restored view immediately emits a blank update back into the ViewModel.

class FormFragment : Fragment(R.layout.fragment_form) {
    // 1. Ensure correct lifecycle scoping
    private val viewModel: FormViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        Log.d("FormDiag", "VM instance hash: ${viewModel.hashCode()}")

        // 2. Observe state without clobbering input during view recreation
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.formText.collect { text ->
                    if (binding.inputField.text.toString() != text) {
                        binding.inputField.setText(text)
                    }
                }
            }
        }
    }
}
Try answering this question with an AI coach

12How does the modern Jetpack Activity Result API replace legacy startActivityForResult, and how does it safeguard against callback loss during Activity recreation?

The modern Jetpack Activity Result API replaces legacy `startActivityForResult()` and `onActivityResult()` with a type-safe, decoupled contract-based mechanism. Instead of managing arbitrary integer request codes and large `onActivityResult` switch statements, callers define an `ActivityResultContract<I, O>` (which specifies the input intent parameters and the expected parsed output) and register a typed callback using `registerForActivityResult()` to obtain an `ActivityResultLauncher`. The API safeguards against callback loss during Activity recreation or process death through strict lifecycle registration: 1. When an external Activity (such as a camera or document picker) is launched, the calling Activity may be destroyed by the OS due to memory pressure or configuration changes. 2. By requiring `registerForActivityResult()` to be called unconditionally before the Activity or Fragment reaches the `STARTED` state (typically as a property initializer or inside `onCreate`), the result callback is registered into the `ActivityResultRegistry` before state restoration occurs. 3. When the launched activity returns and the host activity is recreated, the `ActivityResultRegistry` matches the pending result from the system with the newly registered callback and dispatches the result safely.

class ProfileActivity : AppCompatActivity() {
    // Registered unconditionally during initialization / before onStart()
    private val takePictureLauncher = registerForActivityResult(
        ActivityResultContracts.TakePicturePreview()
    ) { bitmap: Bitmap? ->
        bitmap?.let {
            findViewById<ImageView>(R.id.avatarImage).setImageBitmap(it)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_profile)

        findViewById<Button>(R.id.btnCapture).setOnClickListener {
            takePictureLauncher.launch(null)
        }
    }
}
Try answering this question with an AI coach

13Compare MVVM and MVI for a complex Android screen. What mechanisms make state updates atomic, and what concurrency problems remain even with an immutable state and reducer?

MVVM and MVI names do not by themselves determine concurrency correctness. MVVM can expose one immutable UiState and use a reducer, while an MVI implementation can still have races. A reducer computes a new state from the previous state and an event. Atomic publication requires a concrete mechanism, such as serialized event processing, a mutex, or MutableStateFlow.update for a single state value. With update, the reducer may execute more than once under contention, so keep it free of side effects. Updating related fields in one snapshot avoids exposing a partially applied update, but does not prevent logically invalid states or stale network responses from overwriting newer results. Define state invariants, identify request generations, cancel or ignore obsolete results, and keep effects outside the pure reducer. MVI-style explicit intents and transitions improve tracing and tests; the same techniques can be adopted within MVVM. Tests should cover event interleavings and failure paths, not assume the architectural label guarantees correctness.

data class ScreenState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null
)

sealed interface ScreenIntent {
    data object Refresh : ScreenIntent
    data class DataLoaded(val items: List<Item>) : ScreenIntent
    data class LoadFailed(val message: String) : ScreenIntent
}

fun reduce(state: ScreenState, intent: ScreenIntent): ScreenState = when (intent) {
    is ScreenIntent.Refresh -> state.copy(isLoading = true, error = null)
    is ScreenIntent.DataLoaded -> state.copy(isLoading = false, items = intent.items, error = null)
    is ScreenIntent.LoadFailed -> state.copy(isLoading = false, error = intent.message)
}
Try answering this question with an AI coach

14How would you design a runtime permission flow that handles first request, rationale UI, denial, 'don't ask again', and a settings fallback without coercing the user?

A non-coercive, user-friendly runtime permission flow follows progressive disclosure, clear rationale, and graceful degradation across all denial states: 1. **In-Context Request (First Time)**: Request permissions only at the point of need when the user interacts with a feature (e.g., tapping 'Scan Code' for Camera permission), rather than up-front on app startup. 2. **Rationale UI**: When `shouldShowRequestPermissionRationale()` returns `true`, show a clear in-app explanation (such as a bottom sheet or dialog) explaining why the permission is required and what benefit it provides before triggering the system prompt. 3. **Graceful Degradation on Denial**: If the user denies the request, respect the choice without blocking unrelated features. Provide an alternative workflow where possible (e.g., manual text input if camera access is denied). 4. **Permanent Denial ('Don't Ask Again') & Settings Fallback**: A false result from `shouldShowRequestPermissionRationale()` alone does not prove permanent denial: it can also occur before the first request. Track whether a request was made, examine its result and account for platform or policy restrictions. When another prompt is unavailable, inform the user why the feature is unavailable and offer an optional button directing them to app settings via `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`. Ensure the user can easily cancel or navigate back without being trapped.

fun onScanButtonClicked() {
    when {
        ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
            openScanner()
        }
        activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
            showRationaleDialog(onConfirm = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) })
        }
        else -> {
            cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
        }
    }
}

fun handlePermissionResult(isGranted: Boolean) {
    if (isGranted) {
        openScanner()
    } else if (!activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
        showSettingsRedirectDialog()
    } else {
        showManualEntryFallback()
    }
}
Try answering this question with an AI coach

15How would you design an Android CI/CD pipeline from pull request validation through signed release candidate creation, internal testing, Play track upload, and rollout promotion?

A production Android CI/CD pipeline begins at the pull request validation stage with fast automated checks: static analysis (ktlint, detekt, Android Lint), unit tests, and compiling a debug build. Upon merge to the main or release branch, the release pipeline triggers to generate a signed Release Candidate (RC) Android App Bundle (`.aab`) using CI-managed release keystore credentials, Proguard/R8 shrinking, and version bump automation. The signed AAB and its deobfuscation mapping files are uploaded to Google Play's Internal Testing track (or Firebase App Distribution) using tools like Gradle Play Publisher (GPP) or Fastlane, where automated smoke or instrumentation tests run. Once internal QA and stakeholders approve the build, the exact same binary artifact is promoted downstream (Internal -> Closed Alpha/Beta -> Production Rollout) without rebuilding from source. Finally, staged rollouts (e.g., 5% -> 20% -> 100%) paired with automated crash rate and vitals monitoring ensure safe deployment.

lane :promote_internal_to_beta do
  upload_to_play_store(
    track: 'internal',
    track_promote_to: 'beta',
    skip_upload_aab: true, # Promotes existing binary without rebuilding
    skip_upload_metadata: false,
    skip_upload_changelogs: false
  )
end
Try answering this question with an AI coach

16Why does catching generic Exception or Throwable without rethrowing CancellationException break coroutine cancellation, and how do you diagnose cancellation issues?

Kotlin Coroutines rely on cooperative cancellation implemented through CancellationException. When a coroutine's Job is cancelled, suspending calls (such as delay or yield) throw a CancellationException to unwind the call stack and terminate the coroutine. When code catches generic Exception or Throwable without rethrowing CancellationException, the cancellation signal is swallowed. The coroutine fails to abort and keeps executing, creating a 'zombie coroutine' that wastes CPU/memory, leaks resources, and may trigger invalid state transitions or crashes against destroyed UI components. To diagnose cancellation issues: 1. Verify exception handling hygiene by ensuring CancellationException is explicitly rethrown or domain-specific exceptions are caught instead of generic Throwable/Exception. 2. Inspect coroutine states in debug builds using the Kotlin Coroutines Debugger (-Dkotlinx.coroutines.debug) or the Android Studio Coroutines Inspector to identify running coroutines that should have terminated. 3. Log coroutine lifecycle states (e.g., job.isActive, job.isCancelled) or use structured logging in cancellation completion handlers (job.invokeOnCompletion).

import kotlinx.coroutines.CancellationException

// ANTI-PATTERN: Swallows cancellation, creating a zombie coroutine
try {
    doSuspendingWork()
} catch (e: Exception) {
    logError(e)
}

// CORRECT: Preserves cancellation propagation
try {
    doSuspendingWork()
} catch (e: Exception) {
    if (e is CancellationException) throw e
    logError(e)
}
Try answering this question with an AI coach

17How do state hoisting and Unidirectional Data Flow (UDF) work in Jetpack Compose, and how do you decide whether state belongs in a leaf composable, a parent composable, or a ViewModel?

State hoisting is a pattern in Jetpack Compose where state is moved up the composition hierarchy to make a composable stateless, turning it into a pure UI component. It enables Unidirectional Data Flow (UDF), where state flows down (from parent/ViewModel to child composables as arguments) and events flow up (from child composables to parent/ViewModel as callbacks). Deciding where state belongs follows these guidelines: 1. Leaf Composable (Local UI state): If the state is purely transient, visual, and not needed by any parent or sibling (e.g., whether an internal expand/collapse animation is running or a local ripple effect), keep it local in the leaf composable using `remember`. 2. Parent Composable (Hoisted UI state): If sibling composables need to share or react to the state, or if the parent controls the component's visibility/validation, hoist the state to the immediate common parent. The leaf becomes stateless (takes `value` and `onValueChange`). 3. ViewModel (Screen / Business state): If state represents business data, survives configuration changes, drives navigation, or requires interaction with domain/repository layers, it belongs in a ViewModel exposed as observable state (e.g., `StateFlow`). The ViewModel handles business logic and updates the UI state accordingly.

// Stateless Leaf Composable
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
    TextField(value = query, onValueChange = onQueryChange)
}

// Stateful Screen / ViewModel Owner
@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    SearchBar(
        query = uiState.query,
        onQueryChange = { newQuery -> viewModel.onQueryChanged(newQuery) }
    )
}
Try answering this question with an AI coach

Advanced questions

18How would you design telemetry, crash breadcrumbs, and architectural guardrails to detect, diagnose, and prevent production FragmentManager state loss and navigation race conditions?

A transaction after FragmentManager has saved its state can be rejected with IllegalStateException because it would not be represented in the saved snapshot. Record redacted breadcrumbs for host and Fragment/view lifecycle, FragmentManager.isStateSaved, current destination, task and instance IDs, navigation request IDs, thread, and transaction timing. Run UI navigation on the main thread, use a suitable lifecycle owner, and serialize requests through one navigation owner. Before committing, verify both the current destination and that the manager can accept state changes; defer or discard stale requests according to an explicit policy. Lifecycle-aware collection helps but does not by itself prevent duplicate events or time-of-check/time-of-use races. Durable navigation intent should be represented and acknowledged in state where delivery matters; one-shot streams can lose or replay events depending on configuration. The actual API is commitAllowingStateLoss(), which knowingly accepts that a transaction may disappear after restoration; reserve it for genuinely disposable UI. Jetpack Navigation and Fragment StrictMode provide useful structure and selected diagnostics, but neither guarantees the absence of all state-loss or navigation races. Test rapid taps, delayed callbacks, backgrounding, rotation, and process recreation.

class NavigationDispatcher @Inject constructor() {
    private val _events = Channel<NavigationCommand>(Channel.BUFFERED)
    val events: Flow<NavigationCommand> = _events.receiveAsFlow()

    fun navigate(command: NavigationCommand) {
        _events.trySend(command)
    }
}

// In Fragment / Activity
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
        navigationDispatcher.events.collect { command ->
            CrashReporting.leaveBreadcrumb("Navigating to ${command.destination} at state ${lifecycle.currentState}")
            command.execute(parentFragmentManager)
        }
    }
}
Try answering this question with an AI coach

19How do system-level power management features like Doze Mode, App Standby Buckets, and OEM task killers impact background scheduling, and how would you design a resilient sync engine using WorkManager?

System-level battery optimizations severely restrict background execution. Doze Mode restricts CPU, network access, and background jobs during periods of inactivity, deferring much ordinary background work until maintenance windows, with documented exceptions. App Standby Buckets dynamically throttle background job frequency and network access based on app usage recency (from Active down to Restricted or Never). Furthermore, aggressive OEM custom power managers (such as MIUI or OneUI) frequently kill background processes, ignore standard alarms, and strip autostart permissions regardless of vanilla AOSP behavior. To build a resilient sync engine, WorkManager provides persistent scheduling integrated with platform constraints; it does not bypass Doze, quotas, force-stop, or vendor restrictions. WorkManager allows declaring strict execution preconditions (such as NetworkType.CONNECTED, requiresBatteryNotLow(true)), automatically deferring execution until Doze maintenance windows or until connectivity is restored. To withstand unexpected process termination, transient network drops, and OEM kills, the sync engine must adhere to two core principles: exponential backoff and end-to-end idempotency. Use a suitable retry policy, often exponential backoff, and consider jitter and server rate limits; exponential delays alone do not prevent synchronized retry bursts. In addition, workers should use atomic local checkpoints and idempotent remote operations, without assuming one atomic transaction spans local storage and the server—using deterministic transaction IDs, local state flags, and server-side deduplication keys—so that if a worker is abruptly killed midway and re-enqueued, retrying does not duplicate data or corrupt local databases.

val syncConstraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)
    .build()

val syncWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(syncConstraints)
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        WorkRequest.MIN_BACKOFF_MILLIS,
        TimeUnit.MILLISECONDS
    )
    .addTag("data_sync_tag")
    .build()

WorkManager.getInstance(context).enqueueUniqueWork(
    "unique_data_sync",
    ExistingWorkPolicy.KEEP,
    syncWorkRequest
)
Try answering this question with an AI coach

20How do you design a scalable multi-team Android architecture using the 'API-Implementation' module separation pattern to optimize Gradle build times and enforce strict contract boundaries?

In an enterprise multi-team Android codebase, the API-Implementation (API-Impl) pattern splits each feature or domain module into two distinct Gradle subprojects: a lightweight `:feature:api` module containing public interfaces, models, and navigation contracts, and a `:feature:impl` module containing internal business logic, UI, and repository implementations. Consuming modules depend exclusively on `:feature:api` using `implementation project(':feature:api')`, while the root `:app` module or dedicated composition roots tie concrete implementations together via Dependency Injection (e.g., Dagger/Hilt). This pattern can reduce recompilation through stable API boundaries and smaller compilation classpaths, but its benefit should be measured against extra module and wiring overhead. When engineers modify implementation details in `:feature:impl`, the public ABI of `:feature:api` remains unchanged. Consumers whose compile inputs remain unchanged can avoid recompilation. Implementation modules and app packaging still need affected work, and annotation processors, generated code, or leaked ABI types can broaden invalidation. The build cache reuses task outputs; the configuration cache reuses the configured task graph and is a separate mechanism. From an organizational and governance perspective, this pattern establishes clear cross-team ownership boundaries using tools like CODEOWNERS. Teams can safely evolve their internal implementation details without exposing private classes, reducing accidental coupling when enforced by dependency checks; API modules can still form cycles if the dependency graph is not governed.

// :feature:checkout:api/src/main/java/com/example/checkout/api/CheckoutApi.kt
package com.example.checkout.api

interface CheckoutLauncher {
    fun launchCheckoutFlow(cartId: String)
}

// :feature:checkout:impl/src/main/java/com/example/checkout/impl/CheckoutLauncherImpl.kt
package com.example.checkout.impl

import com.example.checkout.api.CheckoutLauncher
import javax.inject.Inject

internal class CheckoutLauncherImpl @Inject constructor(
    private val paymentGateway: PaymentGateway
) : CheckoutLauncher {
    override fun launchCheckoutFlow(cartId: String) {
        // Concrete checkout implementation
    }
}

// :feature:checkout:impl/src/main/java/com/example/checkout/impl/di/CheckoutModule.kt
package com.example.checkout.impl.di

import com.example.checkout.api.CheckoutLauncher
import com.example.checkout.impl.CheckoutLauncherImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

@Module
@InstallIn(SingletonComponent::class)
internal abstract class CheckoutBindingModule {
    @Binds
    abstract fun bindCheckoutLauncher(impl: CheckoutLauncherImpl): CheckoutLauncher
}
Try answering this question with an AI coach