Android Interview Questions and Answers
Last updated:
Check out 45 of the most common Android interview questions, then take an AI-powered practice interview
Q1Walk through the Activity lifecycle and say exactly which callbacks fire when the device rotates.
BasicLifecycle
Answer
The full sequence on launch is onCreate, onStart, onResume. When another activity or dialog partially covers yours, you get onPause. When it is fully hidden you get onStop, and when the activity is finishing or being destroyed you get onDestroy.
Coming back from stopped goes onRestart, onStart, onResume. On rotation, and by default on any configuration change you have not declared in android:configChanges, the activity is destroyed and recreated: onPause, onStop, onSaveInstanceState, onDestroy, then onCreate with a non-null Bundle, onStart, onRestoreInstanceState, onResume. Note that onSaveInstanceState runs after onStop on API 28 and above, which trips up people who remember the older ordering.
Two details interviewers dig into. First, onPause is not a safe place to persist data, it must return fast because the next activity cannot resume until it does, so use onStop or a ViewModel plus a coroutine for anything with I/O. Second, onDestroy is not guaranteed: if the system kills the process for memory, you get nothing at all, which is why saved state must go through onSaveInstanceState or SavedStateHandle rather than a field. Declaring android:configChanges="orientation|screenSize|keyboardHidden" suppresses recreation and hands you onConfigurationChanged instead, which is legitimate for a video player or a camera preview but is a bad default because it silently hides bugs that reappear on locale change, dark mode toggle, font scale change, or genuine process death.
class ProfileActivity : AppCompatActivity() {
private val viewModel: ProfileViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_profile)
// Non-null bundle means we were recreated, not started fresh
val draft = savedInstanceState?.getString(KEY_DRAFT).orEmpty()
viewModel.restoreDraft(draft)
}
override fun onSaveInstanceState(outState: Bundle) {
// Runs on config change AND before likely process death
outState.putString(KEY_DRAFT, viewModel.currentDraft())
super.onSaveInstanceState(outState)
}
override fun onStop() {
super.onStop()
viewModel.flushAnalytics() // safe place for slower work, not onPause
}
companion object { private const val KEY_DRAFT = "draft" }
}Key Points
- Rotation destroys and recreates: onPause, onStop, onSaveInstanceState, onDestroy, onCreate
- onSaveInstanceState runs after onStop from API 28 onward
- onPause must be fast; do persistence in onStop or a ViewModel scope
- onDestroy is not called when the process is killed for memory
Q2Explain explicit versus implicit Intents, and what package visibility on API 30+ changed about them.
BasicIntents
Answer
An explicit Intent names the target component directly, either by Class reference inside your own app or by ComponentName for another package. An implicit Intent describes an action, data URI and optional category, and lets the system resolve which component can handle it, for example ACTION_VIEW with an https URI, or ACTION_SEND with a MIME type for a share sheet. Explicit intents are what you use inside your own app and are the only safe option when you send anything sensitive, because an implicit intent can be intercepted by whichever app declares a matching intent filter.
From API 30 onward, package visibility filtering changed the ergonomics of implicit intents. Your app can no longer see the full list of installed packages by default, so queryIntentActivities and resolveActivity return nothing for apps you have not declared. If you need to check whether a specific app can handle an intent, add a queries element to the manifest listing either the package name or the intent signature.
Failing to do this is a very common bug: the code works on an API 29 test device and silently falls into the else branch on API 30 and above, so UPI payment flows, WhatsApp share buttons and custom tab checks all break in production. The safe modern pattern is to skip resolveActivity entirely, wrap startActivity in a try or catch for ActivityNotFoundException, and let the system show a chooser. Android 14 also blocks implicit intents from being delivered to non-exported components inside your own app, so internal navigation must be explicit.
<!-- AndroidManifest.xml: declare what you need to see on API 30+ -->
<queries>
<package android:name="com.whatsapp" />
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="upi" />
</intent>
</queries>
// Kotlin: do not rely on resolveActivity, handle the failure instead
val intent = Intent(Intent.ACTION_VIEW, "upi://pay?pa=merchant@bank".toUri())
try {
startActivity(intent)
} catch (e: ActivityNotFoundException) {
showFallbackPaymentSheet()
}Key Points
- Explicit intents name a component; implicit intents describe an action plus data
- API 30 package visibility makes resolveActivity return null for undeclared packages
- Declare a queries block for any package or intent signature you probe
- Android 14 blocks implicit intents targeting your own non-exported components
Q3What are the four Android app components and why is android:exported mandatory from API 31?
BasicApp Components
Answer
Activities present a screen and a window, Services run work without UI (started, bound, or foreground), BroadcastReceivers respond to system or app broadcasts, and ContentProviders expose structured data across process boundaries through a content URI. Each is declared in AndroidManifest.xml, and the manifest is also where you declare permissions, the application class, intent filters and hardware features. From Android 12 (API 31) onward, any component that declares an intent filter must set android:exported explicitly to true or false.
If you omit it, the build fails at install time with an installation error rather than defaulting silently, which was the point of the change: thousands of apps were unintentionally exporting activities and receivers that any other app could launch. Set exported to false for everything internal, and only true where you genuinely want other apps or the system to start it, such as a launcher activity or a deep-linked activity. Two more recent changes matter.
Android 13 and above require runtime-registered receivers for non-system broadcasts to pass RECEIVER_EXPORTED or RECEIVER_NOT_EXPORTED to registerReceiver, and this became enforced for apps targeting API 34. Android 14 also restricts implicit intents from reaching non-exported components. In interviews, the follow-up is usually about ContentProvider: it is initialised before Application.onCreate, which is why libraries such as WorkManager and Firebase historically used a stub provider for auto-initialisation, and why the Jetpack App Startup library exists to consolidate all those providers into one.
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".SyncService"
android:exported="false"
android:foregroundServiceType="dataSync" />
// Runtime receiver registration, required flag on API 33+
ContextCompat.registerReceiver(
context,
priceDropReceiver,
IntentFilter(ACTION_PRICE_DROP),
ContextCompat.RECEIVER_NOT_EXPORTED
)Key Points
- Activity, Service, BroadcastReceiver, ContentProvider are the four manifest components
- android:exported is mandatory on API 31+ for any component with an intent filter
- registerReceiver needs RECEIVER_EXPORTED or RECEIVER_NOT_EXPORTED on API 33+
- ContentProviders initialise before Application.onCreate, which App Startup replaces
Q4How does the Fragment view lifecycle differ from the Fragment lifecycle, and why does viewLifecycleOwner matter?
BasicFragments
Answer
A Fragment instance and its view have separate lifecycles, and conflating them is the single most common source of Fragment crashes. The fragment lifecycle runs onAttach, onCreate, onCreateView, onViewCreated, onStart, onResume, onPause, onStop, onDestroyView, onDestroy, onDetach. When a fragment is placed on the back stack, or when a ViewPager2 detaches an off-screen page, the view is destroyed at onDestroyView but the fragment instance survives.
If you observed a LiveData or collected a Flow using the fragment itself as the LifecycleOwner, that observer stays alive after the view is gone, and the next emission touches a destroyed view hierarchy or a stale binding, producing either a NullPointerException or a leak of the whole view tree. Always use viewLifecycleOwner for anything that touches views, and use the fragment lifecycle only for state that genuinely outlives the view. The related discipline is view binding: keep a nullable backing field, assign it in onCreateView, and null it out in onDestroyView, or use a delegate that does this for you.
Interviewers also probe fragment communication. The modern answer is a shared ViewModel scoped to the parent activity or the navigation graph, or the Fragment Result API (setFragmentResult and setFragmentResultListener) for one-off results, rather than interfaces implemented by the host activity, which was the pre-Jetpack pattern and couples fragments to their host.
class OrdersFragment : Fragment(R.layout.fragment_orders) {
private var _binding: FragmentOrdersBinding? = null
private val binding get() = checkNotNull(_binding)
private val viewModel: OrdersViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
_binding = FragmentOrdersBinding.bind(view)
// viewLifecycleOwner, NOT this
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.orders.collect { binding.list.render(it) }
}
}
}
override fun onDestroyView() {
_binding = null // otherwise the whole view tree leaks
super.onDestroyView()
}
}Key Points
- Fragment instance can outlive its view; onDestroyView is not onDestroy
- Use viewLifecycleOwner for any observer that touches views
- Null the view binding in onDestroyView to avoid leaking the view tree
- Use a shared ViewModel or the Fragment Result API for communication
Q5What exactly does a ViewModel survive, and what does it not?
BasicArchitecture Components
Answer
A ViewModel survives configuration changes, because ComponentActivity retains the ViewModelStore across recreation through the non-configuration instance mechanism. It does not survive process death, and it does not survive the user finishing the activity. Its onCleared callback runs when the owner is finished for good, which is also when viewModelScope is cancelled.
That distinction drives real architecture decisions. In-flight network state, a loaded list, a scroll position derived from data, all of that can live in the ViewModel. Anything the user typed, a selected filter, an in-progress form, must additionally be written to SavedStateHandle, which is backed by the same saved instance state Bundle and therefore survives the system killing your process in the background.
SavedStateHandle has a size limit because it goes through a Binder transaction, so keep it to identifiers and small primitives, never a full list of API results. The ViewModel must never hold a reference to an Activity, a Fragment, a View, or any Context other than the application Context via AndroidViewModel, because it outlives those objects by design and holding one is an instant leak. Scope matters too: by viewModels() gives a ViewModel scoped to the fragment, by activityViewModels() shares one across the activity, and in Navigation Compose or Navigation Component you can scope to a nav graph so a multi-step flow shares state and clears when the flow is popped. In Compose, hiltViewModel() resolves the ViewModel from the nearest NavBackStackEntry, which is how per-destination scoping works.
@HiltViewModel
class SearchViewModel @Inject constructor(
private val repo: SearchRepository,
private val savedState: SavedStateHandle,
) : ViewModel() {
// Survives config change AND process death
val query: StateFlow<String> = savedState.getStateFlow(KEY_QUERY, "")
// Survives config change only
val results: StateFlow<UiState> = query
.debounce(300)
.flatMapLatest { repo.search(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Idle)
fun onQueryChanged(value: String) { savedState[KEY_QUERY] = value }
private companion object { const val KEY_QUERY = "q" }
}Key Points
- Survives configuration change, not process death or finish()
- SavedStateHandle covers process death but is Binder-size limited
- Never hold an Activity, Fragment, View or Activity Context in a ViewModel
- Scope with viewModels(), activityViewModels(), or a navigation graph
Q6In Jetpack Compose, what triggers recomposition, and when do you use remember versus rememberSaveable?
BasicJetpack Compose
Answer
Compose recomposes a composable when a State object it read during composition changes value. The compiler records which composables read which snapshot state, so a change to a mutableStateOf invalidates only the scopes that actually read it, not the whole tree. This is why reading state as late as possible matters: if you read a scroll offset in the top-level composable, everything under it recomposes, but if you pass a lambda that reads it inside a Modifier.graphicsLayer block, only the layer phase reruns. remember caches a value across recompositions of the same composable in the same composition.
It is lost on configuration change, on process death, and when the composable leaves the composition. rememberSaveable additionally persists the value into the saved instance state bundle, so it survives rotation and process death, but only for types the Saver machinery can handle: primitives, Parcelable, and anything with a custom Saver or a mapSaver. The classic mistake is calling mutableStateOf without remember, which recreates the state on every recomposition and makes the UI appear frozen. The other classic mistake is using rememberSaveable for a large object, which quietly bloats the Bundle and risks a TransactionTooLargeException. State hoisting is the pattern interviewers want to hear: a composable that takes a value plus an onValueChange lambda is stateless, testable, previewable and reusable, whereas one that owns its own mutableStateOf can only be driven from inside.
@Composable
fun CouponField(
code: String,
onCodeChange: (String) -> Unit, // hoisted state, stateless composable
modifier: Modifier = Modifier,
) {
OutlinedTextField(value = code, onValueChange = onCodeChange, modifier = modifier)
}
@Composable
fun CheckoutScreen() {
// Survives rotation and process death
var code by rememberSaveable { mutableStateOf("") }
// Cache only, recreated after rotation
val formatter = remember { CurrencyFormatter(Locale("en", "IN")) }
Column {
CouponField(code = code, onCodeChange = { code = it })
Text(formatter.format(totalPaise))
}
}Key Points
- Recomposition is driven by reads of snapshot State, scoped to the reader
- remember survives recomposition only; rememberSaveable survives rotation and process death
- mutableStateOf without remember resets on every recomposition
- Hoist state so composables take a value plus an onValueChange lambda
Q7Explain compileSdk, minSdk and targetSdk, and what the Play Store requires here.
BasicGradle
Answer
compileSdk is the API level your code compiles against. It decides which classes and methods the compiler can see, and changing it never changes runtime behaviour by itself. minSdk is the lowest API level the app installs on, and it gates lint checks, desugaring, and which APIs need a Build.VERSION guard. targetSdk is a compatibility contract: it tells the platform which behaviour changes your app has been tested against. Raising targetSdk opts you into every behavioural change up to that level at once, which is why bumping from 33 to 34 can suddenly require foreground service types, and bumping to 35 enforces edge-to-edge layout.
Google Play enforces a rolling target API requirement: new apps and updates must target a recent API level, with the bar moving up roughly a year after each Android release, and apps that fall behind stop being discoverable to users on newer devices. That is a compliance deadline product teams in India plan releases around, not an optional upgrade. For minSdk, the Indian market is the reason many teams still support older levels than a US-only app would; a large base of budget devices means minSdk 24 or 26 is still common where a Western team might sit at 28 or higher. Use core library desugaring to get java.time and other newer APIs on older minSdk values, and use @RequiresApi plus Build.VERSION.SDK_INT checks rather than raising minSdk when only one screen needs a newer API.
// app/build.gradle.kts
android {
namespace = "com.example.shop"
compileSdk = 36
defaultConfig {
minSdk = 24
targetSdk = 36
versionCode = 1420
versionName = "4.12.0"
}
compileOptions {
isCoreLibraryDesugaringEnabled = true // java.time on minSdk 24
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}Key Points
- compileSdk is compile-time visibility; targetSdk opts into runtime behaviour changes
- minSdk gates installability, lint, and API guards
- Play enforces a rolling minimum target API level for new apps and updates
- Core library desugaring gives newer Java APIs on low minSdk
Q8How do ListAdapter and DiffUtil work in RecyclerView, and why is notifyDataSetChanged a bad default?
BasicRecyclerView
Answer
RecyclerView recycles view holders so that scrolling a thousand-item list only ever inflates as many views as fit on screen plus a small cache. notifyDataSetChanged tells the RecyclerView that everything changed, so it rebinds every visible holder, loses item animations, resets scroll position in some configurations, and defeats the stable-id optimisation. DiffUtil computes the minimal set of insert, remove, move and change operations between two lists using an Eugene Myers difference algorithm, and dispatches granular notifyItemInserted or notifyItemChanged calls instead. ListAdapter wraps this: you call submitList, and AsyncListDiffer runs the diff on a background executor and applies the result on the main thread.
The two callbacks you implement have very specific meanings. areItemsTheSame compares identity, normally a server id, and answers should these two rows be treated as the same row. areContentsTheSame compares the rendered payload and answers does this row need rebinding. If your model is a Kotlin data class, areContentsTheSame is usually just a equality check, but only if every field affecting the UI is in the constructor. Two production gotchas come up constantly.
First, submitting the same mutable list instance twice results in no update at all, because AsyncListDiffer compares references first, so always submit a new list. Second, DiffUtil on a very large list on the main thread will cause jank, which is exactly why ListAdapter moves it off the main thread by default. For partial updates, return a change payload from getChangePayload and handle it in the three-argument onBindViewHolder to avoid a full rebind.
class OrderAdapter : ListAdapter<Order, OrderVH>(DIFF) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
OrderVH(ItemOrderBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: OrderVH, position: Int) =
holder.bind(getItem(position))
private companion object {
val DIFF = object : DiffUtil.ItemCallback<Order>() {
override fun areItemsTheSame(a: Order, b: Order) = a.id == b.id
override fun areContentsTheSame(a: Order, b: Order) = a == b
override fun getChangePayload(a: Order, b: Order) =
if (a.status != b.status) PAYLOAD_STATUS else null
}
const val PAYLOAD_STATUS = "status"
}
}
// Always submit a NEW list instance
adapter.submitList(orders.toList())Key Points
- ListAdapter runs DiffUtil off the main thread via AsyncListDiffer
- areItemsTheSame is identity, areContentsTheSame is rendered content
- Submitting the same list instance twice is a no-op
- Use getChangePayload for partial rebinds on hot-updating rows
Q9How do you set up Room, and what goes wrong with schema migrations in production?
BasicRoom
Answer
Room has three pieces: an @Entity data class that maps to a table, a @Dao interface with @Query, @Insert, @Update, @Delete and @Upsert methods, and an abstract @Database class listing entities and version. Room validates every @Query at compile time against the schema, which is its main advantage over raw SQLite: a typo in a column name is a build error, not a runtime crash. Queries returning Flow or LiveData are observable, so the UI updates automatically when the underlying table changes, and suspend functions run on Room's own executor so you never block the main thread.
Migrations are where teams get burned. Every time you change the schema you must bump version and supply a Migration object with the exact ALTER TABLE statements, or Room throws IllegalStateException at open time. fallbackToDestructiveMigration wipes user data, which is acceptable for a pure cache but catastrophic for a table holding drafts, an offline cart, or queued analytics events. Always set exportSchema to true and commit the generated JSON schema files, because they let Room's MigrationTestHelper verify each migration against real historical schemas in an instrumentation test, and they give you a reviewable diff of every schema change.
Room also supports auto-migrations with @AutoMigration, which handles additive changes and simple renames via @RenameColumn without hand-written SQL, but you still need a manual migration for anything involving type changes or data transformation. A last gotcha: a Room database instance should be a singleton, and Hilt makes that trivial with a @Singleton provider.
@Entity(tableName = "cart_item", indices = [Index("sku")])
data class CartItem(
@PrimaryKey val id: String,
val sku: String,
val qty: Int,
val pricePaise: Long,
)
@Dao
interface CartDao {
@Query("SELECT * FROM cart_item ORDER BY sku")
fun observeAll(): Flow<List<CartItem>>
@Upsert
suspend fun upsert(item: CartItem)
}
@Database(entities = [CartItem::class], version = 3, exportSchema = true)
abstract class ShopDb : RoomDatabase() {
abstract fun cartDao(): CartDao
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE cart_item ADD COLUMN pricePaise INTEGER NOT NULL DEFAULT 0")
}
}Key Points
- Room validates SQL at compile time; Flow queries are observable
- Bump version and write a Migration for every schema change
- fallbackToDestructiveMigration deletes user data, use it only for caches
- exportSchema true plus MigrationTestHelper is how you test migrations
Q10How do you configure Retrofit with OkHttp for a production Android app?
BasicNetworking
Answer
Retrofit turns an annotated Kotlin interface into an HTTP client. You declare methods with @GET, @POST, @Path, @Query, @Body, @Header, mark them suspend so Retrofit bridges the call to a coroutine, and register a converter, usually kotlinx.serialization with the Kotlin serialization converter factory, or Moshi if the codebase predates that. Retrofit delegates all transport to OkHttp, so the interesting production configuration lives on the OkHttpClient: connect, read and write timeouts, a connection pool, an HTTP cache directory, and interceptors.
Interceptors come in two flavours and the distinction is a favourite interview question. An application interceptor runs once per call, sees the request you made, and does not run again on redirect or retry, which makes it right for adding an auth header or a request id. A network interceptor runs once per actual network request, sees redirects and the real headers on the wire, and can observe the served-from-cache case, which makes it right for cache-control rewriting and precise byte-level logging.
Never leave HttpLoggingInterceptor at BODY level in a release build, since it prints tokens and personal data into logcat where any app with log access on a rooted device can read them. For token refresh use an Authenticator rather than an interceptor, because OkHttp calls it only after a 401 and handles the retry for you, and guard it with a mutex so twenty parallel failing calls do not trigger twenty refresh requests.
interface OrderApi {
@GET("v1/orders/{id}")
suspend fun order(@Path("id") id: String): OrderDto
@POST("v1/orders")
suspend fun create(@Body body: CreateOrderDto): OrderDto
}
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.cache(Cache(File(context.cacheDir, "http"), 20L * 1024 * 1024))
.addInterceptor { chain ->
chain.proceed(chain.request().newBuilder()
.header("Authorization", "Bearer " + tokenStore.access())
.build())
}
.apply {
if (BuildConfig.DEBUG) addInterceptor(
HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)
)
}
.build()
val api: OrderApi = Retrofit.Builder()
.baseUrl("https://api.example.in/")
.client(client)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
.create()Key Points
- suspend functions plus a serialization converter is the standard 2026 setup
- Application interceptors run once per call; network interceptors run per network request
- Use an Authenticator with a mutex for 401 token refresh, not an interceptor
- Never ship BODY-level logging in release builds
Q11How do you request runtime permissions correctly, including POST_NOTIFICATIONS on Android 13+?
BasicPermissions
Answer
Dangerous permissions are granted at runtime, not at install. The modern API is the Activity Result contracts: registerForActivityResult with RequestPermission or RequestMultiplePermissions, registered as a field during initialisation, never inside a click handler, because registration must happen before the activity reaches STARTED or you get an IllegalStateException. The flow is check with ContextCompat.checkSelfPermission, then if not granted call shouldShowRequestPermissionRationale to decide whether to show an explanation first, then launch the contract.
If the user has denied twice on Android 11 and above, the system treats it as permanently denied, the dialog no longer appears, and your callback fires immediately with false, so you must detect that case and route the user to app settings with ACTION_APPLICATION_DETAILS_SETTINGS. Android 13 added POST_NOTIFICATIONS as a runtime permission, which caught a lot of Indian consumer apps off guard, because notification-driven retention silently dropped for users on new installs who never granted it. You must declare it in the manifest and request it, ideally at the moment the user does something that clearly implies wanting notifications rather than on first launch.
Android 13 also split READ_EXTERNAL_STORAGE into READ_MEDIA_IMAGES, READ_MEDIA_VIDEO and READ_MEDIA_AUDIO, and Android 14 added READ_MEDIA_VISUAL_USER_SELECTED for partial photo access, where the user grants only specific images. For most gallery use cases the right answer is to request nothing at all and use the Photo Picker, which needs no permission.
class MainActivity : AppCompatActivity() {
private val requestNotif = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (!granted && !shouldShowRequestPermissionRationale(POST_NOTIFICATIONS)) {
// Permanently denied: only Settings can fix it
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null)))
}
}
fun askForNotifications() {
if (Build.VERSION.SDK_INT < 33) return
val state = ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS)
if (state != PackageManager.PERMISSION_GRANTED) requestNotif.launch(POST_NOTIFICATIONS)
}
}Key Points
- Register ActivityResultLauncher as a field, before STARTED
- Two denials on Android 11+ equals permanent denial; route to app settings
- POST_NOTIFICATIONS is a runtime permission from Android 13
- Prefer the Photo Picker over READ_MEDIA_IMAGES where possible
Q12What is the difference between application Context and Activity Context, and where does that cause leaks?
BasicContext and Memory
Answer
Context is the handle to everything the framework provides: resources, system services, package information, starting components, and inflating layouts. The application Context lives for the process lifetime and has no theme or window attached. An Activity Context is tied to one activity instance, carries the activity theme, and is what you must use for anything that inflates a themed view, shows a dialog, or starts an activity without the NEW_TASK flag.
Use the wrong one and you get either a crash or a subtly wrong appearance: inflating a Material component with the application Context throws because the theme lacks the required attributes, and showing an AlertDialog needs a Context with a window token. The leak direction runs the other way. Any long-lived object that holds an Activity Context keeps the entire activity, its view hierarchy, and every bitmap in it alive after the activity is destroyed.
Classic offenders are a singleton initialised with the activity passed in, a static View or Drawable field, a Handler with a delayed Runnable posted from an inner class, a listener registered with a system service and never unregistered, and a custom callback stored on an object with a longer lifetime. The rule interviewers want: singletons and anything cached take the application Context, and anything with UI takes the Activity or Fragment view Context. Applying Kotlin idioms helps, an inner class in Kotlin is not implicitly a non-static inner class unless you write inner, so a plain nested class does not hold the outer reference.
// Leak: singleton holds the Activity forever
object AnalyticsBad {
lateinit var context: Context
fun init(c: Context) { context = c } // called with `this` from an Activity
}
// Correct: normalise to the application Context
class Analytics private constructor(private val app: Context) {
companion object {
@Volatile private var instance: Analytics? = null
fun get(c: Context): Analytics = instance ?: synchronized(this) {
instance ?: Analytics(c.applicationContext).also { instance = it }
}
}
}
// Themed UI still needs the Activity Context
MaterialAlertDialogBuilder(this) // Activity, never applicationContext
.setMessage(R.string.confirm_cancel)
.show()Key Points
- Application Context for singletons and caches; Activity Context for themed UI
- An AlertDialog needs a window token, so applicationContext crashes
- Static Views, Drawables, and unregistered listeners are classic leak sources
- Normalise with context.applicationContext at the boundary of any long-lived object
Q13Explain the four Activity launch modes and how they change the back stack.
BasicNavigation and Tasks
Answer
standard is the default: a new instance is created every time, even if one already exists in the task, so pressing back walks through each copy. singleTop reuses the existing instance only if it is already at the top of the task, delivering the new Intent to onNewIntent instead of onCreate. This is the right mode for a search results screen or a notification target that should not stack duplicates. singleTask creates the activity in a task rooted at its own taskAffinity, and if an instance already exists anywhere in that task, the system clears everything above it and delivers onNewIntent. This is the classic home or launcher screen mode. singleInstance is singleTask plus exclusivity: the activity is the only one in its task, and anything it launches goes to a different task.
It is rarely correct in an app and is normally reserved for things like an incoming call screen. The two follow-ups interviewers ask are: if you use singleTop or singleTask, you must override onNewIntent and call setIntent, otherwise getIntent keeps returning the original launch intent and your deep link silently opens the wrong screen. And launch modes can also be applied per-launch through Intent flags such as FLAG_ACTIVITY_CLEAR_TOP, FLAG_ACTIVITY_NEW_TASK and FLAG_ACTIVITY_SINGLE_TOP, which is often cleaner than baking a mode into the manifest because it keeps the decision at the call site. On Android 12 and above, back on the root activity moves the task to the background rather than destroying it, so you should stop overriding onBackPressed to call finish, and use OnBackPressedDispatcher instead.
<activity
android:name=".SearchActivity"
android:launchMode="singleTop"
android:exported="false" />
class SearchActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent) // critical, or getIntent() stays stale
handleQuery(intent.getStringExtra("q"))
}
}
// Per-launch alternative, decision stays at the call site
startActivity(Intent(this, HomeActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
})
// Android 13+ predictive-back friendly interception
onBackPressedDispatcher.addCallback(this) { showExitConfirmation() }Key Points
- standard stacks copies; singleTop reuses only at the top of the task
- singleTask clears activities above the existing instance in its task
- Always call setIntent inside onNewIntent or deep links break
- Prefer Intent flags at the call site over manifest launch modes
Q14What is the difference between dp, sp and px, and how do resource qualifiers pick the right values?
BasicResources and Layout
Answer
px is a physical pixel and should almost never appear in your code. dp (density-independent pixel) is a unit where 1 dp equals 1 px on a 160 dpi screen, and the framework multiplies it by the device density bucket (mdpi 1.0, hdpi 1.5, xhdpi 2.0, xxhdpi 3.0, xxxhdpi 4.0) so a 48 dp touch target is physically the same size on a budget device and a flagship. sp is dp scaled additionally by the user font-size preference, so all text sizes must be sp and everything else dp. Shipping text in dp is an accessibility failure that Play Store reviewers and accessibility audits flag, and it is very visible in India where a large share of users increase font size on the system settings. Recent Android versions apply non-linear font scaling at large scale factors, so hard-coded pixel maths around text is even less safe than it used to be.
Resource qualifiers let the system pick the right resource directory at runtime by appending a suffix: values-hi for Hindi, values-night for dark mode, values-sw600dp for tablets and unfolded foldables, drawable-xxhdpi for high-density bitmaps, layout-land for landscape. The system matches qualifiers in a fixed precedence order (locale beats density, for example) and falls back to the unqualified directory. In Compose you use Dp and TextUnit types directly, and the equivalent of qualifiers is WindowSizeClass plus BoxWithConstraints rather than parallel layout folders, which is one of the reasons Compose handles foldables and split-screen more gracefully.
<!-- res/values/dimens.xml -->
<dimen name="card_padding">16dp</dimen>
<dimen name="body_text">14sp</dimen>
<!-- res/values-sw600dp/dimens.xml : tablets and unfolded foldables -->
<dimen name="card_padding">32dp</dimen>
// Compose equivalent: adapt on window size class, not on folders
@Composable
fun ProductGrid(windowSize: WindowSizeClass) {
val columns = when (windowSize.widthSizeClass) {
WindowWidthSizeClass.Compact -> 2
WindowWidthSizeClass.Medium -> 3
else -> 4
}
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
contentPadding = PaddingValues(16.dp),
) { /* items */ }
}Key Points
- dp for everything, sp for text only, px essentially never
- Density buckets scale dp; user font preference scales sp
- Qualifiers like values-night, values-hi, values-sw600dp select resources at runtime
- In Compose, use WindowSizeClass instead of parallel layout folders
Q15Compare findViewById, View Binding, Data Binding and Compose for wiring up UI.
BasicUI Toolkits
Answer
findViewById does a runtime tree walk and returns a View you have to cast, so a typo in the id compiles fine and crashes at runtime with a NullPointerException or ClassCastException. View Binding fixes this: the Gradle plugin generates one binding class per layout file with correctly typed, non-null fields for every id, so wrong ids are compile errors, and layouts that only exist in one configuration produce a nullable field so you cannot forget the landscape case. It has essentially no runtime cost and is the right default for any remaining XML in a 2026 codebase.
Data Binding goes further with expressions inside XML, two-way binding with the at-equals syntax, and BindingAdapters, but it generates far more code, slows builds because it needs annotation processing, and puts logic into XML where it cannot be unit tested or stepped through in a debugger. Most teams that adopted it heavily have regretted it, and Google now recommends View Binding for XML and Compose for new UI. Compose replaces the whole inflate-and-bind model with functions that describe UI as a function of state, so there is no id to look up at all.
In a real migration you run both: a ComposeView inside an XML layout, or an AndroidView composable to host a legacy custom view or a map. Interviewers like to ask what happens to a ComposeView inside a RecyclerView row, and the correct answer includes setting a ViewCompositionStrategy such as DisposeOnViewTreeLifecycleDestroyed so compositions are not leaked across recycled holders.
// build.gradle.kts
android { buildFeatures { viewBinding = true; compose = true } }
// View Binding: typed, null-safe, no casts
private lateinit var binding: ActivityCartBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCartBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.checkoutButton.setOnClickListener { viewModel.checkout() }
}
// Hosting Compose inside a legacy XML screen
binding.composeHost.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent { MaterialTheme { CartSummary(viewModel.state.collectAsStateWithLifecycle().value) } }
}Key Points
- View Binding gives compile-time safe, typed references with no runtime cost
- Data Binding adds XML expressions but slows builds and hides logic
- Compose removes id lookup entirely; state drives the UI
- Use ComposeView with an explicit ViewCompositionStrategy in mixed screens
Q16What is the difference between an APK and an Android App Bundle, and what does Play App Signing do?
BasicBuild and Release
Answer
An APK is the installable artifact a device runs. An Android App Bundle (.aab) is a publishing format that contains all your compiled code and resources for every density, ABI and language, plus metadata, and Google Play generates and signs optimised split APKs per device from it. Play has required AAB for new apps since 2021, so uploading an APK to a new listing is simply not an option.
The practical benefit is download size: a user on a Snapdragon device with the English locale and xxhdpi screen does not download arm64 plus armeabi plus x86 native libraries, twelve translations, and five density buckets. For a media-heavy Indian consumer app that difference can be tens of megabytes, which matters directly for install conversion on metered connections. Play App Signing means Google holds the app signing key and you hold an upload key.
You sign the bundle with the upload key, Play verifies it, strips your signature, and re-signs the generated APKs with the real app signing key. The upside is that losing your upload key is recoverable through a support request, whereas before this existed, losing the signing key meant you could never update the app again. The practical consequences to know: your local debug build has a different signature from production, so anything keyed on the signing certificate hash (Google Sign-In, Maps API keys, App Links assetlinks.json, Play Integrity, most payment SDK integrations) needs both the debug SHA-256 and the Play-issued app signing SHA-256 registered. Use bundletool build-apks with a device spec to test the exact split APKs a device would receive before you ship.
# Build the release bundle
./gradlew :app:bundleRelease
# Generate the exact splits a connected device would receive
bundletool build-apks \
--bundle=app/build/outputs/bundle/release/app-release.aab \
--output=shop.apks --connected-device
bundletool install-apks --apks=shop.apks
# Inspect what a given device spec would download
bundletool get-size total --apks=shop.apks --dimensions=SDK,ABI,SCREEN_DENSITY,LANGUAGEKey Points
- AAB is a publishing format; Play generates per-device split APKs from it
- Play App Signing separates an upload key from the real app signing key
- Register both debug and Play app signing SHA-256 fingerprints with every SDK
- Test real splits with bundletool build-apks before release
Q17Which adb commands do you use to reproduce and inspect app state on a real device?
BasicTooling
Answer
adb is where most real Android debugging starts, and interviewers use it as a quick proxy for whether someone has actually shipped apps. The commands worth knowing cold: adb devices to confirm the target and adb -s to disambiguate when more than one is attached. adb logcat with a tag filter and a priority, for example adb logcat MyTag:D *:S, or adb logcat --pid=$(adb shell pidof -s com.example.shop) to see only your process. adb shell am start -n package/.Activity -a android.intent.action.VIEW -d to fire a deep link without building a test harness, which is how you verify App Links quickly. adb shell am kill and adb shell am force-stop for the two different kinds of termination, kill simulates the system reclaiming your background process, which is the only reliable way to test process-death restore. adb shell dumpsys activity activities to inspect the task and back stack, dumpsys meminfo package for a per-process memory breakdown, dumpsys jobscheduler and dumpsys deviceidle for background work debugging. adb shell pm grant and pm revoke to toggle a runtime permission without walking the UI. adb shell cmd package set-app-links-allowed and verify commands for App Links troubleshooting. adb shell setprop debug.firebase.analytics.app for analytics verification, adb shell input tap and input text for scripted repro, and adb shell screenrecord for capturing a bug for the ticket. Two device settings people forget: Developer Options has a Do not keep activities toggle and a Background process limit setting, both of which make process-death bugs reproducible on demand instead of appearing only in Crashlytics.
# Only your process in logcat
adb logcat --pid=$(adb shell pidof -s com.example.shop)
# Fire a deep link straight at the app
adb shell am start -a android.intent.action.VIEW \
-d "https://shop.example.in/order/8821" com.example.shop
# Simulate the system reclaiming the backgrounded process
adb shell am kill com.example.shop
# Inspect the task stack and memory
adb shell dumpsys activity activities | grep -A 20 com.example.shop
adb shell dumpsys meminfo com.example.shop
# Toggle a runtime permission without touching the UI
adb shell pm revoke com.example.shop android.permission.POST_NOTIFICATIONSKey Points
- am kill simulates background process death; force-stop does not
- am start with -d is the fastest way to test a deep link
- dumpsys activity activities, meminfo, jobscheduler and deviceidle cover most triage
- Do not keep activities in Developer Options makes state-restore bugs reproducible
Q18How do buildTypes, productFlavors and source sets combine into build variants?
BasicGradle
Answer
buildTypes describe how the app is built: debug and release exist by default, and teams commonly add a staging type. They control isMinifyEnabled, isShrinkResources, the signing config, applicationIdSuffix, debuggable, and any buildConfigField or resValue you inject. productFlavors describe what is built, grouped by flavorDimensions, for example an environment dimension with dev, staging and prod, or a distribution dimension with playstore and sideload. Gradle produces one variant per combination of every dimension and every build type, so two flavors across one dimension plus three build types gives six variants, which is why adding a third dimension casually can quadruple CI time.
Each variant gets source sets: src/main plus src/dev plus src/debug plus src/devDebug, all merged, with variant-specific sources taking precedence. That is how you ship a different API base URL, a different google-services.json, a different app icon and even a different implementation class per environment without any runtime if-statement. applicationIdSuffix is the small detail worth mentioning: giving dev builds a suffix like .dev means testers can install dev and prod side by side on one phone, which removes an enormous amount of friction for QA teams. buildConfigField generates constants into BuildConfig, and from recent AGP versions buildConfig has to be explicitly enabled under buildFeatures, which surprises people upgrading an older project. Secrets should not go into buildConfigField from a committed file, read them from a gitignored local properties file or the CI environment.
android {
buildFeatures { buildConfig = true }
flavorDimensions += "env"
productFlavors {
create("dev") {
dimension = "env"
applicationIdSuffix = ".dev" // installs alongside prod
buildConfigField("String", "BASE_URL", "\"https://api-dev.example.in/\"")
}
create("prod") {
dimension = "env"
buildConfigField("String", "BASE_URL", "\"https://api.example.in/\"")
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
// Variant-specific code lives in src/dev/java, src/prod/java, src/devDebug/resKey Points
- buildTypes are how you build, productFlavors are what you build
- Variants are the cross product, so dimensions multiply CI cost
- Source sets merge main plus flavor plus buildType plus the combination
- applicationIdSuffix lets dev and prod builds coexist on one device
Q19Compare viewModelScope, lifecycleScope and repeatOnLifecycle, and explain why launchWhenStarted is discouraged.
IntermediateCoroutines
Answer
viewModelScope is a CoroutineScope tied to the ViewModel, cancelled in onCleared, running on Dispatchers.Main.immediate by default. Use it for work that should survive rotation, such as a network call whose result belongs to the screen rather than to the view. lifecycleScope is tied to a LifecycleOwner and cancelled at onDestroy, so on a fragment you almost always want viewLifecycleOwner.lifecycleScope so it dies with the view. Neither of them stops work when the app goes to background, and that is the crux of the question.
If you collect a Flow in lifecycleScope.launch, the collector keeps running while the user is in another app, so you keep consuming location updates, keep holding a WebSocket open, and keep pushing UI updates into views nobody can see, draining battery and occasionally crashing on a state update to a stopped fragment. repeatOnLifecycle(Lifecycle.State.STARTED) solves this properly: it suspends the calling coroutine, launches the block when the lifecycle reaches STARTED, and cancels the block entirely when it drops below STARTED, restarting it on the next STARTED. The upstream Flow is cancelled and re-collected, which is what you want for cold flows. The older launchWhenStarted only pauses dispatch: the coroutine stays alive, the upstream producer keeps emitting, and emissions buffer up, so you get a burst of stale updates on resume and no battery saving at all.
Those APIs are deprecated for exactly that reason. In Compose the equivalent is collectAsStateWithLifecycle from lifecycle-runtime-compose, which applies the same repeatOnLifecycle semantics without the boilerplate, and it should be the default over plain collectAsState in any screen-level collection.
// Views: cancel collection below STARTED
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { viewModel.uiState.collect { render(it) } }
launch { viewModel.events.collect { handleEvent(it) } }
}
}
// Compose: same semantics, one line
@Composable
fun CartRoute(viewModel: CartViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
CartScreen(state = state, onCheckout = viewModel::checkout)
}
// Work that must outlive the screen does NOT belong in any UI scope
class CartViewModel(private val repo: CartRepo) : ViewModel() {
fun checkout() = viewModelScope.launch { repo.checkout() }
}Key Points
- viewModelScope dies in onCleared; lifecycleScope dies at onDestroy
- Plain collection keeps running in the background and burns battery
- repeatOnLifecycle cancels and restarts collection at the STARTED boundary
- launchWhenStarted only pauses dispatch and buffers stale emissions
Q20StateFlow, SharedFlow and LiveData: when do you reach for each?
IntermediateState Management
Answer
StateFlow is a hot flow that always holds exactly one current value, conflates emissions, and de-duplicates using equals, so setting the same value twice emits once. It is the correct type for screen state, because a new collector after rotation immediately receives the latest state. The equals de-duplication is the trap: if your state class is not a data class, or you mutate a list in place and reassign the same reference, the update is silently swallowed and the UI does not change.
SharedFlow is a hot flow with a configurable replay and buffer and no conflation or de-duplication, which makes it the right type for one-off events such as show a snackbar, navigate to checkout, or launch a payment sheet. Use MutableSharedFlow with replay 0 and extraBufferCapacity 1 plus BufferOverflow.DROP_OLDEST so an event fired while nothing is collecting does not deadlock tryEmit. The recurring production bug this solves: modelling a navigation event as StateFlow means it replays after rotation and the user is navigated twice.
LiveData is the older lifecycle-aware holder. It is main-thread-bound, automatically stops delivering below STARTED, and remains fine in legacy code, but it has no operators worth using, cannot be used cleanly in a pure Kotlin module, and has the same replay problem for events. New code in 2026 uses StateFlow plus collectAsStateWithLifecycle. A common senior-level refinement is stateIn with SharingStarted.WhileSubscribed(5000), which keeps the upstream alive for five seconds after the last collector unsubscribes so a rotation does not cancel and re-trigger the network call.
class CheckoutViewModel(repo: CartRepo) : ViewModel() {
// Screen state: conflated, replayed, de-duplicated
val uiState: StateFlow<CartUiState> = repo.observeCart()
.map(::toUiState)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), CartUiState.Loading)
// One-shot events: no replay, so rotation does not re-fire them
private val _events = MutableSharedFlow<CheckoutEvent>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val events: SharedFlow<CheckoutEvent> = _events.asSharedFlow()
fun pay() = viewModelScope.launch {
val result = repo.pay()
_events.emit(if (result.isSuccess) CheckoutEvent.Paid else CheckoutEvent.Failed)
}
}Key Points
- StateFlow always has a value, conflates, and de-duplicates via equals
- SharedFlow with replay 0 is the correct type for one-shot events
- Modelling navigation as StateFlow causes double navigation after rotation
- stateIn with WhileSubscribed(5000) survives rotation without re-fetching
Q21How do you distinguish a configuration change from process death, and how do you test the latter?
IntermediateState Restoration
Answer
A configuration change destroys and recreates the activity inside the same process. The ViewModelStore is retained, static state and singletons survive, Hilt-provided singletons survive, and in-memory caches are intact. Process death is the system reclaiming your entire process while the app is in the background: every object is gone, static fields reset, Hilt graph is rebuilt, and the only thing that survives is what was written to the saved instance state Bundle or to disk.
When the user returns, Android recreates the activity stack from the saved state, so onCreate receives a Bundle and the app looks like it was never killed, except every in-memory assumption is wrong. This is where the worst production bugs live, because they never reproduce on a developer machine with a flagship device and plenty of RAM. The failure signature is a crash right after the user returns from another app, often a lateinit property has not been initialized error or an NPE on a nullable field that was populated by a previous screen.
To test it deliberately: enable Do not keep activities in Developer Options for the activity-recreation half, and use adb shell am kill for real process death, which is different from force-stop because force-stop clears the task and does not exercise the restore path. Android Studio also has a Terminate Application button on the App Inspection or Logcat panel that performs the same background kill. The fix pattern is disciplined: identifiers and user input go into SavedStateHandle or rememberSaveable, never pass non-primitive objects through a companion object or a static holder between screens, and treat every screen as if it can be entered cold with only its arguments.
# Config change only
adb shell settings put global always_finish_activities 1
# Real process death: background the app first, then
adb shell am kill com.example.shop
# Now tap the app in Recents. onCreate gets a non-null Bundle,
# but the Hilt graph, singletons and in-memory caches are all fresh.
// Code that survives BOTH
@HiltViewModel
class OrderViewModel @Inject constructor(
private val repo: OrderRepo,
savedState: SavedStateHandle,
) : ViewModel() {
// route arg, restored automatically after process death
private val orderId: String = checkNotNull(savedState["orderId"])
val state = repo.observe(orderId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), OrderUi.Loading)
}Key Points
- Config change keeps the process; process death resets everything but the Bundle
- am kill exercises restore; force-stop clears the task and does not
- Never pass objects between screens through static or companion holders
- SavedStateHandle receives navigation arguments automatically
Q22When do you use WorkManager, and how do constraints, unique work and backoff actually behave?
IntermediateBackground Work
Answer
WorkManager is for deferrable work that must survive process death and reboot: uploading a queued order, syncing a local database, sending buffered analytics, compressing and uploading a KYC document. It persists work in its own SQLite database and dispatches through JobScheduler on modern API levels, so the OS decides when to run based on constraints and Doze state. It is not for immediate foreground work and not for exact-time alarms, which need AlarmManager with setExactAndAllowWhileIdle plus the SCHEDULE_EXACT_ALARM or USE_EXACT_ALARM permission depending on the use case.
Constraints (NetworkType.CONNECTED or UNMETERED, requiresCharging, requiresBatteryNotLow, requiresStorageNotLow) are hints the system honours before running your worker, and a job with restrictive constraints on a device in deep Doze may not run for hours, which is the correct behaviour and not a bug. Unique work is the API most teams get wrong. enqueueUniqueWork with a name plus an ExistingWorkPolicy (KEEP, REPLACE, APPEND, APPEND_OR_REPLACE) is how you prevent duplicate sync jobs when a user taps twice or an app is launched from three entry points. Without it you queue a new worker each time and hammer your backend.
Backoff is exponential or linear with a fifteen-second floor, applied when you return Result.retry, and the retry count is available as runAttemptCount so you can give up after N attempts and return Result.failure rather than retrying forever. Expedited work (setExpedited with OutOfQuotaPolicy) runs almost immediately using the foreground service quota, but the quota is limited per app and shrinks with the standby bucket, so treat it as a scarce resource.
@HiltWorker
class UploadKycWorker @AssistedInject constructor(
@Assisted ctx: Context,
@Assisted params: WorkerParameters,
private val api: KycApi,
) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val docId = inputData.getString("docId") ?: return Result.failure()
return try {
api.upload(docId); Result.success()
} catch (e: IOException) {
if (runAttemptCount >= 5) Result.failure() else Result.retry()
}
}
}
val request = OneTimeWorkRequestBuilder<UploadKycWorker>()
.setInputData(workDataOf("docId" to docId))
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork("kyc-upload-" + docId, ExistingWorkPolicy.KEEP, request)Key Points
- WorkManager is for deferrable, guaranteed work that survives reboot
- enqueueUniqueWork with a policy is what prevents duplicate sync jobs
- Result.retry uses exponential or linear backoff with a 15 second floor
- Expedited work draws on a limited quota tied to the app standby bucket
Q23What changed for foreground services in Android 14 and 15, and when is a foreground service still the right tool?
IntermediateBackground Work
Answer
A foreground service is for user-visible ongoing work the user is aware of and would notice stopping: music playback, an active navigation session, a live location share, an in-progress large upload, a call. It requires a notification, and since Android 13 the user can dismiss that notification for most types without stopping the service. From Android 14 (API 34) every foreground service must declare a foregroundServiceType in the manifest and pass a matching type to startForeground, and each type has its own required permission, for example FOREGROUND_SERVICE_LOCATION for location, FOREGROUND_SERVICE_DATA_SYNC for sync, FOREGROUND_SERVICE_MEDIA_PLAYBACK for playback.
Declaring a type you cannot justify is grounds for a Play policy rejection, and getting the pairing wrong throws a SecurityException or MissingForegroundServiceTypeException at runtime rather than degrading quietly. Android 15 added a timeout on dataSync and mediaProcessing foreground services: the system calls onTimeout after roughly six hours of cumulative runtime in a day, and you must stop the service or you get an ANR-style crash. That change specifically pushes long-running sync out of foreground services and into WorkManager.
There are also strict rules on starting a foreground service from the background: since Android 12 that throws ForegroundServiceStartNotAllowedException unless you are in one of the documented exemptions, such as responding to a high-priority FCM message, a geofence trigger, or an exact alarm. The pattern that works reliably is to start the service from a foreground context, or to use WorkManager with setForeground inside the worker so the framework handles the promotion for you.
<!-- Manifest: type plus its permission are both mandatory on API 34+ -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<service
android:name=".TripTrackingService"
android:exported="false"
android:foregroundServiceType="location" />
class TripTrackingService : LifecycleService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
ServiceCompat.startForeground(
this,
NOTIF_ID,
buildTripNotification(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION, // must match the manifest
)
return START_STICKY
}
// Android 15: dataSync / mediaProcessing get an onTimeout callback
override fun onTimeout(startId: Int, fgsType: Int) {
stopSelf()
}
}Key Points
- Android 14 requires a declared foregroundServiceType plus its matching permission
- Android 15 times out dataSync and mediaProcessing services after roughly six hours a day
- Background starts throw ForegroundServiceStartNotAllowedException outside documented exemptions
- Prefer WorkManager with setForeground for promotable long-running work
Q24What are the most common Android memory leaks and how do you find them with LeakCanary?
IntermediateMemory
Answer
The recurring leak patterns are all the same shape: something with a long lifetime holds a reference to something with a short lifetime. Concretely: a singleton or companion object holding an Activity, Fragment or View. A view binding field not nulled in onDestroyView, which retains the whole inflated hierarchy.
A non-static inner class or Kotlin lambda capturing the Activity and stored in a long-lived callback list. A Handler with a delayed postDelayed message, because the Message holds the Handler which holds the enclosing class. A listener registered with a system service, sensor manager, location client or broadcast receiver and never unregistered.
A coroutine launched in GlobalScope that captures a View. An AsyncTask or thread in legacy code still holding the Activity. A RxJava Disposable never disposed.
LeakCanary is added as a debugImplementation dependency and needs no code: it installs an ObjectWatcher that watches destroyed activities, fragments, fragment views, and ViewModels, forces a GC after a delay, and if the object is still reachable it dumps the heap with Shark and computes the shortest strong reference path from a GC root to the leaked object. The output names the exact field holding the reference, which is usually enough to fix it in minutes. For deeper analysis, use the Android Studio Memory Profiler to record allocations, capture a heap dump, and sort by shallow and retained size, or run the app under StrictMode with detectLeakedClosableObjects and detectActivityLeaks to catch leaks that LeakCanary does not watch. In production, Play Console vitals plus Crashlytics OutOfMemoryError clusters are the signal that a leak escaped review.
// build.gradle.kts
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
// Watch objects LeakCanary does not know about by default
AppWatcher.objectWatcher.expectWeaklyReachable(
presenter, "OrderPresenter was detached"
)
// Typical fix 1: unregister what you register
override fun onStop() {
sensorManager.unregisterListener(this)
super.onStop()
}
// Typical fix 2: no delayed messages outliving the view
private val handler = Handler(Looper.getMainLooper())
override fun onDestroyView() {
handler.removeCallbacksAndMessages(null)
_binding = null
super.onDestroyView()
}Key Points
- Every leak is a long-lived object holding a short-lived one
- LeakCanary reports the shortest strong reference path to the leaked instance
- Null view bindings, unregister listeners, clear delayed Handler messages
- StrictMode detectActivityLeaks and the Memory Profiler cover what LeakCanary misses
Q25How does Hilt build the dependency graph, and what is the difference between @Binds, @Provides and the component scopes?
IntermediateDependency Injection
Answer
Hilt is Dagger with a fixed set of Android components and generated boilerplate. @HiltAndroidApp on your Application class generates the SingletonComponent, the root of the graph. @AndroidEntryPoint on an Activity, Fragment, Service or BroadcastReceiver generates a base class that performs member injection at the correct lifecycle callback, and @HiltViewModel plus an @Inject constructor lets by viewModels() and hiltViewModel() resolve a ViewModel with its dependencies. For types you own, an @Inject constructor is enough. For types you do not own, such as Retrofit, OkHttp, a RoomDatabase or a Json instance, you write a @Module annotated with @InstallIn to say which component it belongs to. @Provides is a concrete function whose body constructs the object, which is what you need for builders. @Binds is an abstract function in an abstract module that simply maps an interface to an implementation, and it generates strictly less code because Dagger just aliases the binding rather than calling a factory.
Scopes decide instance lifetime: @Singleton lives in SingletonComponent, @ActivityRetainedScoped survives configuration change and is where ViewModel-adjacent state belongs, @ViewModelScoped is per ViewModel, @ActivityScoped and @FragmentScoped are per UI instance. Unscoped is the default and gives a fresh instance at each injection point, which is correct for stateless mappers and wrong for a cache. Two compile errors come up constantly.
A missing binding reports that the type cannot be provided without an @Provides-annotated method, which usually means you installed the module in the wrong component. A scope mismatch appears when a @Singleton binding depends on an @ActivityScoped one, because a longer-lived component cannot see a shorter-lived one. Use @ApplicationContext or @ActivityContext qualifiers rather than injecting a bare Context, use @HiltWorker with HiltWorkerFactory for WorkManager, and switch the compiler to KSP, which is meaningfully faster than kapt on large modules.
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun okHttp(): OkHttpClient = OkHttpClient.Builder().build()
@Provides
@Singleton
fun api(client: OkHttpClient): OrderApi = Retrofit.Builder()
.baseUrl("https://api.example.in/")
.client(client)
.build()
.create()
}
// @Binds: interface to implementation, no factory body generated
@Module
@InstallIn(SingletonComponent::class)
abstract class RepoModule {
@Binds
@Singleton
abstract fun orderRepo(impl: OrderRepositoryImpl): OrderRepository
}
@HiltViewModel
class OrderViewModel @Inject constructor(
private val repo: OrderRepository,
@ApplicationContext private val appContext: Context,
) : ViewModel()Key Points
- @HiltAndroidApp creates the root component; @AndroidEntryPoint injects at the right callback
- @Provides builds an object, @Binds only aliases an interface to an implementation
- @Singleton, @ActivityRetainedScoped, @ViewModelScoped decide instance lifetime
- A long-lived component cannot depend on a shorter-lived scope, which is a compile error
Q26How do you model navigation in Compose in 2026, and what breaks when arguments travel as strings?
IntermediateNavigation
Answer
Navigation Compose centres on a NavHost holding a NavController and a set of destinations. The modern form is type-safe routes: you declare a @Serializable data class or object per destination, register it with composable<OrderDetail>, navigate by passing an instance, and read the arguments back with backStackEntry.toRoute() or savedStateHandle.toRoute() inside the ViewModel. That replaces the older string-template approach where you wrote a route like order/{orderId} and hand-built the URL at the call site.
The string form breaks in predictable ways: any argument containing a slash, question mark, hash or ampersand silently fails to match the route pattern unless you URL-encode it, an argument you forget to declare in the arguments list comes back null, and a typo in the placeholder is a runtime navigation failure rather than a compile error. Type-safe routes turn all of that into compile-time checks. The other rules interviewers look for: pass identifiers, never whole objects, because the back stack is saved into a Bundle and survives process death only if the arguments are small and serializable.
Do not pass NavController down the tree, expose lambdas such as onOrderClick so screens stay previewable and testable. Control the stack explicitly with popUpTo, inclusive and launchSingleTop, which is how you stop a double tap from pushing two copies of a destination or how you clear the login flow after authentication. Scope a ViewModel to a nested graph when a multi-step flow shares state so it clears when the flow is popped.
Return a result to the previous screen through previousBackStackEntry.savedStateHandle rather than a shared singleton. Deep links are declared per destination with navDeepLink, and a deep link into a detail screen should synthesize a sensible back stack so the system back button does not exit the app.
@Serializable object Home
@Serializable data class OrderDetail(val orderId: String)
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(navController, startDestination = Home) {
composable<Home> {
HomeScreen(onOrderClick = { id -> navController.navigate(OrderDetail(id)) })
}
composable<OrderDetail>(
deepLinks = listOf(navDeepLink<OrderDetail>(basePath = "https://shop.example.in/order"))
) { OrderDetailRoute() }
}
}
@HiltViewModel
class OrderDetailViewModel @Inject constructor(
savedState: SavedStateHandle,
repo: OrderRepository,
) : ViewModel() {
private val args: OrderDetail = savedState.toRoute()
val state = repo.observe(args.orderId)
}
// Clear the auth flow instead of stacking on top of it
navController.navigate(Home) {
popUpTo<Login> { inclusive = true }
launchSingleTop = true
}Key Points
- Type-safe routes replace string templates and catch argument errors at compile time
- Pass ids, not objects: the back stack is persisted into a Bundle
- popUpTo, inclusive and launchSingleTop control duplicates and flow cleanup
- Return results through previousBackStackEntry.savedStateHandle, not a singleton
Q27How does Paging 3 work, and what does RemoteMediator add for an offline-first list?
IntermediatePaging
Answer
Paging 3 splits the problem into a PagingSource that knows how to load one page, a Pager that owns config and produces a Flow of PagingData, and a UI adapter that renders it. PagingSource.load receives LoadParams with a key and a loadSize and returns a Page carrying the items plus prevKey and nextKey, where null means no more data in that direction. getRefreshKey tells Paging where to restart after invalidation so the user does not lose their position. On the UI side, collectAsLazyPagingItems in Compose or a PagingDataAdapter in views handles the append trigger, and loadState exposes refresh, append and prepend states so you can show a full-screen spinner, an inline footer spinner, and a retry row from one source.
The single most important call is cachedIn(viewModelScope). Without it, every configuration change re-collects the flow and refetches page one, and collecting the same PagingData twice throws an error about collecting from the page event flow more than once. RemoteMediator is what makes the list work offline.
The PagingSource becomes Room, so the database is the single source of truth, and the mediator fires when the local data runs out: on LoadType.REFRESH you clear and insert, on APPEND you fetch the next page using a remote keys table, and you return MediatorResult.Success with endOfPaginationReached set correctly. Both the item insert and the remote key update must happen inside one Room transaction or a crash mid-write leaves the keys and rows out of sync and paging stalls. The classic production bug is duplicate or skipped rows with offset pagination when the backend list shifts between requests, which is why keyset or cursor pagination is worth pushing for on the API side.
class OrderPagingSource(private val api: OrderApi) : PagingSource<Int, Order>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Order> = try {
val page = params.key ?: 1
val res = api.orders(page = page, size = params.loadSize)
LoadResult.Page(
data = res.items,
prevKey = if (page == 1) null else page - 1,
nextKey = if (res.items.isEmpty()) null else page + 1,
)
} catch (e: IOException) {
LoadResult.Error(e)
}
override fun getRefreshKey(state: PagingState<Int, Order>): Int? =
state.anchorPosition?.let { state.closestPageToPosition(it)?.prevKey?.plus(1) }
}
val orders: Flow<PagingData<Order>> = Pager(
config = PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false),
pagingSourceFactory = { OrderPagingSource(api) },
).flow.cachedIn(viewModelScope) // without this, rotation refetches page 1Key Points
- PagingSource returns prevKey and nextKey; null means end of pagination
- cachedIn(viewModelScope) is mandatory or rotation refetches and the flow errors
- RemoteMediator writes into Room so the database stays the single source of truth
- Insert rows and remote keys in one transaction, and prefer cursor pagination server side
Q28How do you unit test a ViewModel that exposes StateFlow and runs coroutines?
IntermediateTesting
Answer
Three problems have to be solved: the main dispatcher does not exist on the JVM, delays would make tests slow, and a StateFlow built with WhileSubscribed does nothing without a collector. For the first, install a JUnit rule that calls Dispatchers.setMain with a TestDispatcher in before and Dispatchers.resetMain in after, otherwise viewModelScope throws because Dispatchers.Main has no Android looper. For the second, wrap the test body in runTest, which uses a virtual clock so a delay of thirty seconds completes instantly and advanceUntilIdle drains pending work.
StandardTestDispatcher queues coroutines until you advance, which is what you want when asserting on intermediate loading states, while UnconfinedTestDispatcher runs them eagerly, which is convenient but hides ordering bugs. For the third, either collect in backgroundScope, which runTest cancels for you at the end, or use Turbine, whose test block subscribes, gives you awaitItem, and fails the test if items are left unconsumed. Beyond the mechanics, the design point interviewers push on is that Dispatchers.IO must be injected rather than hardcoded, because a repository that calls withContext(Dispatchers.IO) internally cannot be made deterministic from a test.
Pass a dispatcher or a small dispatcher-provider interface through the constructor and substitute the test dispatcher. Prefer hand-written fakes over mocking frameworks for repositories, since a fake in-memory implementation exercises real Flow emission and stays readable. Use MockWebServer to test the Retrofit layer including error codes, malformed JSON and timeouts, and Robolectric when you genuinely need framework classes such as Uri or Resources on the JVM instead of a device.
class MainDispatcherRule(
private val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(d: Description) = Dispatchers.setMain(dispatcher)
override fun finished(d: Description) = Dispatchers.resetMain()
}
class CartViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
@Test
fun `checkout failure surfaces an error state`() = runTest {
val repo = FakeCartRepo(failWith = IOException("no network"))
val vm = CartViewModel(repo)
vm.uiState.test { // Turbine
assertEquals(CartUiState.Loading, awaitItem())
vm.checkout()
advanceUntilIdle()
assertTrue(awaitItem() is CartUiState.Error)
cancelAndIgnoreRemainingEvents()
}
}
}Key Points
- Dispatchers.setMain in a TestWatcher rule, or viewModelScope fails on the JVM
- runTest skips delay with a virtual clock; advanceUntilIdle drains queued work
- A WhileSubscribed StateFlow needs a collector, so use Turbine or backgroundScope
- Inject dispatchers instead of hardcoding Dispatchers.IO inside repositories
Q29How do Compose UI tests synchronise, and why does an infinite animation hang the test?
IntermediateTesting
Answer
createComposeRule gives you a host with no activity for pure composable tests, and createAndroidComposeRule parameterised with your activity is what you use when the screen needs a real Activity, Hilt injection or an intent. You find nodes through the semantics tree with onNodeWithText, onNodeWithContentDescription or onNodeWithTag, then assert with assertIsDisplayed, assertTextEquals or assertIsEnabled and act with performClick, performTextInput and performScrollToNode. Prefer text and content descriptions over test tags where possible, because a test that finds nodes the way a screen reader would also verifies accessibility.
When a component merges its children into one semantics node, pass useUnmergedTree so you can reach the inner text. The synchronisation model is the part interviewers actually probe. The test rule drives a test clock and waits for the composition to become idle before each assertion, which is why Compose tests usually need no sleeps or idling resources.
Idle means no pending recomposition, no pending measure or layout, and no running animation. A rememberInfiniteTransition, an indeterminate CircularProgressIndicator left on screen, or a looping Lottie animation therefore keeps the clock permanently busy, and the test hangs until it times out with a message about the composition never becoming idle. The fix is to set mainClock.autoAdvance to false and step the clock manually with advanceTimeBy, or to drive the animation from state the test controls.
Use waitUntil with a matcher for genuinely asynchronous work coming from outside Compose. For legacy view screens the equivalent is Espresso, which needs an IdlingResource for anything asynchronous, and for pixel-level regressions teams add screenshot testing on the JVM rather than trying to assert layout numerically.
@RunWith(AndroidJUnit4::class)
class CheckoutScreenTest {
@get:Rule val rule = createAndroidComposeRule<MainActivity>()
@Test
fun applyingAnInvalidCouponShowsAnError() {
rule.onNodeWithTag("coupon_field").performTextInput("BADCODE")
rule.onNodeWithText("Apply").performClick()
rule.waitUntil(timeoutMillis = 5_000) {
rule.onAllNodesWithText("Coupon not valid").fetchSemanticsNodes().isNotEmpty()
}
rule.onNodeWithText("Coupon not valid").assertIsDisplayed()
}
@Test
fun loaderDoesNotHangTheClock() {
rule.mainClock.autoAdvance = false // infinite animation on screen
rule.onNodeWithTag("refresh").performClick()
rule.mainClock.advanceTimeBy(600)
rule.onNodeWithTag("skeleton", useUnmergedTree = true).assertIsDisplayed()
}
}Key Points
- The rule waits for composition, layout and animations to be idle before each assertion
- Infinite animations never go idle, so set mainClock.autoAdvance to false
- useUnmergedTree reaches children inside a merged semantics node
- Espresso needs IdlingResource for async work; Compose usually does not
Q30How does image loading blow up memory on Android, and what does Coil do about it?
IntermediateMemory
Answer
A decoded bitmap costs width times height times bytes per pixel, and the default ARGB_8888 config is four bytes per pixel. A 4000 by 3000 camera photo is therefore about 48 MB in memory regardless of the fact that the JPEG on disk was 2 MB. Load three of those into a list on a 3 GB device and you get an OutOfMemoryError, which in Play Console vitals shows up as a crash cluster on exactly the budget devices that dominate the Indian install base.
The fix is to never decode at full resolution: downsample to the size of the view. Coil does this automatically because it knows the target dimensions, which is why an AsyncImage inside a fixed-size composable is safe while an ImageView with wrap_content is not. Coil holds a two-level cache: a memory cache sized as a percentage of available app memory, backed by both strong and weak references, and a disk cache keyed off the URL and the request.
It runs on coroutines, cancels the request automatically when the composable leaves composition or the view detaches, and integrates with OkHttp so it shares your connection pool and interceptors. Points worth raising in an interview: since Android 8 bitmap pixel data lives in the native heap, so a Java heap dump can look healthy while the process is being killed for native memory, and you need the Memory Profiler native view or Perfetto to see it. Hardware bitmaps are fast and memory-efficient but cannot be read back with getPixels and break some canvas operations, so libraries let you disable them per request. Placeholders should be lightweight drawables, and for a shared element transition you reuse the memory cache key rather than reloading.
// Fixed size means Coil decodes at display resolution, not source resolution
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(product.imageUrl)
.crossfade(true)
.placeholder(R.drawable.product_placeholder)
.memoryCacheKey(product.id)
.build(),
contentDescription = product.title,
contentScale = ContentScale.Crop,
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(8.dp)),
)
// Application-level loader: share OkHttp, cap the caches explicitly
val loader = ImageLoader.Builder(context)
.memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.20).build() }
.diskCache { DiskCache.Builder().directory(context.cacheDir.resolve("images")).maxSizeBytes(64L * 1024 * 1024).build() }
.build()Key Points
- ARGB_8888 costs four bytes per pixel, so full-resolution photos are tens of megabytes
- Always give the loader a target size; wrap_content images decode at source resolution
- Bitmap pixels live in the native heap since Android 8 and hide from Java heap dumps
- Coil cancels requests on detach and shares OkHttp, memory cache and disk cache
Q31Why replace SharedPreferences with DataStore, and what are the failure modes of each?
IntermediateStorage
Answer
SharedPreferences loads the entire XML file into memory on first access and does that load synchronously on whatever thread asked for it, so a large preferences file touched during startup is a real StrictMode disk read violation and a real contributor to slow cold starts. commit writes synchronously and blocks the caller, and apply looks asynchronous but the pending write is drained by QueuedWork during onPause and onStop, which is a documented source of ANRs when many writes are queued. There is no error signal at all: if the write fails you never find out, and there is no transactional guarantee across multiple keys. DataStore fixes these properties.
Preferences DataStore keeps the key-value shape with typed keys, Proto DataStore gives you a schema defined in protobuf with real types and defaults. Both expose a Flow so reads are observable and always off the main thread, and both write through a suspending edit block that is transactional and reports failures as exceptions you can catch, typically IOException. Corruption is handled explicitly through a CorruptionHandler instead of crashing on next read, and SharedPreferencesMigration moves existing keys across on first access so users do not lose their settings.
The gotchas: create exactly one DataStore per file, usually via a property delegate on Context, because a second instance for the same file throws an IllegalStateException about multiple active DataStores. There is no synchronous read, so code that needs a flag before the first frame has to either block with runBlocking, which risks an ANR, or restructure to gate the UI on the first emission. DataStore is not a database, so anything relational, queryable or large belongs in Room, and anything secret needs encryption on top since the file is plain on disk.
// One instance per file, at file scope
val Context.settings: DataStore<Preferences> by preferencesDataStore(
name = "settings",
produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "legacy_prefs")) },
)
private val KEY_CITY = stringPreferencesKey("city")
private val KEY_DARK = booleanPreferencesKey("dark_mode")
class SettingsRepository(private val context: Context) {
val city: Flow<String> = context.settings.data
.catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e }
.map { it[KEY_CITY] ?: "Delhi" }
suspend fun setDarkMode(enabled: Boolean) {
context.settings.edit { prefs -> prefs[KEY_DARK] = enabled } // transactional
}
}Key Points
- SharedPreferences apply drains through QueuedWork at onPause, a known ANR source
- DataStore reads are a Flow off the main thread and writes are transactional
- One DataStore per file or you get IllegalStateException about multiple instances
- No synchronous read, so restructure startup instead of wrapping it in runBlocking
Q32In FCM, what is the practical difference between a notification payload and a data payload?
IntermediatePush Notifications
Answer
If the message contains a notification object and the app is in the background or killed, the system tray renders the notification itself and onMessageReceived is never called, so any custom logic, analytics, deduplication or database write you put there silently does not run. If the app is in the foreground, the same message does invoke onMessageReceived. A data-only message always goes to onMessageReceived in both states, and you build the notification yourself, which is why almost every serious app sends data-only messages and treats the display as its own responsibility.
The cost of data-only is that delivery is subject to Doze and App Standby unless the message is sent with high priority, which temporarily lets the app run. High priority is not free: Google monitors apps that mark everything high priority without showing a notification and can downgrade them. Channels are mandatory since Android 8, you must create them before posting, and importance is fixed at creation, so shipping a channel at IMPORTANCE_LOW and then wanting heads-up alerts means creating a new channel id, not editing the old one.
Android 13 added the POST_NOTIFICATIONS runtime permission, and a large share of new installs never grant it, so any funnel that depends on push needs a fallback. Tokens rotate, so implement onNewToken, upload the token on login, and delete it on logout, since a stale token on a shared device delivers another user notifications. Payload size is capped at 4 KB, so push carries identifiers, not content: fetch the real object from your API when the message arrives. Finally, on many Indian OEM builds an app swiped away from Recents may stop receiving messages entirely until reopened, so push is a delivery hint and never a source of truth.
class ShopMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
// Rotates on reinstall, restore, and periodically
applicationScope.launch { api.registerPushToken(token) }
}
override fun onMessageReceived(message: RemoteMessage) {
// Data-only payload, so this runs in background and foreground alike
val orderId = message.data["orderId"] ?: return
val channel = NotificationChannel(
CH_ORDERS, "Order updates", NotificationManager.IMPORTANCE_HIGH,
)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
val notif = NotificationCompat.Builder(this, CH_ORDERS)
.setSmallIcon(R.drawable.ic_stat_order)
.setContentTitle(message.data["title"])
.setContentIntent(deepLinkPendingIntent(orderId))
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(this).notify(orderId.hashCode(), notif)
}
private companion object { const val CH_ORDERS = "orders" }
}Key Points
- A notification payload bypasses onMessageReceived when the app is backgrounded
- Data-only messages need high priority to survive Doze, and that quota is watched
- Channel importance cannot be changed after creation, only replaced by a new channel
- Push carries ids within a 4 KB limit; fetch the real record from your API
Q33How do Android App Links differ from a custom scheme deep link, and how do you debug verification failures?
IntermediateDeep Links
Answer
A custom scheme link such as myapp://order/8821 is trivial to declare and equally trivial for another app to claim, because nothing verifies ownership, so the user sees a disambiguation chooser or the wrong app opens. It also does nothing in a browser or a WhatsApp message where the string is not recognised as a link. Android App Links are http and https intent filters with android:autoVerify set to true, backed by a Digital Asset Links file served at https://yourdomain/.well-known/assetlinks.json that lists your package name and the SHA-256 fingerprint of the signing certificate.
When verification succeeds the system opens your app directly with no chooser, which is the behaviour product teams actually want for campaign and share links. The failure everyone hits at least once is registering the fingerprint of the local upload key instead of the app signing key that Play App Signing uses, so verification passes on a debug build and fails for every real user. Other common causes: the file returns a redirect, is served with the wrong content type, sits behind Cloudflare rules that block the verifier, or the domain in the manifest differs from the one that actually serves the file, for example www versus apex.
Since Android 12 verification is stricter and one failing host inside an autoVerify filter fails the whole set, so keep hosts in separate filters. To debug, use adb shell pm get-app-links on your package to see per-host state, force a re-check with adb shell pm verify-app-links --re-verify, and inspect Settings, Apps, Open by default. On Navigation, attach navDeepLink to the destination and synthesize a back stack so the system back button lands on a real screen instead of leaving the app.
<!-- One host per filter, so a single failure does not fail them all -->
<activity android:name=".MainActivity" android:exported="true">
<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.example.in" />
</intent-filter>
</activity>
// https://shop.example.in/.well-known/assetlinks.json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.shop",
"sha256_cert_fingerprints": ["<PLAY APP SIGNING SHA-256, not the upload key>"]
}
}]
# Debug on device
adb shell pm get-app-links com.example.shop
adb shell pm verify-app-links --re-verify com.example.shopKey Points
- Custom schemes are unverified and can be hijacked; App Links prove domain ownership
- assetlinks.json must carry the Play app signing SHA-256, not the upload key
- Redirects, wrong content type and www versus apex mismatches break verification
- pm get-app-links and pm verify-app-links --re-verify are the debugging commands
Q34Where do you store an auth token on Android, and what is actually protecting it?
IntermediateSecurity
Answer
Start from the honest position: nothing on a rooted or instrumented device is safe, and the goal is to raise the cost of extraction rather than to claim secrecy. A token in plain SharedPreferences sits in an XML file inside the app sandbox, which is protected from other apps by the Linux user id but readable through root, through a device backup if allowBackup is left on, and through any debuggable build. The Android Keystore is the real primitive: keys are generated inside the trusted execution environment or a StrongBox secure element, the private material never enters your process, and you get a Cipher handle rather than the key bytes.
Generate an AES key with KeyGenParameterSpec using GCM and no randomised encryption padding, store the initialisation vector alongside the ciphertext, and keep the ciphertext in DataStore or a file. Two Keystore behaviours are worth naming: setUserAuthenticationRequired binds the key to a recent unlock or a biometric prompt, and enrolling a new fingerprint invalidates such keys, which throws KeyPermanentlyInvalidatedException on the next use, so you must catch it and force re-authentication rather than crash. The androidx.security-crypto wrapper that provided EncryptedSharedPreferences has been deprecated, so new code goes to the Keystore directly. Around the token, several things matter as much as the storage: never put API secrets in BuildConfig, strings.xml or the source, because anyone can unpack the APK in seconds and they are then permanently burned; set android:allowBackup to false or use backup rules that exclude credential files; keep refresh tokens short-lived and revocable server side; and pin certificates through a network security config or OkHttp CertificatePinner with a backup pin and a rotation plan, because a pin that outlives its certificate bricks every installed copy of the app.
private const val KEY_ALIAS = "auth_token_key"
fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(ks.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
gen.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(false)
.build()
)
return gen.generateKey()
}
fun encrypt(plain: ByteArray): Pair<ByteArray, ByteArray> {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
return cipher.iv to cipher.doFinal(plain) // persist both
}Key Points
- Keystore keys live in the TEE or StrongBox and never enter your process memory
- Biometric enrolment invalidates auth-bound keys; catch KeyPermanentlyInvalidatedException
- Secrets in BuildConfig or strings.xml are extractable from the APK and are burned
- Certificate pinning needs a backup pin and a rotation plan or it bricks the app
Q35How do you build an offline-first screen where Room is the single source of truth?
IntermediateArchitecture
Answer
The rule is that the UI never observes the network. The ViewModel collects a Flow from Room, the repository is responsible for keeping Room fresh, and a network response is just a write into the database that the existing Flow picks up. That inversion removes a whole class of bugs: there is no moment where the list on screen and the cached list disagree, rotation and process death cost nothing because the data is on disk, and a user on a patchy 4G connection in a metro basement still sees their last known orders instead of a spinner.
A read looks like this: emit the cached rows immediately, kick off a refresh, write the mapped entities inside a transaction, and let the Flow emit again. Errors are surfaced as a separate signal, never by clearing the cache, because wiping good data on a timeout is the most user-visible mistake in this pattern. Two design details interviewers push on.
First, distinguish empty from unknown: a screen that has never synced is not the same as a screen with zero orders, so keep a synced-at timestamp per collection and drive the empty state off it. Second, writes need their own queue. A user tapping Place Order offline should get a row inserted locally with a pending status plus a WorkManager job carrying an idempotency key, so a retry after a socket timeout does not create a duplicate order server side.
Conflict resolution should be explicit, usually last-write-wins on a server updatedAt field, and you should keep DTO, entity and domain models separate so a backend field rename does not ripple into the UI layer. Keep the mapping in the repository and expose only domain types upward.
class OrderRepositoryImpl @Inject constructor(
private val dao: OrderDao,
private val api: OrderApi,
private val workManager: WorkManager,
) : OrderRepository {
// UI observes the database, never the network
override fun observeOrders(): Flow<List<Order>> =
dao.observeAll().map { rows -> rows.map(OrderEntity::toDomain) }
override suspend fun refresh(): Result<Unit> = runCatching {
val remote = api.orders()
dao.replaceAll(remote.map { it.toEntity() }) // one @Transaction
}
// Offline write: local row first, then a durable, idempotent job
override suspend fun placeOrder(draft: Draft) {
val localId = UUID.randomUUID().toString()
dao.insert(draft.toEntity(id = localId, status = Status.PENDING))
workManager.enqueueUniqueWork(
"place-order-" + localId,
ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<PlaceOrderWorker>()
.setInputData(workDataOf("localId" to localId))
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.build(),
)
}
}Key Points
- The UI collects from Room; the network only writes into Room
- A failed refresh surfaces an error signal and never clears cached rows
- Track synced-at so you can tell never-loaded apart from genuinely empty
- Queue offline writes with WorkManager plus an idempotency key to avoid duplicates
Q36What did edge-to-edge enforcement and predictive back change, and how do you handle window insets correctly?
IntermediateUI and Recent Platform Changes
Answer
From Android 15, apps targeting API 35 are drawn edge to edge by default: your content extends behind the status bar and the gesture navigation bar, statusBarColor and navigationBarColor are deprecated and ignored, and the temporary opt-out attribute is itself ignored once you target API 36 on Android 16. If you do nothing, the visible symptom is a bottom CTA sitting underneath the gesture pill and a header text tucked under the clock, which is exactly the kind of regression that lands in store reviews within hours of a rollout. The correct handling is to call enableEdgeToEdge from androidx.activity in onCreate and then consume insets where they matter rather than globally.
In Compose that means Scaffold contentPadding, Modifier.windowInsetsPadding with WindowInsets.safeDrawing, and Modifier.imePadding for anything above the keyboard. In views it means ViewCompat.setOnApplyWindowInsetsListener, reading systemBars, displayCutout and ime insets from WindowInsetsCompat, and applying them as padding on the right container. Applying insets in two places double pads, so decide on one owner per edge.
The keyboard is a separate inset type: adjustResize plus imePadding is the pairing that works, and WindowInsets.isImeVisible lets you react to it. Predictive back is the second recent change. Set android:enableOnBackInvokedCallback to true in the manifest, stop overriding onBackPressed, which is deprecated, and register an OnBackPressedCallback or use PredictiveBackHandler in Compose so the user gets the animated peek of the previous screen while dragging. A callback that is enabled when it should not be swallows the gesture and makes the app feel broken, so keep its enabled flag driven by real state such as whether a bottom sheet is open.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() // androidx.activity
super.onCreate(savedInstanceState)
setContent { ShopTheme { AppRoot() } }
}
}
@Composable
fun CheckoutScreen(state: CartUiState, onPay: () -> Unit) {
Scaffold(
bottomBar = {
Button(
onClick = onPay,
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))
.imePadding(),
) { Text("Pay " + state.total) }
}
) { inner -> CartList(state, Modifier.padding(inner)) }
}
// Views: apply insets on exactly one owner per edge
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.updatePadding(top = bars.top, bottom = bars.bottom)
insets
}Key Points
- Targeting API 35 forces edge-to-edge; the opt-out is ignored once you target API 36
- Consume safeDrawing and ime insets at one owner per edge to avoid double padding
- statusBarColor and navigationBarColor are deprecated and no longer applied
- Predictive back needs enableOnBackInvokedCallback and OnBackPressedCallback, not onBackPressed
Q37An app takes 4.5 seconds to cold start on a mid-range phone. How do you diagnose and fix it?
AdvancedStartup Performance
Answer
Cold start is process creation, Application.onCreate, activity creation, first layout and first frame. Play Console vitals flags cold start above five seconds as bad behaviour, so 4.5 seconds is already close to the line on the devices most Indian users actually own. Start by measuring, not guessing.
The Displayed line in logcat gives a quick number, reportFullyDrawn gives the honest number that includes your first real content, and Macrobenchmark with StartupTimingMetric gives a repeatable measurement across iterations with CompilationMode controlled, which is the only figure worth putting in a ticket. Then capture a Perfetto or system trace of the first two seconds and look at the main thread. The usual offenders are all the same shape: work done eagerly in Application.onCreate, a chain of library ContentProviders each doing initialisation before your Application even runs, synchronous SharedPreferences or file reads, an analytics or crash SDK initialising on the main thread, a dependency graph that constructs Retrofit, Room and a Json parser before the first frame, and a splash screen artificially held by setKeepOnScreenCondition while a network call completes.
Fixes in order of payoff: move non-essential initialisation behind Jetpack App Startup or a lazy first-use path, consolidate library providers, make the first screen render from cached data instead of waiting on the network, and shrink the first layout so measure and layout are cheap. Then add a Baseline Profile. The baselineprofile Gradle plugin runs a Macrobenchmark journey, records the classes and methods on the startup path, and ships them so ART ahead-of-time compiles them at install instead of interpreting and JIT-ing on the first runs. It is one of the few changes that improves startup and scroll jank across the whole install base without touching product code, and Play also aggregates a cloud profile once you are live.
// build.gradle.kts (app)
plugins { id("androidx.baselineprofile") }
dependencies { baselineProfile(project(":baselineprofile")) }
// :baselineprofile module
@RunWith(AndroidJUnit4::class)
class StartupProfile {
@get:Rule val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(packageName = "com.example.shop") {
pressHome()
startActivityAndWait()
device.findObject(By.res("product_list")).fling(Direction.DOWN)
}
}
// Measure before and after, on a real device, release build
@Test
fun coldStart() = benchmarkRule.measureRepeated(
packageName = "com.example.shop",
metrics = listOf(StartupTimingMetric()),
compilationMode = CompilationMode.Partial(),
startupMode = StartupMode.COLD,
iterations = 10,
) { pressHome(); startActivityAndWait() }Key Points
- Measure with Macrobenchmark and reportFullyDrawn, not the Displayed log line alone
- Library ContentProviders run before Application.onCreate; consolidate them with App Startup
- Never block the splash on a network call; render from cache and refresh in place
- Baseline Profiles give ART pre-compiled startup paths at install time
Q38Play Console shows a 1.2% ANR rate. Walk through how you find the cause.
AdvancedANR and Jank
Answer
An ANR fires when the main thread cannot service something in time: roughly five seconds for input dispatch, about ten seconds for a foreground broadcast receiver, and around twenty seconds for a foreground service to finish starting. Android vitals treats a user-perceived ANR rate above 0.47 percent as bad behaviour and can suppress your store visibility, so 1.2 percent is a release blocker, not a backlog item. Start in Play Console, because it clusters ANRs by the top frame of the main thread stack and tells you which devices, Android versions and screens are affected.
That clustering usually splits into recognisable buckets. Blocked on I/O means a disk or SharedPreferences read on the main thread. Blocked on a lock means a background thread holds a monitor the main thread needs, and the trace shows both stacks so you can name the offending synchronized block.
Blocked in a binder transaction means a system call to another process, commonly PackageManager, ContentResolver or a cross-process SDK. Nothing on the main thread stack at all usually points at the process being starved: GC thrash, an oversubscribed thread pool, or a device that was already thermally throttled. Inside the app, ApplicationExitInfo from API 30 upward is the strongest tool, because on the next launch you can read why the previous process died, and for REASON_ANR you get the actual trace to attach to your own logging.
Locally, StrictMode with detectDiskReads, detectNetwork and penaltyDeath in debug turns future ANRs into immediate crashes during development, and a Perfetto trace shows the exact frame where the main thread stalled. The fixes are unglamorous: move I/O to Dispatchers.IO, never runBlocking on the main thread, keep BroadcastReceiver onReceive trivial and hand off to a worker with goAsync or WorkManager, and initialise SDKs lazily.
// 1. Read why the previous process died, on the next cold start
val am = getSystemService(ActivityManager::class.java)
am.getHistoricalProcessExitReasons(packageName, 0, 5).forEach { info ->
if (info.reason == ApplicationExitInfo.REASON_ANR) {
val trace = info.traceInputStream?.bufferedReader()?.readText()
crashReporter.log("previous_anr", trace.orEmpty())
}
}
// 2. Make main-thread I/O fail loudly in debug builds
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads().detectDiskWrites().detectNetwork()
.penaltyLog().penaltyDeath()
.build()
)
}
// 3. Receivers must return fast; extend the window explicitly if needed
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
scope.launch { try { syncNow() } finally { pending.finish() } }
}Key Points
- Vitals treats a user-perceived ANR rate above 0.47 percent as bad behaviour
- Cluster by top frame: main-thread I/O, lock contention, binder stalls or process starvation
- ApplicationExitInfo REASON_ANR gives you the previous process trace in your own logs
- goAsync or WorkManager for receivers; StrictMode penaltyDeath catches regressions early
Q39A release build crashes but debug works. How do you debug R8 and shrinking problems?
AdvancedBuild and Release
Answer
R8 does three things at once: it removes unreachable code and resources, it renames what remains, and in full mode, which is the default in current AGP versions, it applies more aggressive assumptions about what can be reached. Anything discovered by reflection at runtime is invisible to that analysis, so the failure signature is a release-only crash: a ClassNotFoundException, a NoSuchMethodException, a Gson or Moshi object with every field null, a Retrofit call failing to create a converter, or an enum lookup by name returning nothing because the constant was renamed. First step is always to make the stack trace readable: R8 writes mapping.txt into build outputs, the AAB carries it to Play automatically, and retrace turns an obfuscated trace back into real names.
Never disable obfuscation to debug, because that changes the very thing you are investigating. Then narrow it. Set minifyEnabled true but obfuscation off temporarily to separate a shrinking problem from a renaming problem, and use printusage and printseeds outputs to see exactly what was removed and what was kept.
Most third-party libraries ship consumer keep rules, so if a library-owned class is being stripped the usual cause is your own reflective access or a custom serialiser. Write the narrowest rules you can: keep the specific model package with its members for reflective serialisation, keep attributes such as Signature, InnerClasses and any runtime annotations your libraries read, and keep the classes named in the manifest or in XML. Broad rules like keeping your whole application package defeat the point and inflate the APK.
Resource shrinking is a separate pass that also needs care: anything you resolve with getIdentifier by string name is unreachable to the analysis, so it needs a keep entry in a raw keep file. Finally, run the release build through your instrumentation suite in CI, because that is what catches these before users do.
# proguard-rules.pro : narrow rules, not a blanket keep
-keepattributes Signature, InnerClasses, RuntimeVisibleAnnotations, AnnotationDefault
# kotlinx.serialization models reached reflectively by the plugin runtime
-keep,includedescriptorclasses class com.example.shop.data.dto.** { *; }
# Enum valueOf is a reflective lookup
-keepclassmembers enum * { public static **[] values(); public static ** valueOf(java.lang.String); }
# See what was removed and why
-printusage build/outputs/mapping/release/usage.txt
-printseeds build/outputs/mapping/release/seeds.txt
# Deobfuscate a Play Console trace locally
retrace build/outputs/mapping/release/mapping.txt crash.txt
<!-- res/raw/keep.xml : resources resolved by getIdentifier -->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/badge_*,@string/city_*" />Key Points
- Full-mode R8 assumes no reflection, so reflective models must be kept explicitly
- mapping.txt plus retrace is how you read an obfuscated production stack trace
- Separate shrinking from obfuscation to localise the failure, and read usage.txt
- Resource shrinking misses getIdentifier lookups; declare them in a raw keep file
Q40A Compose list drops frames while scrolling. How do you find and fix unnecessary recomposition?
AdvancedCompose Performance
Answer
First rule: measure a release build with R8 on a real device, because a debug build runs Compose without optimisations and the numbers are meaningless. Then find out what is actually recomposing. Layout Inspector shows per-composable recomposition and skip counts live, and the Compose compiler metrics report tells you, per composable, whether it is restartable and skippable, and per class whether it is considered stable.
Instability is the usual root cause. A composable can only be skipped when all of its parameters are stable, and a parameter is unstable when it is an interface type such as List, when the class has a var property, or when it comes from a module that is not compiled with the Compose compiler. So a screen passing List<Order> down to a hundred rows can be forced to recompose the whole tree whenever the parent recomposes.
Fixes are to use ImmutableList from kotlinx.collections.immutable, mark genuinely immutable domain types with @Immutable, or add third-party classes to a stability configuration file. Recent Compose compiler releases enable strong skipping, which lets composables with unstable parameters skip based on instance equality and memoises lambdas automatically, so a codebase upgrading into it often sees this class of problem shrink. The second cause is reading state too early.
If a scroll offset or an animated value is read in a composable rather than inside a lambda, the whole subtree recomposes every frame. Defer the read into Modifier.offset with a lambda or Modifier.graphicsLayer so only layout or draw reruns. Use derivedStateOf when high-frequency state feeds a low-frequency boolean, such as showing a scroll-to-top button. For lists specifically, always supply a stable key in items, provide contentType for heterogeneous rows, avoid nesting a scrollable inside another scrollable, and confirm the fix with a Macrobenchmark FrameTimingMetric rather than a subjective judgement.
// Unstable: List is an interface, so this row never skips
@Composable fun Row(order: Order, tags: List<String>) { }
// Stable: immutable collection plus an @Immutable domain type
@Immutable
data class Order(val id: String, val total: Long, val tags: ImmutableList<String>)
@Composable
fun OrderList(orders: ImmutableList<Order>, listState: LazyListState) {
// derivedStateOf: recomposes only when the boolean flips, not every scroll pixel
val showTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
LazyColumn(state = listState) {
items(orders, key = { it.id }, contentType = { "order" }) { OrderRow(it) }
}
if (showTop) ScrollToTopButton()
}
// Turn on the compiler metrics report to see skippability per composable
// composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_reports") }Key Points
- Only composables with all-stable parameters can skip; List and var fields are unstable
- Compose compiler metrics and Layout Inspector name the composables that never skip
- Defer state reads into lambda modifiers so layout or draw reruns instead of composition
- Stable keys and contentType in LazyColumn, then verify with FrameTimingMetric
Q41Sync works on a Pixel but never runs on Xiaomi and Vivo devices. What is happening and what do you do?
AdvancedBackground Execution
Answer
Two separate layers are at work. The first is stock Android: Doze defers jobs and alarms when the device is stationary and unused, App Standby Buckets rank your app from active through working set, frequent, rare and restricted based on usage, and a lower bucket means fewer job windows and fewer high-priority push messages. If a user installs your app and never opens it, the OS is correct to run your sync rarely.
The second layer is OEM behaviour, and this is the one that dominates the Indian market, where Xiaomi, Vivo, Oppo and Realme builds together account for a large share of installs. Those ROMs add their own aggressive process management: an app swiped from Recents may be fully killed rather than cached, WorkManager jobs can be dropped, alarms are deferred beyond stock behaviour, and on several ROMs a manual autostart toggle is required before background work runs at all. None of this is exposed through a public API, so you cannot detect it reliably, and the same app version behaves differently on two phones in the same room.
What you can do is design for it. Treat every background job as best effort and always sync opportunistically on app open, so a user who never grants anything still gets correct data. Use WorkManager rather than raw JobScheduler so retries and reboot persistence are handled for you, and use unique work so repeated triggers do not pile up.
For genuinely user-visible ongoing work, use a foreground service with the correct type, which OEM killers respect far more than a background job. Use a high-priority FCM message as a wake path when the server knows something changed. You can point the user at battery settings with ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS, but Play policy restricts requesting the exemption directly to a narrow set of use cases, so an in-app explainer that deep links to settings is the safer pattern. Finally, instrument it: log sync attempts and successes by manufacturer so you can prove the gap instead of arguing about it.
// Best effort background sync, plus a guaranteed sync on open
val periodic = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"sync", ExistingPeriodicWorkPolicy.KEEP, periodic,
)
// Never rely on it alone
class HomeViewModel @Inject constructor(private val repo: OrderRepository) : ViewModel() {
init { viewModelScope.launch { repo.refresh() } }
}
// Measure the gap rather than guessing
analytics.log(
"background_sync_result",
mapOf(
"manufacturer" to Build.MANUFACTURER,
"sdk" to Build.VERSION.SDK_INT,
"bucket" to usageStatsManager.appStandbyBucket,
"ok" to result.isSuccess,
),
)Key Points
- Doze plus App Standby Buckets already restrict jobs on stock Android
- Xiaomi, Vivo, Oppo and Realme ROMs add undetectable, stricter process killing
- Always sync on app open; treat background work as best effort
- Foreground services and high-priority FCM are the reliable wake paths
Q42Crashlytics shows OutOfMemoryError concentrated on 2 GB and 3 GB devices. How do you triage it?
AdvancedMemory
Answer
Read the exception text first, because it states the allocation that failed and the free space at the time, and a failure to allocate a very large contiguous block points at a bitmap or a huge array rather than a slow leak. Next, check the heap ceiling: ActivityManager.getMemoryClass reports the per-app Java heap limit for that device, which is far smaller on entry-level hardware than on a flagship, and largeHeap in the manifest raises it at the cost of longer GC pauses and a higher chance of the low memory killer choosing you. It is a stopgap, not a fix.
Then split leak from load. A leak grows steadily across navigation and shows up in LeakCanary in debug and in a heap dump sorted by retained size. Load is a single moment of excess: decoding a full-resolution photo, holding an entire API response as a list of thousands of objects, an unbounded in-memory cache, or a WebView on a media-heavy page.
Bitmaps are the most common cause and they are also the hardest to see, because since Android 8 pixel data lives in the native heap, so a Java heap dump can look small while the process gets killed anyway. Use the Memory Profiler native tab, dumpsys meminfo for a PSS breakdown, or a Perfetto trace with memory counters. ApplicationExitInfo is again useful in production: on the next launch you can see whether the previous process ended with REASON_LOW_MEMORY, which tells you the process was reclaimed rather than crashed.
The fixes are bounded caches sized as a fraction of the memory class, downsampled image decoding, paging instead of loading whole collections, releasing heavy references in onTrimMemory and onStop, and never caching bitmaps in a singleton. Treat onTrimMemory as a hint rather than a contract, since the framework has narrowed which levels it delivers, and verify the fix by running the flow on a genuinely low-end device rather than an emulator with generous RAM.
// Size caches to the device, never to a hardcoded number
val am = getSystemService(ActivityManager::class.java)
val heapMb = am.memoryClass // largeHeap: am.largeMemoryClass
val cacheBytes = (heapMb * 1024 * 1024) / 8
val thumbCache = object : LruCache<String, Bitmap>(cacheBytes) {
override fun sizeOf(key: String, value: Bitmap) = value.allocationByteCount
}
// Decode at the size you will display, not the size of the file
fun decodeSampled(path: String, reqW: Int, reqH: Int): Bitmap {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
var sample = 1
while (bounds.outWidth / sample > reqW || bounds.outHeight / sample > reqH) sample *= 2
return BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })!!
}
// Was the last process killed for memory, or did it crash?
am.getHistoricalProcessExitReasons(packageName, 0, 1).firstOrNull()?.let {
if (it.reason == ApplicationExitInfo.REASON_LOW_MEMORY) crashReporter.log("lmk_kill", it.description.orEmpty())
}Key Points
- The OOM message names the failed allocation size, which separates bitmaps from leaks
- getMemoryClass is the real ceiling; largeHeap trades GC pauses for headroom
- Native-heap bitmap memory is invisible in a Java heap dump since Android 8
- ApplicationExitInfo REASON_LOW_MEMORY confirms a low memory killer termination
Q43A clean build takes 11 minutes across 40 modules. How do you restructure the build?
AdvancedBuild Performance
Answer
Measure before touching anything. A Gradle build scan or the profile report tells you configuration time, task execution time, and which tasks dominate, and the answer is usually one of three things: annotation processing, an over-connected module graph, or configuration work running on every invocation. For annotation processing, kapt is the single biggest cost in most Kotlin codebases because it generates Java stubs for every module that uses Dagger, Room or Moshi.
Migrating those processors to KSP, which reads Kotlin directly, typically removes a large slice of build time, and Hilt, Room and Moshi all support it. For the module graph, the rule is that api leaks a dependency to every consumer while implementation does not, so a single misplaced api on a core module means changing one file recompiles most of the project. Draw the graph, push shared types into a small pure-Kotlin model module, and make feature modules depend on interfaces rather than on each other.
Depth matters less than fan-in: the module everything depends on is the one that must stay tiny and stable. For configuration, enable the configuration cache and the build cache, keep buildSrc small or replace it with an included build of convention plugins, because a change in buildSrc invalidates everything, and move dependency coordinates into a version catalog so upgrades are one file and not forty. Avoid resolving dependencies at configuration time and avoid reading files or running git commands in build scripts, since both defeat the configuration cache.
On CI, a remote build cache shared between agents usually beats any local optimisation, and running the release variant only on the branches that need it saves more than any micro-tuning. Finally, keep the JVM heap for Gradle and Kotlin daemons explicitly set, because default sizing on a loaded CI agent causes GC thrash that looks like a slow compiler.
# gradle.properties
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC
kotlin.incremental=true
// KSP instead of kapt for the common processors
plugins { id("com.google.devtools.ksp") }
dependencies {
ksp(libs.hilt.compiler)
ksp(libs.room.compiler)
}
// api vs implementation decides how far a change ripples
dependencies {
api(project(":core:model")) // types appear in this module public API
implementation(project(":core:network")) // hidden from consumers
}
# See where the time actually goes
./gradlew :app:assembleRelease --scan
./gradlew :app:assembleRelease --profileKey Points
- Migrate kapt processors to KSP; stub generation dominates most Kotlin builds
- api leaks dependencies downstream, implementation does not, and that drives recompilation
- Configuration cache plus convention plugins in an included build, not a fat buildSrc
- A shared remote build cache on CI usually beats any local tuning
Q44How would you harden a payments screen against tampering, overlays and repackaging?
AdvancedSecurity
Answer
Begin by accepting that the client is untrusted, so every rule that matters (amount, eligibility, coupon validity, final charge) is enforced on the server, and the app is only a presentation layer. Client hardening then becomes about raising the cost of specific attacks. Repackaging and emulator abuse are addressed by the Play Integrity API, which returns a signed verdict describing whether the app binary matches what Play distributed, whether the account has a Play install history, and whether the device passes basic integrity.
Verify that token on your backend, never in the app, and treat a failing verdict as a signal for step-up verification rather than a hard block, because false negatives on rooted developer devices and some regional ROMs are real. Overlay attacks matter for payments: another app drawing a transparent window over your Pay button can capture or redirect a tap, so set android:filterTouchesWhenObscured on the confirm control or check the obscured flag in the touch event, and set FLAG_SECURE on the window to block screenshots and screen recording of card entry. Keep every sensitive component non-exported, use explicit intents for internal navigation, and use a PendingIntent with FLAG_IMMUTABLE so a receiving app cannot rewrite the payload, which is mandatory from API 31 anyway.
If any part of the flow is a WebView, disable JavaScript unless required, never register a JavaScript interface that exposes app internals, and refuse to load a URL that is not on an allowlist. Pin certificates with a backup pin, disable cleartext traffic in the network security config, obfuscate with R8 so string constants and class names are not trivially readable, and instrument the flow so an unusual pattern of failed integrity checks is visible in your dashboards rather than discovered in a chargeback report.
<!-- Confirm button must ignore taps delivered through an overlay -->
<Button
android:id="@+id/payButton"
android:filterTouchesWhenObscured="true"
android:text="@string/pay_now" />
// Block screenshots and screen recording on the card entry screen
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
}
// Request an integrity token, then verify it server side
val manager = IntegrityManagerFactory.createStandard(context)
val token = manager.requestIntegrityToken(
StandardIntegrityTokenRequest.builder().requestHash(orderHash).build()
).await()
api.startPayment(orderId, integrityToken = token.token())
// Immutable PendingIntent: receivers cannot rewrite the extras
PendingIntent.getActivity(
context, 0, Intent(context, OrderActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)Key Points
- Enforce every money rule server side; the client only presents state
- Play Integrity verdicts must be verified on your backend, not parsed in the app
- filterTouchesWhenObscured and FLAG_SECURE defend against overlays and capture
- FLAG_IMMUTABLE PendingIntents and non-exported components close intent-level holes
Q45How do you roll out a risky Android release and detect a regression before it reaches everyone?
AdvancedRelease Engineering
Answer
Android releases are slow to reverse, which is the whole reason this question gets asked. You cannot recall an installed APK, and even a halted rollout leaves the bad build on every device that already updated, so the plan has to be staged and instrumented up front. Move through internal testing, then closed and open testing tracks, then a staged production rollout starting at a small percentage and expanding only when the metrics hold.
Watch two families of signals. Android vitals gives you the objective ones: user-perceived crash rate, ANR rate, excessive wakeups, slow cold start and slow frames, compared against the previous release and against the bad behaviour thresholds where roughly 1.09 percent crash rate and 0.47 percent ANR rate put your store visibility at risk. Crashlytics gives you the fast ones: velocity alerts, new fatal issues attributed to the version code, and non-fatal counts on the paths you care about.
Alongside those, your own funnel metrics matter more than either, because the worst regressions are silent: a checkout that no longer completes crashes nothing. Every meaningful new feature ships behind a remote flag so you can turn it off without a release, and the flag has to be fetched early enough to matter and cached so the first launch after install is not undefined. Keep the rollback path real: a halt plus a fast follow-up build with an incremented version code, because a lower version code cannot be shipped.
Use the in-app update API for critical fixes, immediate mode for a security or payments break and flexible mode otherwise, since a large share of users will otherwise sit on the broken build for weeks. Finally, keep version codes, mapping files and native symbols uploaded for every build, or your first production stack trace will be unreadable exactly when speed matters.
// Kill switch fetched at startup, with a safe cached default
val remoteConfig = Firebase.remoteConfig.apply {
setDefaultsAsync(mapOf("upi_intent_flow_enabled" to false))
setConfigSettingsAsync(remoteConfigSettings { minimumFetchIntervalInSeconds = 3600 })
}
remoteConfig.fetchAndActivate()
if (remoteConfig.getBoolean("upi_intent_flow_enabled")) NewUpiFlow() else LegacyUpiFlow()
// Force the fix onto users still on a broken build
val updateManager = AppUpdateManagerFactory.create(context)
updateManager.appUpdateInfo.addOnSuccessListener { info ->
val critical = info.updatePriority() >= 4
if (info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) && critical
) {
updateManager.startUpdateFlowForResult(info, AppUpdateType.IMMEDIATE, activity, RC_UPDATE)
}
}
# Native symbols so NDK traces are readable in Play Console
./gradlew :app:bundleRelease # AAB carries mapping.txt and debug symbolsKey Points
- Staged rollout plus vitals and Crashlytics comparison against the previous version code
- Silent regressions show up in funnel metrics, not in crash dashboards
- Every risky feature needs a remote kill switch with a safe cached default
- Rollback means halting and shipping a higher version code, so keep in-app updates wired
Frequently Asked Questions
What does an Android developer earn in India in 2026?
The working band is ₹6-24 LPA. Freshers and one-year developers at service companies usually land ₹4-8 LPA, mid-level engineers with three to five years of Kotlin, Compose and coroutines sit around ₹12-20 LPA, and senior or lead roles at product companies such as Flipkart, Swiggy, PhonePe, CRED, Dream11 and Meesho go well past that once you can own performance, release engineering and architecture. The clearest pay separator is not years of experience, it is whether you can show measurable work: a startup time you cut, an ANR rate you brought down, a crash cluster you closed.
How long does it take to prepare for an Android interview?
For a working Android developer, four to six weeks of focused evenings is realistic: two weeks revisiting lifecycle, process death, coroutines and Compose state, two weeks on performance, memory and background work, and the rest on system design for mobile plus your own project stories. Someone switching from Java and XML to Kotlin and Compose should plan three to four months, because Compose changes how you reason about state rather than just changing the syntax. Building and shipping one real app to Play is worth more than any amount of reading, because most interview follow-ups start from something that went wrong in production.
How do fresher and experienced Android interviews differ?
Fresher rounds are definition-driven: lifecycle callbacks, the four components, RecyclerView, Retrofit basics, and a small coding problem. You are expected to have one or two apps you can explain end to end. From three years onward the questions invert: interviewers stop asking what an API does and start asking what happened when it failed for you. Expect an ANR trace to read, a memory leak to explain, a background job that does not fire on a Xiaomi device, and a design question about offline sync or modularisation. At senior level you are also assessed on release process, rollout safety and how you argue technical trade-offs with a product team.
Is native Android still worth learning in 2026 with Flutter and React Native around?
Yes, and the demand is concentrated where the money is. Cross-platform frameworks handle a lot of content and commerce apps well, but payments, media, camera, maps, background reliability, deep OEM behaviour and anything needing platform performance still get built natively, and even Flutter and React Native teams need someone who can write the platform channel and debug the crash under it. In India specifically, fintech, quick commerce, gaming and streaming companies keep dedicated native Android teams. The safest position is native Kotlin depth plus enough familiarity with one cross-platform stack to work alongside it.
Should I still learn XML views if I am starting with Jetpack Compose?
Learn Compose first, then enough of the view system to be dangerous. Compose is where new screens are written, and interviews for greenfield teams assume it. But almost every real Indian product app of any age is a hybrid: a Compose feature module living beside hundreds of XML screens, a legacy custom view hosted in an AndroidView, a ComposeView inside a RecyclerView row. You will be asked about ViewCompositionStrategy, about interop with fragments, and about migrating a screen without a rewrite. Knowing RecyclerView, ListAdapter, DiffUtil and View Binding is still expected at mid-level and above.
How much Kotlin depth do Android interviews actually test?
Enough that Kotlin questions are effectively part of the Android round. Coroutines and structured concurrency come up in almost every mid-level interview: scopes, cancellation, supervisor jobs, exception propagation and Flow operators. Beyond that, expect sealed interfaces and when-exhaustiveness for UI state, data classes and equality (which is what makes StateFlow de-duplication work), delegates, inline and reified functions, and null-safety design. Kotlin Multiplatform is a bonus rather than a requirement in most Indian job descriptions today, though teams sharing logic with an iOS app increasingly ask about it, so it is a good differentiator once your core Android depth is solid.
Introduction
Android hiring in 2026 looks nothing like it did five years ago. Kotlin is the only language most teams will write new code in, Jetpack Compose is the default UI toolkit on greenfield screens, coroutines and Flow have replaced callbacks and RxJava in the majority of codebases, and Hilt plus Room plus Retrofit form a near-universal baseline stack. At the same time the platform itself has tightened aggressively: background execution limits, scoped storage, foreground service types, notification permissions, and edge-to-edge enforcement all break apps that were written against older assumptions. An Android interview now tests whether you understand those constraints, not just whether you can inflate a layout.
Indian interviews for Android roles have a recognisable shape. Round one is fundamentals: lifecycle, process death, Context, launch modes, and the difference between a configuration change and a cold start. Round two goes into concurrency and state, where interviewers at Flipkart, Swiggy, PhonePe, CRED and Dream11 will push on repeatOnLifecycle, StateFlow versus SharedFlow, and what happens to an in-flight coroutine when the user backgrounds the app. Round three is almost always production reality: an ANR trace, an OutOfMemoryError on a 3 GB device, a WorkManager job that never fires on a Xiaomi phone, or a Play Console vitals regression you have to explain and fix.
This guide covers 45 Android interview questions asked in 2026, ordered from fundamentals to senior-level systems questions. Most carry a Kotlin code example that shows the exact API being discussed, and every answer includes the production failure mode that makes the topic worth asking about in the first place. Work through the basic section to firm up lifecycle, Compose state and Gradle configuration, then spend real time on the intermediate and advanced sections, because coroutine scoping, recomposition cost, R8 keep rules, cold start optimisation and OEM background restrictions are the areas where senior Android offers in India are actually won or lost.
Ready to practice Android interviews?
Don't just read, practice these Android questions live with an AI interviewer that asks follow-ups and scores your answers.