Android Fundamentals Interview Questions

Questions on how Android itself works: the Activity and Fragment lifecycle, Intents and deep links, Services and background work, Context, permissions, storage, security and accessibility. Interviewers use these to check that you understand the platform, not just the libraries on top of it.

By Vivek Choudhary, Android engineer · Updated October 2026 · How answers are checked

50 questions with answers1 easy · 27 medium · 22 hard31 marked most asked

All 50 questions

  1. Explain the Activity lifecycle. What happens when you press Home vs Back button? Most asked
  2. What is the difference between Fragment lifecycle and Activity lifecycle? How do they interact? Most asked
  3. What are the different types of Intent? Explain explicit vs implicit with examples. Most asked
  4. What is the difference between Service, IntentService, and WorkManager? When do you use each? Most asked
  5. What is the difference between Context and ApplicationContext? When do you use each? Most asked
  6. How does the Android permission model work in Android 14/15? What changed recently?
  7. What is a BroadcastReceiver? What is the difference between static and dynamic registration? Most asked
  8. How does Android handle process death and state restoration? What is the difference between ViewModel, SavedStateHandle, and onSaveInstanceState?
  9. What is the Android Predictive Back Gesture introduced in Android 13/14? How do you implement it?
  10. How does the Android Splash Screen API work? What replaced the old SplashActivity pattern?
  11. What is the difference between Serializable and Parcelable? Which should you use in 2025? Most asked
  12. What is the difference between LaunchMode: standard, singleTop, singleTask, and singleInstance? Most asked
  13. How does Android handle deep links? What is the difference between deep links, App Links, and custom scheme URIs?
  14. What is ANR? What are the most common causes and how do you diagnose it? Most asked
  15. What is the difference between compileSdk, minSdk, and targetSdk? What happens if you set targetSdk incorrectly?
  16. What is edge-to-edge display in Android? How do you handle window insets properly? Most asked
  17. How do you implement push notifications on Android in 2025? Walk through the full flow. Most asked
  18. What is the Android App Startup library and how does it improve cold start time?
  19. What is the difference between View.GONE, View.INVISIBLE, and View.VISIBLE? How do these work in the layout system? Most asked
  20. What is Baseline Profiles and how do they improve app performance in 2025?
  21. What is a ContentProvider? When and why would you use one? Most asked
  22. What is the difference between LiveData and StateFlow? Which should you use in 2025? Most asked
  23. What is the difference between RecyclerView and LazyColumn? When do you use each? Most asked
  24. What is Doze Mode and App Standby? How do they affect background work? Most asked
  25. What is the difference between Handler, Looper, and MessageQueue? Most asked
  26. What is ViewBinding and how is it different from DataBinding and findViewById? Most asked
  27. How do you support foldable phones and large screens in Android?
  28. What is Jetpack Navigation Component? How does it handle deep links and back stack? Most asked
  29. How do you implement Android security best practices? Cover SSL pinning, ProGuard, and secure storage.
  30. What is ConstraintLayout and why is it preferred over other layouts? Most asked
  31. What is the Android Manifest file and what are its key components? Most asked
  32. What is Jetpack Paging 3? How does it handle large datasets efficiently?
  33. How does Android handle accessibility? What are the key APIs and best practices? Most asked
  34. What are the key features and breaking changes in Android 15 and Android 16?
  35. What is Kotlin Multiplatform Mobile (KMM) and how does it relate to Android development?
  36. What is IPC in Android? Explain AIDL and when you would use it over other IPC mechanisms. Most asked
  37. What is Scoped Storage? How do you access media files with MediaStore in Android 10+?
  38. How do you implement biometric authentication using BiometricPrompt?
  39. How do you create a Custom View in Android? Explain onMeasure, onLayout, and onDraw. Most asked
  40. What is the difference between DVM, ART, and JVM? How does Android compile and run code? Most asked
  41. What is AlarmManager? When do you use it vs WorkManager? Most asked
  42. What is SparseArray? When should you use it instead of HashMap? Most asked
  43. How does Android handle Bitmap memory? What is BitmapPool and how do image loading libraries use it? Most asked
  44. How do you implement App Widgets in Android? What changed in Android 12? Most asked
  45. How do you ensure backward compatibility in Android? Explain @RequiresApi, BuildConfig, and AndroidX. Most asked
  46. What is StrictMode and how do you use it to catch issues in development? Most asked
  47. What is WebView in Android? What are the security risks and how do you mitigate them?
  48. What are Android App Shortcuts? Explain static, dynamic, and pinned shortcuts.
  49. What is multi-process architecture in Android? When would you run components in separate processes?
  50. What is the Android Vitals dashboard? What metrics should you monitor in production?
EasyMost asked

1. Explain the Activity lifecycle. What happens when you press Home vs Back button?

Think of an Activity's lifecycle like a person's daily routine — it's created in the morning, becomes active, goes to the background when interrupted, and eventually gets destroyed at night. Android calls specific methods at each of these transitions so you can react correctly — starting a camera when the user opens your app, pausing it when a call comes in, releasing memory when the user leaves. If you don't handle these correctly, you get crashes, battery drain, or memory leaks.

// Activity Lifecycle flow:
// onCreate → onStart → onResume → [RUNNING]
// [RUNNING] → onPause → onStop → onDestroy

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Called ONCE when Activity is first created
        // ✅ Do: inflate layout, init ViewModel, set up RecyclerView
        // ❌ Don't: start animations or sensors here
    }

    override fun onStart() {
        super.onStart()
        // Activity is now VISIBLE but not yet interactive
        // Called after onCreate and also when returning from background
    }

    override fun onResume() {
        super.onResume()
        // Activity is fully VISIBLE and INTERACTIVE — user can touch it
        // ✅ Do: start camera preview, resume animations, register sensors
    }

    override fun onPause() {
        super.onPause()
        // Activity is PARTIALLY hidden (e.g. a dialog appears on top)
        // ✅ Do: pause animations, release camera, save quick unsaved data
        // ⚠️ Keep this fast — next Activity won't resume until this returns
    }

    override fun onStop() {
        super.onStop()
        // Activity is COMPLETELY hidden — user pressed Home or opened another app
        // ✅ Do: release heavy resources (video players, DB connections)
        // Activity still EXISTS in memory — may come back via onRestart
    }

    override fun onRestart() {
        super.onRestart()
        // Called when returning from stopped state (not from scratch)
        // Followed by onStart → onResume
    }

    override fun onDestroy() {
        super.onDestroy()
        // Activity is being PERMANENTLY destroyed
        // Triggered by: back button, finish(), or system low memory
        // ✅ Do: final cleanup of any remaining references
    }
}

onCreate runs once on first creation — initialize your layout, ViewModel, and adapter here. It's also called after screen rotation since the Activity is fully recreated from scratch.

onStart is called when the Activity becomes visible to the user but isn't yet interactive. It fires both on first launch and when the user returns from another app.

onResume means the Activity is in the foreground and fully interactive — the user is actively using your app. Start camera, sensors, and animations here.

onPause fires when another component partially covers your Activity (a permission dialog, a translucent overlay). Pause anything that shouldn't continue — camera, sensors, location updates. Keep it fast: Android waits for it to finish before showing the next screen.

onStop means the Activity is fully hidden — the user pressed Home, switched apps, or another full-screen Activity opened. Release heavy resources like video players. The Activity instance stays alive in memory.

onDestroy is the final cleanup callback, called when the user presses Back or when the system kills the Activity to free memory. After this, the object is garbage collected.

Home button triggers onPause → onStop, but the Activity stays in memory. Returning to the app via the recents screen calls onRestart → onStart → onResume. Back button goes further: onPause → onStop → onDestroy, fully removing the Activity from the back stack.

Screen rotation triggers the full teardown and recreation cycle: onPause → onStop → onDestroy → onCreate → onStart → onResume. Save transient UI data in onSaveInstanceState() or use a ViewModel to survive it. A phone call arriving gives your app onPause (the call UI overlaps); if the call is long and the system needs memory, you may also receive onStop.

💡 Interview Tip

"What's the difference between onPause and onStop?" is the most common follow-up. onPause = Activity partially visible (dialog on top), onStop = completely hidden (user pressed Home). Also always mention: screen rotation destroys and recreates the Activity, so use ViewModel to survive it — this shows senior-level awareness.

Mark it done or save it in the question bank · Back to top

MediumMost asked

2. What is the difference between Fragment lifecycle and Activity lifecycle? How do they interact?

A Fragment is a reusable piece of UI that lives inside an Activity — think of it as a "mini Activity". Apps like Instagram use Fragments for each tab (Home, Search, Reels, Profile) inside one Activity. Because a Fragment lives inside an Activity, it has its own lifecycle that is tied to — but not identical to — the Activity's lifecycle. The key gotcha: a Fragment can survive while its view is destroyed and recreated (e.g. when placed on the back stack), which is why Fragment has extra lifecycle callbacks that Activity doesn't have.

// Fragment Lifecycle (extra steps compared to Activity):
// onAttach → onCreate → onCreateView → onViewCreated → onStart → onResume
// → onPause → onStop → onDestroyView → onDestroy → onDetach

class HomeFragment : Fragment(R.layout.fragment_home) {

    // Use view binding — null it out in onDestroyView
    private var _binding: FragmentHomeBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        _binding = FragmentHomeBinding.inflate(inflater, container, false)
        return binding.root
        // ✅ Only inflate the layout here, don't access views yet
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // ✅ Views are ready — set up click listeners, observe LiveData here

        // ✅ viewLifecycleOwner = tracks the VIEW's lifecycle (not the Fragment)
        viewModel.data.observe(viewLifecycleOwner) { data ->
            updateUi(data)
        }

        // ❌ WRONG — 'this' tracks the Fragment lifecycle, NOT the view
        // If view is destroyed and recreated, observer keeps running → memory leak!
        // viewModel.data.observe(this) { ... }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        // ✅ CRITICAL: null the binding here to avoid memory leaks
        // The view is destroyed but the Fragment object is still alive on the back stack
        // Without this, binding holds a reference to destroyed views → leak!
        _binding = null
    }
}

onAttach fires when the Fragment is attached to its host Activity — the earliest point where you can get a reference to the Activity.

onCreate initializes the Fragment itself (ViewModel, arguments) but views don't exist yet — never touch UI here. onCreateView inflates and returns the Fragment's layout. Don't set up click listeners or observers yet; wait for onViewCreated, where the view is fully ready. This is the right place to configure your RecyclerView, button clicks, and Flow/LiveData observers — always using viewLifecycleOwner, not this.

onDestroyView is the critical Fragment-specific callback: the view is destroyed but the Fragment instance stays alive on the back stack. Always null your ViewBinding here — failing to do so keeps the entire view hierarchy in memory until the Fragment is finally destroyed, leaking every view reference it holds.

onDestroy and onDetach fire when the Fragment is finally removed and detached from the Activity. The Fragment lifecycle mirrors the Activity's — when the host pauses, stops, or is destroyed, all its Fragments follow in the same direction.

viewLifecycleOwner vs this — a Fragment's view can be destroyed and recreated multiple times during back-stack navigation while the Fragment object itself stays alive. viewLifecycleOwner tracks the view's own lifecycle, so observers automatically stop when the view is gone. Using this (the Fragment's lifecycle) keeps observers running on a destroyed view, causing memory leaks and crashes — one of the most common Fragment bugs.

💡 Interview Tip

The most common follow-up is "Why do we use viewLifecycleOwner instead of this?" — the answer is that a Fragment outlives its view on the back stack, so using 'this' as the lifecycle owner keeps observers running on a destroyed view, causing leaks and crashes. Mentioning this proactively signals you understand real-world memory issues.

Mark it done or save it in the question bank · Back to top

MediumMost asked

3. What are the different types of Intent? Explain explicit vs implicit with examples.

An Intent is Android's messaging system — it's how components talk to each other. Think of it as a "request slip" you hand to Android saying "I want to do something — either you do it, or find someone who can." There are three kinds: Explicit (you know exactly who should handle it), Implicit (you describe what you want and Android finds the right app), and PendingIntent (a deferred Intent that another process can fire later on your behalf — used in notifications and alarms). Understanding Intents is fundamental because almost everything in Android — starting screens, sharing content, opening the camera, setting alarms — goes through them.

// EXPLICIT INTENT — you know exactly which component to start
val explicitIntent = Intent(this, DetailActivity::class.java).apply {
    putExtra("userId", "123")
    putExtra("userName", "Rahul")
}
startActivity(explicitIntent)

// IMPLICIT INTENT — declare action, system decides handler
val shareIntent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_TEXT, "Check out Droidly!")
}
startActivity(Intent.createChooser(shareIntent, "Share via"))

// PENDING INTENT — for future execution (notifications, widgets)
val pendingIntent = PendingIntent.getActivity(
    context, 0, explicitIntent,
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

// INTENT FLAGS — control back stack behavior
Intent(this, HomeActivity::class.java).apply {
    flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}

Explicit Intent — you specify exactly which component to start, always for navigation within your own app. Example: navigating from MainActivity to ProfileActivity, or starting a background download Service. The target is hardcoded in the Intent constructor.

Implicit Intent — you describe the action you want (e.g. ACTION_SEND to share text) and Android finds all apps that can handle it, showing a chooser. If no app can handle it and you haven't checked first, your app crashes with ActivityNotFoundException — always call resolveActivity(packageManager) or wrap in a try/catch.

PendingIntent is a wrapper that grants another process permission to fire your Intent later on your behalf — used in notifications (so tapping them opens your app), AlarmManager (scheduled triggers), and widgets. Since Android 12, every PendingIntent must include FLAG_IMMUTABLE — omitting it causes a crash on API 31+.

Intent Extras are key-value pairs that carry data between components — primitives, Strings, and Parcelable objects. Avoid passing large objects; pass an ID and let the destination load from a shared ViewModel or database instead.

Intent Flags control back-stack behavior. FLAG_ACTIVITY_CLEAR_TASK combined with FLAG_ACTIVITY_NEW_TASK clears the entire back stack — the standard pattern after a successful login so the user can't press Back to the login screen. Intent Filters, declared in AndroidManifest.xml, advertise which implicit Intents your component can handle — declaring ACTION_VIEW with a URL scheme makes your app appear in browser choosers for that URL pattern.

💡 Interview Tip

A great follow-up answer to "What is an implicit Intent?" is to mention the safety check: always call intent.resolveActivity(packageManager) != null before firing an implicit intent — if no app handles it and you don't check, your app will crash with an ActivityNotFoundException. Also always mention FLAG_IMMUTABLE for PendingIntents — it shows you know Android 12 breaking changes.

Mark it done or save it in the question bank · Back to top

MediumMost asked

4. What is the difference between Service, IntentService, and WorkManager? When do you use each?

Android is very aggressive about killing background processes to preserve battery and memory. This means if your app needs to do work while the user isn't actively using it — like syncing data, uploading a photo, or playing music — you need to pick the right tool, otherwise Android will silently kill your work mid-way. The landscape has changed significantly: IntentService is deprecated, plain Services run on the main thread and get killed easily, and WorkManager is now the recommended solution for almost all background tasks because it survives app kills, device reboots, and can be constrained (only run on Wi-Fi, only when battery isn't low).

// WorkManager — RECOMMENDED for guaranteed background work
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {

    override suspend fun doWork(): Result {
        return try {
            syncDataWithServer()
            Result.success()
        } catch (e: Exception) {
            Result.retry() // automatic retry with backoff
        }
    }
}

// Schedule with constraints
val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)
    .build()

val workRequest = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
    .setConstraints(constraints)
    .build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "sync", ExistingPeriodicWorkPolicy.KEEP, workRequest
)

// Foreground Service — for ongoing user-visible work
class MusicService : Service() {
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        startForeground(NOTIFICATION_ID, buildNotification())
        return START_STICKY
    }
}

Service (plain) runs on the main thread — you must launch your own coroutine or thread inside it. It gets killed by the system under memory pressure and has no built-in retry mechanism. Use a plain Service only when you need a component bound to an Activity for IPC; it's not the right choice for simple background tasks.

IntentService is deprecated since API 30 (Android 11). It ran tasks on a background thread but had no lifecycle awareness or retry capability. Don't use it in new code — WorkManager replaces it entirely.

WorkManager is the recommended solution for deferrable, guaranteed background work. It survives app kills and device reboots, supports constraints (network type, battery level, storage), and handles automatic retries with exponential backoff. Under the hood it uses JobScheduler on API 23+ — you never need to choose the implementation yourself. Use CoroutineWorker for any suspending work.

Foreground Service is for long-running work the user is aware of and expects to continue uninterrupted — music playback, navigation, workout tracking, large file downloads. It must show a persistent notification so the user knows background work is ongoing. Since Android 14, you must declare the foregroundServiceType in the manifest (e.g. mediaPlayback, location, dataSync) — missing this attribute causes a crash on API 34+ devices.

The decision rule is straightforward: uploading a photo after the user closes the app → WorkManager. Playing a podcast → Foreground Service. Syncing data every 24 hours on Wi-Fi → WorkManager with NetworkType.UNMETERED constraint. Streaming sensor data live to your Activity → Bound Service.

💡 Interview Tip

The key phrase to remember: "WorkManager for guaranteed, deferrable tasks. Foreground Service for user-visible ongoing work." Interviewers love asking "What happens to WorkManager if the user force-quits the app?" — WorkManager work is re-enqueued when the app restarts. That guarantee is what makes it special over a plain Service or coroutine.

Mark it done or save it in the question bank · Back to top

MediumMost asked

5. What is the difference between Context and ApplicationContext? When do you use each?

Context is one of the most fundamental — and most misunderstood — classes in Android. It's basically a handle to the Android system: it lets your code access resources (strings, drawables, layouts), start Activities, get system services (like the clipboard or notification manager), and create Views. The problem is there are multiple types of Context with different lifespans, and using the wrong one in the wrong place is one of the most common causes of memory leaks in Android apps. The golden rule: the longer an object lives, the more generic (long-lived) its Context should be.

// Activity Context — tied to Activity lifecycle
// ✅ Use for: UI operations, dialogs, layouts, startActivity
val dialog = AlertDialog.Builder(this) // 'this' = Activity context
startActivity(Intent(this, DetailActivity::class.java))

// Application Context — lives for the entire app lifetime
// ✅ Use for: singletons, databases, repos, long-lived objects
class AppDatabase {
    companion object {
        fun create(context: Context) =
            Room.databaseBuilder(
                context.applicationContext, // ✅ not context directly!
                AppDatabase::class.java, "app.db"
            ).build()
    }
}

// ❌ MEMORY LEAK — storing Activity context in singleton
object BadSingleton {
    lateinit var context: Context // Never do this with Activity context!
}

// ✅ CORRECT — use applicationContext in singletons
object GoodSingleton {
    lateinit var appContext: Context
    fun init(context: Context) { appContext = context.applicationContext }
}

Activity Context (this inside an Activity) is tied to the Activity's lifecycle and destroyed when the Activity is destroyed. Use it for anything UI-related: inflating layouts, creating dialogs, showing Toasts, calling startActivity(). It carries the Activity's theme, which dialogs and custom Views require to render correctly.

Application Context (applicationContext) lives for the entire app process lifetime. Use it in long-lived objects like singletons, Room databases, and Retrofit instances. It does not carry a UI theme, so creating an AlertDialog with it will crash or look wrong — only use it for non-UI system service calls.

The memory leak trap — if you store an Activity context in a singleton or static field, the Activity object can never be garbage collected even after it's destroyed, because that singleton still holds a reference. Always call .applicationContext when passing Context to objects that outlive the screen.

Fragment context — inside a Fragment use requireContext(), which returns the host Activity context safely and throws IllegalStateException if the Fragment is detached. This is safer than a silent NullPointerException. Every View also has a Context accessible via view.context — it's the Activity the view was inflated with.

ContextWrapper — Activity, Service, and Application all extend ContextWrapper, which is why all three can be passed wherever a Context is expected. When starting an Activity from a non-Activity context (such as a Service or repository), you must add FLAG_ACTIVITY_NEW_TASK to the Intent because there's no existing task to attach to.

💡 Interview Tip

The single rule that prevents most Context mistakes: "If the object outlives the screen, use applicationContext." A great real-world answer: "In our Retrofit singleton, we never store the Activity context — we always call context.applicationContext so the singleton doesn't prevent the Activity from being garbage collected after the user leaves."

Mark it done or save it in the question bank · Back to top

Hard

6. How does the Android permission model work in Android 14/15? What changed recently?

Android's permission model has gone through several major evolutions. Starting from Android 6 (API 23), dangerous permissions must be requested at runtime — the user decides when they actually need the feature, not at install time. Think of it like a restaurant: instead of listing every ingredient before you sit down, the waiter asks "would you like cheese?" only when you order. Android 13, 14, and 15 have continued tightening this model: granular media permissions replaced the broad READ_EXTERNAL_STORAGE, notifications now require explicit user approval, and Android 14 introduced partial photo library access so users can grant access to just selected photos rather than their entire gallery.

// Modern permission request — ActivityResultContracts (replaces deprecated onRequestPermissionsResult)
class CameraFragment : Fragment() {

    private val requestCameraPermission = registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { isGranted ->
        if (isGranted) {
            openCamera()
        } else {
            // shouldShowRequestPermissionRationale = false means "Don't ask again" was ticked
            if (!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
                showSettingsDialog() // guide user to App Settings manually
            } else {
                showRationaleDialog() // explain why camera is needed, then re-ask
            }
        }
    }

    private fun onTakePhotoClicked() {
        // ✅ Check before requesting — avoid unnecessary dialogs
        when {
            ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) ==
                    PackageManager.PERMISSION_GRANTED -> openCamera()
            else -> requestCameraPermission.launch(Manifest.permission.CAMERA)
        }
    }
}

// Android 13+ (API 33) — granular media permissions
// Old: READ_EXTERNAL_STORAGE (one permission for everything)
// New: each media type has its own permission
val mediaPermissions = if (Build.VERSION.SDK_INT >= 33) {
    arrayOf(
        Manifest.permission.READ_MEDIA_IMAGES,
        Manifest.permission.READ_MEDIA_VIDEO,
        Manifest.permission.READ_MEDIA_AUDIO
    )
} else {
    arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}

// Android 14+ (API 34) — partial photo access
// User can grant access to SELECTED photos only, not entire library
if (Build.VERSION.SDK_INT >= 34) {
    // READ_MEDIA_VISUAL_USER_SELECTED — partial access granted by photo picker
    // Always re-check on resume: user may have removed access to some photos
    val hasPartialAccess = ContextCompat.checkSelfPermission(ctx,
        Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) == PackageManager.PERMISSION_GRANTED
}

// Android 13+ — POST_NOTIFICATIONS is now a runtime permission!
// Apps targeting API 33+ must request this or notifications are silently blocked
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    requestPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}

Runtime permissions (API 23+) — dangerous permissions must be requested at the moment the feature is actually needed, not in a batch at app launch. Batching permissions on first open is the top reason users deny them. Use registerForActivityResult(RequestPermission()) — the lifecycle-aware replacement for the deprecated onRequestPermissionsResult().

"Don't ask again" handling — after the user denies a permission and checks "Don't ask again," shouldShowRequestPermissionRationale() returns false on the next call. At this point the system dialog can no longer be shown — your only option is to display a message directing the user to manually grant it in App Settings.

Android 13 granular media (API 33) — READ_EXTERNAL_STORAGE no longer works on API 33+. Apps must now request targeted permissions: READ_MEDIA_IMAGES for photos, READ_MEDIA_VIDEO for videos, READ_MEDIA_AUDIO for music. Request only what your feature actually needs.

Android 14 partial photo access (API 34) — READ_MEDIA_VISUAL_USER_SELECTED lets users grant access to only selected photos instead of the whole gallery. Apps that need media access should prefer the system Photo Picker, which requires zero permissions and works on all API levels via the Jetpack library.

POST_NOTIFICATIONS (API 33+) — since Android 13, displaying any notification requires this runtime permission. Apps that forget to request it will silently fail to show notifications. One-time permissions (Android 11+) allow granting camera or location "only this time" — your app loses the permission when backgrounded, so design flows to handle this gracefully mid-session. SCHEDULE_EXACT_ALARM (API 31+) requires either the SCHEDULE_EXACT_ALARM permission or USE_EXACT_ALARM; always check canScheduleExactAlarms() before calling setExact().

💡 Interview Tip

The most impressive answer structure: explain the 3-tier permission model (install-time → runtime → one-time), then call out the API 33+ changes as the most impactful recent shift. Interviewers love hearing about the "Don't ask again" flow — many candidates get it wrong. Say: "When shouldShowRequestPermissionRationale() returns false after a denial, the user has permanently denied it — I show a dialog directing them to App Settings, since I can no longer trigger the system dialog programmatically."

Mark it done or save it in the question bank · Back to top

MediumMost asked

7. What is a BroadcastReceiver? What is the difference between static and dynamic registration?

A BroadcastReceiver is Android's event system — it's like subscribing to a newsletter. When a system event happens (device boots, battery gets low, network changes) or your app sends a custom event, all registered receivers are notified simultaneously. The key distinction is how you register: statically in the Manifest (works even when your app isn't running) or dynamically in code (only works while your component is alive). Since Android 8.0, Google heavily restricted static receivers for most system events to prevent battery drain — so most receivers should be registered dynamically today.

// DYNAMIC registration — registered in code, lifecycle-tied, preferred approach
class NetworkActivity : AppCompatActivity() {

    private val networkReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            // ⚠️ Runs on the MAIN THREAD — do NOT block here!
            // Timeout: 10s foreground, 60s background — ANR if exceeded
            val action = intent.action
            when (action) {
                "com.app.NETWORK_CHANGED" -> updateNetworkUI()
                Intent.ACTION_BATTERY_LOW -> showBatteryWarning()
            }
            // For long work: use goAsync() to get extra time, then use coroutine
            // val pendingResult = goAsync()
            // CoroutineScope(Dispatchers.IO).launch { ... pendingResult.finish() }
        }
    }

    override fun onResume() {
        super.onResume()
        val filter = IntentFilter().apply {
            addAction("com.app.NETWORK_CHANGED")
            addAction(Intent.ACTION_BATTERY_LOW)
        }
        // Android 14+ (API 34): MUST specify exported flag or crash
        registerReceiver(networkReceiver, filter, RECEIVER_NOT_EXPORTED)
    }

    override fun onPause() {
        super.onPause()
        unregisterReceiver(networkReceiver) // ✅ ALWAYS unregister — prevents memory leaks
    }
}

// STATIC registration — AndroidManifest.xml
// ✅ Only use for: BOOT_COMPLETED, ACTION_MY_PACKAGE_REPLACED, and your own broadcasts
// ❌ Most system implicit broadcasts are blocked for static receivers since Android 8.0
//
// <receiver android:name=".BootReceiver"
//           android:exported="false">
//   <intent-filter>
//     <action android:name="android.intent.action.BOOT_COMPLETED" />
//   </intent-filter>
// </receiver>

// BOOT_COMPLETED receiver — re-schedule WorkManager/AlarmManager after reboot
class BootReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
            // WorkManager re-enqueues itself — no action needed
            // AlarmManager alarms: must re-schedule here
            rescheduleAlarms(context)
        }
    }
}

// Ordered broadcasts — receivers process in priority order, can abort chain
sendOrderedBroadcast(Intent("com.app.ORDERED_ACTION"), null)

Dynamic registration — registered in code (e.g. in onResume) and unregistered when the component pauses or stops. Only active while your Activity or Service is alive. This is the preferred approach for UI-related events like network changes where you only care while the screen is visible.

Static registration (Manifest) — declared in AndroidManifest.xml and can receive broadcasts even when the app isn't running. Since Android 8.0, most system implicit broadcasts (network changes, battery changes) are blocked for static receivers to prevent unnecessary process wake-ups. Only a small set — BOOT_COMPLETED, NEW_OUTGOING_CALL, ACTION_MY_PACKAGE_REPLACED — still work statically. For everything else, use dynamic registration or dedicated APIs like ConnectivityManager.registerNetworkCallback().

Android 14 required flag (API 34+) — when calling registerReceiver() you must now pass either RECEIVER_EXPORTED or RECEIVER_NOT_EXPORTED. Missing this causes a crash on API 34+ devices. Use RECEIVER_NOT_EXPORTED for any receiver that should only handle broadcasts from your own app.

Main thread execution — onReceive() runs on the main thread with a 10-second timeout in the foreground. Never do disk I/O, network calls, or database queries directly in it. Use goAsync() to get a PendingResult, then offload the work to a coroutine and call finish() when done.

Ordered broadcasts — sendOrderedBroadcast() delivers to receivers one by one in priority order; each can modify the result or abort the chain with abortBroadcast(). LocalBroadcastManager is deprecated — for intra-app communication use StateFlow, LiveData, or shared ViewModels instead, which are lifecycle-aware and far more efficient.

💡 Interview Tip

Three things that impress interviewers on this topic: (1) Mentioning the Android 8.0 background restriction — most candidates don't know most implicit broadcasts no longer work statically. (2) The Android 14 RECEIVER_NOT_EXPORTED requirement — a very recent breaking change. (3) Recommending against LocalBroadcastManager (deprecated) and suggesting StateFlow instead — shows you know modern patterns. Always mention that onReceive() runs on the main thread with a timeout.

Mark it done or save it in the question bank · Back to top

Hard

8. How does Android handle process death and state restoration? What is the difference between ViewModel, SavedStateHandle, and onSaveInstanceState?

Android is brutally aggressive about reclaiming memory — your app's process can be killed at any time when it's in the background, and the user expects to return to exactly where they left off. This is one of the most nuanced and frequently asked senior-level questions because there are three different mechanisms with different survival scopes, and many developers confuse them. Think of it like saving a document: ViewModel is RAM (fast but lost on crash), SavedStateHandle is a temp file (survives app kill), and Room/DataStore is actual disk storage (always survives). Understanding which tool fits which data type is what separates senior engineers from juniors.

// THE THREE LAYERS OF STATE SURVIVAL
// ┌─────────────────────────────────────────────────────────────┐
// │ Layer 3: Room/DataStore  — survives EVERYTHING (disk)       │
// │ Layer 2: SavedStateHandle — survives process death (Bundle) │
// │ Layer 1: ViewModel        — survives config change only     │
// └─────────────────────────────────────────────────────────────┘

// Layer 1: ViewModel — survives screen rotation, NOT process death
// ✅ Use for: list data, loaded images, API responses, UI state
// ❌ Lost when: user swipes app away, system kills process
@HiltViewModel
class SearchViewModel @Inject constructor(
    private val savedState: SavedStateHandle,  // Layer 2 built-in
    private val repository: UserRepository
) : ViewModel() {

    // Layer 2: SavedStateHandle — backed by Bundle, survives process death
    // ✅ Use for: search query, selected tab, scroll position, form input
    // ❌ Not for: large objects (lists, bitmaps) — Bundle has ~50KB limit
    val searchQuery: StateFlow<String> = savedState.getStateFlow("query", "")

    fun updateQuery(q: String) {
        savedState["query"] = q  // automatically serialized to Bundle
        loadResults(q)
    }

    // ViewModel holds large data — lost on process death but reloaded from Room
    val results: StateFlow<List<User>> = repository.searchUsers(searchQuery.value)
        .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
}

// Layer 3: onSaveInstanceState — Activity/Fragment-level, called just before kill
// Mostly superseded by SavedStateHandle — but still useful for very simple cases
override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putString("tab_selected", selectedTab)
    outState.putInt("scroll_y", scrollView.scrollY)
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // null = first launch; non-null = restored after kill or rotation
    val restoredTab = savedInstanceState?.getString("tab_selected")
}

// HOW TO TEST process death (critical for development):
// 1. Open app to the state you want to test
// 2. Press Home (app goes to background)
// 3. Run: adb shell am kill com.yourapp.package
// 4. Tap app icon — does it restore correctly?
// OR: Developer Options → Apps → "Don't keep activities" (simulates death on every Home press)

ViewModel (Layer 1) lives in memory and survives screen rotation and all configuration changes (language, theme, orientation). However, when the system kills your app's process due to memory pressure, the ViewModel is also destroyed. It's ideal for large datasets, loaded images, and UI state that can simply be reloaded if lost.

SavedStateHandle (Layer 2) lives inside your ViewModel but is backed by the same Bundle as onSaveInstanceState(). It survives process death and is automatically restored when the user returns to your app. Use it only for small, serializable data: a search query string, a selected item ID, a boolean flag. Bundles have a hard limit around 500KB, with ~50KB recommended in practice — never store large objects here.

onSaveInstanceState is called on Activity and Fragment just before the system might kill the process. It's mostly superseded by SavedStateHandle for ViewModel-backed screens, but remains useful for saving the state of Views not tracked by a ViewModel — such as an EditText's scroll position.

Room/DataStore (permanent layer) — data that must survive everything: process death, force-kill, device restart, or app update. Use Room for structured relational data (user profiles, cached API responses) and DataStore for simple key-value preferences. These are the source of truth for your app's state.

Configuration change vs process death — screen rotation is a configuration change where the Activity is recreated but the ViewModel survives. Process death means everything in memory is gone; only SavedStateHandle data and disk-persisted data remain. Test process death explicitly with adb shell am kill com.yourpackage after pressing Home — most developers never do this, which is why process-death bugs reach production.

💡 Interview Tip

Draw the 3-layer pyramid: ViewModel at top (fastest, least persistent), Room at bottom (slowest, always persistent). The most impressive detail: explain HOW to test process death — "adb shell am kill com.package" after pressing Home. Most developers never actually test this, so knowing the testing method signals real-world experience. Also mention the Bundle size limit for SavedStateHandle — it shows you know the practical constraints, not just the theory.

Mark it done or save it in the question bank · Back to top

Hard

9. What is the Android Predictive Back Gesture introduced in Android 13/14? How do you implement it?

Predictive Back is a UX feature introduced in Android 13 (API 33) that gives users a live preview of where the back gesture will take them — they can see the destination screen peek in from behind before completing the gesture. Think of it like peeking behind a door before you open it fully. Before this feature, the back gesture was a black box: users had no idea if pressing back would close the app, go to the previous screen, or dismiss a dialog. Implementing this correctly is now mandatory for modern Android apps, and it requires abandoning the deprecated onBackPressed() in favor of the new OnBackPressedDispatcher API.

// Step 1: Opt-in in AndroidManifest.xml (required!)
// <application android:enableOnBackInvokedCallback="true" ... >
// Without this, the predictive animation is disabled even on Android 13+ devices

// Step 2: Replace deprecated onBackPressed() with OnBackPressedDispatcher
class EditProfileActivity : AppCompatActivity() {

    private var hasUnsavedChanges = false

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Lifecycle-aware callback — automatically disabled when Activity is destroyed
        val backCallback = object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                if (hasUnsavedChanges) {
                    showDiscardChangesDialog(
                        onDiscard = {
                            isEnabled = false  // disable this callback
                            onBackPressedDispatcher.onBackPressed()  // let system handle
                        }
                    )
                } else {
                    isEnabled = false
                    onBackPressedDispatcher.onBackPressed()
                }
            }
        }
        onBackPressedDispatcher.addCallback(this, backCallback)

        // Dynamically enable/disable based on form state
        viewModel.formModified.observe(this) { modified ->
            backCallback.isEnabled = modified
        }
    }
}

// In Jetpack Compose — BackHandler composable
@Composable
fun EditProfileScreen(viewModel: ProfileViewModel = hiltViewModel()) {
    val hasChanges by viewModel.formModified.collectAsState()

    // BackHandler is automatically tied to Compose's lifecycle
    BackHandler(enabled = hasChanges) {
        viewModel.showDiscardDialog()
    }

    // Rest of your UI
}

// Android 14+ — custom back animation with OnBackAnimationCallback
@RequiresApi(34)
val animCallback = object : OnBackAnimationCallback {
    override fun onBackStarted(backEvent: BackEventCompat) {
        // User started back gesture — animate your view
        myView.animate().translationX(backEvent.touchX).start()
    }
    override fun onBackProgressed(backEvent: BackEventCompat) {
        // Progress 0.0 → 1.0 as user swipes — animate along with finger
        myView.alpha = 1f - backEvent.progress
    }
    override fun onBackInvoked() { finish() } // gesture completed
    override fun onBackCancelled() { myView.animate().translationX(0f).start() }
}

What is Predictive Back — users see a live peek of the destination screen while pulling back, before committing to the gesture. It appears as a floating card animating in from behind the current screen. Android 13 enabled this at the system level, but apps must explicitly opt in via a manifest flag.

Opt-in required — add android:enableOnBackInvokedCallback="true" to your <application> tag in AndroidManifest.xml. Without it, Android falls back to the old behavior and predictive animations never appear, even on Android 13+ devices.

onBackPressed() is deprecated since API 33. Use OnBackPressedDispatcher.addCallback() instead — it's lifecycle-aware, so the callback is automatically removed when the Activity or Fragment is destroyed. In Compose, BackHandler(enabled = condition) { } wraps the dispatcher with a clean one-liner; the enabled flag lets you toggle interception dynamically, for example only when a form has unsaved changes.

OnBackAnimationCallback (API 34+) provides live progress values as the user swipes back, letting you animate your own UI in sync with the gesture. Use BackEventCompat from AndroidX for backward compatibility across API levels. Apps targeting API 34+ that still override the deprecated onBackPressed() will see Lint warnings, and their destinations won't show predictive animations.

💡 Interview Tip

Three things to say: (1) Mention the manifest opt-in — many developers forget this step and wonder why the animation doesn't appear. (2) Explain HOW OnBackPressedCallback works: set isEnabled = false then call onBackPressedDispatcher.onBackPressed() to "pass through" to the system — this pattern is what makes it composable when multiple components want back handling. (3) For Compose apps, just mention BackHandler — it's two lines and handles everything automatically. This shows you know both paradigms.

Mark it done or save it in the question bank · Back to top

Hard

10. How does the Android Splash Screen API work? What replaced the old SplashActivity pattern?

Before Android 12, developers used a hacky approach: either an empty SplashActivity that immediately launched MainActivity (adding an extra Activity hop and slowing startup), or setting a theme with a drawable background (which looked like a splash but was actually just the window background with no control over timing). Android 12 (API 31) introduced the official SplashScreen API that fixes all of this — the splash is drawn by the system before any app code runs, so there's no white flash, and you get precise control over the icon, background, animation, and when to dismiss it. For apps targeting API 31+, the system now enforces a branded splash screen automatically.

// STEP 1: Add Jetpack dependency (backports to API 21+)
// implementation("androidx.core:core-splashscreen:1.0.1")

// STEP 2: Define splash theme in res/values/themes.xml
// <style name="Theme.App.Starting" parent="Theme.SplashScreen">
//   <item name="windowSplashScreenBackground">@color/brand_green</item>
//   <!-- Animated icon: use animated vector drawable for Android 12+ -->
//   <item name="windowSplashScreenAnimatedIcon">@drawable/ic_logo_animated</item>
//   <item name="windowSplashScreenIconBackgroundColor">@color/white</item>
//   <!-- Transition to your actual app theme after splash -->
//   <item name="postSplashScreenTheme">@style/Theme.App</item>
// </style>

// STEP 3: Set splash theme on MainActivity in AndroidManifest.xml
// <activity android:name=".MainActivity"
//           android:theme="@style/Theme.App.Starting" />

// STEP 4: Install in MainActivity.onCreate() — MUST be before super.onCreate()
class MainActivity : AppCompatActivity() {

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        // ⚠️ installSplashScreen() MUST come before super.onCreate()
        // It installs the theme and transitions — after super() is too late
        val splashScreen = installSplashScreen()

        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // Keep splash on screen while app loads initial data
        // Called every frame — splash stays visible while this returns true
        splashScreen.setKeepOnScreenCondition {
            viewModel.isInitializing.value  // returns false when ready
        }

        // Custom exit animation — called once when splash is about to dismiss
        splashScreen.setOnExitAnimationListener { splashScreenViewProvider ->
            val iconView = splashScreenViewProvider.iconView
            val splashRootView = splashScreenViewProvider.view

            // Animate icon upward while fading out splash
            ObjectAnimator.ofFloat(iconView, View.TRANSLATION_Y, 0f, -200f).apply {
                duration = 400L
                interpolator = AccelerateInterpolator()
                start()
            }
            ObjectAnimator.ofFloat(splashRootView, View.ALPHA, 1f, 0f).apply {
                duration = 400L
                doOnEnd { splashScreenViewProvider.remove() } // ✅ always call remove()!
                start()
            }
        }
    }
}

// OLD approach (pre-Android 12) — don't do this anymore:
// 1. SplashActivity: adds Activity hop, slows cold start, bad UX
// 2. windowBackground drawable: no timing control, no animations
// 3. Thread.sleep() in onCreate: causes ANR — never do this!

System-drawn splash — the SplashScreen API renders the splash before any of your code runs, even before Application.onCreate(). This eliminates the white or black flash of the old SplashActivity approach, because there's no gap between process launch and the first rendered frame.

installSplashScreen() before super.onCreate() — the most common mistake is calling it after. The theme transition happens inside super.onCreate(), so you must intercept before it. Make it the very first line in your MainActivity's onCreate().

setKeepOnScreenCondition is polled every frame — as long as the lambda returns true, the splash stays visible. Use this to delay dismissal until your initial data (auth state, feature flags) is ready. Only read a ViewModel boolean here; never do heavy work inside this callback.

setOnExitAnimationListener fires once when your condition returns false, giving you access to the splash icon and root view to build a custom exit animation. You must call splashScreenViewProvider.remove() at the end — forgetting it leaves the splash frozen on screen, making the app appear hung.

Animated icons (Android 12+) — provide an AnimatedVectorDrawable for the splash icon; Android automatically caps the animation at 1000ms. The Jetpack core-splashscreen library backports the entire API to Android 5.0 (API 21) with a best-effort simulation on older devices.

💡 Interview Tip

The two details that always impress: (1) installSplashScreen() before super.onCreate() — explain WHY (theme transition happens in super.onCreate() so you must intercept before it). (2) Mention that you must always call splashScreenViewProvider.remove() in your exit animation — forgetting this is the most common splash bug where the app appears frozen with a blank white screen. If you've actually implemented this in production, say so — hands-on experience with this API is rare.

Mark it done or save it in the question bank · Back to top

MediumMost asked

11. What is the difference between Serializable and Parcelable? Which should you use in 2025?

When you navigate between screens in Android — say, from a product list to a product detail screen — you often need to pass objects along with the Intent or Fragment arguments. Android provides two ways to make objects "passable": Serializable (a standard Java interface) and Parcelable (Android's own optimized interface). The key difference is speed: Serializable uses Java reflection to figure out what to serialize at runtime (slow, lots of temporary objects), while Parcelable writes data to a shared memory block with no reflection at all (very fast). In 2025, the answer is always @Parcelize — a Kotlin annotation that auto-generates all the Parcelable boilerplate for you, giving you Parcelable performance with the simplicity of Serializable.

// ❌ Serializable — uses Java reflection, ~10x slower, creates many temp objects
// GC pressure in frequently-passed objects (RecyclerView items, etc.)
data class UserOld(val id: String, val name: String, val age: Int) : Serializable

// ❌ Manual Parcelable — fast but 30+ lines of boilerplate for a simple class
class UserManual(val id: String, val name: String, val age: Int) : Parcelable {
    constructor(parcel: Parcel) : this(
        parcel.readString()!!,
        parcel.readString()!!,
        parcel.readInt()
    )
    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeString(id)
        parcel.writeString(name)
        parcel.writeInt(age)
    }
    override fun describeContents() = 0
    companion object CREATOR : Parcelable.Creator<UserManual> {
        override fun createFromParcel(parcel: Parcel) = UserManual(parcel)
        override fun newArray(size: Int) = arrayOfNulls<UserManual>(size)
    }
}

// ✅ @Parcelize — RECOMMENDED in 2025
// Same Parcelable performance, zero boilerplate — 1 line!
// Requires: id("kotlin-parcelize") plugin in build.gradle.kts
@Parcelize
data class User(val id: String, val name: String, val age: Int) : Parcelable

// Nested Parcelable — works automatically with @Parcelize
@Parcelize
data class Address(val street: String, val city: String) : Parcelable

@Parcelize
data class UserWithAddress(val user: User, val address: Address) : Parcelable

// Passing via Intent
val intent = Intent(this, DetailActivity::class.java).apply {
    putExtra("user", user)  // works because User is Parcelable
}

// Receiving — API 33+ requires typed getParcelableExtra()
// Old (deprecated, ClassCastException risk):
// val user = intent.getParcelableExtra<User>("user")
// New (safe, required API 33+):
val user = if (Build.VERSION.SDK_INT >= 33) {
    intent.getParcelableExtra("user", User::class.java)
} else {
    @Suppress("DEPRECATION") intent.getParcelableExtra("user")
}

// In Jetpack Navigation (recommended approach) — pass IDs, not objects
// Best practice: pass only the userId via args, fetch the full object in the destination
navController.navigate(DetailRoute(userId = user.id))

Serializable (avoid) — the standard Java interface uses reflection to serialize all fields at runtime, creating many temporary objects and causing GC pressure. It's roughly 10x slower than Parcelable and should be avoided in objects that are frequently passed between screens or appear in lists.

Parcelable (fast, but verbose) — Android's own interface that writes directly to a shared memory block with no reflection, making it very fast. The downside is the manual implementation: writeToParcel(), CREATOR, and describeContents() add about 30 lines of error-prone boilerplate for even a simple 3-field class.

@Parcelize (recommended 2025) — a Kotlin annotation that auto-generates all Parcelable boilerplate at compile time. You get Parcelable's performance with a single line. Enable it by adding id("kotlin-parcelize") to your build.gradle.kts plugins block. This is always the right choice in new Kotlin code.

API 33+ typed getParcelableExtra() — the old getParcelableExtra<Type>(key) is deprecated in API 33 and replaced with getParcelableExtra(key, Type::class.java). The new form is type-safe and avoids ClassCastException. Use the version-guarded approach for backward compatibility.

Bundle size limits — Bundles passed via Intents share a ~1MB transaction buffer across all extras. Never pass large lists or bitmaps this way — pass an ID and load the full object from a repository in the destination. With Jetpack Navigation's type-safe routes, you rarely need to pass full objects at all; pass only the ID.

💡 Interview Tip

Never say "I use Serializable because it's simpler." The correct answer in 2025 is always @Parcelize — it gives you Parcelable performance with just one annotation. Two bonus points: (1) mention the Bundle transaction limit (~1MB) — many candidates don't know this and it's a common source of TransactionTooLargeException crashes in production; (2) mention the API 33 typed getParcelableExtra() change — it shows you stay current with deprecations.

Mark it done or save it in the question bank · Back to top

MediumMost asked

12. What is the difference between LaunchMode: standard, singleTop, singleTask, and singleInstance?

Activity launch modes control what happens when you try to start an Activity that might already exist somewhere in the back stack. The back stack is like a pile of cards — each new screen is a card placed on top. Launch modes let you say "don't add a new card if one is already on top" or "there can only ever be one card of this type in the entire pile." Getting this wrong causes confusing navigation bugs — like pressing Back from a screen and landing on the same screen you just came from, or the user being unable to return to the main screen properly. singleTask in particular has a surprising behavior that trips up many developers in interviews.

// Defined in AndroidManifest.xml — applied to the Activity
// <activity android:name=".ProductActivity" android:launchMode="singleTop" />
// Can also be set per-intent with Intent flags (overrides manifest)

// ── STANDARD (default) ──────────────────────────────────────────────
// A new instance is ALWAYS created, every time
// Back stack: [A] → startActivity(B) → [A, B]
// startActivity(B) again → [A, B, B, B]  ← three B instances!
// Use for: most screens (product detail, article, settings)

// ── SINGLE TOP ──────────────────────────────────────────────────────
// Reuses existing instance ONLY if it's already at the TOP of the stack
// Back stack: [A, B] → startActivity(B) → [A, B] (reused, onNewIntent called)
// Back stack: [A, B, C] → startActivity(B) → [A, B, C, B] (new instance, B not at top!)
// Use for: notification deep links, search results (prevent duplicates)

// ── SINGLE TASK ─────────────────────────────────────────────────────
// One instance per task. If exists anywhere in stack, brings to front + CLEARS above it
// Back stack: [A, B, C] → startActivity(A with singleTask) → [A]  ← B and C DESTROYED!
// Use for: MainActivity, home screen entry points

// ── SINGLE INSTANCE ─────────────────────────────────────────────────
// Only one instance exists — it lives in its OWN separate task
// No other activities can be in the same task as it
// Use for: Calculator, Camera app — completely isolated screens

// ── HANDLING onNewIntent ─────────────────────────────────────────────
// When singleTop or singleTask reuses an existing instance, onCreate() is NOT called
// Instead, onNewIntent() is called — you MUST handle it
class MainActivity : AppCompatActivity() {

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)  // ✅ Update getIntent() to return the new intent
        // Handle the new data — e.g., navigate to a deep link destination
        val deepLinkPath = intent.getStringExtra("deep_link")
        if (deepLinkPath != null) handleDeepLink(deepLinkPath)
    }
}

// ── INTENT FLAGS (set per-intent, overrides launchMode) ──────────────
// FLAG_ACTIVITY_SINGLE_TOP = singleTop behavior for this launch only
// FLAG_ACTIVITY_CLEAR_TOP  = singleTask behavior for this launch only
// FLAG_ACTIVITY_NEW_TASK + FLAG_ACTIVITY_CLEAR_TASK = full stack clear (after login)
Intent(this, HomeActivity::class.java).apply {
    flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}

standard creates a new Activity instance every single time, even if one already exists in the stack. This is the default and most common mode — use it for screens where multiple instances are fine, such as a product detail screen where the user might open several products in sequence.

singleTop reuses the existing instance only if it's currently at the very top of the stack. If the same Activity exists somewhere below the top, a brand-new instance is created anyway — this catches many developers by surprise. It's perfect for notification deep-link screens to prevent duplicate stacking.

singleTask keeps only one instance per task. If the Activity already exists anywhere in the stack, Android brings it to the front and destroys every Activity above it. Use this for your MainActivity and primary app entry points.

singleInstance goes further — the Activity lives in its own completely isolated task with no other Activities allowed in it. Pressing Back from another app that launched a singleInstance Activity returns the user to their original app, bypassing your Activity's parent entirely. Use sparingly, for truly standalone screens like a system-overlay calculator.

onNewIntent() is critical — when singleTop or singleTask reuses an existing instance, onCreate() is not called; only onNewIntent() fires. Always override it and call setIntent(intent) to update what getIntent() returns, otherwise your screen processes stale data from the original launch. For Compose apps, Jetpack Navigation handles all these scenarios via popUpTo() and launchSingleTop = true, so you rarely need manifest launchMode at all.

💡 Interview Tip

Draw the back stack on paper (or verbally describe it as a stack): "Imagine A-B-C on the stack. Now I start A with singleTask. B and C are popped off and destroyed — only A remains." This visual explanation makes the singleTask behavior crystal clear. The most common trap question is: "What if a singleTop Activity exists in the stack but is NOT at the top?" — answer: a NEW instance is created. Many candidates get this wrong. Also mention: FLAG_ACTIVITY_NEW_TASK + FLAG_ACTIVITY_CLEAR_TASK for post-login stack clearing — a very practical real-world use case.

Mark it done or save it in the question bank · Back to top

Hard

13. How does Android handle deep links? What is the difference between deep links, App Links, and custom scheme URIs?

Deep links let external sources — emails, SMS messages, web pages, QR codes, other apps — navigate users directly to a specific screen inside your app, bypassing the home screen. There are three types in Android, and they differ critically in security and reliability. Custom URI schemes (like myapp://product/123) are the oldest and most fragile approach — any app can register the same scheme, causing disambiguation dialogs. App Links use verified HTTPS URLs that are cryptographically tied to your app, providing direct launch with no dialog. In 2025, App Links are the recommended standard for all production apps.

// 3 TYPES OF DEEP LINKS:
// 1. Custom URI schemes  → myapp://product/123   ❌ any app can intercept
// 2. Deep links (HTTP)   → http://myapp.com/...  ⚠️ shows chooser dialog
// 3. App Links (HTTPS)   → https://myapp.com/... ✅ verified, opens directly

// ── APP LINKS SETUP ─────────────────────────────────────────────────

// Step 1: Declare in AndroidManifest.xml with autoVerify="true"
// <activity android:name=".ProductActivity">
//   <intent-filter android:autoVerify="true">
//     <action android:name="android.intent.action.VIEW" />
//     <category android:name="android.intent.category.DEFAULT" />
//     <category android:name="android.intent.category.BROWSABLE" />
//     <data android:scheme="https" android:host="shop.myapp.com" />
//     <data android:pathPattern="/product/.*" />
//   </intent-filter>
// </activity>

// Step 2: Host assetlinks.json at your domain
// URL: https://shop.myapp.com/.well-known/assetlinks.json
// Content:
// [{
//   "relation": ["delegate_permission/common.handle_all_urls"],
//   "target": {
//     "namespace": "android_app",
//     "package_name": "com.myapp.shop",
//     "sha256_cert_fingerprints": ["AB:CD:EF:..."]
//   }
// }]
// Get fingerprint: keytool -list -v -keystore release.keystore

// Step 3: Handle the deep link in Activity
class ProductActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        handleDeepLinkIntent(intent)
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        handleDeepLinkIntent(intent) // handle when Activity already running
    }

    private fun handleDeepLinkIntent(intent: Intent) {
        if (intent.action == Intent.ACTION_VIEW) {
            val uri: Uri = intent.data ?: return
            // https://shop.myapp.com/product/123 → lastPathSegment = "123"
            val productId = uri.lastPathSegment
            val referrer = uri.getQueryParameter("ref")  // ?ref=email
            loadProduct(productId, referrer)
        }
    }
}

// ── JETPACK NAVIGATION — built-in deep link support ──────────────────
// Compose Navigation handles deep links declaratively
NavHost(navController, startDestination = "home") {
    composable(
        route = "product/{productId}",
        arguments = listOf(navArgument("productId") { type = NavType.StringType }),
        deepLinks = listOf(
            navDeepLink { uriPattern = "https://shop.myapp.com/product/{productId}" },
            navDeepLink { uriPattern = "myapp://product/{productId}" }  // fallback
        )
    ) { backStackEntry ->
        ProductScreen(productId = backStackEntry.arguments?.getString("productId") ?: return@composable)
    }
}

// Test deep links with adb:
// adb shell am start -W -a android.intent.action.VIEW \
//   -d "https://shop.myapp.com/product/123" com.myapp.shop

Custom URI schemes (myapp://) are simple to implement but insecure — any app can declare the same scheme in its manifest, triggering an "Open with..." disambiguation dialog when a user clicks the link. Avoid these for any security-sensitive flow like OAuth callbacks or payment redirects.

Deep links (HTTP/HTTPS without verification) use real web URLs but without an assetlinks.json file on your server. Android still shows the app chooser because it can't verify the URL belongs to your app. These are fine for testing but not for production user-facing links.

App Links (verified HTTPS) are the gold standard. You host an assetlinks.json file at https://yourdomain.com/.well-known/assetlinks.json containing your app's package name and SHA-256 certificate fingerprint. Android verifies this at install time, and from then on clicking matching URLs opens your app directly — no dialog, no ambiguity. The file must be served with Content-Type: application/json, reachable without redirects, and should include fingerprints for both debug and release keystores.

Handling in code — deep link Intents arrive via onCreate() on first launch and via onNewIntent() if the Activity is already running. Always handle both cases, especially for singleTask Activities. Jetpack Navigation's navDeepLink { } DSL auto-registers deep link routes and handles argument extraction automatically — far simpler than manual intent parsing.

Testing verification — use adb shell pm get-app-links com.package.name to check if App Links verification succeeded after install. The most common failure reasons are: assetlinks.json unreachable, wrong SHA-256 fingerprint, or missing Content-Type header on the server response.

💡 Interview Tip

The three-way distinction is the key: custom scheme (insecure), plain deep link (shows chooser), App Link (verified, opens directly). Then explain assetlinks.json: "It's a JSON file hosted at /.well-known/assetlinks.json on your domain, containing your app's package name and SHA-256 certificate fingerprint. Android downloads this at install time to verify the association — only your app gets to handle those URLs." Also mention testing with adb — it shows you've actually debugged this in practice.

Mark it done or save it in the question bank · Back to top

MediumMost asked

14. What is ANR? What are the most common causes and how do you diagnose it?

ANR — Application Not Responding — is one of the most user-frustrating issues in Android. When Android detects that your app's main thread hasn't responded to a user input event for 5 seconds (or a broadcast for 10 seconds), it shows a dialog letting the user force-close your app. Think of the main thread as a single cashier at a store checkout — if that cashier is busy doing stock inventory (disk I/O, network call), no customers can be served and they get angry. The fix is always the same: keep the main thread free for UI work only, and push all slow operations to background threads or coroutines with IO dispatcher.

// ANR TIMEOUTS (when Android shows the "App Not Responding" dialog):
// Input event (touch/key): 5 seconds on the main thread
// BroadcastReceiver:       10 seconds (foreground), 60 seconds (background)
// Service start/bind:      20 seconds (foreground), 200 seconds (background)

// ── COMMON ANR CAUSES ────────────────────────────────────────────────

// ❌ 1. Disk I/O on main thread (most common)
override fun onCreate(savedInstanceState: Bundle?) {
    val config = File("/storage/config.json").readText()  // BLOCKS main thread
    val prefs = PreferenceManager.getDefaultSharedPreferences(context) // disk read!
}

// ❌ 2. Network call on main thread (NetworkOnMainThreadException)
val response = URL("https://api.example.com/data").readText() // instant crash/ANR

// ❌ 3. Long-running database query
val users = db.userDao().getAllUsers()  // synchronous Room query on main thread

// ❌ 4. Holding a lock that another thread owns
// ❌ 5. Deadlock between main thread and a background thread

// ── FIXES ───────────────────────────────────────────────────────────

// ✅ Move ALL slow work to Dispatchers.IO
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleScope.launch {
            val data = withContext(Dispatchers.IO) {
                loadFromDisk()  // runs on IO thread pool
            }
            updateUi(data)   // automatically back on Main dispatcher
        }
    }
}

// ✅ Enable StrictMode in debug — catches violations before they become ANRs
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) {
            StrictMode.setThreadPolicy(
                StrictMode.ThreadPolicy.Builder()
                    .detectAll()    // catches disk, network, slow calls
                    .penaltyLog()   // logs violations to Logcat
                    .build()
            )
        }
    }
}

// ── DIAGNOSING ANRs IN PRODUCTION ───────────────────────────────────
// 1. Play Console → Android Vitals → ANRs & Crashes
// 2. Look for the main thread stack trace — what was it blocked on?
// 3. adb pull /data/anr/traces.txt  (on debug devices)
// 4. adb shell dumpsys activity  → shows main thread state
// 5. Android Studio Profiler → CPU section → "Detect Jank"

// ── KEY THRESHOLD ────────────────────────────────────────────────────
// Play Store "bad behavior" threshold: ANR rate > 0.47% of daily sessions
// Exceeding this triggers Play Store warnings and ranking penalties

What triggers ANR — input timeout: the main thread doesn't respond to a touch or key event within 5 seconds. BroadcastReceiver timeout: onReceive() blocks for more than 10 seconds in the foreground. Service timeout: a Service fails to complete startService() or bindService() within 20 seconds.

Most common cause — disk I/O — reading SharedPreferences, writing files, or querying Room synchronously on the main thread. Even reading a small config file can block for hundreds of milliseconds if storage is busy. Always use Dispatchers.IO for any file operations. Android throws NetworkOnMainThreadException immediately for network calls on the main thread, but locking on a monitor held by a network thread can still produce ANRs.

StrictMode — your early warning system — enable StrictMode in debug builds to catch all disk and network violations on the main thread before they become production ANRs. Use penaltyDeath() for zero tolerance during development so every violation is a hard crash that forces a fix before merging.

Diagnosing production ANRs — Play Console → Android Vitals → ANRs shows real device stack traces from affected users. The main thread's stack trace tells you exactly what it was blocked on — a lock, a synchronized block, or a blocking I/O call. Deadlocks are a sneaky ANR cause: main thread holds lock A while waiting for lock B, background thread holds lock B while waiting for lock A. Use thread-safe structures (ConcurrentHashMap, Channel, StateFlow) instead of manual locking.

ANR rate threshold — Google Play flags apps with ANR rates above 0.47% of daily sessions as "bad behavior," which can affect Play Store ranking and visibility. Monitor this metric weekly in Android Vitals and aim to keep it well below 0.3%.

💡 Interview Tip

The strongest answer structure: (1) Explain what causes ANR with the 5-second input timeout rule. (2) Say "I enable StrictMode in debug builds with penaltyDeath — any disk or network violation on the main thread crashes the debug app, forcing us to fix it." (3) Mention Android Vitals: "I keep our ANR rate below 0.3% — well under the 0.47% bad behavior threshold." This trifecta shows you understand prevention, detection, and production monitoring.

Mark it done or save it in the question bank · Back to top

Hard

15. What is the difference between compileSdk, minSdk, and targetSdk? What happens if you set targetSdk incorrectly?

Every Android app has three SDK version numbers in its build.gradle, and confusing them is one of the most common mistakes for new Android developers. Think of them as three different things: compileSdk is which Android API you write code against (like choosing which dictionary to use), minSdk is the minimum Android version your app can install on (your minimum customer requirement), and targetSdk is which Android version you've tested your app against and certified it works on (tells Android which new behaviors to apply to your app). Getting targetSdk wrong is the most dangerous mistake — raising it without testing can silently activate new system behaviors that break your app in subtle ways on users' devices.

// build.gradle.kts — the three SDK version settings explained
android {
    // compileSdk: which Android API your code is compiled AGAINST
    // ✅ Always use the latest stable Android API (currently 36)
    // Does NOT affect what device can install your app
    // Just determines which APIs appear in autocomplete / compile-time checks
    compileSdk = 36

    defaultConfig {
        // minSdk: the minimum Android version your app supports
        // API 24 (Android 7.0) = ~98% of active Android devices (2025)
        // API 26 (Android 8.0) = ~97% — enables all modern Kotlin features
        // Raising minSdk drops support for older devices but simplifies code
        minSdk = 26

        // targetSdk: what Android version you've TESTED and certified your app on
        // Android applies NEW OS behaviors based on this number
        // ⚠️ Never raise this without testing — new behaviors can BREAK your app
        // Google Play requirements 2025:
        //   - New apps must target API ≥ 35 from August 2025
        //   - Existing apps must update within 12 months of Android release
        targetSdk = 36
    }
}

// WHAT targetSdk UNLOCKS (behavior changes per API level):
// targetSdk 31 (Android 12) → PendingIntent.FLAG_IMMUTABLE required
// targetSdk 31              → Exact alarms need SCHEDULE_EXACT_ALARM permission
// targetSdk 33 (Android 13) → POST_NOTIFICATIONS is a runtime permission
// targetSdk 33              → Foreground service types mandatory
// targetSdk 34 (Android 14) → Photos partial access (READ_MEDIA_VISUAL_USER_SELECTED)
// targetSdk 35 (Android 15) → Edge-to-edge enforced by default (BIGGEST CHANGE)
// targetSdk 35              → Health connect privacy controls
// targetSdk 36 (Android 16) → Predictive Back enforcement

// Runtime API level check — always do this before using new APIs
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { // API 35
    // Use API 35+ features safely
}

// Relationship: minSdk ≤ targetSdk ≤ compileSdk
// compileSdk = 36, targetSdk = 35, minSdk = 26 ← valid
// compileSdk = 34, targetSdk = 35 ← INVALID — can't target what you don't compile against

// Example: targetSdk 35 breaks apps that don't handle window insets
// Before: apps could opt OUT of edge-to-edge
// After:  edge-to-edge is FORCED — your content extends behind system bars
// Fix: add proper WindowInsets handling (or your FAB hides behind nav bar!)

compileSdk is the API level your code is compiled against — it determines which methods and classes appear in autocomplete and are checked at compile time. It does not affect which devices can install your app. Always set it to the latest stable API to get access to the newest APIs immediately.

minSdk is the minimum Android version that can install your app. Set too low and you need many version-check workarounds; set too high and you exclude real users. In 2025, minSdk 26 (Android 8.0) is reasonable — it covers 97%+ of active devices and unlocks all modern Kotlin standard library features.

targetSdk is the most important and most misunderstood setting. It tells Android which OS behaviors to activate for your app. Raising it is not a simple version bump — when you increase targetSdk, Android flips on new behavioral changes. Never raise it without thorough testing, because these changes can silently break things that worked before.

targetSdk 35 — edge-to-edge enforcement — the biggest breaking change of 2025. Apps targeting API 35 automatically draw behind the status bar and navigation bar. Any app that doesn't properly handle WindowInsets will have FABs, BottomNavigationBars, and last list items hidden behind the system navigation bar — invisible and untappable.

Google Play requirements — Play has minimum targetSdk requirements that increase every year. In 2025, new apps must target API 35 or higher. You can compile against API 36 while still supporting API 26 devices — just guard new API calls with Build.VERSION.SDK_INT checks to prevent crashes on older Android versions.

💡 Interview Tip

The single most impressive point on this topic: targetSdk 35 forcing edge-to-edge is the biggest breaking change for Android apps in 2025. Say "Raising targetSdk is not just a number bump — it activates new OS behaviors. When we upgraded to targetSdk 35, our entire team had to audit every screen for WindowInsets handling. FABs were hidden behind the navigation bar, and bottom sheets had overlapping content." This shows you've experienced the pain firsthand — interviewers who've done this upgrade will immediately recognize your experience as genuine.

Mark it done or save it in the question bank · Back to top

MediumMost asked

16. What is edge-to-edge display in Android? How do you handle window insets properly?

Edge-to-edge means your app's content draws all the way to the edges of the screen — behind the status bar at the top and the navigation bar at the bottom — instead of stopping just below and above them. This creates a more immersive, modern look. Before Android 15 (targetSdk 35), this was optional and apps had to explicitly enable it. Now it's mandatory: any app targeting API 35 or higher draws edge-to-edge automatically whether it wants to or not. This broke thousands of apps when they upgraded — buttons were hidden behind the navigation bar, bottom sheets were partially clipped, and navigation menus became untappable. The fix is learning to properly handle WindowInsets.

// STEP 1: Enable edge-to-edge in MainActivity (must be before setContentView)
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()  // androidx.activity:activity 1.8.0+ — handles flags automatically
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

// STEP 2: Handle insets in View system
// Apply padding to root view so content isn't hidden behind system bars
ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { view, windowInsets ->
    val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
    view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
    windowInsets
}

// More precise — only add top padding to toolbar, bottom to content
ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { v, insets ->
    v.updatePadding(top = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top)
    insets
}
ViewCompat.setOnApplyWindowInsetsListener(binding.fab) { v, insets ->
    val navBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
    v.updateLayoutParams<MarginLayoutParams> { bottomMargin = navBars.bottom + 16.dpToPx() }
    insets
}

// STEP 3: In Jetpack Compose — Scaffold handles most insets automatically
@Composable
fun HomeScreen() {
    Scaffold(
        topBar = { TopAppBar(title = { Text("Home") }) },
        floatingActionButton = { FloatingActionButton(onClick = {}) { Icon(/*...*/) } },
        // Scaffold automatically pads content to avoid top bar, FAB, bottom bar
    ) { paddingValues ->
        LazyColumn(contentPadding = paddingValues) {  // ✅ pass padding to list
            items(items) { item -> ItemCard(item) }
        }
    }
}

// For screens without Scaffold — use WindowInsets directly
@Composable
fun CustomScreen() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(WindowInsets.safeDrawing.asPaddingValues())
    ) {
        // Content here — safely avoids all system bars, notches, etc.
    }
}

// Useful inset types:
// WindowInsets.systemBars       → status bar + navigation bar
// WindowInsets.statusBars       → just the status bar
// WindowInsets.navigationBars   → just the nav bar
// WindowInsets.safeDrawing      → all system UI + notch + cutout
// WindowInsets.ime              → software keyboard height

What edge-to-edge means — your app's decorView draws behind the status bar and navigation bar. The system bars become transparent or semi-transparent, with your content visible through them. This looks great for immersive experiences like photo viewers and maps, but requires extra work to ensure interactive content isn't hidden behind those bars.

enableEdgeToEdge() is the single recommended way to opt into (or comply with) edge-to-edge on all API levels. It handles the internal window flag differences across Android versions. Call it before super.onCreate().

WindowInsets — the solution — WindowInsets tell you the exact pixel dimensions of each system bar, keyboard, display cutout, and safe area. Apply them as padding or margins so your interactive content is always visible. In the View system, use ViewCompat.setOnApplyWindowInsetsListener — it fires whenever insets change (keyboard appears, device rotates). Apply systemBars insets to your root view, or target specific insets to specific views (toolbar gets statusBars insets, FAB gets navigationBars insets).

Compose — Scaffold handles it — Scaffold's contentPadding (received as paddingValues) already accounts for the TopAppBar, BottomNavigationBar, and FAB. Always pass it to your LazyColumn's contentPadding — without this, list items scroll under the bottom navigation bar and become unreachable.

IME (keyboard) insets — WindowInsets.ime gives you the keyboard's current height. Use it to push content above the keyboard on login, search, and chat screens. In Compose, the imePadding() modifier and WindowInsets.ime.asPaddingValues() handle this automatically and animate smoothly as the keyboard slides in and out.

💡 Interview Tip

This is the hottest Android topic of 2025 — many apps broke when targeting API 35. The most impressive answer has three parts: (1) Explain WHAT edge-to-edge is and why it matters; (2) Show you know BOTH the View system (setOnApplyWindowInsetsListener) and Compose (Scaffold + paddingValues) approaches; (3) Mention the IME insets too — keyboard handling with WindowInsets.ime is a common real-world pain point that shows deeper knowledge beyond just the status bar issue.

Mark it done or save it in the question bank · Back to top

MediumMost asked

17. How do you implement push notifications on Android in 2025? Walk through the full flow.

Push notifications in Android require two systems working together: Firebase Cloud Messaging (FCM) on the server side delivers the message to the device, and Android's NotificationManager on the device side displays it to the user. The complete flow goes: your backend sends a message to FCM servers → FCM delivers it to the device's FCM service → your app's FirebaseMessagingService handles it → you build and display a notification. In 2025, there are three key requirements that newcomers often miss: requesting POST_NOTIFICATIONS permission on Android 13+, handling both foreground and background message receipt differently, and creating NotificationChannels (required since API 26) — without a channel, notifications are silently dropped.

// ── STEP 1: Request POST_NOTIFICATIONS permission (Android 13+ / API 33+) ──
class MainActivity : AppCompatActivity() {

    private val requestNotifPermission = registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        if (!granted) showPermissionExplanation()
    }

    override fun onResume() {
        super.onResume()
        if (Build.VERSION.SDK_INT >= 33 &&
            ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
            != PackageManager.PERMISSION_GRANTED) {
            requestNotifPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
        }
    }
}

// ── STEP 2: FirebaseMessagingService ────────────────────────────────
class MyFCMService : FirebaseMessagingService() {

    // Called when FCM generates a new token (first launch or token refresh)
    // Send this to YOUR backend immediately — the old token is invalid
    override fun onNewToken(token: String) {
        CoroutineScope(Dispatchers.IO).launch {
            runCatching { backend.updatePushToken(userId, token) }
                .onFailure { /* save locally, retry later */ }
        }
    }

    // Called for ALL messages when app is IN FOREGROUND
    // Called for DATA-ONLY messages when app is IN BACKGROUND
    // ⚠️ NOT called for notification messages when app is in background!
    override fun onMessageReceived(message: RemoteMessage) {
        // Data messages: message.data["key"] — always use these for full control
        val title = message.notification?.title ?: message.data["title"] ?: return
        val body  = message.notification?.body  ?: message.data["body"]  ?: return
        val orderId = message.data["orderId"]

        showNotification(title, body, orderId)
    }
}

// ── STEP 3: Build and show notification ─────────────────────────────
fun showNotification(title: String, body: String, orderId: String?) {
    val channelId = "order_updates"

    // Create channel — required API 26+. Safe to call multiple times.
    val channel = NotificationChannel(
        channelId, "Order Updates", NotificationManager.IMPORTANCE_HIGH
    ).apply {
        description = "Notifications about your orders"
        enableVibration(true)
    }
    getSystemService(NotificationManager::class.java).createNotificationChannel(channel)

    // Build PendingIntent to open the right screen when tapped
    val intent = Intent(this, OrderDetailActivity::class.java).apply {
        putExtra("orderId", orderId)
        flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
    }
    val pendingIntent = PendingIntent.getActivity(
        this, orderId.hashCode(), intent,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE  // API 31+ required
    )

    val notification = NotificationCompat.Builder(this, channelId)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle(title)
        .setContentText(body)
        .setAutoCancel(true)
        .setPriority(NotificationCompat.PRIORITY_HIGH)
        .setContentIntent(pendingIntent)
        .build()

    NotificationManagerCompat.from(this).notify(orderId.hashCode(), notification)
}

POST_NOTIFICATIONS permission (Android 13+) — apps targeting API 33+ must request this runtime permission before showing any notifications. Without it, all notifications are silently blocked. Request it at a contextually appropriate moment — after sign-up or before entering the notification settings screen — never on cold launch.

FCM token management — onNewToken() fires on first app launch and whenever FCM rotates the token. You must send the new token to your backend and associate it with the user immediately. An outdated token means you can no longer reach that device. If the token update fails, save it locally and retry on the next launch.

Data messages vs notification messages — the most commonly confused distinction. Notification messages (FCM payload with a "notification" key) are handled automatically by the system when the app is in the background — your code is never called. Data messages (only a "data" key) always deliver to onMessageReceived() regardless of app state. Use data-only messages in production for full control over when and how notifications appear.

NotificationChannels (required API 26+) — since Android 8.0, every notification must be assigned to a channel. Channels let users control behavior per category (e.g., disable promotions but keep order updates). Create channels at app startup — createNotificationChannel() is safe to call repeatedly. Without a valid channel ID, your notification is silently dropped.

PendingIntent with FLAG_IMMUTABLE — required for all PendingIntents since API 31. Missing this flag causes a crash on Android 12+ devices. Use FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE for notification intents. FCM also supports topic messaging — subscribe users with subscribeToTopic("news") and broadcast to all subscribers at once from your backend, instead of looping through individual tokens.

💡 Interview Tip

The single most impressive detail: explain the data message vs notification message distinction clearly. "Notification messages are handled by the system in the background — your onMessageReceived() is never called. Data messages always reach your code. I always use data-only messages in production so I have full control over when and how notifications are displayed." Then mention three easy gotchas: POST_NOTIFICATIONS permission on API 33+, NotificationChannel required on API 26+, and FLAG_IMMUTABLE required on API 31+. Three concrete breaking changes shows real platform awareness.

Mark it done or save it in the question bank · Back to top

Hard

18. What is the Android App Startup library and how does it improve cold start time?

When Android launches your app, every ContentProvider registered in your Manifest gets initialized before your code runs — this happens before Application.onCreate(). The problem is that many popular libraries (Firebase, WorkManager, Timber, Coil) each register their own ContentProvider to auto-initialize themselves without needing you to call any setup code. On a project with 10 third-party libraries, that's 10 ContentProviders being created sequentially at cold start — each adding approximately 2ms overhead, totaling 20ms just in library initialization before you even see a blank screen. Jetpack's App Startup library solves this by collapsing all of them into a single ContentProvider that initializes everything in the correct dependency order.

// Without App Startup: 10 libraries × 1 ContentProvider each = ~20ms overhead
// With App Startup: all merged into 1 ContentProvider = ~2ms overhead

// STEP 1: Implement Initializer for each library you control
// implementation("androidx.startup:startup-runtime:1.1.1")

class TimberInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        if (BuildConfig.DEBUG) {
            Timber.plant(Timber.DebugTree())
        }
    }
    // No dependencies — can initialize first
    override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

class AnalyticsInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        Analytics.initialize(context)
        Analytics.setLoggingEnabled(BuildConfig.DEBUG) // use Timber for logging
    }
    // Analytics needs Timber first so log messages appear during init
    override fun dependencies() = listOf(TimberInitializer::class.java)
}

// STEP 2: Disable libraries' own auto-init ContentProviders
// (to prevent double initialization)
// In AndroidManifest.xml:
// <provider android:name="androidx.work.impl.WorkManagerInitializer"
//           android:authorities="${applicationId}.workmanager-init"
//           tools:node="remove" />

// STEP 3: Register your initializers
// In AndroidManifest.xml (App Startup reads this automatically):
// <provider android:name="androidx.startup.InitializationProvider"
//   android:authorities="${applicationId}.androidx-startup"
//   android:exported="false">
//   <meta-data android:name="com.myapp.TimberInitializer"
//              android:value="androidx.startup" />
//   <meta-data android:name="com.myapp.AnalyticsInitializer"
//              android:value="androidx.startup" />
// </provider>

// STEP 4: Manual (lazy) initialization when component needed on demand
val analytics = AppInitializer.getInstance(context)
    .initializeComponent(AnalyticsInitializer::class.java)

// MEASURE startup impact
// Method 1: adb shell am start -W -n com.package/.MainActivity
// Look for: TotalTime in the output (total cold start time)
// Method 2: Macrobenchmark with StartupTimingMetric
// Method 3: Android Studio → Profiler → App Startup section

The ContentProvider startup problem — Android creates every ContentProvider synchronously before your Application.onCreate() runs. Popular SDKs (Firebase, WorkManager, Timber, Coil) each register their own ContentProvider to auto-initialize. With 10+ libraries, this adds 20–50ms of overhead before you control anything. App Startup merges all of them into a single ContentProvider, cutting that overhead to ~2ms.

Initializer interface — implement create() to do the actual initialization work, and dependencies() to declare which other initializers must run first. App Startup topologically sorts all initializers and runs them in the correct order, eliminating initialization race conditions between libraries that depend on each other.

Disabling library auto-init — when you migrate a library to App Startup, you must also remove its own ContentProvider from the merged manifest using tools:node="remove". Failing to do so causes double initialization — once via the library's ContentProvider and once via your initializer.

Lazy initialization — the best optimization — not every component needs to initialize at app start. Use by lazy { } for repositories and services not needed on the first screen. App Startup also supports on-demand lazy initialization via AppInitializer.getInstance(context).initializeComponent().

Measuring impact — always measure before and after. Use adb shell am start -W -n com.package/.MainActivity to get TotalTime in milliseconds. For CI, use the Macrobenchmark library with StartupTimingMetric to track cold start over time and catch regressions automatically. App Startup and Baseline Profiles are complementary — use both together for maximum cold start improvement.

💡 Interview Tip

The most impactful sentence in any interview about app startup: "I audited our merged manifest and found 14 ContentProviders registered by third-party libraries — all creating before our Application.onCreate(). After migrating to App Startup and lazy-initializing non-critical components, our cold start dropped from 2.4 seconds to 1.8 seconds." Concrete numbers + specific technique = immediately credible. Even if you haven't done this, explaining the ContentProvider-per-library problem shows you understand the underlying Android startup mechanism at a deep level.

Mark it done or save it in the question bank · Back to top

MediumMost asked

19. What is the difference between View.GONE, View.INVISIBLE, and View.VISIBLE? How do these work in the layout system?

View visibility might seem simple — show or hide — but Android has three states, and the difference between INVISIBLE and GONE is subtle yet critical. Think of it like furniture in a room: VISIBLE is a chair you can see and sit on; INVISIBLE is a chair covered with an invisibility cloak (you can't see it, but you still bump into it and can't place another chair there); GONE is a chair that was physically removed from the room (the remaining furniture rearranges to fill the gap). Using INVISIBLE when you should use GONE is a very common source of layout bugs — especially in RecyclerView items where the space is never reclaimed — and it also wastes measure/layout passes.

// ── View.VISIBLE ─────────────────────────────────────────────────────
// Drawn on screen AND occupies layout space (default)
view.visibility = View.VISIBLE

// ── View.INVISIBLE ───────────────────────────────────────────────────
// NOT drawn (transparent) but STILL occupies layout space
// Other views lay out as if this view is VISIBLE
// ✅ Use for: placeholder, loading states where you want stable layout
// ✅ Use for: cross-fade animations (briefly invisible during transition)
view.visibility = View.INVISIBLE

// ── View.GONE ────────────────────────────────────────────────────────
// NOT drawn AND does NOT occupy layout space
// Layout re-measures as if this view doesn't exist at all
// ✅ Use for: show/hide features (error messages, badges, optional content)
// ⚠️ Triggers a layout pass — can cause jank if overused in RecyclerView
view.visibility = View.GONE

// ── PRACTICAL EXAMPLES ──────────────────────────────────────────────
// Error message: GONE when no error, VISIBLE when error occurs
binding.tvError.visibility = if (error != null) View.VISIBLE else View.GONE

// Loading spinner: VISIBLE while loading, GONE when done
binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE

// Kotlin extension — cleaner code
fun View.showIf(condition: Boolean) {
    visibility = if (condition) View.VISIBLE else View.GONE
}
binding.errorMessage.showIf(error != null)

// ── COMPOSE EQUIVALENTS ──────────────────────────────────────────────
@Composable
fun StatusBadge(isVisible: Boolean, text: String) {
    // View.GONE equivalent — not rendered, no space reserved
    if (isVisible) {
        Text(text)
    }

    // View.INVISIBLE equivalent — space reserved, but transparent
    Text(
        text = text,
        modifier = Modifier.alpha(if (isVisible) 1f else 0f)  // occupies space but invisible
    )

    // Smooth animated transition (between GONE and VISIBLE)
    AnimatedVisibility(
        visible = isVisible,
        enter = fadeIn() + expandVertically(),
        exit = fadeOut() + shrinkVertically()
    ) {
        Text(text)
    }
}

// Performance note for RecyclerView:
// INVISIBLE in a list item still triggers measure/layout for that item's full height
// GONE removes the view from layout — but may cause height changes and list jank
// Best practice: use fixed-height containers and INVISIBLE for stable list items

VISIBLE (default) — the view is rendered on screen and occupies space in the layout. All sibling views position themselves relative to it as if it's fully present. This is the normal state for anything the user should see and interact with.

INVISIBLE — the view is not rendered (fully transparent) but still claims its space in the layout. Sibling views behave as if it were VISIBLE — they don't move to fill the gap. Use this when you want a stable placeholder that will be replaced by content of the same size, such as a loading shimmer transitioning to loaded content.

GONE — the view is not rendered and claims zero layout space. The layout engine recalculates as if the view doesn't exist, and siblings fill the gap. This is what you want for show/hide functionality. The trade-off: switching from GONE to VISIBLE triggers a full measure and layout pass for the parent hierarchy.

Performance in RecyclerView — avoid toggling between VISIBLE and GONE inside list items during scroll; each toggle triggers a layout pass. Prefer INVISIBLE with a fixed-height container for list items so the cell height doesn't change and sibling views don't shift. The common bug is using INVISIBLE thinking you've hidden the view, then wondering why there are mysterious blank spaces in the layout — if you don't need the space reserved, use GONE.

Compose equivalents — if (visible) { Composable() } is equivalent to GONE: the composable is completely removed from the layout tree. Modifier.alpha(0f) is equivalent to INVISIBLE: the composable occupies space but is transparent. AnimatedVisibility provides smooth animated transitions between present and absent states.

💡 Interview Tip

Draw a simple three-column layout to explain: "Imagine A | B | C. If B is INVISIBLE: A _ C (gap where B was). If B is GONE: A C (sibling C moves left)." This visual immediately makes the distinction clear. The senior-level follow-up is about performance: "In RecyclerView, toggling between VISIBLE and GONE can cause item height changes and relayout cascades — I prefer INVISIBLE with fixed-height containers for list items, or I pre-inflate hidden views and make them GONE before binding." This shows you've thought about the real-world implications.

Mark it done or save it in the question bank · Back to top

Hard

20. What is Baseline Profiles and how do they improve app performance in 2025?

When a user installs your app for the first time, Android's ART runtime (Android Runtime) doesn't know which code paths are "hot" — frequently executed and performance-critical. On first launch, it interprets the bytecode and uses JIT (Just-In-Time) compilation, which is slower. Over the next few days, ART profiles which methods are hot and compiles them ahead-of-time (AOT) — but this optimization only kicks in days after install, not on the first critical launch. Baseline Profiles solve this by shipping a pre-built profile alongside your app in the AAB — Play Store uses this to AOT-compile your critical methods before the user ever launches the app, giving first-launch performance that previously took days to achieve.

// Baseline Profiles reduce: cold start by up to 40%, frame render jank, JIT overhead
// Required dependencies:
// implementation("androidx.profileinstaller:profileinstaller:1.3.1")
// androidTestImplementation("androidx.benchmark:benchmark-macro-junit4:1.2.4")

// STEP 1: Create a Baseline Profile generator test
// (in a separate :baseline-profile Gradle module, or in androidTest)
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {

    @get:Rule
    val rule = BaselineProfileRule()

    @Test
    fun generateBaselineProfile() = rule.collect(
        packageName = "com.myapp.shop",
        // Enable stability — profile only stable methods
        stableIterations = 3,
        maxIterations = 8
    ) {
        // Simulate critical user journeys — these paths will be pre-compiled

        // 1. Cold start to home screen
        pressHome()
        startActivityAndWait()
        device.waitForIdle()

        // 2. Navigate to product list
        device.findObject(By.text("Shop")).click()
        device.waitForIdle()

        // 3. Open a product
        device.findObject(By.res("com.myapp.shop:id/product_card")).click()
        device.waitForIdle()

        // 4. Scroll the list (tests RecyclerView performance)
        device.swipe(540, 1600, 540, 400, 10)
        device.waitForIdle()
    }
}

// STEP 2: Generate the profile (run on physical device or emulator)
// ./gradlew generateBaselineProfile
// This creates: app/src/main/baseline-prof.txt

// STEP 3: The profile ships with your AAB
// Play Store uses it to pre-compile before first launch
// For local testing: profileinstaller library installs it on sideloaded APKs

// STEP 4: Startup Profiles (subset of Baseline, Android 9+)
// Mark specifically startup-critical classes for even faster cold start
@RunWith(AndroidJUnit4::class)
class StartupProfileGenerator {
    @get:Rule val rule = BaselineProfileRule()
    @Test
    fun startup() = rule.collect("com.myapp.shop", includeInStartupProfile = true) {
        pressHome(); startActivityAndWait()  // just cold start
    }
}

// Verify profile was installed on device:
// adb shell cmd package compile -r bg-dexopt com.myapp.shop
// adb shell pm dump com.myapp.shop | grep "compiler-filter"

Why Baseline Profiles matter — without them, ART's JIT compiler doesn't know which code paths are hot until the user has run the app for several days. First launches and the first few days of use are slower than necessary. Baseline Profiles ship a pre-built hint alongside your AAB: Play Store uses it to AOT-compile your critical methods before the user ever launches the app. Measured improvements of 20–40% in cold start time are common.

How they work — you write an instrumentation test using BaselineProfileRule that simulates your critical user journeys (startup, navigation, scrolling, search). The rule records which methods were executed during those journeys and saves them to a baseline-prof.txt file in your app module. This file is included in your AAB and uploaded to Play Store, which pre-compiles your app on the user's device at install time.

Cloud Profiles (automatic) — Google Play also generates Baseline Profiles automatically from real user interaction data, aggregated across millions of sessions and updated continuously. Your handcrafted profile and Play's cloud profile are merged — you get both targeted optimization for your known hot paths and real-world optimization from actual user behavior.

Startup Profiles (subset) — use includeInStartupProfile = true in your generator to mark exclusively the cold-start path. These are AOT-compiled at install time before first launch, while the rest of the Baseline Profile compiles lazily in the background.

profileinstaller library — add this to your app module dependencies; it installs the baseline profile when you sideload an APK for local testing or CI benchmarking. Without it, profiles only apply to Play Store installs. Always measure with the Macrobenchmark library and StartupTimingMetric — track cold, warm, and hot start times in CI to catch regressions before they reach users.

💡 Interview Tip

Baseline Profiles are the highest-ROI performance optimization available in 2025, and very few developers have actually implemented them. Two ways to stand out: (1) Explain the JIT-to-AOT lifecycle problem clearly — "Without a profile, the first launch always uses the interpreter. Play Store's Cloud Profiles fix this from aggregated user data, but that takes time. Baseline Profiles fix it from day zero." (2) If you've set up Baseline Profile generation in CI, say so explicitly — this is genuinely rare and impressive.

Mark it done or save it in the question bank · Back to top

MediumMost asked

21. What is a ContentProvider? When and why would you use one?

ContentProvider is one of Android's four fundamental building blocks (alongside Activity, Service, and BroadcastReceiver). It's a structured way to share data between apps — think of it as a database API that other apps can query over a standardized URI-based interface. The classic example: when you open the camera app and your app gets to access the photo, that's ContentProvider at work. Or when you access the user's contacts — you query the system's Contacts ContentProvider. In modern Android development, most apps don't need to create their own ContentProvider unless they're sharing data with other apps or implementing AutoFill. The exception you will encounter: FileProvider, a special ContentProvider used to share files between apps.

// ── QUERYING a ContentProvider (read-only access) ─────────────────────
// Example: reading device contacts
suspend fun getContacts(context: Context): List<String> = withContext(Dispatchers.IO) {
    val contacts = mutableListOf<String>()
    val projection = arrayOf(
        ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
        ContactsContract.Contacts.HAS_PHONE_NUMBER
    )
    context.contentResolver.query(
        ContactsContract.Contacts.CONTENT_URI,
        projection,
        "${ContactsContract.Contacts.HAS_PHONE_NUMBER} = 1",
        null,
        "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} ASC"
    )?.use { cursor ->
        while (cursor.moveToNext()) {
            contacts.add(cursor.getString(cursor.getColumnIndexOrThrow(
                ContactsContract.Contacts.DISPLAY_NAME_PRIMARY
            )))
        }
    }
    contacts
}

// ── CREATING your own ContentProvider ────────────────────────────────
// Only needed when sharing data with OTHER apps
class UserContentProvider : ContentProvider() {
    private lateinit var db: SQLiteDatabase

    override fun onCreate(): Boolean {
        db = UserDbHelper(context!!).writableDatabase
        return true
    }

    override fun query(
        uri: Uri, projection: Array<String>?, selection: String?,
        selectionArgs: Array<String>?, sortOrder: String?
    ): Cursor? = db.query("users", projection, selection, selectionArgs, null, null, sortOrder)

    override fun getType(uri: Uri) = "vnd.android.cursor.dir/vnd.com.myapp.users"
    override fun insert(uri: Uri, values: ContentValues?) = null
    override fun delete(uri: Uri, s: String?, a: Array<String>?) = 0
    override fun update(uri: Uri, v: ContentValues?, s: String?, a: Array<String>?) = 0
}

// Manifest declaration:
// <provider android:name=".UserContentProvider"
//           android:authorities="com.myapp.users"
//           android:exported="false" />  ← private by default

// ── FileProvider — the ContentProvider you WILL use ──────────────────
// Securely share private files with camera/email apps

// AndroidManifest.xml:
// <provider android:name="androidx.core.content.FileProvider"
//   android:authorities="${applicationId}.fileprovider"
//   android:exported="false"
//   android:grantUriPermissions="true">
//   <meta-data android:name="android.support.FILE_PROVIDER_PATHS"
//              android:resource="@xml/file_paths" />
// </provider>

// Share a private file with the camera
val photoFile = File(context.getExternalFilesDir(null), "photo.jpg")
val photoUri: Uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", photoFile)
val cameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
    putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
    addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
startActivity(cameraIntent)

ContentProvider purpose — provides a standardized, secure interface for sharing structured data between apps via URI-based CRUD operations (query, insert, update, delete). The URI format is content://authority/table/id. Android ships several built-in providers: ContactsContract (contacts), MediaStore (photos, videos, audio), CalendarContract (calendar events), and TelephonyContract (SMS) — always query these on a background thread since they're backed by disk storage.

When to create your own — only create a ContentProvider if you're sharing structured data with other apps, such as building a custom keyboard that exposes suggestions, or implementing an AutoFill service. For data used only within your own app, use Room directly — it's simpler, type-safe, and has no IPC overhead.

FileProvider — the one you'll actually use — a special AndroidX ContentProvider that lets you securely share private files with the camera, email, or any other app. Using a raw file:// URI has been blocked since Android 7 (throws FileUriExposedException) — you must use FileProvider to generate a temporary content:// URI with scoped permission via FLAG_GRANT_WRITE_URI_PERMISSION.

Security and ContentResolver — always set android:exported="false" unless you specifically intend other apps to access your provider. A misconfigured ContentProvider with exported=true exposes your app's entire database to any app on the device. ContentResolver is the client-side API that dispatches queries to the correct provider based on the URI's authority — use it to query both system providers and your own.

💡 Interview Tip

Interviewers often ask "When would you use ContentProvider?" The real-world answer is: "In modern apps, I almost never create one. The most common use case I encounter is FileProvider — required for sharing a camera photo file with the camera app via a content:// URI. For data sharing between my own app components, I use Room or a shared ViewModel. I'd create a full ContentProvider only if building something like a custom keyboard or a contacts sync adapter that needs to expose data to the system."

Mark it done or save it in the question bank · Back to top

MediumMost asked

22. What is the difference between LiveData and StateFlow? Which should you use in 2025?

LiveData and StateFlow both solve the same problem: observing data changes from a ViewModel in a lifecycle-safe way. LiveData was the original Jetpack answer — it's lifecycle-aware by design, automatically stopping updates when your UI is in the background. StateFlow is the modern Kotlin Coroutines answer that works everywhere (including pure Kotlin code, Kotlin Multiplatform, and unit tests without Android framework), supports rich operators (map, filter, combine), and is thread-safe out of the box. In 2025, Google's official guidance is to prefer StateFlow for new code — but there's an important gotcha: collecting StateFlow safely requires using repeatOnLifecycle(), which many developers forget.

// ── LiveData approach (older, still valid but being phased out) ──────
class SearchViewModel : ViewModel() {
    private val _results = MutableLiveData<List<Product>>(emptyList())
    val results: LiveData<List<Product>> = _results

    // LiveData issues:
    // ❌ Android-only — can't use in pure Kotlin/KMM code
    // ❌ No operators — no map, filter, combine
    // ❌ setValue() must be called on main thread (postValue for background)
    // ❌ No initial value required — observers can receive null
}

// ── StateFlow approach (RECOMMENDED 2025) ────────────────────────────
class SearchViewModel : ViewModel() {
    private val _query = MutableStateFlow("")
    val query: StateFlow<String> = _query.asStateFlow()

    // Transform using Flow operators — not possible with LiveData
    val results: StateFlow<List<Product>> = _query
        .debounce(300)              // wait 300ms after typing stops
        .filter { it.length >= 2 }  // only search for 2+ chars
        .flatMapLatest { q -> repository.search(q) }  // cancel previous search
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    fun onQueryChanged(q: String) { _query.value = q }
}

// ── Collecting StateFlow SAFELY in Fragment ──────────────────────────
// ❌ WRONG — collects even when app is backgrounded (wastes resources)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    viewLifecycleOwner.lifecycleScope.launch {
        viewModel.results.collect { results ->  // ❌ no lifecycle protection!
            updateUI(results)
        }
    }
}

// ✅ CORRECT — stops collecting when Fragment goes to background
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    viewLifecycleOwner.lifecycleScope.launch {
        // repeatOnLifecycle: automatically pauses/resumes collection with lifecycle
        viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.results.collect { results ->
                updateUI(results)  // only runs when Fragment is STARTED or RESUMED
            }
        }
    }
}

// In Compose — collectAsStateWithLifecycle (even simpler)
@Composable
fun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) {
    // collectAsStateWithLifecycle: lifecycle-aware collection, stops in background
    val results by viewModel.results.collectAsStateWithLifecycle()
    LazyColumn { items(results) { ProductCard(it) } }
}

// SharedFlow — for events (one-time navigation, toast messages)
// Unlike StateFlow, doesn't replay last value to new collectors
private val _events = MutableSharedFlow<UiEvent>()
val events = _events.asSharedFlow()
fun navigateToDetail(id: String) { viewModelScope.launch { _events.emit(UiEvent.Navigate(id)) } }

LiveData (older approach) — Android-only class that's lifecycle-aware by default, automatically stopping delivery when the observer's lifecycle is stopped. Simple but limited: no built-in operators, setValue() must be called on the main thread, and it can't be used in non-Android modules or Kotlin Multiplatform code.

StateFlow (recommended 2025) — Kotlin Coroutines-based state holder. Always has a current value (readable via .value), is thread-safe (can update from any thread), supports all Flow operators (map, filter, combine, debounce, flatMapLatest), and works in pure Kotlin or KMM without any Android dependency.

repeatOnLifecycle — the critical pattern — unlike LiveData, StateFlow is not automatically lifecycle-aware. Without wrapping collection in repeatOnLifecycle(STARTED), your collector keeps running when the app is backgrounded — processing updates nobody can see and wasting resources. Always use repeatOnLifecycle in Fragments or collectAsStateWithLifecycle() in Compose, which stops collecting when the composable leaves the composition or the lifecycle goes to STOPPED.

stateIn operator — converts a regular Flow into a StateFlow scoped to a CoroutineScope. SharingStarted.WhileSubscribed(5000) keeps the upstream flow active for 5 seconds after the last subscriber leaves — preventing the upstream from being cancelled and restarted during configuration changes, which would re-trigger a network request.

SharedFlow for one-time events — StateFlow replays its last value to every new collector, making it perfect for UI state (cart count, loading flag). SharedFlow doesn't replay — use it for one-time events like navigation commands, toast messages, and error dialogs that should fire exactly once regardless of when the collector subscribes.

💡 Interview Tip

The single most impressive point on this topic: explain the repeatOnLifecycle mistake. "Many developers just do lifecycleScope.launch { flow.collect { } } — this looks correct but isn't lifecycle-safe. Without repeatOnLifecycle(STARTED), the collection continues in the background. The correct pattern is always repeatOnLifecycle or collectAsStateWithLifecycle in Compose." Then explain StateFlow vs SharedFlow: "StateFlow for UI state (cart count, loading state) — it's like the current snapshot. SharedFlow for one-time events (navigation, errors) — it's like a command that should fire exactly once."

Mark it done or save it in the question bank · Back to top

MediumMost asked

23. What is the difference between RecyclerView and LazyColumn? When do you use each?

Showing a large list of items efficiently is one of the most fundamental challenges in mobile UI — you can't create a View for every item in a list of 10,000 products. Both RecyclerView and LazyColumn solve this through virtualization: only the visible items (plus a small buffer) are actually rendered, and as you scroll, the invisible views are recycled and rebound with new data. RecyclerView is the battle-tested View system component that's been around since 2014. LazyColumn is Jetpack Compose's equivalent — written in a declarative style with much less boilerplate. In 2025, choose LazyColumn for all new Compose screens and RecyclerView (with ListAdapter) for existing View-based code.

// ── RecyclerView (View system) ───────────────────────────────────────
// ✅ Use ListAdapter (not RecyclerView.Adapter directly) — built-in DiffUtil
class ProductAdapter : ListAdapter<Product, ProductAdapter.ViewHolder>(DiffCallback()) {

    inner class ViewHolder(private val binding: ItemProductBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(product: Product) {
            binding.tvName.text = product.name
            binding.tvPrice.text = "₹${product.price}"
            binding.root.setOnClickListener { onProductClick(product) }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val binding = ItemProductBinding.inflate(
            LayoutInflater.from(parent.context), parent, false
        )
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) =
        holder.bind(getItem(position))

    // DiffUtil — compares old vs new list and animates only changed items
    // Runs on background thread — no UI jank from large list diffs
    class DiffCallback : DiffUtil.ItemCallback<Product>() {
        override fun areItemsTheSame(old: Product, new: Product) = old.id == new.id
        override fun areContentsTheSame(old: Product, new: Product) = old == new
    }
}

// In Fragment: submit new data — DiffUtil calculates diff asynchronously
viewModel.products.observe(viewLifecycleOwner) { adapter.submitList(it) }

// ── LazyColumn (Compose) ─────────────────────────────────────────────
// Much less code — no Adapter, ViewHolder, or DiffUtil needed
@Composable
fun ProductList(
    products: List<Product>,
    onProductClick: (Product) -> Unit
) {
    LazyColumn(
        contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        item { HeaderCard() }  // easy header

        // key: stable identity prevents recomposition for unchanged items
        // ⚠️ Always provide key! Without it, all items recompose on any list change
        items(products, key = { it.id }) { product ->
            ProductCard(product = product, onClick = { onProductClick(product) })
        }

        item { FooterSpacer() } // easy footer
    }
}

// LazyColumn with Paging 3 — infinite scroll
@Composable
fun PaginatedList(viewModel: ProductViewModel = hiltViewModel()) {
    val items = viewModel.pagedProducts.collectAsLazyPagingItems()
    LazyColumn {
        items(items, key = { it.id }) { product ->
            if (product != null) ProductCard(product)
        }
        if (items.loadState.append is LoadState.Loading) {
            item { LoadingIndicator() }
        }
    }
}

RecyclerView ViewHolder pattern — ViewHolder caches references to child views of a list item, avoiding repeated expensive findViewById() calls during scroll. When an item scrolls off screen, RecyclerView recycles the ViewHolder and rebinds it with new data rather than inflating a new view — this is what makes RecyclerView efficient for large lists. Always use ListAdapter rather than plain RecyclerView.Adapter: it runs DiffUtil calculations on a background thread and animates only the items that actually changed.

DiffCallback defines the two identity rules — areItemsTheSame() checks if two items represent the same entity (same database ID), while areContentsTheSame() checks if the displayed data is identical (same name, price, etc.). ListAdapter uses these to produce minimal, animated diffs without blocking the main thread.

LazyColumn advantages — no Adapter, no ViewHolder, no DiffUtil — just a composable function. Compose handles recycling internally. Adding headers, footers, and different item types is trivial (just add more item { } blocks). Paging 3 integrates cleanly via collectAsLazyPagingItems() with only a few lines of code.

key parameter in LazyColumn is critical — without a key, Compose cannot identify which items changed and recomposes the entire visible list on any data update. With key = { it.id }, only items whose data actually changed get recomposed — a significant performance improvement for large, frequently-updating lists. It serves the exact same role as areItemsTheSame in DiffUtil.

When to choose RecyclerView over LazyColumn — for existing View-based screens too costly to migrate, for complex custom animations via ItemAnimator, or for apps with non-Compose UIs. For all new Compose screens, LazyColumn is the clear choice.

💡 Interview Tip

The key comparison: "RecyclerView requires Adapter + ViewHolder + DiffUtil — about 80 lines of code for a simple list. LazyColumn does the same in 10 lines. For new Compose screens I always use LazyColumn. For existing RecyclerView code, I always use ListAdapter — never plain RecyclerView.Adapter — because DiffUtil on the background thread prevents UI jank during list updates." Then mention the key parameter for LazyColumn — it's the equivalent of DiffUtil's areItemsTheSame and many developers skip it, causing poor performance.

Mark it done or save it in the question bank · Back to top

HardMost asked

24. What is Doze Mode and App Standby? How do they affect background work?

Doze mode is Android's aggressive battery optimization system that kicks in when the device is stationary, screen off, and unplugged for a period of time. In this state, Android dramatically restricts what background code can do: network access is blocked, wakelocks are ignored, AlarmManager alarms are deferred, GPS is disabled, and Wi-Fi scanning stops. The device only wakes periodically for short "maintenance windows" where background work can run. App Standby Buckets (added in Android 9) extend this concept — apps you haven't used recently are placed in increasingly restricted buckets (ACTIVE → WORKING_SET → FREQUENT → RARE → RESTRICTED) with fewer allowed background jobs. WorkManager was specifically designed to work around these restrictions transparently.

// Doze Mode trigger conditions:
// 1. Device is stationary (accelerometer detects no movement)
// 2. Screen is off
// 3. Not plugged in (on battery)
// After ~30 minutes → Light Doze → then Deep Doze

// What Doze BLOCKS:
// ❌ Network access (WiFi and cellular)
// ❌ WakeLocks (device stays asleep)
// ❌ AlarmManager.set() and setInexactRepeating()
// ❌ JobScheduler (deferred to maintenance window)
// ❌ GPS/location updates

// ── SOLUTION: Use WorkManager — handles Doze automatically ───────────
// WorkManager schedules around Doze maintenance windows transparently
val syncWork = PeriodicWorkRequestBuilder<DataSyncWorker>(15, TimeUnit.MINUTES)
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .setRequiresBatteryNotLow(true)
            .build()
    )
    .build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "data_sync",
    ExistingPeriodicWorkPolicy.KEEP,
    syncWork
)

// ── FOR EXACT ALARMS (e.g., alarm clock) ────────────────────────────
// setExactAndAllowWhileIdle() fires even during Doze
// But requires SCHEDULE_EXACT_ALARM permission (API 31+)
if (alarmManager.canScheduleExactAlarms()) {
    alarmManager.setExactAndAllowWhileIdle(
        AlarmManager.RTC_WAKEUP,
        alarmTimeMs,
        pendingIntent
    )
}

// ── FCM HIGH PRIORITY — pierces Doze ────────────────────────────────
// A high-priority FCM message wakes the device even in deep Doze
// Use for: chat messages, urgent alerts
// Server-side FCM payload: { "priority": "high", "data": {...} }

// ── App Standby Buckets (Android 9+) ────────────────────────────────
// ACTIVE:       App in foreground or recent use — no restrictions
// WORKING_SET:  Used daily — mild restrictions
// FREQUENT:     Used weekly — stricter job limits
// RARE:         Used monthly — severe restrictions
// RESTRICTED:   Rarely used — minimal background work allowed

// Check which bucket your app is in (useful for debugging)
val usageManager = getSystemService(UsageStatsManager::class.java)
val bucket = usageManager.appStandbyBucket
// 10=ACTIVE, 20=WORKING_SET, 30=FREQUENT, 40=RARE, 45=RESTRICTED

// ── TESTING Doze mode ────────────────────────────────────────────────
// Simulate Doze: adb shell dumpsys deviceidle force-idle
// Step through Doze stages: adb shell dumpsys deviceidle step
// Check state: adb shell dumpsys deviceidle
// Exit Doze: adb shell dumpsys deviceidle unforce

Doze mode stages — Android uses two stages: Light Doze (enters quickly after screen-off, still allows some network) and Deep Doze (enters after roughly 30 minutes of the device being stationary, extremely restrictive). Both stages grant periodic maintenance windows where deferred background work can run briefly before the device sleeps again.

WorkManager is Doze-aware — it automatically schedules work to run during Doze maintenance windows. You don't handle Doze manually; just set the right constraints and WorkManager executes your work at the next available opportunity, whether that's immediately or hours later.

setExactAndAllowWhileIdle() — the only AlarmManager method that fires during Deep Doze. Required for genuinely time-critical alarms (alarm clock apps, medication reminders). It requires the SCHEDULE_EXACT_ALARM permission on API 31+ and Android enforces frequency limits — don't abuse it for non-critical work.

FCM high-priority messages — the only reliable mechanism to wake a device in Deep Doze for a real-time notification. Set "priority": "high" in the FCM server payload. Android grants a limited number of high-priority wake-ups per app per day; overusing them causes Android to start deprioritizing your app's messages.

App Standby Buckets (Android 9+) — a five-tier system (ACTIVE → WORKING_SET → FREQUENT → RARE → RESTRICTED) that restricts background job slots based on how recently the user interacted with your app. WorkManager handles bucket-aware scheduling internally. Test background work under simulated Doze with adb shell dumpsys deviceidle force-idle — many apps that work in testing fail in production because they were never tested under actual Doze conditions.

💡 Interview Tip

The most impressive answer connects three concepts: "Doze mode blocks most background work to save battery. WorkManager is specifically designed to schedule around Doze maintenance windows, so my background syncs always happen eventually — even if delayed. For time-critical notifications I use FCM high-priority messages, which pierce Doze. I test this during development with adb shell dumpsys deviceidle force-idle to make sure background work actually runs under Doze." This covers the problem, the solution, the exception, and the testing — a complete answer.

Mark it done or save it in the question bank · Back to top

MediumMost asked

25. What is the difference between Handler, Looper, and MessageQueue?

Android's entire UI system runs on a single main thread. Handler, Looper, and MessageQueue are the three classes that power how that thread processes work — and how background threads can safely communicate with the main thread. Think of it like a restaurant: the Looper is a waiter who keeps walking in circles looking for orders; the MessageQueue is the order book on the counter; and the Handler is the chef who places orders in the book and also processes them when the waiter delivers them. Every Activity callback, every View click, every animation frame — all delivered via this mechanism. While modern code uses coroutines instead of Handlers directly, this system runs underneath everything in Android.

// ── CONCEPTUAL OVERVIEW ──────────────────────────────────────────────
// Thread:       executes code
// Looper:       attached to a thread; runs an infinite loop checking MessageQueue
// MessageQueue: FIFO queue of Messages and Runnables
// Handler:      posts to a Looper's queue; processes items when Looper delivers them

// Main thread already has a Looper — created at app startup by ActivityThread
// Background threads do NOT have a Looper by default — must call Looper.prepare()

// ── HANDLER ON MAIN THREAD ───────────────────────────────────────────
// Use to post work TO the main thread from a background thread
val mainHandler = Handler(Looper.getMainLooper())

// From background thread: post a Runnable to run on main thread
Thread {
    val result = networkCall()  // slow background work
    mainHandler.post {
        tvResult.text = result   // back on main thread — safe to update UI
    }
}.start()

// Delayed execution
mainHandler.postDelayed({
    binding.splashView.visibility = View.GONE
}, 2000L)  // hides splash after 2 seconds

// ── CUSTOM LOOPER THREAD ─────────────────────────────────────────────
// Create a background thread with its own Looper (for serial message processing)
class MyHandlerThread : HandlerThread("MyWorker") {
    lateinit var handler: Handler

    fun init() {
        start()  // starts the thread with Looper.prepare() + Looper.loop()
        handler = Handler(looper)  // handler backed by this thread's looper
    }
}

val workerThread = MyHandlerThread().apply { init() }
workerThread.handler.post {
    // runs on MyWorker thread — not main thread
    processLargeDataSet()
}

// ── COROUTINES are the modern replacement ────────────────────────────
// Don't write Handler/Looper manually in new code — use coroutines + Dispatchers

// Handler.post {} equivalent:
lifecycleScope.launch(Dispatchers.Main) { updateUI() }

// Handler.postDelayed(2000) equivalent:
lifecycleScope.launch {
    delay(2000)
    updateUI()
}

// Background work then post to main:
lifecycleScope.launch {
    val result = withContext(Dispatchers.IO) { networkCall() }
    updateUI(result)  // automatically back on Main
}

// ── HOW ANDROID USES HANDLER INTERNALLY ─────────────────────────────
// All Activity/Fragment lifecycle callbacks (onCreate, onResume, etc.) are delivered
// to the main Looper's queue by the ActivityThread via Handler messages
// View.invalidate() → posts a "draw" message to the main Looper
// ViewRootImpl schedules VSYNC callbacks via a Choreographer (backed by Handler)
// Toast, AlertDialog, all UI operations → posted as Messages to main Looper

Looper runs an infinite loop on a thread, continuously checking the MessageQueue for pending items and executing them one by one. The main thread gets a Looper created at app startup by ActivityThread. Background threads don't have one by default — you must call Looper.prepare() then Looper.loop() to give them one.

MessageQueue is the FIFO queue attached to each Looper. It holds Message objects and Runnable tasks. Messages can be scheduled for immediate delivery or with a future timestamp (for postDelayed). Each Looper has exactly one MessageQueue.

Handler is the gateway to a Looper's queue. Calling handler.post(runnable) enqueues the Runnable to execute on the thread that owns the Looper. A Handler constructed with Looper.getMainLooper() lets background threads safely post work to the main thread — the classic way to update UI from a background operation.

HandlerThread is a convenience class that extends Thread and automatically calls Looper.prepare() and Looper.loop(), giving you a background thread with its own serial message queue. Useful for processing tasks one at a time in strict order without blocking the main thread.

Why this architecture exists — every Activity/Fragment lifecycle callback, View click event, animation frame, and invalidate() call is delivered as a message to the main Looper's queue. This guarantees all UI operations happen on a single thread in a controlled, prioritized order — no concurrency bugs. Coroutines as the modern replacement — Dispatchers.Main posts coroutine continuations to this same main Looper queue. The mechanism is identical; coroutines just provide cleaner, structured concurrency syntax. For new code, always use coroutines over direct Handler usage.

💡 Interview Tip

This question tests your foundational Android knowledge. The answer that impresses: "Looper, Handler, and MessageQueue are the engine behind Android's main thread. Every Activity callback, every View.invalidate(), every animation frame is a message in the main Looper's queue. Coroutines don't bypass this — Dispatchers.Main posts continuations to the same main Looper. Understanding this explains why you must never block the main thread — the entire event loop stalls, and no more messages (including touch events) can be processed until your blocking call returns. That's what causes ANR."

Mark it done or save it in the question bank · Back to top

MediumMost asked

26. What is ViewBinding and how is it different from DataBinding and findViewById?

Every Android developer has experienced the pain of a NullPointerException from a wrong view ID, or a ClassCastException from the wrong type in a cast, or the frustration of refactoring a layout and forgetting to update the Java code. ViewBinding solves all of this by generating a strongly-typed binding class for each XML layout at compile time. If you rename a view in XML, the binding class is regenerated and your code won't compile until you update the reference — errors caught at compile time instead of runtime. ViewBinding is lighter than DataBinding (no annotation processing, no LiveData binding in XML), making it the preferred choice for the vast majority of screens where you just need safe view access.

// STEP 1: Enable in build.gradle.kts
android {
    buildFeatures {
        viewBinding = true  // generates binding class for each XML layout
        // dataBinding = true  ← heavier, only if you need XML expressions
    }
}

// STEP 2: Use in Activity
class LoginActivity : AppCompatActivity() {
    // ActivityLoginBinding generated from activity_login.xml
    private lateinit var binding: ActivityLoginBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityLoginBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // Type-safe access — no cast, no null risk
        binding.etEmail.text.toString()
        binding.btnLogin.setOnClickListener { login() }
        binding.tvError.visibility = View.GONE
    }
}

// STEP 3: Use in Fragment — MUST null the binding in onDestroyView!
class HomeFragment : Fragment() {
    // Nullable backing field — Fragment outlives its View on the back stack
    private var _binding: FragmentHomeBinding? = null
    // Non-null accessor — only safe to use between onViewCreated and onDestroyView
    private val binding get() = _binding!!

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        _binding = FragmentHomeBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.rvProducts.adapter = productAdapter
        binding.fabAdd.setOnClickListener { showAddDialog() }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        // ✅ CRITICAL: null the binding to prevent memory leak
        // The Fragment object lives on the back stack after onDestroyView
        // If _binding held a reference to destroyed views, they can't be GC'd
        _binding = null
    }
}

// ── COMPARISON ───────────────────────────────────────────────────────
// ❌ findViewById — no type safety, no null safety
val btn = findViewById<Button>(R.id.btnLogin)  // crashes if wrong type or wrong ID

// ✅ ViewBinding — compile-time safety, no cast
val btn = binding.btnLogin  // type is Button, guaranteed non-null

// DataBinding — superset of ViewBinding, adds XML expressions
// android:text="@{viewModel.userName}"  ← evaluate expression in XML
// android:onClick="@{() -> viewModel.onSubmit()}"
// Two-way binding: android:text="@={viewModel.editableText}"
// Use DataBinding when you need XML expressions; ViewBinding otherwise

// Exclude a layout from ViewBinding generation (not all layouts need it)
// <LinearLayout tools:viewBindingIgnore="true">

ViewBinding vs findViewById — ViewBinding generates a binding class at compile time with a typed property for each view that has an ID. No casts needed, and if the view doesn't exist in the layout, the property doesn't exist in the binding class — compilation fails instead of a runtime NullPointerException. For each XML layout (e.g., fragment_home.xml), it generates a class (FragmentHomeBinding) where every android:id view becomes a typed property, accessed via binding.viewId after calling FragmentHomeBinding.inflate().

Memory leak in Fragment — the _binding pattern — A Fragment stays alive on the back stack even after its view is destroyed. Storing the ViewBinding directly (not in a nullable field) holds references to the destroyed view hierarchy, preventing garbage collection. The fix: use a nullable _binding field, null it in onDestroyView(), and expose it only via a non-null property getter. This is not optional — forgetting it leaks the entire view hierarchy of every fragment on the back stack.

ViewBinding vs DataBinding — ViewBinding generates simple binding classes with view references only. DataBinding is a superset that supports XML binding expressions (@{viewModel.name}), two-way binding, and custom binding adapters, but uses kapt annotation processing that can add 10–30% to build times in large projects. Use ViewBinding as the default and only switch to DataBinding when you specifically need XML expression binding. Add tools:viewBindingIgnore="true" to layout root tags where you don't want binding classes generated (e.g., include layouts, list item layouts used only via adapters) to keep generated code lean.

💡 Interview Tip

The answer that shows real experience: explain the Fragment memory leak pattern in detail. "The _binding = null in onDestroyView() is not optional — it's the difference between your app leaking the entire view hierarchy of every fragment on the back stack versus cleaning up after itself. I've seen apps with 5 fragments in the back stack leak 15MB this way." Then mention the ViewBinding vs DataBinding choice: "I use ViewBinding by default — it's faster to compile. I only switch to DataBinding when I need two-way binding for form inputs in a heavily data-driven screen."

Mark it done or save it in the question bank · Back to top

Hard

27. How do you support foldable phones and large screens in Android?

With over 200 million large-screen Android devices (foldables, tablets, and Chromebooks) in active use in 2025, Google has made large screen compatibility a major priority — and Play Store now surfaces apps that handle large screens well. A foldable phone like the Samsung Galaxy Z Fold can have two completely different window sizes: unfolded it's a small tablet, folded it's a regular phone. Your app needs to adapt its layout dynamically when the user unfolds the phone, not just handle phone vs tablet as static configurations. The key tool for this is WindowSizeClass — a categorization of the window into COMPACT (phone), MEDIUM (foldable), or EXPANDED (tablet/desktop) that your UI adapts to reactively.

// implementation("androidx.window:window:1.3.0")
// implementation("androidx.compose.material3.adaptive:adaptive:1.0.0")

// ── WindowSizeClass — categorize screen size ─────────────────────────
@Composable
fun App() {
    // Calculate size class reactively — updates on fold/unfold
    val windowSizeClass = LocalContext.current.getActivity()
        ?.let { calculateWindowSizeClass(it) } ?: return

    when (windowSizeClass.widthSizeClass) {
        WindowWidthSizeClass.COMPACT  -> PhoneLayout()    // <600dp — phone portrait
        WindowWidthSizeClass.MEDIUM   -> FoldableLayout() // 600-840dp — unfolded/tablet
        WindowWidthSizeClass.EXPANDED -> TabletLayout()   // >840dp — large tablet/desktop
    }
}

// ── ListDetailPaneScaffold — adaptive two-pane layout ────────────────
// Automatically uses single-pane on phone, two-pane on tablet/foldable
@Composable
fun EmailApp() {
    val navigator = rememberListDetailPaneScaffoldNavigator<Email>()

    NavigableListDetailPaneScaffold(
        navigator = navigator,
        listPane = {
            EmailList(
                onEmailClick = { email ->
                    navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, email)
                }
            )
        },
        detailPane = {
            val email = navigator.currentDestination?.content
            if (email != null) EmailDetail(email)
            else EmptyDetailPane()
        }
    )
}

// ── FoldingFeature — detect hinge position for foldables ─────────────
@Composable
fun CameraScreen() {
    val activity = LocalContext.current as Activity
    val windowInfo = collectWindowInfo(activity)
    val foldingFeature = windowInfo.displayFeatures
        .filterIsInstance<FoldingFeature>()
        .firstOrNull()

    when {
        foldingFeature?.isTableTop() == true -> {
            // Phone flat on table like a laptop — viewfinder on top half, controls on bottom
            TableTopCameraLayout(foldingFeature)
        }
        foldingFeature?.state == FoldingFeature.State.HALF_OPENED -> {
            HalfOpenLayout()
        }
        else -> NormalCameraLayout()
    }
}

// Manifest — required for proper large screen/foldable support
// <activity android:name=".MainActivity"
//   android:resizeableActivity="true"
//   android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize" />

WindowSizeClass categorizes the current window into COMPACT (<600dp width), MEDIUM (600–840dp), or EXPANDED (>840dp) — mapping cleanly to phone portrait, unfolded foldable/small tablet, and large tablet respectively. It reacts dynamically when a foldable is folded or unfolded, making it the correct abstraction over the old static sw600dp resource qualifier approach. ListDetailPaneScaffold from the Material 3 Adaptive library implements the list-detail navigation pattern on top of this: on COMPACT screens it navigates between panes sequentially; on MEDIUM/EXPANDED it shows both side-by-side with back navigation handled automatically.

FoldingFeature provides information about the physical hinge — its position, orientation, and state (FLAT or HALF_OPENED). Use isTableTop() to detect tabletop mode (hinge horizontal, phone lying flat) and adapt your UI, such as showing a camera viewfinder on the top half and controls on the bottom. Folding/unfolding itself triggers a configuration change, so declare screenLayout|screenSize|smallestScreenSize in android:configChanges to avoid Activity recreation, and verify your ViewModel preserves state across fold/unfold.

On large screens, always maintain 48×48dp minimum touch targets per Material Design guidelines — stylus users still need adequate tap areas. From a business perspective, Google Play now surfaces "Designed for large screens" badges and weights foldable/tablet support in search rankings, making large-screen compatibility a direct driver of organic installs.

💡 Interview Tip

Google has explicitly made large screen support a Play Store ranking signal — any company with significant tablet/foldable user base cares deeply about this. The two APIs that show real knowledge: (1) WindowSizeClass (reactive to fold/unfold) vs the old TypedArray "sw600dp" resource qualifier approach (static, can't handle foldables). (2) ListDetailPaneScaffold from Material 3 Adaptive — the modern, declarative way to build adaptive UIs without writing breakpoint logic yourself. Mentioning you use calculateWindowSizeClass() rather than Display.getSize() shows you know the right abstraction.

Mark it done or save it in the question bank · Back to top

MediumMost asked

28. What is Jetpack Navigation Component? How does it handle deep links and back stack?

Before Jetpack Navigation Component, managing the back stack in an Android app was a fragile manual process: developers wrote their own FragmentManager.beginTransaction() code, manually handled back presses, and deep links required custom parsing in every Activity. Navigation Component standardizes all of this — it's a single framework that handles back stack management, argument passing, deep link handling, and transitions. Think of it like a GPS for your app's screens: you define all possible destinations and paths (the navigation graph), and Navigation handles the driving. In 2025, Navigation Compose with type-safe routes (Navigation 2.8+) is the recommended approach for all new apps.

// ── Navigation Compose (RECOMMENDED 2025) ────────────────────────────
// implementation("androidx.navigation:navigation-compose:2.8.0")
// implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")

// STEP 1: Define type-safe routes using @Serializable (Navigation 2.8+)
@Serializable object HomeRoute          // no args
@Serializable object SearchRoute
@Serializable data class ProductRoute(val productId: String)  // with args
@Serializable data class CheckoutRoute(val cartId: String, val promoCode: String? = null)

// STEP 2: Build the NavGraph in your composable
@Composable
fun AppNavGraph() {
    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = HomeRoute) {

        composable<HomeRoute> {
            HomeScreen(onProductClick = { id -> navController.navigate(ProductRoute(id)) })
        }

        composable<ProductRoute> { backStackEntry ->
            val route: ProductRoute = backStackEntry.toRoute()  // type-safe arg extraction
            ProductScreen(productId = route.productId)
        }

        composable<CheckoutRoute> { backStackEntry ->
            val route: CheckoutRoute = backStackEntry.toRoute()
            CheckoutScreen(cartId = route.cartId, promoCode = route.promoCode)
        }
    }
}

// STEP 3: Navigate with type-safe routes — compile-time argument checking
navController.navigate(ProductRoute(productId = "prod_123"))
navController.navigate(CheckoutRoute(cartId = "cart_456"))

// ── Back Stack Control ────────────────────────────────────────────────
// Navigate and clear login from back stack (post-login navigation)
navController.navigate(HomeRoute) {
    popUpTo<LoginRoute> { inclusive = true }  // remove login from stack
    launchSingleTop = true                         // no duplicate home screens
}

// Navigate up (back button equivalent)
navController.popBackStack()  // go back one
navController.navigateUp()    // respects app's navigation structure (prefer this)

// ── Deep Links with Navigation Compose ───────────────────────────────
composable<ProductRoute>(
    deepLinks = listOf(navDeepLink { uriPattern = "https://myapp.com/product/{productId}" })
) { backStackEntry ->
    ProductScreen(backStackEntry.toRoute<ProductRoute>().productId)
}

// ── Passing data back to previous screen ─────────────────────────────
// Use SavedStateHandle on the previous destination's ViewModel
// Or use a shared ViewModel with a StateFlow result
navController.currentBackStackEntry?.savedStateHandle?.set("selected_item", item)

Navigation graph — the NavHost lambda in Compose (or XML nav graph in Fragment Navigation) — defines all destinations and routes in one place, making app flow legible and preventing inconsistent back stack behavior. Navigation 2.8+ introduced type-safe routes using @Serializable data classes and objects as route definitions. Arguments become regular Kotlin constructor parameters, so passing the wrong type or omitting a required argument is a compile error, not a runtime crash. Use toRoute() in the back stack entry to extract arguments in a fully typed way.

popUpTo controls which destinations are cleared from the back stack when navigating. popUpTo<LoginRoute> { inclusive = true } removes LoginRoute and everything above it — the standard post-login pattern so the user can't press Back to reach the login screen. launchSingleTop prevents duplicate entries at the top of the stack; tapping the Home tab while already on Home no longer pushes a second Home screen.

Compared to manual FragmentTransactions, Navigation Component provides automatic back stack management, deep link handling, transition animations, and accessibility-correct back button behavior out of the box. navGraphViewModels scopes a ViewModel to an entire navigation graph, letting screens within a flow (e.g., multi-step checkout) share a single ViewModel that is destroyed only when the user exits the graph — not when they navigate between screens within it.

💡 Interview Tip

Navigation 2.8 type-safe routes are the 2025 best practice — mention them prominently. "I've migrated from string-based routes to @Serializable type-safe routes — now if I rename a product ID parameter, the compiler tells me everywhere that breaks instead of discovering it as a runtime crash." Also explain the popUpTo pattern for post-login navigation — it's a very common requirement and shows you know real navigation scenarios beyond simple forward navigation.

Mark it done or save it in the question bank · Back to top

Hard

29. How do you implement Android security best practices? Cover SSL pinning, ProGuard, and secure storage.

Android security is not optional — a single vulnerability can expose your users' financial data, personal information, or account credentials. Security has layers: data in transit (network security), data at rest (local storage), code security (obfuscation and reverse engineering prevention), and device integrity verification. Think of it like a house: you need locks on the door (SSL/TLS), a safe for valuables (encrypted storage), curtains on windows (code obfuscation), and a burglar alarm (integrity attestation). In 2025, with increasingly sophisticated attackers and stricter Play Store policies, security-conscious apps must address all layers systematically.

// ── 1. NETWORK SECURITY — SSL Pinning ────────────────────────────────
// Prevents MITM attacks even if device has a rogue CA certificate

// Option A: Network Security Config (declarative, recommended)
// res/xml/network_security_config.xml
// <network-security-config>
//   <domain-config cleartextTrafficPermitted="false">
//     <domain includeSubdomains="true">api.myapp.com</domain>
//     <pin-set expiration="2026-01-01">
//       <pin digest="SHA-256">primaryPinBase64==</pin>
//       <pin digest="SHA-256">backupPinBase64==</pin>  <!-- MUST have backup pin! -->
//     </pin-set>
//   </domain-config>
// </network-security-config>

// Option B: OkHttp CertificatePinner (more control)
val pinner = CertificatePinner.Builder()
    .add("api.myapp.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
    .add("api.myapp.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // backup
    .build()
val client = OkHttpClient.Builder().certificatePinner(pinner).build()

// ── 2. SECURE STORAGE — Android Keystore + EncryptedSharedPreferences ─
// Store tokens, passwords, and secrets — never in plain SharedPreferences
val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .setUserAuthenticationRequired(false)  // true = requires biometric for each access
    .build()

val securePrefs = EncryptedSharedPreferences.create(
    context, "secure_prefs", masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
securePrefs.edit().putString("auth_token", token).apply()

// ── 3. CODE OBFUSCATION — R8/ProGuard ────────────────────────────────
// build.gradle.kts (release build type)
// buildTypes { release { isMinifyEnabled = true; isShrinkResources = true } }
// R8 does: dead code removal, class/method renaming, constant folding

// ── 4. PLAY INTEGRITY API — device & app attestation ─────────────────
// Replaces SafetyNet (deprecated 2024). Verifies:
// - App is genuine (from Play Store, not modified)
// - Device is genuine (not emulator, not rooted)
// - Account hasn't been abused
val integrityManager = IntegrityManagerFactory.create(context)
val request = IntegrityTokenRequest.builder()
    .setNonce(serverNonce)  // prevents replay attacks
    .build()
integrityManager.requestIntegrityToken(request)
    .addOnSuccessListener { tokenResponse ->
        // Send token to your server for verification
        verifyOnServer(tokenResponse.token())
    }

// ── 5. PREVENT SCREENSHOTS on sensitive screens ───────────────────────
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // Prevents screenshots, screen recording, and Recent Apps preview
    window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}

SSL/Certificate Pinning ensures your app only trusts your specific server certificate even when a user or attacker installs a malicious CA certificate. Always include at least one backup pin — if your primary certificate expires before an app update ships, the backup pin prevents a full outage. Use Network Security Config for declarative simplicity or OkHttp's CertificatePinner for more programmatic control. The Android Keystore backs this up at rest: key material never leaves the secure element or TEE, so even root access cannot extract keys. Use it for AES encryption via EncryptedSharedPreferences and EncryptedFile, and to back BiometricPrompt CryptoObject flows.

R8/ProGuard obfuscation renames classes, methods, and fields to meaningless short strings, making reverse engineering significantly harder while also removing unused code to shrink APK size. Always set isMinifyEnabled = true in release builds and test with obfuscation enabled — reflection-based libraries can break silently without proper keep rules. The Play Integrity API (which replaced SafetyNet in 2024) provides server-verified attestation that your app is unmodified, installed from the Play Store, and running on a certified device — use it to gate sensitive operations like payments or account creation.

FLAG_SECURE prevents screenshots, screen recordings, and Recent Apps thumbnail captures on sensitive screens such as payment flows, password entry, and personal data views. Finally, never hardcode API keys or secrets in source — obfuscated APKs can still be decompiled with tools like jadx. Secrets belong in the Android Keystore; server-side API keys should never be in the client app at all, accessed only via your backend proxy.

💡 Interview Tip

For fintech/banking interviews, security is often a dedicated interview round. Structure your answer with four layers: (1) Network — SSL pinning with backup pin. (2) Storage — Keystore + EncryptedSharedPreferences, never plain SharedPreferences for sensitive data. (3) Code — R8 obfuscation enabled in release, ProGuard rules maintained. (4) Device integrity — Play Integrity API to detect rooted/emulated devices. Mentioning that SafetyNet was deprecated in 2024 and Play Integrity API is the replacement shows you stay current with security APIs.

Mark it done or save it in the question bank · Back to top

MediumMost asked

30. What is ConstraintLayout and why is it preferred over other layouts?

Before ConstraintLayout, building complex Android UIs required nesting multiple LinearLayouts and RelativeLayouts — and each nesting level multiplied the measure/layout passes required to render the screen. A deeply nested hierarchy (6+ levels) could take dozens of measurement passes per frame, causing jank and poor performance. ConstraintLayout solved this by allowing any layout complexity with a completely flat hierarchy — just one ConstraintLayout containing all views, each positioned relative to other views or parent boundaries. Think of it like a constraint satisfaction system: you define rules ("this view is 8dp below that view, and aligned to the center"), and the system figures out the exact pixel positions.

<!-- ── ConstraintLayout — flat hierarchy, any complexity ────────────────
     All views are SIBLINGS, no nesting needed -->
<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!-- Profile photo: constrained to top-start corner -->
    <ImageView android:id="@+id/ivAvatar"
        android:layout_width="48dp" android:layout_height="48dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="@+id/tvSubtitle" />

    <!-- Name: to the right of avatar, aligned to avatar's top -->
    <TextView android:id="@+id/tvName"
        app:layout_constraintStart_toEndOf="@+id/ivAvatar"
        app:layout_constraintTop_toTopOf="@+id/ivAvatar" />

    <!-- Subtitle: below name, same start as name -->
    <TextView android:id="@+id/tvSubtitle"
        app:layout_constraintStart_toStartOf="@+id/tvName"
        app:layout_constraintTop_toBottomOf="@+id/tvName" />

    <!-- Guideline: invisible reference line at 50% width -->
    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/glCenter"
        android:orientation="vertical"
        app:layout_constraintGuide_percent="0.5" />

    <!-- Barrier: dynamic guideline based on largest of multiple views -->
    <androidx.constraintlayout.widget.Barrier
        android:id="@+id/barrierEnd"
        app:barrierDirection="end"
        app:constraint_referenced_ids="tvLabel1,tvLabel2,tvLabel3" />
    <!-- Views positioned after barrierEnd — always aligned regardless of label length -->

    <!-- Chain: distribute views horizontally (spread, packed, or weighted) -->
    <Button android:id="@+id/btnCancel"
        app:layout_constraintHorizontal_chainStyle="spread"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/btnConfirm" />
    <Button android:id="@+id/btnConfirm"
        app:layout_constraintStart_toEndOf="@+id/btnCancel"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

// ── MotionLayout — animate between constraint states ──────────────────
// MotionLayout extends ConstraintLayout
// Define two ConstraintSets (start state and end state) in MotionScene XML
// MotionLayout interpolates between them based on progress
motionLayout.transitionToEnd()    // trigger animation
motionLayout.setTransitionDuration(300)

// ── ConstraintLayout in Compose ──────────────────────────────────────
// implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")
@Composable
fun ProfileCard() {
    ConstraintLayout(modifier = Modifier.fillMaxWidth()) {
        val (avatar, name, subtitle) = createRefs()
        Image(modifier = Modifier.constrainAs(avatar) {
            start.linkTo(parent.start, margin = 16.dp)
            top.linkTo(parent.top, margin = 16.dp)
        })
        Text("Rahul", modifier = Modifier.constrainAs(name) {
            start.linkTo(avatar.end, margin = 12.dp)
            top.linkTo(avatar.top)
        })
    }
}

Why flat hierarchy matters — Android's layout system performs multiple measurement passes per view for RelativeLayout and LinearLayout. Nesting creates exponential work: three nested LinearLayouts can require 8× the measurement passes. ConstraintLayout's constraint solver calculates all positions in a single linear pass regardless of layout complexity, which is why Google recommends it for complex View-based screens. Guidelines are invisible helper references (e.g., a vertical guideline at 50% width as a center anchor) with zero rendering cost. Barriers are dynamic constraints that position relative to the largest of a set of views — if three label TextViews have varying text lengths, a Barrier ensures the adjacent value TextViews always align at the same x-coordinate regardless of which label is widest.

Chains group multiple views between two constraints and distribute them using chain styles: spread (evenly spaced), spread inside (space between but not at edges), packed (grouped together), or weighted (proportional sizes, replacing LinearLayout weights). MotionLayout, a ConstraintLayout subclass, interpolates between two complete ConstraintSets — ideal for scroll-driven collapsing toolbars, swipe-to-reveal gestures, and complex transition animations defined entirely in XML without any Kotlin code.

In 2025, ConstraintLayout is less critical for new Compose-based screens since Compose's layout system is inherently flat. It remains most valuable for complex XML screens that are expensive to rewrite, MotionLayout animations, and teams with deep XML layout expertise — but for all new UI work, standard Compose composables (Column, Row, Box) are the right starting point.

💡 Interview Tip

The performance answer: "Nested LinearLayouts trigger O(2^n) measurement passes where n is nesting depth. ConstraintLayout solves all constraints in a single linear pass — this is why Google recommends it for complex layouts. In practice, I use ConstraintLayout for View-based screens with more than 5-6 sibling views that would otherwise require nesting. For new Compose screens, I use standard Compose layout composables (Column, Row, Box) since Compose's layout system is already flat."

Mark it done or save it in the question bank · Back to top

MediumMost asked

31. What is the Android Manifest file and what are its key components?

AndroidManifest.xml is the app's contract with the Android operating system — it describes what your app is, what it can do, and what it needs, all before a single line of your code runs. The system reads it at install time to register your components, check permissions, and set up app launch behavior. Every Activity, Service, BroadcastReceiver, and ContentProvider that your app uses must be declared here, or Android won't know they exist and attempting to start them will crash your app. Getting the Manifest right is essential — a missing android:exported attribute can cause crashes on Android 12+ devices, and an incorrect permission declaration can silently prevent features from working.

<!-- AndroidManifest.xml — the complete blueprint of your app -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- Permissions — declared here, requested at runtime for "dangerous" ones -->
    <uses-permission android:name="android.permission.INTERNET" />          <!-- normal -->
    <uses-permission android:name="android.permission.CAMERA" />             <!-- dangerous, needs runtime request -->
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- runtime API 33+ -->

    <!-- Hardware features — android:required="false" prevents Play Store from blocking install -->
    <!-- If required="true" (default), devices without camera can't install your app -->
    <uses-feature android:name="android.hardware.camera" android:required="false" />

    <application
        android:name=".MyApp"                         <!-- Custom Application subclass -->
        android:label="@string/app_name"
        android:icon="@mipmap/ic_launcher"
        android:theme="@style/Theme.App.Starting"      <!-- Splash screen theme -->
        android:networkSecurityConfig="@xml/network_security_config"
        android:enableOnBackInvokedCallback="true"     <!-- Required for Predictive Back -->
        android:allowBackup="true"                     <!-- Auto Backup to Google Drive -->
        android:dataExtractionRules="@xml/extraction_rules">

        <!-- LAUNCHER activity — the entry point shown in app drawer -->
        <!-- android:exported="true" required since API 31 — crash without it! -->
        <activity android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:windowSoftInputMode="adjustResize">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <!-- Deep link support -->
            <intent-filter android:autoVerify="true">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="https" android:host="myapp.com" />
            </intent-filter>
        </activity>

        <!-- Service — android:exported="false" unless other apps need to bind -->
        <service android:name=".MyFCMService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>

        <!-- Foreground service — must declare type for Android 14+ -->
        <service android:name=".DownloadService"
            android:exported="false"
            android:foregroundServiceType="dataSync" />

        <!-- Receiver — static registration, exported flag required API 34+ -->
        <receiver android:name=".BootReceiver"
            android:exported="false">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
            </intent-filter>
        </receiver>

        <!-- Provider — FileProvider for secure file sharing -->
        <provider android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
        </provider>

    </application>
</manifest>

// ── MANIFEST MERGING ─────────────────────────────────────────────────
// Libraries inject their own manifest entries automatically
// Check merged result: Android Studio → Build → Merged Manifest tab

// Override library manifest entries with tools: namespace
// Remove an unwanted component added by a library:
// <activity android:name="com.lib.InternalActivity" tools:node="remove" />

// Replace a library attribute with your own value:
// <application android:allowBackup="false" tools:replace="android:allowBackup" />

Four component declarations — Every Activity, Service, BroadcastReceiver, and ContentProvider must be declared in the Manifest. Starting an undeclared Activity throws ActivityNotFoundException; undeclared BroadcastReceivers simply never receive broadcasts. android:exported (required API 31+) must be explicitly set to true or false on every component that has an intent-filter — omitting it causes a crash at install time on Android 12+ devices. Set it to true only for components other apps need to reach.

uses-permission vs uses-feature — uses-permission requests access to a capability; uses-feature declares a hardware requirement. Always add android:required="false" to uses-feature for optional hardware (camera, NFC, Bluetooth), otherwise Play Store blocks installation on devices without that hardware, silently shrinking your user base. Declaring a permission in the Manifest is also not the same as having it granted: normal permissions (INTERNET) are auto-granted at install, while dangerous permissions (CAMERA, LOCATION) must additionally be requested at runtime.

Manifest merging — at build time, your Manifest is merged with the manifests of every library dependency. Libraries like WorkManager, Firebase, and Glide inject their own entries. Always check the Merged Manifest viewer in Android Studio before each release and use tools:node="remove" or tools:replace to strip unwanted library components or override conflicting attributes. android:foregroundServiceType (API 34+) must be declared for every foreground service and passed to startForeground() — missing it crashes the app on Android 14+. Set android:enableOnBackInvokedCallback="true" once you've migrated to OnBackPressedDispatcher to enable Predictive Back animations on Android 13+. If you have a custom Application subclass for Hilt or DI initialization, declare it via android:name — omitting it means the default Application class is used and your initialization never runs.

💡 Interview Tip

Manifest merging is a very common source of production bugs that developers overlook. The strongest answer: "I always check the Merged Manifest tab in Android Studio before every release to verify that no library has injected unexpected permissions or components. I've caught libraries accidentally adding android:allowBackup=true (which copies app data to Google Drive, a potential privacy issue) and removed it with tools:replace." Knowing about tools:node="remove" and tools:replace shows real production experience.

Mark it done or save it in the question bank · Back to top

Hard

32. What is Jetpack Paging 3? How does it handle large datasets efficiently?

Imagine a food delivery app like Swiggy showing a restaurant's menu with 500 items, or Instagram's explore feed with millions of posts. Loading all of that at once would exhaust memory and take forever. Jetpack Paging 3 solves this by loading data in small chunks (pages) on demand — when the user scrolls near the bottom, it automatically fetches the next page. What makes Paging 3 powerful beyond simple pagination is its built-in loading state management (loading, error, retry), seamless integration with Room for offline caching, and a Kotlin Flow-based API that plays naturally with ViewModels and Compose. Without a library like this, developers often reinvent pagination badly — with scroll listeners that fire too late, no error handling, and no offline support.

// implementation("androidx.paging:paging-runtime-ktx:3.3.0")
// implementation("androidx.paging:paging-compose:3.3.0")

// ── LAYER 1: PagingSource — defines how to load data ─────────────────
// Responsible for loading one page of data given a "key" (page number, cursor, etc.)
class ProductPagingSource(
    private val api: ProductApi,
    private val category: String
) : PagingSource<Int, Product>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Product> {
        val page = params.key ?: 1  // null key = first load = page 1
        return try {
            val response = api.getProducts(
                category = category,
                page = page,
                size = params.loadSize  // Paging library controls the page size
            )
            LoadResult.Page(
                data = response.products,
                prevKey = if (page == 1) null else page - 1,  // null = no previous page
                nextKey = if (response.products.isEmpty()) null else page + 1
            )
        } catch (e: IOException) { LoadResult.Error(e) }
        catch (e: HttpException) { LoadResult.Error(e) }
    }

    // Called when Paging needs to reload after invalidation (e.g., pull-to-refresh)
    // Return the key near the anchor position so the user doesn't jump to page 1
    override fun getRefreshKey(state: PagingState<Int, Product>): Int? {
        return state.anchorPosition?.let { anchor ->
            state.closestPageToPosition(anchor)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
        }
    }
}

// ── LAYER 2: ViewModel — creates the Pager and exposes PagingData Flow ─
@HiltViewModel
class ProductViewModel @Inject constructor(
    private val api: ProductApi
) : ViewModel() {

    fun getProducts(category: String): Flow<PagingData<Product>> =
        Pager(
            config = PagingConfig(
                pageSize = 20,           // items per page
                prefetchDistance = 5,     // load next page when 5 items from end
                enablePlaceholders = false // true = show empty items as placeholders
            ),
            pagingSourceFactory = { ProductPagingSource(api, category) }
        ).flow
        .cachedIn(viewModelScope)  // ✅ CRITICAL — cache pages, survive rotation
        // Without cachedIn: rotation/recomposition re-fetches from page 1!
}

// ── LAYER 3: Compose UI — collect and display ─────────────────────────
@Composable
fun ProductListScreen(viewModel: ProductViewModel = hiltViewModel()) {
    // collectAsLazyPagingItems: lifecycle-aware collection, Paging-aware LazyList integration
    val products = viewModel.getProducts("electronics").collectAsLazyPagingItems()

    LazyColumn {
        // Render loaded items — key prevents recomposition for unchanged items
        items(products, key = { it.id }) { product ->
            if (product != null) ProductCard(product)
            else ProductPlaceholder()  // shown when enablePlaceholders=true
        }

        // Show loading spinner at bottom while next page loads
        products.loadState.apply {
            when {
                append is LoadState.Loading -> item { LoadingIndicator() }
                append is LoadState.Error   -> item { RetryButton { products.retry() } }
                refresh is LoadState.Error  -> item { ErrorScreen { products.refresh() } }
            }
        }
    }

    // Pull-to-refresh
    if (products.loadState.refresh is LoadState.Loading) {
        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            CircularProgressIndicator()
        }
    }
}

// ── RemoteMediator — offline-first paging (network + Room) ────────────
// Loads from network into Room, LazyPagingItems reads from Room
// User sees cached data while new data loads in the background
@OptIn(ExperimentalPagingApi::class)
class ProductRemoteMediator(
    private val api: ProductApi,
    private val db: AppDatabase
) : RemoteMediator<Int, Product>() {
    override suspend fun load(loadType: LoadType, state: PagingState<Int, Product>): MediatorResult {
        return try {
            val page = when (loadType) {
                LoadType.REFRESH -> 1
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
                LoadType.APPEND  -> (db.remoteKeyDao().getLastKey()?.nextPage) ?: return MediatorResult.Success(endOfPaginationReached = true)
            }
            val products = api.getProducts(page, state.config.pageSize)
            db.withTransaction {
                if (loadType == LoadType.REFRESH) db.productDao().clearAll()
                db.productDao().insertAll(products)
            }
            MediatorResult.Success(endOfPaginationReached = products.isEmpty())
        } catch (e: Exception) { MediatorResult.Error(e) }
    }
}

PagingSource loads one page of data given a key — Int for page-number APIs, String for cursor-based APIs. Returning LoadResult.Error automatically surfaces the error state in the UI and enables retry. getRefreshKey() is called during invalidation to resume near the user's current scroll position rather than jumping back to page 1. Pager and PagingConfig wrap the PagingSource: pageSize sets items per request, prefetchDistance triggers the next page load before the user reaches the end, and enablePlaceholders controls whether null items are shown during loading. Pager.flow produces a Flow<PagingData> that the ViewModel exposes to the UI.

cachedIn(viewModelScope) is always required. Without it, every screen rotation or recomposition causes the entire list to refetch from page 1. cachedIn stores loaded pages in memory and serves them instantly — it is the single most commonly forgotten piece of Paging 3 setup. Paging 3 surfaces three LoadState types: refresh (initial load or pull-to-refresh), prepend (loading above the first visible item), and append (loading below the last item). Each can be Loading, Error, or NotLoading — handle all three to show appropriate spinners and retry buttons.

RemoteMediator bridges network and Room for offline-first pagination. The UI always reads from Room via a DAO-backed PagingSource; RemoteMediator fetches from the network and writes to Room. Users see cached data instantly and new data appears when the network load completes — the pattern powering feeds in apps like Instagram. In Compose, collectAsLazyPagingItems() connects the PagingData Flow to LazyColumn with built-in loadState, retry(), and refresh(). For RecyclerView screens, use PagingDataAdapter (which handles DiffUtil internally) and observe adapter.loadStateFlow for loading indicators. Trigger a full re-fetch by calling pagingSource.invalidate() or adapter.refresh() — Paging creates a new PagingSource via your pagingSourceFactory lambda on each invalidation.

💡 Interview Tip

Two things that separate senior answers from junior answers on Paging 3: (1) Explaining cachedIn — "Without it, screen rotation causes a full re-fetch from page 1. I've seen apps scroll the user back to the top of a 500-item list on rotation because someone forgot cachedIn." (2) Explaining RemoteMediator — "RemoteMediator is how you build offline-first infinite scroll. The UI always reads from Room, and RemoteMediator silently syncs from the network in the background. This is what Instagram-style feeds look like under the hood." These two concepts show genuine production experience with pagination at scale.

Mark it done or save it in the question bank · Back to top

MediumMost asked

33. How does Android handle accessibility? What are the key APIs and best practices?

Accessibility is about making your app usable by everyone — including people who are blind, have low vision, motor impairments, or cognitive disabilities. In India alone, over 70 million people have some form of disability, and globally over 1 billion people benefit from accessible apps. Beyond the ethical obligation, Google Play now surfaces apps with accessibility issues and companies like Google, Flipkart, and Paytm specifically ask about accessibility in their interviews. The key tools are: TalkBack (Android's screen reader for blind users), Switch Access (for users who can't use a touchscreen), and the Accessibility Inspector (for finding issues). Accessibility is not an afterthought — it should be built in from day one, because retrofitting accessibility onto an existing app is significantly harder.

// ── View system: contentDescription and touch targets ────────────────

// ✅ Always describe what an interactive element DOES, not what it is
binding.ivUserPhoto.contentDescription = "Profile photo of Rahul"
binding.btnShare.contentDescription = "Share this post"  // not "Share button"

// ✅ Dynamic descriptions — update when state changes
binding.btnLike.contentDescription = if (isLiked) "Remove like" else "Like this post"

// ✅ Decorative images — hide from TalkBack (prevents useless announcements)
binding.ivBackground.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO

// ✅ Minimum touch target: 48x48dp (Material Design requirement)
// If icon is 24dp, wrap in a 48dp container or use padding to expand hit area
binding.iconButton.setPadding(12.dpToPx(), 12.dpToPx(), 12.dpToPx(), 12.dpToPx())

// ✅ Group related views into one focusable unit for TalkBack
// In XML: android:focusable="true" on the parent container
// android:contentDescription="Rahul, Software Engineer, 3 connections"
// This reads as one unit instead of three separate announcements

// ── Compose: Semantics API ────────────────────────────────────────────
@Composable
fun LikeButton(isLiked: Boolean, likeCount: Int, onClick: () -> Unit) {
    IconButton(
        onClick = onClick,
        modifier = Modifier.semantics {
            // What TalkBack announces when focused
            contentDescription = if (isLiked) "Remove like. $likeCount likes."
                                 else "Like this post. $likeCount likes."
            // Role: helps TalkBack announce the control type
            role = Role.Button
            // stateDescription: announces ON/OFF toggle state
            stateDescription = if (isLiked) "Liked" else "Not liked"
            // onClickLabel: describes the action (in addition to content description)
            onClickLabel = if (isLiked) "remove like" else "add like"
        }
    ) {
        Icon(
            imageVector = if (isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
            contentDescription = null  // null because parent has contentDescription
        )
    }
}

// ✅ mergeDescendants: treat group as single accessible element
// Without this, TalkBack announces each child separately — annoying for list items
@Composable
fun UserListItem(user: User, onClick: () -> Unit) {
    Row(
        modifier = Modifier
            .clickable(onClick = onClick)
            .semantics(mergeDescendants = true) {}  // merge all child semantics
    ) {
        AsyncImage(model = user.photoUrl, contentDescription = null)  // decorative here
        Column {
            Text(user.name)        // TalkBack reads: "Rahul, Software Engineer, Tap to view profile"
            Text(user.jobTitle)    // all merged into one focus node
        }
    }
}

// ✅ Custom accessibility actions
Modifier.semantics {
    customActions = listOf(
        CustomAccessibilityAction("Add to cart") { onAddToCart(); true },
        CustomAccessibilityAction("Save for later") { onSaveForLater(); true }
    )
}

// ── Testing accessibility ─────────────────────────────────────────────
// 1. Enable TalkBack in Settings → Accessibility → TalkBack
// 2. Navigate your app using ONLY swipe gestures (no looking at screen)
// 3. Use Google's Accessibility Scanner app — automated checks
// 4. Automated tests in CI:
// dependencies { androidTestImplementation("com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework:4.0.0") }
// @Rule val accessibilityChecks = AccessibilityChecks.enable().setRunChecksFromRootView(true)

contentDescription — TalkBack reads this aloud when a user focuses on a View. Every ImageView, ImageButton, and icon-only button needs a description of the action, not the appearance: "Camera icon" is useless; "Take a photo" is helpful. Update it dynamically when state changes (liked vs unliked). Maintain a minimum 48×48dp touch target: if an icon is only 24dp visually, add 12dp padding on all sides to expand the interactive area without changing visual size. Accessibility Scanner flags elements smaller than 48dp.

Semantics API in Compose — the semantics modifier tells accessibility services what a composable is and what it does: role defines the control type (Button, Checkbox, Switch), stateDescription announces toggle state, and onClickLabel describes the action for voice commands. mergeDescendants on a list item's container collapses its children into a single focus node — without it, TalkBack announces each child separately ("Rahul… Software Engineer… 3 connections… Tap to open") as four stops instead of one, making navigation painfully slow for screen reader users.

Decorative images (dividers, background patterns) should be hidden from accessibility via importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO (View system) or contentDescription = null (Compose) to prevent TalkBack from announcing "Image" for every embellishment. Add custom accessibility actions for swipe gestures (delete, archive) so Switch Access and TalkBack users can trigger them via the accessibility menu rather than a physical swipe. Ensure text meets a 4.5:1 color contrast ratio (3:1 for large text) — Material Design 3's color system handles this when used correctly. For dynamic content changes (new message arrived, load complete), use ViewCompat.announceForAccessibility() or a Compose liveRegion to announce the change without stealing focus.

💡 Interview Tip

Most developers say "I add contentDescription" and stop there — that's the junior answer. The senior answer covers the full picture: "I use mergeDescendants to group list item children into single focus nodes, I add custom accessibility actions for swipe gestures, and I run Accessibility Scanner in CI to catch contrast and target size regressions automatically. Before any release, someone on my team navigates the critical flows with TalkBack enabled and eyes closed — if they can't complete the checkout flow, it doesn't ship." This shows you treat accessibility as a first-class feature, not an afterthought.

Mark it done or save it in the question bank · Back to top

Hard

34. What are the key features and breaking changes in Android 15 and Android 16?

Staying current with Android platform changes is one of the clearest signals of a senior developer — it shows you don't just write code, you actively track how the platform evolves and proactively update your apps. Android 15 (Vanilla Ice Cream, API 35) and Android 16 (Baklava, API 36) introduced significant breaking changes that caught many teams off-guard. The biggest: edge-to-edge is now mandatory in Android 15, meaning any app targeting API 35 automatically draws behind the system bars whether it wants to or not. This single change broke thousands of apps by hiding navigation buttons, bottom sheets, and FABs behind the system navigation bar. Android 16 goes further by enforcing Predictive Back animations for apps that haven't opted in.

// ── ANDROID 15 (API 35, codename "Vanilla Ice Cream") ─────────────────
// Released: October 2024 | Google Play minimum for new apps from 2025

// BREAKING CHANGE 1: Edge-to-edge enforced by default ─────────────────
// Apps targeting API 35+ draw behind system bars automatically
// Before API 35: opt-in via enableEdgeToEdge() — now mandatory
// Impact: FABs hidden behind nav bar, BottomSheet overlapping nav bar

// ✅ Fix: handle WindowInsets on every screen
ViewCompat.setOnApplyWindowInsetsListener(rootView) { v, insets ->
    val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
    v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
    insets
}
// In Compose: use Scaffold's paddingValues and WindowInsets.safeDrawing

// BREAKING CHANGE 2: Foreground service types MANDATORY ───────────────
// Missing foregroundServiceType = crash on Android 14+ (API 34+)
// Manifest:
// <service android:foregroundServiceType="dataSync|mediaPlayback" />
// Code:
startForeground(notifId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)

// BREAKING CHANGE 3: Stricter BroadcastReceiver registration ──────────
// Dynamic receivers MUST specify exported flag — crash without it on API 34+
registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED)  // for internal broadcasts
registerReceiver(receiver, filter, RECEIVER_EXPORTED)      // if other apps should send

// NEW FEATURE: Health Connect 2.0 ──────────────────────────────────────
// Background read permissions now tightened — must re-request each session
// New data types: MenstrualCycle, PlannedExerciseSession, SkinTemperature

// NEW FEATURE: Photos picker improvements ─────────────────────────────
// Android 14+ Partial photo access: READ_MEDIA_VISUAL_USER_SELECTED
// Users can grant access to only selected photos — not whole gallery
// Always check both READ_MEDIA_IMAGES and READ_MEDIA_VISUAL_USER_SELECTED:
val hasFullAccess = ContextCompat.checkSelfPermission(ctx, READ_MEDIA_IMAGES) == GRANTED
val hasPartialAccess = ContextCompat.checkSelfPermission(ctx, READ_MEDIA_VISUAL_USER_SELECTED) == GRANTED

// ── ANDROID 16 (API 36, codename "Baklava") ───────────────────────────
// Released: Q2 2025 — first Android with two major releases per year

// BREAKING CHANGE: Predictive Back enforced ────────────────────────────
// Apps targeting API 36 that still use deprecated onBackPressed() get warnings
// Apps must have android:enableOnBackInvokedCallback="true" in manifest
// Without it: back gesture animations are disabled on user's device
// ✅ Migrate: use OnBackPressedCallback or BackHandler (Compose)
val callback = object : OnBackPressedCallback(true) {
    override fun handleOnBackPressed() {
        if (hasUnsavedChanges) showDiscardDialog()
        else { isEnabled = false; onBackPressedDispatcher.onBackPressed() }
    }
}
onBackPressedDispatcher.addCallback(this, callback)

// NEW: Adaptive refresh rate improvements ─────────────────────────────
// Better frame rate management for animations and scrolling
// Apps using Choreographer and Compose animations benefit automatically

// NEW: Large screen / foldable improvements ───────────────────────────
// WindowSizeClass and ListDetailPaneScaffold now recommended more strongly
// Play Store quality badges updated — foldable/tablet support now weighted more

// ── API LEVEL CHECK pattern for new features ─────────────────────────
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { // API 35
    // Use Android 15 specific APIs
}
if (Build.VERSION.SDK_INT >= 36) { // API 36 — Baklava (VERSION_CODES.BAKLAVA not yet in stable)
    // Use Android 16 specific APIs
}

Edge-to-edge enforcement (Android 15, API 35 — biggest 2025 breaking change) — apps targeting API 35 automatically draw behind the status bar and navigation bar with no opt-out. Teams that upgraded targetSdk to 35 without testing found FABs, bottom navigation bars, and bottom sheets hidden behind the system nav bar. Every screen must handle WindowInsets via ViewCompat.setOnApplyWindowInsetsListener (View system) or Scaffold/WindowInsets.safeDrawing (Compose). Foreground service types (mandatory since Android 14, API 34) must be declared in the Manifest and passed to startForeground() — omitting the type throws MissingForegroundServiceTypeException on API 34+. Valid types include dataSync, mediaPlayback, location, camera, and microphone.

BroadcastReceiver exported flag (Android 14, API 34) — dynamically registered receivers via registerReceiver() must now explicitly pass RECEIVER_EXPORTED or RECEIVER_NOT_EXPORTED; omitting it throws an IllegalArgumentException crash. Use RECEIVER_NOT_EXPORTED for internal receivers. Predictive Back enforcement (Android 16, API 36) — apps that haven't migrated from deprecated onBackPressed() to OnBackPressedDispatcher have the predictive back peek animation disabled. Set android:enableOnBackInvokedCallback="true" in the Manifest once the migration is complete.

Photo library partial access (Android 14) — users can now grant access to only selected photos via READ_MEDIA_VISUAL_USER_SELECTED; always offer the system Photo Picker as the primary selection method since it requires no permission at all. Health Connect stricter privacy (Android 15+) requires re-requesting health data permissions each app session — apps silently broke if they relied on previously granted background read access. Starting with Android 16, Google moved to two major Android releases per year, so subscribe to developer release notes to stay ahead of breaking changes. Google Play requires new apps to target at least API 35 from August 2025, with existing apps needing to comply within 12 months or risk being hidden from new users.

💡 Interview Tip

Interviewers at top companies specifically ask "What's new in the latest Android?" to gauge whether you actively follow the platform. Knowing the codenames (Android 15 = Vanilla Ice Cream = API 35, Android 16 = Baklava = API 36) and the specific breaking changes is impressive. The single most impactful answer: "Android 15's edge-to-edge enforcement is the biggest breaking change I've seen — we spent a full sprint auditing every screen in our app for WindowInsets handling after upgrading targetSdk to 35. FABs on 3 screens were completely hidden behind the navigation bar and we never would have caught it without testing on real API 35 devices."

Mark it done or save it in the question bank · Back to top

Hard

35. What is Kotlin Multiplatform Mobile (KMM) and how does it relate to Android development?

Kotlin Multiplatform (KMP, formerly called KMM — Kotlin Multiplatform Mobile) is JetBrains' solution for sharing business logic across Android, iOS, web, and desktop while keeping native UI for each platform. The key philosophy is "share what makes sense, keep native what matters" — share the boring, repetitive parts (network calls, data parsing, business rules, database queries) and keep the platform-specific UI that users actually see and touch. Unlike Flutter or React Native, KMP doesn't abstract away the UI — you still write Jetpack Compose for Android and SwiftUI for iOS, so you get full platform fidelity with none of the "feels like a web app" problem. KMP became stable in November 2023 (Kotlin 1.9.20) and is used in production by Netflix, Philips, Cash App, McDonald's, and many others.

// ── KMP Project Structure ────────────────────────────────────────────
// myapp/
// ├── shared/                  ← shared Kotlin module
// │   ├── commonMain/          ← pure Kotlin, NO platform APIs here
// │   │   ├── data/            ← API models, DTOs
// │   │   ├── domain/          ← use cases, business logic
// │   │   └── repository/      ← repository interfaces + implementations
// │   ├── androidMain/         ← Android-specific implementations
// │   ├── iosMain/             ← iOS-specific implementations
// │   └── commonTest/          ← shared unit tests (run on both platforms!)
// ├── androidApp/              ← Android UI module (Jetpack Compose)
// └── iosApp/                  ← iOS UI module (SwiftUI, in Xcode)

// ── Shared business logic ─────────────────────────────────────────────
// commonMain/repository/UserRepository.kt
// Pure Kotlin — no import android.* or import UIKit here!
class UserRepository(
    private val api: UserApi,      // Ktor HttpClient (multiplatform)
    private val db: UserDatabase    // SQLDelight (multiplatform)
) {
    suspend fun getUsers(): List<User> {
        return try {
            val users = api.fetchUsers()  // Ktor: works on Android and iOS
            db.insertAll(users)
            users
        } catch (e: Exception) {
            db.getAllUsers()  // fallback to cache
        }
    }

    fun observeUsers(): Flow<List<User>> = db.getAllUsersFlow()
    // Kotlin Flow works on both Android (Coroutines) and iOS (via KMP Swift adapters)
}

// ── expect/actual — platform-specific implementations ─────────────────
// Pattern: declare interface in commonMain, implement per platform

// commonMain/platform/Platform.kt
expect class Platform() {
    val name: String
    val version: String
}

expect fun createHttpClient(): HttpClient  // each platform uses its own engine
expect fun getDeviceId(): String             // Android: Settings.Secure, iOS: identifierForVendor

// androidMain/platform/Platform.android.kt
actual class Platform actual constructor() {
    actual val name = "Android"
    actual val version = Build.VERSION.RELEASE
}
actual fun createHttpClient() = HttpClient(OkHttp)  // Android uses OkHttp engine

// iosMain/platform/Platform.ios.kt
actual class Platform actual constructor() {
    actual val name = UIDevice.currentDevice.systemName()
    actual val version = UIDevice.currentDevice.systemVersion
}
actual fun createHttpClient() = HttpClient(Darwin)  // iOS uses Darwin/NSURLSession

// ── KMP-ready libraries (multiplatform-compatible) ───────────────────
// Networking  → Ktor (replace Retrofit — Retrofit is Android-only)
// Database    → SQLDelight (replace Room — Room is Android-only)
// JSON        → kotlinx.serialization (multiplatform native)
// Async       → kotlinx.coroutines (fully multiplatform)
// DI          → Koin (KMP-ready), Kotlin-inject
// Date/Time   → kotlinx-datetime (multiplatform)
// Settings    → multiplatform-settings (replaces SharedPreferences)

// ── Android ViewModel using shared repository ────────────────────────
// androidApp — uses shared UserRepository directly in a Hilt-injected ViewModel
@HiltViewModel
class UserViewModel @Inject constructor(
    private val repository: UserRepository  // from shared module!
) : ViewModel() {
    val users = repository.observeUsers()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}

What to share vs what to keep native — share data models, API calls (Ktor), database logic (SQLDelight), repository layer, use cases, and business rules. Keep native: UI (Compose for Android, SwiftUI for iOS), platform services (camera, GPS, notifications), and anything with platform-specific UX requirements. The golden rule: if it deals with pixels or platform UX, keep it native. The expect/actual mechanism is the core of KMP's platform abstraction — declare a function or class with expect in commonMain, and each platform provides its actual implementation. This lets shared code call getPlatformName() and receive "Android 15" or "iOS 18" without any if-else checks in shared code.

Library replacements — Retrofit is Android-only, replace with Ktor. Room is Android-only, replace with SQLDelight. SharedPreferences is Android-only, replace with multiplatform-settings. kotlinx.coroutines and Flow are fully multiplatform, so your repository can return Flow<List<User>> from shared code and both Android (via collectAsStateWithLifecycle) and iOS (via KMPNativeCoroutines or Swift's async/await adapters) can consume it. Tests in commonTest run on both JVM and Kotlin/Native targets — one test file covers business logic on both platforms.

KMP has significant production adoption: Netflix uses it for their cross-platform mobile SDK, with Cash App, Philips, VMware, and McDonald's also running it in production. Compose Multiplatform (CMP) extends this further by sharing the UI layer itself — using Compose on iOS, desktop, and web — currently stable for iOS and desktop. The shared module uses the kotlin("multiplatform") Gradle plugin with per-platform compilation targets (androidTarget, iosX64, iosArm64, iosSimulatorArm64). Check library multiplatform compatibility at libs.kmp.gg before adopting new dependencies.

💡 Interview Tip

KMP knowledge is a genuine competitive advantage in 2025 — most Android developers don't know it, and companies with both Android and iOS apps increasingly want engineers who can bridge both. The most impressive answer frames the business value: "With KMP, a company that previously needed separate Android and iOS teams for each feature now needs one team. Business logic bugs are fixed once and deployed to both platforms simultaneously. I've seen teams reduce their mobile feature development time by 30-40% after adopting KMP for their shared layer." If you have real KMP experience, always lead with the concrete benefits you achieved.

Mark it done or save it in the question bank · Back to top

HardMost asked

36. What is IPC in Android? Explain AIDL and when you would use it over other IPC mechanisms.

Android enforces strict process isolation — each app runs in its own Linux process with its own memory space. Inter-Process Communication (IPC) is the set of mechanisms that allow processes to talk to each other. Every Android device relies heavily on IPC — when you open the camera app from your photo editor, when a music app communicates with its background service, when WhatsApp sends data between its main app and its notification processing component. The challenge is that crossing process boundaries is expensive (serialization overhead) and requires careful thread management. Android offers several IPC mechanisms at different complexity levels, and AIDL is the lowest-level, most powerful option — it's also what powers most of Android's system services under the hood.

// ── IPC Mechanisms: from simplest to most powerful ───────────────────
// 1. Intent/Bundle      → fire-and-forget, components in same or different app
// 2. ContentProvider    → structured CRUD data sharing via content:// URI
// 3. Messenger          → sequential one-way message passing, simple use cases
// 4. AIDL               → bidirectional, multi-threaded, full IPC interface
// 5. Broadcast          → one-to-many, system events, no return value

// ── AIDL: Android Interface Definition Language ───────────────────────
// AIDL generates Java/Kotlin Binder stub code from an interface definition
// The generated code handles: serialization, thread dispatch, proxy pattern

// STEP 1: Define the interface in an .aidl file
// app/src/main/aidl/com/myapp/IUserService.aidl
// package com.myapp;
// import com.myapp.User;  // Parcelable types need explicit import
// interface IUserService {
//     User getUser(String id);                    // synchronous, blocks caller
//     List<User> getAllUsers();                   // returns list of Parcelable
//     void deleteUser(String id);                 // one-way could use 'oneway'
//     oneway void logEvent(String event);         // 'oneway' = fire-and-forget, no block
// }
// Build project → generates IUserService.java with Stub and Proxy inner classes

// STEP 2: Implement in the Service (server process)
class UserService : Service() {

    // Extend the generated Stub class — this is the Binder object
    private val binder = object : IUserService.Stub() {

        // ⚠️ CRITICAL: AIDL methods run on the BINDER THREAD POOL — NOT the main thread!
        // You can have multiple simultaneous calls from different clients
        // All shared state (db, cache) MUST be thread-safe
        override fun getUser(id: String): User {
            // This is called on a Binder thread — safe for disk/network work
            // But if you need the result on main thread, post it back
            return db.findUser(id) ?: throw RemoteException("User not found")
        }

        override fun getAllUsers(): List<User> {
            return db.getAllUsers()  // returned list serialized to Binder buffer
        }

        override fun logEvent(event: String) {
            // 'oneway' in AIDL — returns immediately, doesn't block client
            analytics.logEvent(event)
        }
    }

    override fun onBind(intent: Intent): IBinder = binder
}

// STEP 3: Connect from client (could be another app or another process)
class MainActivity : AppCompatActivity() {

    private var userService: IUserService? = null

    private val connection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName, binder: IBinder) {
            // asInterface handles same-process (cast) and cross-process (proxy) transparently
            userService = IUserService.Stub.asInterface(binder)
        }
        override fun onServiceDisconnected(name: ComponentName) {
            userService = null
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        bindService(Intent(this, UserService::class.java), connection, BIND_AUTO_CREATE)
    }

    fun loadUser(id: String) {
        // ❌ Never call AIDL methods on main thread — they can block!
        // ✅ Always call on a background thread
        lifecycleScope.launch(Dispatchers.IO) {
            try {
                val user = userService?.getUser(id)  // may throw RemoteException
                withContext(Dispatchers.Main) { updateUI(user) }
            } catch (e: RemoteException) {
                // RemoteException = the other process crashed or disconnected
                showError("Service unavailable")
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        unbindService(connection)  // ✅ always unbind to prevent leaks
    }
}

// ── Messenger: simpler alternative for sequential messaging ──────────
// Use when: simple one-way messages, no concurrent calls, simpler setup
// NOT use when: multiple simultaneous clients, need return values
class MessengerService : Service() {
    private val handler = object : Handler(Looper.getMainLooper()) {
        override fun handleMessage(msg: Message) {
            // Runs on main thread — Messenger serializes all messages
            when (msg.what) {
                MSG_DO_WORK -> processWork(msg.data)
            }
        }
    }
    private val messenger = Messenger(handler)
    override fun onBind(intent: Intent) = messenger.binder
}

Binder is Android's IPC kernel — all Android IPC (AIDL, ContentProvider, Messenger, even startActivity) goes through the Binder kernel driver, a shared-memory mechanism built into Linux. AIDL generates the Stub/Proxy code that talks to Binder, saving you from writing that low-level plumbing by hand. The most critical operational fact: AIDL methods run on the Binder thread pool (up to 15 threads per process), not on the main thread. Multiple clients can call service methods simultaneously, so all shared state — database access, caches, file I/O — must be thread-safe using synchronized blocks, ConcurrentHashMap, or coroutine-safe structures.

Supported AIDL types are primitives, String, CharSequence, List, Map, and Parcelable objects (which must be explicitly imported in the .aidl file). Marking a method oneway makes it asynchronous — the caller returns immediately without blocking, ideal for fire-and-forget operations like logging or event notifications. Always catch RemoteException when calling AIDL methods from a client — it signals the remote process has crashed, disconnected, or the Binder transaction buffer (~1MB limit) is full. Pass IDs and file paths rather than large Parcelable blobs to avoid TransactionTooLargeException.

Messenger vs AIDL — Messenger serializes all messages to a single Handler (thread-safe by design, simpler to set up) but only supports one-way fire-and-forget messages with no return values and no concurrency. Use Messenger for simple command-pattern IPC; use AIDL when clients need return values or concurrent access. In 2025, AIDL is primarily needed for services that other apps bind to — custom accessibility services, input method editors, media browser services. For intra-app IPC between your own components in the same process, a simple Binder subclass suffices with no .aidl file required.

💡 Interview Tip

The single most impressive detail about AIDL is the threading model: "AIDL methods run on the Binder thread pool — not the main thread. This means if two clients call getUser() simultaneously, two Binder threads execute it concurrently. Any shared mutable state in my AIDL implementation must be thread-safe. I use a Room database (which handles its own thread safety) or synchronized access to shared collections." This shows you understand the concurrency implications, not just the syntax. Also mention that most modern Android code avoids AIDL in favor of WorkManager, Room, or coroutines for intra-app communication.

Mark it done or save it in the question bank · Back to top

Hard

37. What is Scoped Storage? How do you access media files with MediaStore in Android 10+?

Before Android 10, apps could declare READ_EXTERNAL_STORAGE and get access to every file on the device — photos from other apps, documents, everything. This was a massive privacy problem: a flashlight app could secretly read all your photos. Scoped Storage, introduced in Android 10 and enforced in Android 11+, ends this by restricting each app to its own private storage area and requiring explicit user consent to access shared media. Think of it like apartments in a building: each tenant (app) has their own locked apartment (private storage) they can access freely, but accessing a neighbor's apartment requires the building management's (system) permission. Android 13 went further by splitting the old single READ_EXTERNAL_STORAGE permission into three granular media permissions — one for photos, one for videos, one for audio — so a podcast app can only access audio, not your photos.

// ── STORAGE REGIONS in Android ───────────────────────────────────────
// 1. App-specific internal: context.filesDir, context.cacheDir
//    → Private to app, deleted on uninstall, NO permission needed
// 2. App-specific external: context.getExternalFilesDir(), context.externalCacheDir
//    → On SD card / shared storage, but still private to app, NO permission needed
// 3. Shared media (MediaStore): Photos, Videos, Audio, Downloads
//    → Requires permissions on API 33+, or use Photo Picker (no permission!)
// 4. Arbitrary files: MANAGE_EXTERNAL_STORAGE
//    → Only for file managers — Google Play restricts heavily

// ── PERMISSION EVOLUTION ─────────────────────────────────────────────
// API 28 and below: READ_EXTERNAL_STORAGE → access to ALL files
// API 29-32:        READ_EXTERNAL_STORAGE → access to shared media only
// API 33+:          READ_EXTERNAL_STORAGE deprecated — use granular permissions:
//                   READ_MEDIA_IMAGES  → photos and images
// (Android 13)      READ_MEDIA_VIDEO   → videos
//                   READ_MEDIA_AUDIO   → music and audio files
// API 34+:          READ_MEDIA_VISUAL_USER_SELECTED → partial photo access

// ── 1. App-specific storage — no permissions needed ───────────────────
fun saveConfig(context: Context, data: String) {
    // Internal storage — fastest, most secure
    val internalFile = File(context.filesDir, "config.json")
    internalFile.writeText(data)  // no permission needed!

    // External app-specific — good for large files (photos taken by the app)
    val externalFile = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), "photo.jpg")
    // Deleted when app is uninstalled, but visible to file managers
}

// ── 2. Photo Picker — BEST PRACTICE (no permission needed!) ──────────
// Available API 33+ natively, backported to API 21+ via Jetpack PickVisualMedia
// User sees a system UI to select photos — your app never gets MediaStore access
val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
    if (uri != null) {
        processSelectedPhoto(uri)
    }
}
// Launch — no permission request needed!
pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
// For multiple photos:
val pickMultiple = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(5)) { uris -> }

// ── 3. MediaStore — query all shared photos (requires READ_MEDIA_IMAGES) ─
// Use when: building a gallery-like app that needs to show ALL device photos
suspend fun getAllImages(context: Context): List<MediaItem> = withContext(Dispatchers.IO) {
    val images = mutableListOf<MediaItem>()
    val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
    val projection = arrayOf(
        MediaStore.Images.Media._ID,
        MediaStore.Images.Media.DISPLAY_NAME,
        MediaStore.Images.Media.SIZE,
        MediaStore.Images.Media.DATE_ADDED
    )
    context.contentResolver.query(
        collection, projection,
        null, null,
        "${MediaStore.Images.Media.DATE_ADDED} DESC"
    )?.use { cursor ->
        val idCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
        val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
        while (cursor.moveToNext()) {
            val id = cursor.getLong(idCol)
            val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
            images.add(MediaItem(id, cursor.getString(nameCol), uri))
        }
    }
    images
}

// ── 4. Save a new photo to the shared Pictures folder ────────────────
// No permission needed on API 29+ — Scoped Storage allows inserting your own media
suspend fun savePhotoToGallery(context: Context, bitmap: Bitmap): Uri? = withContext(Dispatchers.IO) {
    val values = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, "photo_${System.currentTimeMillis()}.jpg")
        put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
        put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/MyApp")  // API 29+
        put(MediaStore.Images.Media.IS_PENDING, 1)  // lock while writing
    }
    val uri = context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return@withContext null
    context.contentResolver.openOutputStream(uri)?.use { stream ->
        bitmap.compress(Bitmap.CompressFormat.JPEG, 95, stream)
    }
    values.clear()
    values.put(MediaStore.Images.Media.IS_PENDING, 0)  // release lock
    context.contentResolver.update(uri, values, null, null)
    uri
}

Scoped Storage sandboxes each app to its own private directories plus shared media it has explicit permission for. Before it, any app with READ_EXTERNAL_STORAGE could read every file on the device. Now apps freely read/write their own directories — context.filesDir (internal), context.cacheDir (cleared by system when storage is low), and context.getExternalFilesDir() (external, deleted on uninstall) — with no permission required.

Photo Picker (always prefer this first) — ActivityResultContracts.PickVisualMedia requires zero permissions and gives users a familiar system UI to select photos. Your app receives only the URI of the selected item, never broad gallery access. Available on API 21+ via Jetpack. For apps needing gallery-style access to all media: on Android 13 (API 33+), READ_EXTERNAL_STORAGE is non-functional — request READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, or READ_MEDIA_AUDIO for only the types you actually use. On Android 14 (API 34+), READ_MEDIA_VISUAL_USER_SELECTED grants partial access to only user-selected photos; always handle both the full and partial grant states and re-check on app resume.

When saving a new file to MediaStore, set IS_PENDING = 1 before writing bytes and IS_PENDING = 0 after — this prevents other apps from seeing a partially-written photo or video. To share private files with other apps, use FileProvider to generate a content:// URI with temporary read permission — a direct file:// URI throws FileUriExposedException on Android 7+ and is blocked by the system. Never request MANAGE_EXTERNAL_STORAGE as a shortcut around Scoped Storage — Google Play only permits it for file managers and antivirus apps, and any other app requesting it will be rejected.

💡 Interview Tip

The answer hierarchy that shows real expertise: "For user-selected media, I always use the Photo Picker — zero permissions, best UX, works on all API levels with Jetpack. For gallery-style apps that need to show all photos, I request READ_MEDIA_IMAGES on API 33+ and handle READ_MEDIA_VISUAL_USER_SELECTED on API 34+ for partial access. For app-generated files that the user doesn't need to browse, I use getExternalFilesDir() with no permissions at all. I never request MANAGE_EXTERNAL_STORAGE — that's for file managers and would get my app rejected from Play Store." This ladder of preferences shows you know the right tool for each use case.

Mark it done or save it in the question bank · Back to top

Medium

38. How do you implement biometric authentication using BiometricPrompt?

Biometric authentication — fingerprint, face recognition, iris scanning — is now table stakes for any serious Android app, especially in fintech, banking, and healthcare. Before BiometricPrompt (introduced in Android 9, API 28), developers had to use the now-deprecated FingerprintManager which only supported fingerprints, required managing UI themselves, and behaved differently across Android versions and device manufacturers. BiometricPrompt unifies all of this: one API that handles all biometric modalities plus device PIN/pattern fallback, with a consistent system-provided UI that users already know. Think of it like a single universal key that works on any lock (fingerprint scanner, face camera, iris scanner) — you don't need separate keys for each.

// implementation("androidx.biometric:biometric:1.2.0-alpha05")
// Works on API 23+ via Jetpack backport

// ── STEP 1: Check what's available before showing the prompt ─────────
fun checkBiometricAvailability(context: Context): BiometricStatus {
    val manager = BiometricManager.from(context)
    return when (manager.canAuthenticate(
        BiometricManager.Authenticators.BIOMETRIC_STRONG // Class 3 biometrics
            or BiometricManager.Authenticators.DEVICE_CREDENTIAL
    )) {
        BiometricManager.BIOMETRIC_SUCCESS            -> BiometricStatus.AVAILABLE
        BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE  -> BiometricStatus.NO_HARDWARE
        BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> BiometricStatus.HARDWARE_UNAVAILABLE
        BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricStatus.NOT_ENROLLED
        BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> BiometricStatus.UPDATE_REQUIRED
        else -> BiometricStatus.UNKNOWN
    }
}

// ── STEP 2: Basic authentication (no crypto binding) ─────────────────
// Use for: app unlock, confirming identity for non-sensitive operations
fun authenticateUser(activity: FragmentActivity, onSuccess: () -> Unit) {
    val promptInfo = BiometricPrompt.PromptInfo.Builder()
        .setTitle("Verify your identity")
        .setSubtitle("Use your fingerprint or face ID")
        .setDescription("Required to access your account")
        .setAllowedAuthenticators(
            BiometricManager.Authenticators.BIOMETRIC_STRONG or
            BiometricManager.Authenticators.DEVICE_CREDENTIAL  // PIN/pattern fallback
        )
        // Do NOT call setNegativeButtonText when DEVICE_CREDENTIAL is included
        .build()

    BiometricPrompt(activity, MoreExecutors.directExecutor(),
        object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                onSuccess()  // authentication confirmed — proceed with action
            }
            override fun onAuthenticationError(code: Int, errString: CharSequence) {
                // Terminal error — max retries exceeded, user cancelled, or hardware error
                // errorCode 10 = BIOMETRIC_ERROR_USER_CANCELED (user pressed cancel)
                // errorCode 11 = BIOMETRIC_ERROR_CANCELED (system cancelled)
                handleAuthError(code, errString.toString())
            }
            override fun onAuthenticationFailed() {
                // A single failed attempt (wrong finger/face) — system handles retry UI
                // Do NOT show an error here — system already shows "Not recognized"
                // Only act after onAuthenticationError (terminal failure)
            }
        }
    ).authenticate(promptInfo)
}

// ── STEP 3: Crypto-bound authentication (STRONGLY RECOMMENDED for fintech) ──
// Ties biometric authentication to a cryptographic key in the Keystore
// Even if biometrics are bypassed (accessibility service exploit), the key won't unlock
fun authenticateWithCrypto(activity: FragmentActivity) {
    val keyName = "my_biometric_key"

    // Create AES key that requires user authentication to use
    val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
    keyGenerator.init(
        KeyGenParameterSpec.Builder(keyName,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
            .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
            .setUserAuthenticationRequired(true)  // key unusable without biometric!
            .setInvalidatedByBiometricEnrollment(true)  // invalidate if new biometric added
            .build()
    )
    keyGenerator.generateKey()

    // Create Cipher with the key — this Cipher requires biometric to unlock
    val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    val key = keyStore.getKey(keyName, null) as SecretKey
    val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding").apply {
        init(Cipher.ENCRYPT_MODE, key)
    }

    // Wrap Cipher in CryptoObject — Cipher only usable after successful biometric
    val cryptoObject = BiometricPrompt.CryptoObject(cipher)

    val promptInfo = BiometricPrompt.PromptInfo.Builder()
        .setTitle("Confirm payment")
        .setNegativeButtonText("Cancel")  // required when NOT using DEVICE_CREDENTIAL
        .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
        .build()

    BiometricPrompt(activity, executor,
        object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                // result.cryptoObject?.cipher is now unlocked and ready to use
                val encryptedData = result.cryptoObject?.cipher?.doFinal(sensitiveData.toByteArray())
                processEncryptedPayment(encryptedData)
            }
        }
    ).authenticate(promptInfo, cryptoObject)  // ← pass CryptoObject here
}

BiometricPrompt unifies all biometric types — fingerprint, face, and iris are handled by one API with a consistent system-provided UI. The system selects the strongest available biometric automatically; no code changes are needed when devices add new biometric methods. Authenticator classes: BIOMETRIC_STRONG (Class 3, hardware-backed, required for Keystore-bound flows), BIOMETRIC_WEAK (Class 2, software-based face, lower security), and DEVICE_CREDENTIAL (PIN/pattern/password fallback). Always specify at least BIOMETRIC_STRONG or BIOMETRIC_STRONG or DEVICE_CREDENTIAL for fintech apps.

onAuthenticationFailed vs onAuthenticationError — onAuthenticationFailed is called after each individual failed attempt; the system already shows "Not recognized" in its UI, so never add your own error message there. onAuthenticationError is the terminal callback, fired when max retries are exceeded, the user cancels, or hardware fails — only do error handling here. CryptoObject is critical for fintech: basic biometric authentication can be bypassed by a malicious accessibility service simulating a successful result. Binding a Keystore-backed Cipher or Signature to a CryptoObject means the cryptographic object is physically locked in secure hardware until real biometric success — it's cryptographic proof of authentication, not just a boolean flag. Pass the CryptoObject to authenticate(promptInfo, cryptoObject) and use result.cryptoObject?.cipher in onAuthenticationSucceeded.

setInvalidatedByBiometricEnrollment(true) automatically invalidates Keystore keys when a new biometric is enrolled, preventing an attacker from adding their own fingerprint to a stolen device to access cryptographic operations. Handle KeyInvalidatedException on Cipher initialization by deleting the old key, generating a new one, and re-prompting the user. You cannot use both DEVICE_CREDENTIAL and setNegativeButtonText simultaneously — the SDK throws an exception. When DEVICE_CREDENTIAL is included, the system provides its own PIN fallback option. Never use the deprecated FingerprintManager; the BiometricPrompt Jetpack library backports the unified API to API 23.

💡 Interview Tip

For any fintech, banking, or healthcare interview, biometric authentication with CryptoObject is a mandatory topic. The key insight that impresses: "Basic BiometricPrompt without CryptoObject is just checking that someone authenticated — the result can be faked by a malicious accessibility service. For payment confirmation, I always use a CryptoObject backed by a Keystore key with setUserAuthenticationRequired(true). This means the Cipher is physically locked in the secure hardware until biometric succeeds — it's cryptographic proof of authentication, not just a boolean flag." Also mention setInvalidatedByBiometricEnrollment(true) — this detail shows you understand the threat model.

Mark it done or save it in the question bank · Back to top

HardMost asked

39. How do you create a Custom View in Android? Explain onMeasure, onLayout, and onDraw.

Custom Views are the foundation of any polished Android UI — they let you build components that the standard SDK doesn't offer, like progress rings, waveform visualizers, star ratings with half-stars, or the circular profile pictures in apps like Instagram and WhatsApp. Every Android app eventually needs a Custom View, making this a reliable interview topic for mid-to-senior roles. The Android drawing system calls your View through three sequential phases: measure (how big should I be?), layout (where should I sit?), then draw (what do I look like?). Getting any phase wrong causes either layout issues (views overlap, clip, or request the wrong size) or performance problems (dropped frames, jank). The most common mistake — allocating Paint or RectF objects inside onDraw — runs at 60fps and creates thousands of short-lived objects per second, triggering constant garbage collection and visible stutter.

// ── Full CircularProgressView — correct pattern ────────────────────────
class CircularProgressView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    // ✅ Pre-allocate ALL drawing objects as fields — never inside onDraw
    private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.STROKE
        strokeWidth = 16f
        color = 0xFFE0E0E0.toInt()  // background ring color
    }
    private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.STROKE
        strokeWidth = 16f
        color = 0xFF4CAF50.toInt()  // progress arc color
        strokeCap = Paint.Cap.ROUND  // rounded arc ends
    }
    private val oval = RectF()  // ✅ reused every frame — no allocation in onDraw

    // Backing field with invalidate() on set — triggers redraw automatically
    var progress: Float = 0f
        set(value) {
            field = value.coerceIn(0f, 100f)
            invalidate()  // schedule next draw pass
        }

    // ── Phase 1: MEASURE ─────────────────────────────────────────────────
    // Called when parent needs to know our size
    // widthSpec and heightSpec encode both size AND constraint mode
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val desiredSize = 200  // default size if wrap_content

        // MeasureSpec.EXACTLY → parent dictates exact size (match_parent or 200dp)
        // MeasureSpec.AT_MOST → wrap_content: use as little as needed up to max
        // MeasureSpec.UNSPECIFIED → ScrollView: use whatever size you want
        val width = when (MeasureSpec.getMode(widthMeasureSpec)) {
            MeasureSpec.EXACTLY  -> MeasureSpec.getSize(widthMeasureSpec)
            MeasureSpec.AT_MOST  -> minOf(desiredSize, MeasureSpec.getSize(widthMeasureSpec))
            else                 -> desiredSize
        }
        setMeasuredDimension(width, width)  // ✅ MUST call this — crash if omitted
    }

    // ── Phase 2: SIZE CHANGED (part of layout) ───────────────────────────
    // Called after measure when final pixel dimensions are confirmed
    // ✅ Pre-calculate geometry here, not in onDraw
    override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) {
        super.onSizeChanged(w, h, oldW, oldH)
        val inset = trackPaint.strokeWidth / 2f  // inset so stroke doesn't clip
        oval.set(inset, inset, w - inset, h - inset)
    }

    // ── Phase 3: DRAW ────────────────────────────────────────────────────
    // ❌ NEVER: val paint = Paint() inside onDraw — GC every frame!
    // ❌ NEVER: database queries, file I/O, network calls here
    // ✅ ONLY: draw operations using pre-allocated objects
    override fun onDraw(canvas: Canvas) {
        // Draw full background ring
        canvas.drawArc(oval, 0f, 360f, false, trackPaint)
        // Draw progress arc — start at -90° (top), sweep clockwise
        val sweepAngle = progress / 100f * 360f
        canvas.drawArc(oval, -90f, sweepAngle, false, progressPaint)
    }
}

// ── Triggering redraws correctly ─────────────────────────────────────
// invalidate()              → redraw only (no size change)
// requestLayout()           → re-measure + re-layout + redraw
// postInvalidateOnAnimation() → sync redraw to next vsync (smooth animation)

// ── Custom attributes (attrs.xml) ────────────────────────────────────
// <declare-styleable name="CircularProgressView">
//   <attr name="progressColor" format="color" />
//   <attr name="strokeWidth" format="dimension" />
// </declare-styleable>
// Read in init: context.obtainStyledAttributes(attrs, R.styleable.CircularProgressView)

// ── ViewGroup override for custom layouts ─────────────────────────────
// Override onLayout() when extending ViewGroup (not View)
// Call child.layout(l, t, r, b) for each child with absolute pixel positions

onMeasure — the parent passes two MeasureSpec integers encoding the available size and a constraint mode (EXACTLY, AT_MOST, or UNSPECIFIED). Read the mode with MeasureSpec.getMode() and respond correctly: wrap_content views return their preferred size capped at AT_MOST; match_parent views return the EXACTLY value. Always call setMeasuredDimension() — the system throws a RuntimeException if you forget. onSizeChanged fires when final pixel dimensions are known, making it the right place to pre-calculate geometry (RectF bounds, center coordinates, gradient bounds) that depends on actual size. Only override onLayout when building a ViewGroup — it's where you position each child with child.layout(left, top, right, bottom) in absolute pixel coordinates.

onDraw performance — non-negotiable: onDraw runs at 60fps (every 16ms). Any object allocation triggers GC which pauses all threads and drops frames. Declare all Paint, RectF, Path, and Bitmap objects as class fields initialized once — never call new Paint() inside onDraw. This is the most common custom View mistake and an almost guaranteed interview trap. invalidate() schedules only a redraw (correct when visual appearance changes but size stays the same). requestLayout() triggers full re-measure → re-layout → redraw — use only when the view's preferred size changes. Calling requestLayout() unnecessarily is a significant performance waste.

Declare custom XML attributes in res/values/attrs.xml via declare-styleable; read them in the init block with context.obtainStyledAttributes() and always call recycle() on the TypedArray. Override onSaveInstanceState() and onRestoreInstanceState() with a custom Parcelable to survive rotation — without this, a progress view resets to zero on every configuration change. Most drawing operations (drawArc, drawBitmap, drawText) run on the hardware-accelerated canvas; a few (PorterDuff.Mode.XOR, certain clipPath calls) fall back to software rendering — avoid them in performance-critical views. In Compose, use Modifier.drawBehind/Modifier.drawWithContent or the Canvas composable with DrawScope primitives instead of extending View.

💡 Interview Tip

The interviewer almost always probes the onDraw allocation trap: "What would happen if you created a new Paint() inside onDraw?" — the correct answer is "It creates a new object at 60fps, causing constant garbage collection which pauses the main thread and drops frames." Then demonstrate you know the fix: "Pre-allocate all drawing objects as class fields." Follow up by mentioning invalidate() vs requestLayout() — using requestLayout() when only appearance changed is another common performance mistake. Mentioning the Compose equivalent (DrawScope via Modifier.drawBehind) as a bonus shows you know both worlds.

Mark it done or save it in the question bank · Back to top

MediumMost asked

40. What is the difference between DVM, ART, and JVM? How does Android compile and run code?

Understanding the Android runtime stack is a question that separates developers who truly understand the platform from those who just use it. The JVM (Java Virtual Machine) was designed for servers and desktop machines with abundant memory and battery — it's not suitable for a 512MB phone with a limited battery. Dalvik VM was Android's original custom runtime designed specifically for mobile: register-based architecture, DEX file format (smaller and faster to load than Java class files), and per-app processes for isolation. Android 5.0 (Lollipop, 2014) replaced Dalvik with ART (Android Runtime), which pre-compiles bytecode to native machine code at install time — cold start times dropped dramatically. Today understanding ART's compilation pipeline (especially Baseline Profiles) is critical for building apps with fast startups, which directly impacts user retention and Play Store ranking.

// ── Build pipeline overview ───────────────────────────────────────────
// .kt source files
//   → kotlinc → .class files (JVM bytecode)
//   → D8/R8 compiler → .dex files (Dalvik Executable — compact bytecode)
//   → packaged into APK or AAB
//   → installed on device → dex2oat → .oat (native machine code)

// JVM vs DVM vs ART — key differences
// ┌─────────────────┬──────────────────┬──────────────────┬────────────────────┐
// │                 │ JVM              │ DVM (pre-5.0)    │ ART (5.0+)         │
// ├─────────────────┼──────────────────┼──────────────────┼────────────────────┤
// │ Architecture    │ Stack-based      │ Register-based   │ Register-based     │
// │ File format     │ .class files     │ .dex (compact)   │ .dex → .oat        │
// │ Compilation     │ JIT at runtime   │ JIT at runtime   │ AOT at install     │
// │ Startup speed   │ Slow (JIT warm)  │ Slow (JIT warm)  │ Fast (native ready)│
// │ Install time    │ Fast             │ Fast             │ Slower (dex2oat)   │
// │ Multiple .dex   │ N/A              │ One per APK      │ Multi-dex supported│
// └─────────────────┴──────────────────┴──────────────────┴────────────────────┘

// ── ART compilation modes (Android 7+ hybrid model) ──────────────────
// At install:    dex2oat does PARTIAL AOT (speed-profile strategy)
// First runs:    ART interprets DEX + profiles hot methods (JIT)
// Background:    dex2oat re-runs, compiling hot methods to native (.oat)
// Day 2+:        hot code paths run as native, cold code still interpreted
// Problem:       new installs are slow for days until profile builds up

// ── Baseline Profiles — solve the "day 1 slow startup" problem ────────
// Baseline Profile = pre-built profile of hot methods for your app
// Included in the APK/AAB — installed alongside the app
// ART uses this profile at install time → AOT compiles those methods
// Result: day-one startup is as fast as a "warmed up" app
// Google reports 20-40% faster cold start with Baseline Profiles

// ── Generate Baseline Profile with Macrobenchmark ─────────────────────
// In your :app module, add androidx.profileinstaller
// In a separate :benchmark module:
@ExperimentalBaselineProfilesApi
class BaselineProfileGenerator {
    @get:Rule val rule = BaselineProfileRule()

    @Test fun startup() = rule.collect(
        packageName = "com.myapp",
        profileBlock = {
            pressHome()
            startActivityAndWait()  // Macrobenchmark records which methods are JIT-compiled
            // navigate critical user flows: login → home → product page
        }
    )
    // Output: baseline-prof.txt added to your APK — AOT compiled at install!
}

// ── R8 vs D8 — the DEX compilers ─────────────────────────────────────
// D8: converts .class bytecode → .dex (mandatory, always runs)
// R8: extends D8 + tree shaking (remove unused code) + minification
//     + optimization (inlining, dead code removal) + obfuscation
// R8 enabled by default in release builds — reduces APK size 10-30%
// android.enableR8.fullMode=true in gradle.properties for most aggressive R8

// ── Multi-dex: the 65,536 method limit ────────────────────────────────
// Each DEX file can only reference 65,536 methods (16-bit method index)
// Large apps hit this limit → requires Multi-dex support
// API 21+ (ART): native multi-dex, no extra setup needed
// API <21 (DVM): requires legacy MultiDex library in Application.attachBaseContext()

JVM is not Android's runtime — the JVM was designed for servers with large heaps and unlimited power. Android needed a runtime for 512MB RAM devices with limited battery, leading to Dalvik: a register-based VM (stores values in registers, needing ~30% fewer instructions than the stack-based JVM) that uses the compact DEX file format rather than standard .class files. ART (Android 5.0+) replaced Dalvik by running dex2oat at install time, converting DEX to native machine code (.oat) matched to the device's CPU. Subsequent launches execute native code directly — no JIT warmup, dramatically faster cold starts than Dalvik.

ART's hybrid compilation (Android 7+) — full AOT at install is slow and wastes storage. Android 7 introduced a hybrid model: partial AOT at install, JIT during real usage to build a "hot method" profile, then overnight dex2oat re-compiles only those hot methods. The weakness: new installs are slow for 1–2 days while the profile builds. Baseline Profiles solve this by shipping a pre-built baseline-prof.txt in your APK — ART uses it to AOT-compile critical paths on day one, typically yielding 20–40% faster cold starts. Generate them with the Macrobenchmark library by exercising your critical user flows.

D8 converts .class bytecode to DEX on every build. R8 extends D8 with tree shaking (removing unused classes/methods), obfuscation (renaming to a, b, c), and bytecode optimization — enabled by default in release builds, typically shrinking APK size by 10–30%. This is why libraries using reflection (Retrofit, Room) require explicit keep rules: R8 may remove code that looks unused but is accessed reflectively, causing NoClassDefFoundError in production. Each DEX file can reference at most 65,536 methods (16-bit index); large apps need multi-dex, which ART (API 21+) supports natively with no extra setup. On API 20 and below, add the legacy MultiDex library and call MultiDex.install() in Application.attachBaseContext().

💡 Interview Tip

The interviewer often asks "how did switching from Dalvik to ART improve performance?" — the correct answer is AOT compilation (apps launch as native code, no JIT warmup). Then pivot to the modern follow-up: "ART's profile-guided AOT takes days to warm up on a fresh install. Baseline Profiles solve this — I include a pre-generated profile in the APK so ART can AOT-compile critical paths on day one, giving 20-40% faster cold starts immediately." Mentioning both Baseline Profiles and R8 tree shaking demonstrates you understand the full modern Android build and runtime pipeline.

Mark it done or save it in the question bank · Back to top

MediumMost asked

41. What is AlarmManager? When do you use it vs WorkManager?

Android's background execution restrictions have become increasingly strict with every major version — Doze mode (Android 6), background process limits (Android 8), exact alarm restrictions (Android 12 and 13), and foreground service type requirements (Android 14). Understanding which API to use for which background task is essential for building reliable apps that work correctly after hours of device inactivity. AlarmManager and WorkManager are often confused because both involve "something happening in the background later" — but they serve fundamentally different purposes. AlarmManager is for tasks where the exact time of execution is critical (like an alarm clock or a medication reminder that must fire at 8:00 AM sharp). WorkManager is for tasks where eventual completion matters but the exact timing doesn't (like uploading logs, syncing data, or compressing photos — "do this when conditions are right").

// AlarmManager — time-specific execution
// Use when: exact time matters, user-scheduled reminders, calendar events
val alarmManager = getSystemService(AlarmManager::class.java)
val intent = PendingIntent.getBroadcast(
    context, 0,
    Intent(context, ReminderReceiver::class.java),
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

// Exact alarm — requires SCHEDULE_EXACT_ALARM permission (API 31+)
if (alarmManager?.canScheduleExactAlarms() == true) {
    alarmManager.setExactAndAllowWhileIdle(
        AlarmManager.RTC_WAKEUP,
        triggerTimeMs,
        intent
    )
}

// WorkManager — constraint-based, guaranteed execution
// Use when: network sync, file upload, data backup
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setInitialDelay(15, TimeUnit.MINUTES) // NOT exact timing
    .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
    .build()

// Decision table:
// Exact time reminder (alarm clock) → AlarmManager.setExact()
// Periodic sync (15+ min intervals)  → WorkManager PeriodicWorkRequest
// Upload on WiFi when charging        → WorkManager with Constraints
// Timer/countdown in app              → Handler.postDelayed() or delay()

AlarmManager — for exact time — use when the user has explicitly set a moment and expects action at precisely that time: alarm clocks, medication reminders, meeting notifications. The system wakes the device even in Doze mode. Starting API 31 (Android 12), exact alarms require explicit user approval — check canScheduleExactAlarms() before calling setExact(). If approval hasn't been granted, guide the user to Settings → Apps → Alarms and Reminders. Most apps should prefer setWindow() (inexact, no special permission) unless true precision is essential. AlarmManager alarms are cleared on device reboot — always re-register them in a BroadcastReceiver listening for ACTION_BOOT_COMPLETED, persisting alarm details in Room. Forgetting this is a classic production bug: users report alarms stop working after restarting the phone.

AlarmManager clock types — RTC_WAKEUP fires at wall-clock time and wakes the device (use for user-visible alarms); RTC fires at wall-clock time but doesn't wake; ELAPSED_REALTIME_WAKEUP fires after a duration since boot and wakes the device; ELAPSED_REALTIME fires after a duration without waking. Use ELAPSED variants for internal app timers.

WorkManager — for guaranteed eventual execution — WorkManager queues tasks in its own Room database and retries them until completion, surviving both process death and device reboots. Use it when exact timing doesn't matter but eventual completion does: uploading media on WiFi, syncing data when charging, compressing photos when idle. Constraints (UNMETERED network, charging, battery not low) are impossible with AlarmManager. The system enforces a 15-minute minimum interval for PeriodicWorkRequest — if you need more frequent or time-precise execution, use AlarmManager instead. Always use enqueueUniqueWork() or enqueueUniquePeriodicWork() with a stable name to prevent duplicate workers from stacking up across app launches; ExistingWorkPolicy.KEEP preserves the existing job while REPLACE cancels and restarts it.

💡 Interview Tip

The key question is: "Does the user care about the exact moment of execution?" If yes, AlarmManager. If no, WorkManager. Then mention the two classic traps: AlarmManager alarms are lost on reboot (must rescue in BOOT_COMPLETED receiver), and WorkManager's minimum periodic interval is 15 minutes. Also mention SCHEDULE_EXACT_ALARM permission on API 31+ — many candidates miss this Android 12 breaking change. A real example: "Our delivery app uses AlarmManager for 'Your food arrives in 5 minutes' notifications (exact time) and WorkManager for order history sync (whenever WiFi is available)."

Mark it done or save it in the question bank · Back to top

MediumMost asked

42. What is SparseArray? When should you use it instead of HashMap?

Android devices have historically been memory-constrained compared to desktop applications, which makes memory-efficient data structures important for smooth performance — especially in code that runs frequently, like RecyclerView adapters or Custom View drawing logic. SparseArray is an Android-specific alternative to HashMap that exists because of one very specific Java performance issue: autoboxing. When you use HashMap<Int, User> in Kotlin/Java, every integer key is converted ("boxed") from the primitive int into a heap-allocated Integer object. In a RecyclerView with hundreds of items, this means hundreds of extra object allocations, extra GC pressure, and slower map operations. SparseArray stores int keys as primitive arrays internally — no boxing, no extra heap objects. The tradeoff is O(log n) lookup via binary search instead of HashMap's O(1) hash lookup, but for the small-to-medium datasets typical in Android UI code, this is faster in practice because of cache locality and zero boxing overhead.

// ── The autoboxing problem ────────────────────────────────────────────
// ❌ HashMap<Int, User> — autoboxes int to Integer on every access
val hashMap = HashMap<Int, User>()
hashMap[42] = user        // int 42 boxed → new Integer(42) on heap
val u = hashMap[42]      // int 42 boxed again for lookup
// Result: extra allocations, GC pressure, slower in tight loops

// ✅ SparseArray<User> — keys stored as primitive int[] internally
val sparseArray = SparseArray<User>()
sparseArray.put(42, user)      // no boxing — stored as int primitive
val u = sparseArray.get(42)   // binary search in int[] — no boxing
sparseArray.delete(42)       // removes entry
sparseArray.remove(42)       // same as delete()

// Iterating SparseArray (no entrySet() like HashMap)
for (i in 0 until sparseArray.size()) {
    val key   = sparseArray.keyAt(i)    // int key
    val value = sparseArray.valueAt(i)  // User value
}

// ── Primitive-to-primitive variants (zero object allocation) ──────────
val viewStateMap  = SparseBooleanArray() // Int → Boolean (e.g. expanded state by ID)
val positionScore = SparseIntArray()     // Int → Int (e.g. score per list position)
val timestampMap  = SparseLongArray()    // Int → Long (e.g. message ID → timestamp)
// These store BOTH key and value as primitives — absolutely zero boxing overhead

// ── LongSparseArray for Long keys ─────────────────────────────────────
val userCache = LongSparseArray<User>()  // Long key → Object (database IDs)
userCache.put(1_000_000L, user)           // no Long boxing

// ── When to use what ─────────────────────────────────────────────────
// ✅ SparseArray    → Int keys, <1000 items, performance-sensitive code
// ✅ SparseIntArray → Int → Int mapping (positions, counts, states)
// ✅ LongSparseArray → Long keys (database row IDs, user IDs)
// ✅ HashMap        → String or Enum keys, or >1000 items needing O(1)
// ✅ ArrayMap       → String keys in small maps (more memory-efficient than HashMap)

// Real-world example: RecyclerView expanded state tracking
class PostAdapter : RecyclerView.Adapter<PostViewHolder>() {
    // ✅ Track which items are expanded by their position ID
    private val expandedStates = SparseBooleanArray()

    fun toggleExpanded(position: Int) {
        val expanded = expandedStates.get(position, false)  // default false
        expandedStates.put(position, !expanded)
        notifyItemChanged(position)
    }
}

Autoboxing is the core problem SparseArray solves — HashMap<Int, User> boxes every integer key into a heap-allocated Integer object on every put() and get(). In tight loops — RecyclerView binding, drawing code at 60fps — this creates thousands of short-lived objects per second, increasing GC frequency and causing frame drops. SparseArray stores keys as a primitive int[] with no allocation overhead. Internally it maintains two parallel sorted arrays (int[] keys, Object[] values) and uses binary search for O(log n) lookup — slower in theory than HashMap's O(1), but the cache-friendly contiguous memory layout often wins in practice for small-to-medium datasets.

Primitive-to-primitive variants — SparseBooleanArray (Int→Boolean, perfect for expanded/selected/checked state by position), SparseIntArray (Int→Int, position-to-count or position-to-score), and SparseLongArray (Int→Long) store both keys and values as primitives — zero heap allocation per operation. LongSparseArray<T> handles Long keys (database row IDs, server-generated IDs) without boxing to java.lang.Long.

When HashMap is better — for more than ~1,000 entries the O(log n) binary search adds meaningful latency, and SparseArray only supports integer keys. For String or Object keys in small maps, use ArrayMap — an Android-optimized map using sorted arrays internally, more memory-efficient than HashMap under ~100 entries (Bundle and Intent use ArrayMap internally). Neither SparseArray nor HashMap is thread-safe — use synchronized blocks, ConcurrentHashMap, or confine the map to a single thread. Android Lint's UseSparseArrays rule warns whenever you declare HashMap<Int, *> in Kotlin, suggesting the correct sparse alternative.

💡 Interview Tip

Explain why SparseArray exists by starting with autoboxing: "In Kotlin, Int is a primitive on the JVM, but HashMap<Int, User> forces the compiler to box each key into a heap-allocated Integer object. SparseArray stores keys as a raw int[] — no boxing, no GC pressure." Then mention the O(log n) vs O(1) tradeoff and when each wins. The interviewer might also ask about ArrayMap — it's the answer for non-integer keys in small maps. Saying "Android Lint flags HashMap<Int, *> and I always fix those warnings" shows you write idiomatic Android code rather than Java-style code transplanted to Android.

Mark it done or save it in the question bank · Back to top

HardMost asked

43. How does Android handle Bitmap memory? What is BitmapPool and how do image loading libraries use it?

Bitmap memory management is one of the most critical skills in Android development — get it wrong and your app crashes with OutOfMemoryError on millions of devices, or scrolls through a photo feed with constant frame drops and GC pauses. A single full-resolution photo from a 12MP camera is 4032×3024 pixels — at ARGB_8888 (4 bytes per pixel), that's 48MB in RAM for one image. A RecyclerView showing 20 thumbnails could consume nearly 1GB if you load full-resolution photos. Understanding how bitmap memory is allocated, how BitmapPool reuses existing memory to avoid allocation overhead, and how image loading libraries (Coil, Glide) automate all of this correctly is essential for any Android developer working on image-heavy features like social feeds, product galleries, or photo editors — which covers most popular apps.

// ── Memory cost of a Bitmap ───────────────────────────────────────────
// Bitmap memory = width × height × bytes_per_pixel
// ARGB_8888 (default): 4 bytes/pixel — full quality with alpha
// RGB_565:             2 bytes/pixel — no alpha, 50% memory saving
// HARDWARE (API 26+):  stored in GPU memory — fastest rendering, immutable

// 1080×1920 ARGB_8888 = 1080 × 1920 × 4 = 8,294,400 bytes ≈ 8MB per image
// Loading 10 of these → 80MB → OOM on 256MB devices!

// ── ❌ Loading full-res into memory — the crash recipe ────────────────
val bitmap = BitmapFactory.decodeFile(photoPath)  // ❌ 48MB for a 12MP photo!
imageView.setImageBitmap(bitmap)                   // ImageView is maybe 200×200px
// All that memory for a thumbnail-sized display — 99% of pixels wasted

// ── ✅ inSampleSize — load at display resolution ──────────────────────
// Two-pass decode: first pass reads dimensions (no pixel data), second decodes at 1/N size
fun decodeSampledBitmap(path: String, reqWidth: Int, reqHeight: Int): Bitmap {
    return BitmapFactory.Options().run {
        inJustDecodeBounds = true  // pass 1: decode only size info, no pixels
        BitmapFactory.decodeFile(path, this)
        // outWidth/outHeight now contain image dimensions
        inSampleSize = calculateInSampleSize(outWidth, outHeight, reqWidth, reqHeight)
        // inSampleSize=4 → decode at 1/4 size → 1/16 memory (power of 2 only)
        inJustDecodeBounds = false  // pass 2: decode actual pixels at reduced size
        BitmapFactory.decodeFile(path, this)
    }
}

fun calculateInSampleSize(imgW: Int, imgH: Int, reqW: Int, reqH: Int): Int {
    var inSampleSize = 1
    if (imgH > reqH || imgW > reqW) {
        val halfH = imgH / 2; val halfW = imgW / 2
        // Keep halving while result is still larger than required
        while (halfH / inSampleSize >= reqH && halfW / inSampleSize >= reqW) inSampleSize *= 2
    }
    return inSampleSize
}

// ── BitmapPool — memory reuse, not reallocation ───────────────────────
// Problem: scrolling a RecyclerView constantly allocates and GCs bitmaps
// Solution: maintain a pool of unused bitmaps; reuse their memory allocation
// Glide/Coil both maintain an LRU-based BitmapPool internally

// Manual BitmapPool via inBitmap option
val pooledBitmap: Bitmap = getFromPool()  // retrieve unused bitmap of same size
val options = BitmapFactory.Options().apply {
    inBitmap = pooledBitmap    // REUSE this existing allocation — no new heap memory
    inMutable = true          // required for inBitmap to work
    inSampleSize = 1
}
// Requirements: pooledBitmap must be mutable and same or larger byteCount than new bitmap
// On API 19+: can reuse bitmaps of equal or larger byte size (not just same dimensions)

// ── ✅ Coil — the recommended approach for 2025 ───────────────────────
// Handles sampling, pooling, memory cache, disk cache, transformations
// Kotlin-first, coroutine-native, Compose-native
@Composable
fun ProductImage(url: String, modifier: Modifier = Modifier) {
    AsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(url)
            .crossfade(true)
            .size(400, 400)          // ✅ decode at display size — key savings
            .diskCachePolicy(CachePolicy.ENABLED)
            .memoryCachePolicy(CachePolicy.ENABLED)
            .build(),
        contentDescription = "Product image",
        contentScale = ContentScale.Crop,
        modifier = modifier
    )
}

// Coil memory cache: keeps recently decoded bitmaps in RAM (default 25% of available memory)
// Coil disk cache: stores downloaded images on disk (avoid re-downloading)
// Coil BitmapPool: reuses decoded bitmap allocations when scrolling lists

// ── Bitmap configs — choosing the right one ───────────────────────────
// ARGB_8888  → full quality, transparency supported (default, use for photos)
// RGB_565    → no alpha channel, 2 bytes/pixel instead of 4 (use for backgrounds)
// HARDWARE   → bitmap stored in GPU memory (API 26+) — fastest rendering, but immutable
//              can't read pixels, can't use with Canvas, perfect for display-only images
// ARGB_4444  → deprecated, poor quality, avoid entirely

Bitmap memory formula — memory usage = width × height × bytes per pixel. ARGB_8888 (default) uses 4 bytes/pixel: a 1080×1920 screen bitmap is 8MB; a 12MP camera photo at full resolution is 48MB. A RecyclerView showing 20 full-resolution photos could exhaust device RAM. Always load at display resolution — a 200×200 thumbnail needs only 160KB. The classic two-pass technique: first pass sets inJustDecodeBounds = true to read dimensions without allocating pixel memory, calculate inSampleSize as a power-of-2 ratio, then decode at 1/N resolution for a 1/N² memory reduction. inSampleSize of 4 shrinks that 48MB photo to 3MB.

BitmapPool stores "retired" bitmaps when RecyclerView items scroll off screen. When a new bitmap is needed, the library reuses an existing memory block via Options.inBitmap instead of allocating fresh heap memory — eliminating the GC pauses that constant bitmap allocation causes in a busy feed. The pooled bitmap must be mutable and have a byte size ≥ the new bitmap (API 19+). Coil and Glide manage all of this bookkeeping automatically. HARDWARE bitmaps (API 26+) store pixel data directly in GPU memory — faster rendering since no CPU-to-GPU upload is needed — but are completely immutable: no pixel reads, no Canvas operations, no use as inBitmap for pooling. Coil and Glide use HARDWARE automatically for display-only images.

Coil vs Glide in 2025 — Coil is recommended for new Compose projects: Kotlin-first, coroutine-native, smaller APK, first-class AsyncImage support. Glide remains widely used in View-based apps with slightly better performance for very large images. Both maintain a memory cache (instant access, no decoding) and a disk cache (compressed JPEG/WebP, faster than network but needs decoding) automatically. Never manually decode bitmaps for network images — always use a library. Bitmap.recycle() was essential pre-Android 3.0 when pixel data lived in native memory; since 3.0 it lives in the Java heap and the GC handles it. With Coil or Glide, never call recycle() — the library manages lifecycle.

💡 Interview Tip

The inSampleSize technique is a classic interview question for any role involving photo or media features. Give the memory formula first: "width × height × 4 bytes — a full-screen ARGB_8888 bitmap is 8MB." Then explain the two-pass decode with inJustDecodeBounds. The follow-up question is always about BitmapPool: "Why do we pool bitmaps instead of just GC-ing them?" — answer: "Constant bitmap allocation and GC causes 'GC pauses' (stop-the-world events) visible as jank at 60fps. Pooling means we reuse existing memory blocks via Options.inBitmap, and Coil/Glide handle this automatically." Mentioning that Coil is Kotlin-first and Coroutine-native shows you're aware of the modern stack.

Mark it done or save it in the question bank · Back to top

MediumMost asked

44. How do you implement App Widgets in Android? What changed in Android 12?

App Widgets are one of the most visible differentiating features on Android versus iOS (which only added basic widgets in iOS 14) — they let your app provide live, glanceable information directly on the home screen without the user opening the app. Weather apps, sports score apps, calendar apps, task managers, and music players all use home screen widgets as a primary engagement surface. Before Android 12 and the Glance API, building widgets required RemoteViews — a severely limited XML-based API where you could only use a restricted subset of View types, no custom drawing, and updating data was clunky. Glance (released in 2022) brought a Compose-like declarative API to widget development, making widgets far easier to build and maintain. Understanding both the old RemoteViews approach (which many existing apps still use) and the modern Glance API is important for any mid-to-senior Android interview.

// ── Step 1: AppWidgetProviderInfo XML (res/xml/weather_widget_info.xml) ─
// <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
//     android:minWidth="250dp"
//     android:minHeight="110dp"
//     android:targetCellWidth="2"           <!-- Android 12+: grid cells instead of dp -->
//     android:targetCellHeight="1"
//     android:updatePeriodMillis="1800000"  <!-- system minimum: 30 minutes -->
//     android:initialLayout="@layout/widget_loading"
//     android:previewLayout="@layout/widget_preview"   <!-- Android 12: animated preview -->
//     android:description="@string/widget_description"
//     android:widgetCategory="home_screen"/>

// ── Step 2: GlanceAppWidget — the modern Compose-style approach ────────
// implementation("androidx.glance:glance-appwidget:1.1.0")
// implementation("androidx.glance:glance-material3:1.1.0")

class WeatherWidget : GlanceAppWidget() {

    // provideGlance is a suspending function — can call your repo/DAO directly
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        // Read DataStore state (thread-safe, persisted across updates)
        val prefs = currentState<Preferences>()
        val temp      = prefs[intPreferencesKey("temperature")]  ?: 0
        val cityName  = prefs[stringPreferencesKey("city")]        ?: "—"
        val condition = prefs[stringPreferencesKey("condition")]   ?: "Loading…"

        provideContent {
            // GlanceModifier mirrors Compose Modifier — different class, same concept
            GlanceTheme {
                Column(
                    modifier = GlanceModifier
                        .fillMaxSize()
                        .background(GlanceTheme.colors.widgetBackground)
                        .padding(12.dp)
                        .clickable(actionStartActivity<MainActivity>()),  // tap opens app
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text(text = cityName, style = TextStyle(fontSize = 14.sp))
                    Text(text = "${temp}°C", style = TextStyle(fontSize = 32.sp, fontWeight = FontWeight.Bold))
                    Text(text = condition, style = TextStyle(fontSize = 12.sp))
                    Button(
                        text = "↻ Refresh",
                        onClick = actionRunCallback<WeatherRefreshAction>()
                    )
                }
            }
        }
    }
}

// ── Step 3: ActionCallback — handles button clicks in the widget ───────
class WeatherRefreshAction : ActionCallback {
    override suspend fun onAction(
        context: Context,
        glanceId: GlanceId,
        parameters: ActionParameters
    ) {
        // Fetch fresh data (suspend function — safe to do I/O here)
        val weather = WeatherRepository().getCurrentWeather()

        // Update the widget's DataStore state
        updateAppWidgetState(context, glanceId) { prefs ->
            prefs[intPreferencesKey("temperature")]  = weather.temp
            prefs[stringPreferencesKey("city")]        = weather.city
            prefs[stringPreferencesKey("condition")]   = weather.condition
        }

        // Trigger a re-render of the widget with new state
        WeatherWidget().update(context, glanceId)
    }
}

// ── Step 4: GlanceAppWidgetReceiver — bridges system to Glance ────────
class WeatherWidgetReceiver : GlanceAppWidgetReceiver() {
    override val glanceAppWidget = WeatherWidget()
}

// ── Manifest declaration ──────────────────────────────────────────────
// <receiver android:name=".WeatherWidgetReceiver" android:exported="true">
//   <intent-filter>
//     <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
//   </intent-filter>
//   <meta-data android:name="android.appwidget.provider"
//              android:resource="@xml/weather_widget_info" />
// </receiver>

// ── Updating widget from WorkManager (recommended pattern) ────────────
// Don't rely on updatePeriodMillis alone — minimum 30min and battery-constrained
// Instead: use PeriodicWorkRequest to fetch data + update widget
class WeatherUpdateWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val weather = WeatherRepository().getCurrentWeather()
        // Update ALL instances of this widget type
        WeatherWidget().updateAll(applicationContext)
        return Result.success()
    }
}

Glance — the modern widget API — Glance wraps the old RemoteViews system with a Compose-like declarative API. You write composable-style UI in provideGlance(), use GlanceModifier like Compose Modifier, and Glance translates it to RemoteViews internally. Widget state is stored in a DataStore Preferences file keyed by GlanceId — read it with currentState<Preferences>() inside provideGlance(), update it with updateAppWidgetState() in an ActionCallback. This is thread-safe and survives process death. Buttons wire to ActionCallback via actionRunCallback<MyAction>(); the callback's suspending onAction() function runs in a coroutine scope safe for network and database calls.

Glance vs Compose distinction — Glance's Column, Row, Text, and Button are NOT Jetpack Compose composables; they're Glance composables that emit RemoteViews. GlanceModifier and Compose Modifier are different classes. You cannot use Compose state, Compose animations, or Compose modifiers in a Glance widget — a common source of confusion. RemoteViews only supports a limited set of View types (FrameLayout, LinearLayout, TextView, ImageView, Button, ProgressBar, ListView, GridView, StackView) — ConstraintLayout, RecyclerView, and custom Views are not supported.

updatePeriodMillis has a system-enforced minimum of 30 minutes. For more frequent updates (live scores, music controls, weather), pair WorkManager PeriodicWorkRequest (15-minute minimum) with explicit widget updates, or broadcast from a foreground service. Android 12 improvements include responsive layouts for different widget sizes, dynamic colors matching Material You, rounded corners, the previewLayout attribute for an animated picker preview, and targetCellWidth/targetCellHeight for grid-based sizing. Always use enqueueUniquePeriodicWork() with a stable name for WorkManager-driven widget updates to prevent duplicate workers from stacking.

💡 Interview Tip

The answer that impresses: "For new widgets I use Glance — it's Compose-style, far simpler than writing RemoteViews XML, and handles state via DataStore. The key gotcha is that Glance composables are NOT real Compose composables — they're a different API that translates to RemoteViews internally. For widget data updates, I use WorkManager rather than relying on updatePeriodMillis because the 30-minute system minimum is too infrequent for live data, and WorkManager survives reboots." Mentioning Android 12's dynamic colors and targetCellWidth/Height shows you know the modern widget specification requirements.

Mark it done or save it in the question bank · Back to top

MediumMost asked

45. How do you ensure backward compatibility in Android? Explain @RequiresApi, BuildConfig, and AndroidX.

Android's fragmentation — hundreds of millions of devices still running Android 7, 8, and 9 even as Android 16 launches — means that every API you use must be checked for backward compatibility. A crash on an older Android version is just as damaging as a crash on the latest version; Play Console shows crashes by API level, and missing a compatibility check on Android 8 (Oreo) with 15% of your users is a significant production incident. The three pillars of backward compatibility are: runtime SDK_INT checks (guard platform-specific APIs), AndroidX Compat libraries (backported implementations of new platform APIs), and @RequiresApi annotations (tell the Lint checker about your intent). Understanding the crucial difference between @RequiresApi (a Lint annotation that does nothing at runtime) and Build.VERSION.SDK_INT >= N (an actual runtime guard) is one of the most common interview trap questions.

// ── Runtime API check — the actual guard ─────────────────────────────
// Build.VERSION.SDK_INT is the running device's API level
// Build.VERSION_CODES.S = API 31 (Android 12)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
    // Safe to use API 31+ features here
    window.setDecorFitsSystemWindows(false)  // edge-to-edge
} else {
    // Fallback for Android 11 and below
    @Suppress("DEPRECATION")
    window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
}

// ── @RequiresApi — a Lint annotation, NOT a runtime check ────────────
// Tells Lint: "this function uses API 31+ features"
// The caller gets a lint warning to add their own SDK_INT guard
// At runtime, @RequiresApi is completely ignored — no protection!
@RequiresApi(Build.VERSION_CODES.S)
fun applyBlurEffect(view: View) {
    // This function body uses API 31 — @RequiresApi suppresses lint here
    view.setRenderEffect(RenderEffect.createBlurEffect(10f, 10f, Shader.TileMode.CLAMP))
}

// Caller still needs the guard — calling applyBlurEffect() on Android 10 crashes:
fun setupBackground(view: View) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        applyBlurEffect(view)  // ✅ safe: caller guards with SDK_INT
    }
    // No blur on older devices — acceptable graceful degradation
}

// ── AndroidX Compat classes — preferred over direct platform APIs ─────
// These Compat classes handle SDK differences INTERNALLY — one call works everywhere

// ✅ NotificationCompat — handles channel requirement (API 26+) internally
val notification = NotificationCompat.Builder(context, "order_updates")
    .setContentTitle("Order Shipped")
    .setSmallIcon(R.drawable.ic_delivery)
    .setPriority(NotificationCompat.PRIORITY_HIGH)  // ignored on API 26+ (channels control this)
    .build()                                          // works API 16+

// ✅ ContextCompat.checkSelfPermission — consistent across all API levels
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
    PackageManager.PERMISSION_GRANTED

// ✅ WindowCompat — edge-to-edge without version checks
WindowCompat.setDecorFitsSystemWindows(window, false)  // works API 17+
val insets = ViewCompat.getRootWindowInsets(view)  // works API 20+

// ✅ ActivityResultContracts — replaces deprecated onActivityResult()
val cameraLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
    if (success) loadCapturedPhoto()
}
// No startActivityForResult() + onActivityResult() needed — cleaner, type-safe

// ✅ FileProvider — share files across apps (required API 24+, but use for all)
val photoUri = FileProvider.getUriForFile(context, "${packageName}.fileprovider", photoFile)

// ── BuildConfig — compile-time conditions ────────────────────────────
// BuildConfig.DEBUG is true only in debug builds — stripped by R8 in release
if (BuildConfig.DEBUG) {
    Timber.plant(Timber.DebugTree())  // only log in debug builds
    StrictMode.enableDefaults()           // only strict mode in debug
}

// Custom BuildConfig fields (build.gradle.kts)
// buildConfigField("String", "API_BASE_URL", "\"https://api.myapp.com\"")
// Access as: BuildConfig.API_BASE_URL — different value per build variant

// ── @SuppressLint("NewApi") — for documented, guarded usages ─────────
// Use when you KNOW the code is guarded but Lint still complains
@SuppressLint("NewApi")
fun safeApiCall() {
    // Lint is wrong here — we checked SDK_INT two lines above, but Lint can't see through the abstraction
    if (Build.VERSION.SDK_INT >= 33) { someApi33Method() }
}
// Prefer @RequiresApi over @SuppressLint — @RequiresApi propagates the warning to callers

@RequiresApi vs SDK_INT check — the critical distinction — @RequiresApi is a Lint annotation that tells static analysis "this method uses API N+" — it is completely ignored at runtime and provides zero crash protection. Build.VERSION.SDK_INT >= N is the actual runtime guard. You need both: @RequiresApi on your wrapper function to warn callers, and SDK_INT checks at every call site to actually prevent execution on older APIs. AndroidX Compat libraries eliminate most of this branching: NotificationCompat.Builder works on API 16 and silently ignores channel IDs on pre-26 devices; WindowCompat and ViewCompat handle version differences internally. Always prefer Compat classes over raw APIs — they let you write code once without scattered if/else SDK_INT blocks.

ActivityResultContracts is the modern, type-safe replacement for the deprecated startActivityForResult()/onActivityResult() pattern. Use registerForActivityResult() with standard contracts like TakePicture, PickVisualMedia, RequestPermission, or RequestMultiplePermissions — no request code matching required, and the launcher is testable. Works on API 16+ via Jetpack. Core library desugaring (enabled via isCoreLibraryDesugaringEnabled = true) backports Java 8+ APIs (java.time.LocalDateTime, streams, Optional) to older Android by substituting implementations at compile time — without it, using java.time.Instant on API 24 crashes with NoClassDefFoundError.

BuildConfig.DEBUG gates debug-only code (Timber, StrictMode, LeakCanary) — R8 removes those blocks entirely from release builds at compile time. Custom BuildConfig fields let you configure API URLs or feature flags per build type without runtime String comparisons. For gradual rollouts of new behavior (edge-to-edge, predictive back), wrap changes in remote feature flags (Firebase Remote Config, LaunchDarkly) to enable them for a percentage of users without a full Play Store release. The only reliable compatibility test is running your full suite on an AVD at your minSdk level — Lint misses runtime-only failures like reflection-based code, FileProvider setup, and notification channel registration.

💡 Interview Tip

The trap question is: "@RequiresApi prevents crashes on older Android, right?" — the correct answer is "No — @RequiresApi is purely a Lint annotation. It tells the static analyzer about your intent, but the actual runtime protection comes from a Build.VERSION.SDK_INT check." Then follow up with: "I always prefer AndroidX Compat classes like NotificationCompat and WindowCompat — they handle the version differences internally so I don't need SDK_INT branches everywhere." This shows you understand both the annotation system AND the correct idiomatic approach to backward compatibility.

Mark it done or save it in the question bank · Back to top

MediumMost asked

46. What is StrictMode and how do you use it to catch issues in development?

ANR (Application Not Responding) errors are one of the most damaging metrics for an Android app — Android Vitals penalizes apps with high ANR rates and Play Store ranking suffers. Most ANRs are caused by developers accidentally doing slow I/O (disk reads, database queries) or network calls on the main thread, which blocks the UI for more than 5 seconds. StrictMode is a debug-only developer tool that detects these violations in real time during development — before they ship to production and appear in your ANR rate. Beyond thread violations, StrictMode also detects resource leaks (unclosed Cursors, file streams) and Activity memory leaks, which are subtle bugs that degrade app performance over time. Enabling StrictMode with penaltyDeath() in debug builds ensures that any violation causes an immediate, impossible-to-ignore crash during development — forcing the team to fix issues before they reach code review.

// ── Enable StrictMode in Application.onCreate() ───────────────────────
// Must be one of the FIRST things in onCreate() — before any I/O happens
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()

        // ✅ Always guard with DEBUG — never ship StrictMode to production
        if (BuildConfig.DEBUG) {
            enableStrictMode()
        }
        // ... rest of initialization
    }
}

fun enableStrictMode() {
    // ThreadPolicy: detects what you're doing on the main thread that you shouldn't
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder()
            .detectDiskReads()       // SharedPreferences.getString() on main thread
            .detectDiskWrites()      // Room query without .allowMainThreadQueries()
            .detectNetwork()         // Any HTTP/socket call on main thread
            .detectResourceMismatches() // Wrong resource type for screen density (API 23+)
            .detectCustomSlowCalls()  // Code marked with StrictMode.noteSlowCall()
            .penaltyLog()            // Print violation stack trace to Logcat
            .penaltyFlashScreen()    // Flash screen red briefly (visual indicator)
            .penaltyDeath()          // CRASH the app — forces immediate fix
            .build()
    )

    // VmPolicy: detects memory and resource leaks in the VM
    StrictMode.setVmPolicy(
        StrictMode.VmPolicy.Builder()
            .detectLeakedSqlLiteObjects()    // Cursor or SQLiteDatabase not closed
            .detectLeakedClosableObjects()    // InputStream/OutputStream not closed
            .detectActivityLeaks()            // Activity still referenced after finish()
            .detectLeakedRegistrationObjects() // BroadcastReceiver not unregistered
            .detectFileUriExposure()           // file:// URI passed to other app (use FileProvider)
            .detectCleartextNetwork()          // HTTP instead of HTTPS (API 23+)
            .detectUnsafeIntentLaunch()        // Intent with mutable PendingIntent (API 31+)
            .penaltyLog()
            .penaltyDeath()
            .build()
    )
}

// ── Temporarily allow a known-safe violation ──────────────────────────
// Some third-party SDKs or one-time reads at startup legitimately need disk access
// Use allowThreadDiskReads() to temporarily suspend the check
fun loadLegacyConfig(): Config {
    // Save current policy, temporarily allow disk reads
    val savedPolicy = StrictMode.allowThreadDiskReads()
    return try {
        readConfigFromDisk()  // known-safe: only called once at startup
    } finally {
        StrictMode.setThreadPolicy(savedPolicy)  // ✅ always restore in finally block
    }
}

// ── Mark custom slow operations ───────────────────────────────────────
// detectCustomSlowCalls() + noteSlowCall() lets you track your own slow paths
fun parseHeavyJson(json: String): List<Product> {
    StrictMode.noteSlowCall("parseHeavyJson")  // triggers detectCustomSlowCalls penalty
    return heavyParsing(json)  // this should be moved to a background thread
}

// ── StrictMode in tests ───────────────────────────────────────────────
// Use StrictMode.ThreadPolicy.Builder().detectAll().penaltyDeath() in @Before
// Ensures tests that accidentally do disk/network I/O are caught automatically

ThreadPolicy detects what you're doing on the main thread that you shouldn't: disk reads (SharedPreferences on main thread), disk writes (Room without a background dispatcher), network calls (accidental synchronous HTTP), and custom slow operations marked with StrictMode.noteSlowCall(). Each violation logs a full stack trace showing the exact offending line. VmPolicy detects resource and memory leaks: unclosed SQLite Cursors, unclosed InputStreams, Activity instances not garbage collected after finish(), unregistered BroadcastReceivers, file:// URI exposure (use FileProvider instead), cleartext HTTP calls, and (API 31+) unsafe Intent launches with mutable PendingIntents — a security vulnerability enabling intent redirection attacks.

penaltyDeath() is the right default for a team enforcing code quality — penaltyLog() only logs, easy to miss in Logcat. penaltyDeath() crashes immediately with a detailed stack trace, impossible to ignore. Start with penaltyLog() while burning down existing violations, then switch to penaltyDeath() once the codebase is clean. Enable StrictMode as the very first line of Application.onCreate() — it only catches violations that happen after it's enabled, so any disk access before the call is silently missed.

Some legitimate operations require temporary suppression — use StrictMode.allowThreadDiskReads() or allowThreadDiskWrites(), always restoring the saved policy in a finally block. The most common ANR causes in production — SharedPreferences writes completing on the main thread, synchronous database queries, synchronous HTTP calls — are all caught by StrictMode in development, before they produce 5-second freezes that show up in your Android Vitals ANR rate. Always guard the entire enableStrictMode() block with if (BuildConfig.DEBUG) to ensure it never ships to production.

💡 Interview Tip

Saying "I enable StrictMode with penaltyDeath() in debug builds and fix every violation before merging to main" immediately signals professional engineering discipline — most Android developers have never enabled StrictMode. Then explain the two-policy structure: ThreadPolicy catches what you're doing on the main thread you shouldn't (disk, network), and VmPolicy catches leaked resources (cursors, streams, Activities). The interviewer may ask "why not just use a Lint rule instead?" — the answer is "Lint catches some cases statically, but StrictMode catches runtime violations — actual execution paths that Lint can't trace, including third-party SDK calls."

Mark it done or save it in the question bank · Back to top

Hard

47. What is WebView in Android? What are the security risks and how do you mitigate them?

WebView embeds a full web browser engine inside your Android app, letting you display web content without sending users to Chrome. It's used extensively for showing rich content that lives on the web (terms of service, dynamic help pages), for hybrid apps that mix web and native (Zomato's restaurant menus, banking apps with web-rendered statements), and for in-app payment flows. The power of WebView comes with serious security risks that have been exploited in real-world attacks — particularly the JavaScript interface bridge (addJavascriptInterface), which can expose your entire app's native capabilities to malicious JavaScript if implemented incorrectly. Understanding these risks and their mitigations is important for any Android role, and critical for fintech, banking, and healthcare interviews where security is a top priority.

// ── Secure WebView configuration — apply ALL of these ────────────────
val webView: WebView = findViewById(R.id.webView)

webView.settings.apply {
    javaScriptEnabled = true           // only if your content actually needs JS
    allowFileAccess = false             // ✅ no access to app's local files via file:// URLs
    allowContentAccess = false          // ✅ no access to content:// URIs (ContentProviders)
    allowFileAccessFromFileURLs = false  // ✅ file:// pages can't read other file:// URLs
    allowUniversalAccessFromFileURLs = false  // ✅ file:// can't access arbitrary URLs
    setSupportZoom(false)
    mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW  // ✅ block HTTP in HTTPS pages
    safeBrowsingEnabled = true          // ✅ Google Safe Browsing for phishing/malware URLs
    domStorageEnabled = false           // disable localStorage if not needed
}

// ── JavaScript Interface — the highest-risk WebView feature ───────────
// ❌ DANGEROUS: addJavascriptInterface without @JavascriptInterface annotation
// On API 16 and below, ALL methods of the object are accessible from JS
// This allowed attackers to call Runtime.exec() to execute arbitrary shell commands!
webView.addJavascriptInterface(dangerousObject, "Android")  // ❌ never do this unsecured

// ✅ SAFE: Only methods annotated @JavascriptInterface are exposed (API 17+)
// BUT: XSS in the loaded page can STILL call any @JavascriptInterface method
// If attacker injects JS: Android.postMessage("steal_session_token")
class SecureBridge(
    private val allowedOrigin: String = "myapp.com"
) {
    @JavascriptInterface  // ✅ required annotation — only this method is exposed
    fun postMessage(action: String, payload: String) {
        // ✅ Validate input — treat ALL JS data as untrusted
        if (!isValidAction(action)) return
        if (payload.length > 1024) return  // ✅ limit payload size
        // ✅ Handle on main thread — @JavascriptInterface is called on background thread
        Handler(Looper.getMainLooper()).post { handleBridgeMessage(action, payload) }
    }
}

// ── URL validation — only allow trusted domains ───────────────────────
// shouldOverrideUrlLoading is called for every navigation
// Use it to block redirects to untrusted domains
webView.webViewClient = object : WebViewClient() {
    override fun shouldOverrideUrlLoading(view: WebView, req: WebResourceRequest): Boolean {
        val host = req.url.host ?: return true  // block null-host URLs
        val trusted = listOf("myapp.com", "api.myapp.com", "cdn.myapp.com")
        return if (trusted.any { host.endsWith(it) }) {
            false  // allow — WebView loads it
        } else {
            // ❌ Not trusted — open in Chrome instead, not in our WebView
            startActivity(Intent(Intent.ACTION_VIEW, req.url))
            true  // we handled it — WebView does not navigate
        }
    }

    override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
        handler.cancel()  // ✅ reject SSL errors — never call handler.proceed() in production!
    }
}

// ── WebView process isolation (API 28+) ───────────────────────────────
// WebView can run in a separate process from your app (isolated_process)
// Crash in WebView renderer won't crash your main app process
webView.webViewRenderProcessClient = object : WebViewRenderProcessClient() {
    override fun onRenderProcessUnresponsive(view: WebView, renderer: WebViewRenderProcess?) {
        renderer?.terminate()  // kill unresponsive renderer
    }
    override fun onRenderProcessResponsive(view: WebView, renderer: WebViewRenderProcess?) {}
}

// ── WebView is separately updated via Play Store ──────────────────────
// Android WebView is a system app updated independently from Android OS
// Security patches arrive via WebView updates — not OS updates
// Most devices auto-update WebView, but enterprise/rooted devices may not

addJavascriptInterface security history — before API 17, ALL methods of the interface object were accessible from JavaScript, including Runtime.exec(). Attackers could use XSS to run arbitrary shell commands with your app's permissions. API 17 patched this by requiring the @JavascriptInterface annotation on every exposed method — always target API 17+ and always annotate. Even with the annotation, if the loaded page has an XSS vulnerability, injected JavaScript can call any annotated method. For sensitive operations (payments, authentication, accessing user data), never use a JS bridge — implement natively. Methods annotated with @JavascriptInterface run on a background thread, not the main thread — post any UI work back via Handler or runOnUiThread.

onReceivedSslError — never call handler.proceed() — overriding this and calling proceed() to silence SSL errors is the most common WebView security mistake. It makes your app fully vulnerable to MITM attacks. Always call handler.cancel(). allowFileAccess and allowContentAccess — before API 30, WebView could read files via file:// URLs and access ContentProviders via content:// URLs by default; an XSS attack could read your app's private SharedPreferences or database. Explicitly set both to false unless your use case specifically requires them (they default to false from API 30).

Safe Browsing (safeBrowsingEnabled = true) warns users before loading known phishing or malware URLs via Google's Safe Browsing service — especially important if users can navigate to arbitrary URLs. WebView process isolation (API 28+) runs the renderer in a separate isolated process; a website crashing the renderer no longer crashes your app. Handle renderer crashes gracefully via WebViewRenderProcessClient. WebView is separately updatable via the Play Store — security patches ship immediately without waiting for OEM OS updates, though managed enterprise devices with restricted Play access may have outdated WebViews, which should factor into your threat model.

💡 Interview Tip

The security-specific follow-up to always expect: "What's the risk of addJavascriptInterface?" — the correct answer combines history and present risk: "Before API 17, it exposed ALL methods including Runtime.exec() — attackers could run shell commands. After API 17, only @JavascriptInterface methods are exposed, but XSS vulnerabilities in the loaded page can still call those methods. For fintech or banking flows, we avoid WebView entirely for sensitive operations and use native UI instead — a JS bridge is a permanently elevated attack surface." Also mention the handler.proceed() SSL error trap, which is a guaranteed interview follow-up in security interviews.

Mark it done or save it in the question bank · Back to top

Hard

48. What are Android App Shortcuts? Explain static, dynamic, and pinned shortcuts.

App Shortcuts are a powerful but underutilized engagement feature — they appear when a user long-presses your app icon and provide immediate access to common actions. WhatsApp shows recent contacts, Instagram shows camera and search, Swiggy shows your usual restaurants — all as long-press shortcuts, reducing the friction for power users. Android supports three types: static (defined in XML, identical for all users), dynamic (created at runtime, personalized per user), and pinned (user explicitly pins to home screen). Implementing the reportShortcutUsed() feedback loop is what connects your shortcuts to Android's intelligence engine, allowing it to proactively surface your shortcuts on the launcher before the user even long-presses.

// ── Type 1: STATIC shortcuts — XML, same for all users ───────────────
// res/xml/shortcuts.xml — linked via AndroidManifest meta-data
// <shortcuts xmlns:android="...">
//   <shortcut
//     android:shortcutId="new_order"
//     android:enabled="true"
//     android:icon="@drawable/ic_add"
//     android:shortcutShortLabel="@string/new_order"    <!-- max 10 chars -->
//     android:shortcutLongLabel="@string/new_order_desc" <!-- max 25 chars -->
//   >
//     <intent android:action="android.intent.action.VIEW"
//             android:targetPackage="com.myapp"
//             android:targetClass="com.myapp.OrderActivity" />
//   </shortcut>
// </shortcuts>
// AndroidManifest: <meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/>

// ── Type 2: DYNAMIC shortcuts — runtime, personalized ────────────────
// WhatsApp recent contacts, Swiggy recent restaurants, Uber recent destinations
fun updateRecentOrderShortcuts(recentOrders: List<Order>) {
    val shortcutManager = getSystemService(ShortcutManager::class.java) ?: return

    val shortcuts = recentOrders
        .take(3)  // max 5 total (static + dynamic), leave room for statics
        .map { order ->
            ShortcutInfo.Builder(this, "order_${order.restaurantId}")
                .setShortLabel(order.restaurantName.take(10))  // max 10 chars
                .setLongLabel("Reorder from ${order.restaurantName}")
                .setIcon(Icon.createWithBitmap(order.restaurantBitmap))
                .setIntent(Intent(this, OrderActivity::class.java).apply {
                    action = Intent.ACTION_VIEW
                    putExtra("restaurantId", order.restaurantId)
                    putExtra("reorder", true)
                })
                .build()
        }
    shortcutManager.setDynamicShortcuts(shortcuts)  // replaces ALL dynamic shortcuts
}

// ── Type 3: PINNED shortcuts — user-initiated, permanent ─────────────
// User explicitly pins to home screen via your app's "Pin to home" button
// ⚠️ Persists even after app uninstall + reinstall — cannot be programmatically removed
fun pinRestaurantShortcut(restaurant: Restaurant) {
    val shortcutManager = getSystemService(ShortcutManager::class.java) ?: return
    if (!shortcutManager.isRequestPinShortcutSupported) return

    val shortcut = ShortcutInfo.Builder(this, "fav_${restaurant.id}")
        .setShortLabel(restaurant.name.take(10))
        .setIcon(Icon.createWithResource(this, R.drawable.ic_restaurant))
        .setIntent(Intent(this, RestaurantActivity::class.java).apply {
            action = Intent.ACTION_VIEW
            putExtra("restaurantId", restaurant.id)
        }).build()

    val callbackIntent = PendingIntent.getBroadcast(
        this, 0, Intent("com.myapp.SHORTCUT_PINNED"),
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )
    shortcutManager.requestPinShortcut(shortcut, callbackIntent.intentSender)
    // System shows confirmation dialog — user approves or rejects
}

// ── reportShortcutUsed() — feeds Android's prediction engine ─────────
// Call this when the user performs the action the shortcut represents
// Android uses this to proactively surface shortcuts BEFORE long-press
fun onRestaurantOpened(restaurantId: String) {
    getSystemService(ShortcutManager::class.java)?.reportShortcutUsed("order_$restaurantId")
}

// ── Limits summary ────────────────────────────────────────────────────
// Max 5 shortcuts visible (static + dynamic combined)
// shortLabel: max 10 chars  (shown when pinned to home screen)
// longLabel:  max 25 chars  (shown in the long-press popup)

Static shortcuts are defined in res/xml/shortcuts.xml, linked via AndroidManifest meta-data on the launcher Activity, and are identical for all users — use them for universal actions like "New Post," "Search," or "Scan QR Code" that are relevant from day one. Dynamic shortcuts are created and updated at runtime via ShortcutManager — ideal for personalized content like WhatsApp's recent contacts, Swiggy's recently ordered restaurants, or Spotify's recently played playlists. Update them every time the user completes the corresponding action so they stay fresh. Pinned shortcuts are user-initiated: your app calls requestPinShortcut(), the system shows a confirmation dialog, and the approved shortcut appears permanently on the home screen. Only the user can remove a pinned shortcut — your app cannot.

Maximum 5 visible shortcuts (static + dynamic combined) — static shortcuts take precedence. With 2 statics, add at most 3 dynamics. setDynamicShortcuts() replaces all dynamic shortcuts at once; call it after each action that changes the user's recents. updateShortcuts() modifies existing shortcuts (including pinned ones with the same ID) without removing them — use this to refresh icons or labels on pinned shortcuts without breaking the user's pin.

reportShortcutUsed() is the intelligence feedback loop — call it every time the user performs the action a shortcut represents (opens the restaurant, starts the chat). Android's engine uses these signals to proactively surface shortcuts in Discover, Pixel Launcher suggestions, and Assistant responses before the user even long-presses. Label length limits are enforced: shortLabel maximum is 10 characters (shown on home screen when pinned), longLabel maximum is 25 (shown in the long-press popup) — use .take(10) for dynamic content. Every Intent inside ShortcutInfo must have an explicit action set (e.g., Intent.ACTION_VIEW) — omitting it throws an exception from ShortcutInfo.Builder.

💡 Interview Tip

Open with real-world examples to make the answer stick: "WhatsApp uses dynamic shortcuts for recent contacts, Instagram uses static shortcuts for Camera and New Post, and Swiggy uses dynamic shortcuts for recently ordered restaurants." Then explain the three types clearly. The key detail many candidates miss: reportShortcutUsed() is what feeds Android's predictive intelligence — without it, your shortcuts never get proactively surfaced to users. Also mention the label length limits (10 chars for shortLabel, 25 for longLabel) — getting this detail right shows you've actually implemented shortcuts, not just read about them.

Mark it done or save it in the question bank · Back to top

Hard

49. What is multi-process architecture in Android? When would you run components in separate processes?

By default, every component in an Android app — Activity, Service, BroadcastReceiver, ContentProvider — runs in the same OS process, sharing the same memory space and JVM heap. Most apps never need anything else. But in specific scenarios — crash isolation, memory budgets, security sandboxing — running components in separate processes gives you powerful guarantees. The catch is that separate processes don't share memory, so your singleton patterns break, SharedPreferences becomes unsafe, and all cross-process communication must go through IPC (Binder/AIDL). Misunderstanding this is one of the most common sources of subtle bugs in apps that mix multi-process architecture with standard singleton patterns.

// ── DECLARING separate processes in AndroidManifest.xml ─────────────
// ":remote" — private process, name becomes com.myapp:remote
// "com.myapp.sync" — global process, can be shared between apps

// <service android:name=".HeavyParserService"
//          android:process=":parser" />   ← runs in separate process

// ── THE CRITICAL GOTCHA: Application.onCreate() runs in EVERY process ──
// Your app has 2 processes → Application.onCreate() is called TWICE
// Each process has its OWN memory — singletons, static fields, everything
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()

        // ✅ Only initialize heavy components in the MAIN process
        // Otherwise: Firebase, Crashlytics, Analytics all init twice → crashes
        val processName = getProcessName()  // API 28+; use ActivityManager on older
        if (processName == packageName) {
            initFirebase()
            initCrashlytics()
            initAnalytics()
        }
        // Light-weight init that every process needs
        initTimber()
    }
}

// ── WHAT IS NOT SHARED between processes ────────────────────────────
// ❌ Kotlin object singletons — each process has its own copy
// ❌ static fields — not shared
// ❌ SharedPreferences — NOT multi-process safe (data races)
// ❌ in-memory Room database — separate DBs
// ✅ File system — shared (but use proper locking)
// ✅ SQLite database file — shared (Room multi-process mode)
// ✅ ContentProvider — designed for cross-process access

// ── MULTI-PROCESS SAFE alternatives ─────────────────────────────────

// For data sharing: use ContentProvider or DataStore (multi-process mode)
// DataStore multi-process:
// implementation("androidx.datastore:datastore-preferences:1.1.0")
val dataStore = context.createDataStore(
    fileName = "settings.preferences_pb",
    corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }
)

// WorkManager in separate process:
// Keeps heavy Worker execution isolated from UI process
// implementation("androidx.work:work-multiprocess:2.9.0")
val config = Configuration.Builder()
    .setWorkerFactory(hiltWorkerFactory)
    .setDefaultProcessName("$packageName:worker")
    .build()

// ── REAL WORLD use cases ─────────────────────────────────────────────
// 1. Crash isolation: PDF renderer / WebView in :renderer process
//    → crash in renderer doesn't kill your UI
// 2. Memory: large image processing in :worker to avoid OOM in UI
// 3. Security: DRM operations or payment in :secure process
// 4. System: SyncAdapter always runs in a separate process by Android

Each process = separate JVM heap — every process gets completely independent memory. No Kotlin object singletons, no static fields, no in-memory caches are shared across processes. If your Service runs in :remote and calls MyRepository.getInstance(), it gets a fresh, empty instance — not the one your Activity is using. This is the most common multi-process bug and the source of classic "why is my singleton null in the service?" questions. Application.onCreate() runs in every process — with two processes, onCreate() is called twice. Initializing Firebase, Crashlytics, Analytics, or Hilt in every process causes crashes, duplicate events, and wasted memory. Always check getProcessName() and initialize conditionally.

SharedPreferences is NOT multi-process safe — concurrent reads/writes from two processes can corrupt the file. Use DataStore with multi-process support, or a ContentProvider, for settings shared across processes. The two primary motivations for multi-process in practice are crash isolation (a native crash in a WebView renderer, PDF engine, or third-party SDK kills only that process — the UI process stays alive and shows a graceful error) and memory isolation (heavy processing of images or video in a :worker process prevents its memory usage from counting against the UI process's limit, reducing OOM kill risk).

The work-multiprocess WorkManager artifact routes Workers to a dedicated process, preventing background sync work from competing with UI memory and CPU. Cross-process calls go through Binder — synchronous from the caller's perspective but involving kernel transitions, so always make IPC calls off the main thread and batch data transfers. AIDL is the lowest-level option; Messenger and ContentProvider are simpler alternatives for most use cases.

💡 Interview Tip

The interview question is almost always a gotcha: "Why is my singleton null in my Service?" Walk through the answer clearly: "If the Service has android:process=':remote', it runs in a separate JVM — every object singleton is recreated from scratch. The ViewModel, the Repository, the in-memory cache — all empty. You either need to avoid multi-process, or architect the Service to be self-contained and communicate via IPC or disk." This explanation shows you understand both the OS-level mechanism and the practical engineering consequence.

Mark it done or save it in the question bank · Back to top

Hard

50. What is the Android Vitals dashboard? What metrics should you monitor in production?

Android Vitals is the section of Google Play Console that shows your app's real-world performance data — collected passively from users' devices who have opted in to sharing usage data. Unlike test environments where everything is fast and controlled, Vitals shows you how your app actually behaves on real devices in real conditions: low-end phones, poor network, full storage, extreme battery saver mode. What makes it high-stakes is that Google Play's ranking algorithm directly penalizes apps that exceed certain "bad behavior" thresholds. An app with a high ANR rate doesn't just frustrate users — it gets deprioritized in search results and recommendations, costing you installs. Understanding, monitoring, and improving these metrics is what separates production-quality engineering from hobby projects.

// ── ANDROID VITALS METRICS AND THRESHOLDS ───────────────────────────
// Google Play flags apps exceeding these as "bad behavior":
//
// Metric                  Bad behavior threshold
// ─────────────────────   ──────────────────────
// ANR rate                > 0.47% of daily sessions
// Crash rate              > 1.09% of daily sessions
// Excessive wakeups       > 10 wakeups/hour (doze mode)
// Stuck partial wakelocks > 1 hour
// Slow rendering          > 50ms frames on > 0.1% of sessions
// Frozen frames           > 700ms frames on any session
// Cold start slow         > 5 seconds
// Warm start slow         > 2 seconds

// ── INSTRUMENTING your app for Vitals improvement ────────────────────

// 1. Firebase Crashlytics — rich crash context for investigation
class CheckoutViewModel : ViewModel() {
    fun processPayment(orderId: String) {
        viewModelScope.launch {
            // Add context BEFORE the crash — visible in Crashlytics report
            Firebase.crashlytics.setCustomKey("orderId", orderId)
            Firebase.crashlytics.setCustomKey("userId", auth.currentUser?.uid ?: "anonymous")
            Firebase.crashlytics.setCustomKey("cartSize", cart.items.size)

            try {
                repository.processPayment(orderId)
            } catch (e: PaymentException) {
                Firebase.crashlytics.recordException(e)  // non-fatal — logs but doesn't crash
                showPaymentError(e.message)
            }
        }
    }
}

// 2. Firebase Performance — custom traces for business-critical flows
suspend fun loadHomeScreen() {
    val trace = Firebase.performance.newTrace("home_screen_load")
    trace.start()
    try {
        val feed = withContext(Dispatchers.IO) { repository.getHomeFeed() }
        trace.putMetric("items_count", feed.size.toLong())
        updateUI(feed)
    } finally {
        trace.stop()  // always stop — even on exception
    }
}

// 3. Macrobenchmark — measure startup in CI, catch regressions
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
    @get:Rule val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun coldStart() = benchmarkRule.measureRepeated(
        packageName = "com.myapp",
        metrics = listOf(StartupTimingMetric(), FrameTimingMetric()),
        iterations = 5,
        startupMode = StartupMode.COLD
    ) {
        pressHome()
        startActivityAndWait()
    }
}

// 4. StrictMode — catch ANR-causing violations in development
if (BuildConfig.DEBUG) {
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder()
            .detectAll()
            .penaltyDeath()  // crash on violations — zero tolerance in dev
            .build()
    )
}

// 5. JankStats — measure frame rendering in production
val jankStats = JankStats.createAndTrack(window) { frameData ->
    if (frameData.isJank) {
        Firebase.performance.newTrace("jank_frame").apply {
            putMetric("duration_ms", frameData.frameDurationUiNanos / 1_000_000)
            start(); stop()
        }
    }
}

Android Vitals = Play Store ranking signal — Google Play's algorithm uses your ANR rate, crash rate, and rendering metrics to determine search and recommendation prominence. Exceeding "bad behavior" thresholds (ANR > 0.47% of daily sessions, crashes > 1.09%) directly reduces organic discoverability. The Vitals console shows exact main-thread stack traces for each ANR, so you know precisely what to fix. Aim well below the thresholds — a healthy target is under 0.1% ANR rate. Slow frames (>50ms) and frozen frames (>700ms) are also tracked; use the JankStats library to measure these in production and the Android Studio Frame Profiler to locate root causes.

Startup time — cold starts >5 seconds are flagged as slow across your real user device distribution. Improve cold start with Baseline Profiles (pre-built AOT compilation hints) and the App Startup library. Run Macrobenchmark in CI to catch startup regressions before they reach production. Firebase Crashlytics provides rich crash context: set custom keys (userId, orderId, screen name) before any risky operation so they appear in the crash report — turning "NullPointerException in ProfileActivity" into "NullPointerException when loading profile for user X on screen Y." Use Crashlytics.recordException() for recoverable errors (payment failures, network timeouts) — logged with full context but not counted against your crash rate.

Firebase Performance custom traces let you instrument business-critical flows invisible to Vitals: checkout time, home feed load time, search response time. Set threshold alerts in the Firebase console so you're notified when your checkout P95 suddenly doubles — catching regressions before users report them. A production monitoring mindset — weekly Vitals reviews, crash rate alerts at 0.3%, startup regression gates in CI — is what separates apps that stay healthy in the Play Store from those that silently degrade over time.

💡 Interview Tip

The most impressive answer here isn't just listing the metrics — it's showing that you have a monitoring mindset. Say: "I set up a weekly Vitals review in our team rituals. We have alerts in Firebase if crash rate goes above 0.3% or cold start P95 exceeds 3 seconds. Before any major release, I check the Vitals dashboard on the release candidate — catching a regression in ANR rate before it ships is far cheaper than fixing it after." This shows you think about production health proactively, not reactively.

Mark it done or save it in the question bank · Back to top

More Android interview topics

See all 600+ questions in the question bank