Cracking Android SDE2/SDE3 Interviews in 2026: Deep Dives, Code, Follow-ups

 



Lifecycle & Components

1. Activity lifecycle states?

Interviewer:
 Walk me through the full Activity lifecycle and where you’d place data syncs.

Candidate:
 Absolutely. The Activity lifecycle defines how Android creates, displays, pauses, and destroys a screen. Correct usage ensures good performance, no leaks, and a smooth user experience.

  • onCreate()
     Called once when the Activity is created.
     Used for one-time initialization such as:
  • Dependency injection (Hilt)
  • Initializing ViewModels
  • Setting the UI with setContentView() or setContent {}
     The UI is not yet visible, so no user interaction or heavy work should start here.
  • onStart()
     The Activity becomes visible to the user but is not yet interactive.
     This is a good place to prepare UI-related resources or start observing data.
  • onResume()
     The Activity is in the foreground and fully interactive.
     This is the best place to:
  • Start sensors, camera, or location updates
  • Perform foreground data synchronization
  • Resume animations or real-time updates
     Any work here should stop automatically when the user leaves the screen.
  • onPause()
     Called when the Activity is about to lose focus.
     Used to pause or suspend ongoing work such as:
  • Camera or AR previews
  • Sensor listeners
     This method must execute quickly to avoid ANRs.
  • onStop()
     The Activity is no longer visible.
     Used to stop or release UI-related resources like media playback.
  • onDestroy()
     Final cleanup when the Activity is destroyed.
     Used to release remaining resources, but it is not reliable for saving critical data because the system may kill the process without calling it.

Where I place data syncs?

In a high-scale production app (for example, a 500M DAU app like Zomato), I start foreground data syncing when the Activity is resumed, so it runs only while the user is actively viewing the screen and stops automatically when the screen is paused.

@AndroidEntryPoint
class OrdersActivity : ComponentActivity() {
@Inject lateinit var repo: OrderRepository
private val vm by viewModels<OrderVm>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { OrdersScreen(vm.orders.collectAsState()) }
}
override fun onResume() {
super.onResume()
lifecycleScope.launchWhenResumed {
repo.liveOrders.collectLatest { vm.updateOrders(it) } // Prod foreground sync
}
}
override fun onPause() {
super.onPause()
// Pause AR previews etc.
}
}

Using launchWhenResumed ensures that:

  • Data collection starts only when the Activity is active
  • Collection is automatically cancelled when the Activity is paused
  • No unnecessary background work or memory leaks occur

Performance and reliability considerations

  • P99 onResume < 80ms
     This means that for 99% of users, the onResume() execution completes in under 80 milliseconds, ensuring a fast and smooth user experience.
  • onDestroy is unreliable during OOM kills
     Because the system may terminate the process without calling onDestroy(), critical state is persisted using:
  • SavedStateHandle
  • DataStore
  • Improvement trade-off
     repeatOnLifecycle(Lifecycle.State.STARTED) is often preferred over launchWhenResumed because it handles configuration changes more safely.
  • Testing approach
     Lifecycle behavior is validated using:
  • ActivityScenario for lifecycle testing
  • Turbine for verifying Kotlin Flow emissions
By aligning data syncing and resource usage with the Activity lifecycle, we ensure optimal performance, battery efficiency, and correctness, even at large scale.

2. Config change handling?

Interviewer:
 How do you ensure no state loss on rotation or multi-window?

Candidate:
 Configuration changes such as screen rotation, multi-window, or foldable posture changes cause the Activity to be recreated, which means all Activity fields are lost. To prevent state loss, I use a layered, battle-tested state management approach:

Core principle

UI state should not live in the Activity.

My state management stack

  • ViewModel
     Holds UI state in memory and survives configuration changes like rotation.
     Best for data needed while the process is alive.
  • SavedStateHandle
     Lightweight, Bundle-backed storage.
     Used for small, critical UI state that must survive configuration changes and short-term process recreation.
     Fast and size-limited (recommended under ~1MB).
  • Proto DataStore
     Disk-based persistence.
     Used for process death scenarios (app swiped away, low-memory kill).
     Ensures state is restored even when the app is relaunched.

UI strategy

I follow an optimistic UI approach:

  • Render immediately from cached or in-memory state
  • Trigger full async reload in the background
     This ensures fast visual restore and good user experience.
@HiltViewModel
class CartViewModel @Inject constructor(
private val stateHandle: SavedStateHandle,
private val prefs: DataStore<CartPrefs>
) : ViewModel() {
private val _cartItems = MutableStateFlow<List<CartItem>>(emptyList())
val cartItems: StateFlow<List<CartItem>> = _cartItems.asStateFlow()
val totalCount get() = stateHandle.getLiveData<Int>("total_count", 0)
fun add(item: CartItem) = viewModelScope.launch {
val updated = _cartItems.value + item
_cartItems.value = updated
stateHandle["total_count"] = updated.size // Config survive
prefs.updateData { it.copy(totalItems = updated.size) } // Death-proof
}
init {
viewModelScope.launch {
val persistedCount = prefs.data.map { it.totalItems }.first()
stateHandle["total_count"] = persistedCount
}
}
}

What this achieves

  • Rotation-safe: ViewModel and SavedStateHandle restore UI instantly
  • Process-death safe: DataStore restores persisted state
  • Fast restore: Visible UI restored within P99 < 40ms
  • No duplicated API calls
  • No user-visible flicker

Common pitfalls and how I handle them

  • Complex objects in SavedStateHandle
     Avoid storing large or complex objects.
     Use Proto DataStore schemas instead.
  • Foldables / multi-window devices
     Handle posture-specific UI state using DisplayManager and keep state scoped per posture where needed.
By separating transient UI state, configuration-safe state, and process-death state across ViewModel, SavedStateHandle, and DataStore, I ensure zero state loss across rotation, multi-window, and system-initiated kills.

3. ViewModel destruction timing?

Interviewer:
 Precisely when is a ViewModel destroyed, and how do you manage shared state?

Candidate:
 A ViewModel is destroyed only when its ViewModelStoreOwner is permanently destroyed and calls clear() on its ViewModelStore.

This means:

  • A ViewModel does NOT die on configuration changes (like rotation)
  • It does die when the owning scope is truly finished

When exactly is a ViewModel destroyed?

A ViewModel is cleared when its owner is permanently removed, for example:

  • Activity
  • finish()
  • finishAffinity()
  • Fragment
  • Removed via FragmentTransaction.remove()
  • Popped permanently from back stack
  • Navigation graph
  • Nav graph is popped (NavController.popBackStack(graphId, true))

Internally, this happens when:

ViewModelStoreOwner.clear() is called

What a ViewModel survives

  • ✅ Configuration changes (rotation, multi-window)
  • ✅ Temporary backgrounding
  • ✅ Soft low-memory pressure

The ViewModel instance is retained in heap memory as long as the owner is recreated.

What a ViewModel does NOT survive

  • ❌ Process death
  • ❌ Permanent owner destruction

That’s why persistent storage (DataStore / DB) is still needed for critical data.

Managing shared state across multiple Fragments

Shared cart example (Navigation Graph–scoped ViewModel)

For shared state (like a cart) across multiple fragments in the same flow, I scope the ViewModel to the navigation graph:

// In CartFragment and CheckoutFragment
private val cartVm by navGraphViewModels<CartViewModel>(
R.id.shopping_graph
)

This ensures:

  • One shared instance
  • Lives as long as the navigation graph is active
  • Automatically cleared when the graph is popped

Custom ViewModelStore for special cases

For components like dialogs or dynamic features, I explicitly manage the ViewModelStore lifecycle to prevent leaks.

// In CheckoutFragment & CartFragment
private val cartVm by navGraphViewModels<CartViewModel>(R.id.shopping_graph)

class FeatureDialogFragment : DialogFragment() {
private lateinit var customStore: ViewModelStore
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
customStore = ViewModelProvider(this)[FeatureVm::class.java].viewModelStore
}
override fun onDestroy() {
super.onDestroy()
if (isRemoving && !isChangingConfigurations) {
customStore.clear() // Prod prevent orphans
}
}
}

Why this matters

  • Dialogs can outlive their UI unexpectedly
  • Explicit clearing avoids memory leaks
  • Ensures predictable ViewModel destruction

Verification

  • Heap Profiler confirms 0 leaked ViewModels
  • ViewModel instances are released exactly when expected

Common pitfalls and how I handle them

  • Floating dialogs
     Scope ViewModels to the parent FragmentManager, not the dialog itself.
  • Navigation back stack pops
     Use nav-graph–scoped ViewModels, which are automatically cleared when the graph is popped.
A ViewModel is destroyed only when its ViewModelStoreOwner is permanently cleared. By scoping ViewModels correctly — to Activities, Fragments, or Navigation graphs — and explicitly clearing custom stores when needed, I ensure shared state works correctly with zero memory leaks.

4. Foreground vs Bound Service?

Interviewer:
 Explain foreground, bound, and background Services — use cases and pitfalls.

Candidate:
 Android Services run work without a UI, but since Android Oreo, their usage is heavily restricted. I choose the type based on user awareness, lifecycle guarantees, and battery impact.

Foreground Service

What it is:
 A Service that runs with a persistent notification, making the user explicitly aware.

Characteristics:

  • Must call startForeground() quickly
  • Visible to the user via notification
  • Less restricted by Doze and background limits

Typical use cases:

  • Music playback
  • VOIP calls
  • Active navigation
  • Fitness tracking

Pitfalls:

  • Notification is mandatory
  • Limited quota per app (per UID)
  • Misuse leads to Play Store rejection

Bound Service

What it is:
 A client–server model where components bind to a Service and communicate via IPC.

How it works:

  • Clients call bindService()
  • onBind() returns an IBinder
  • Allows method calls (RPC-style communication)

Typical use cases:

  • Sharing a long-lived resource
  • Cross-process communication
  • Service APIs used by multiple components

Pitfalls:

  • Clients must handle disconnections
  • Service dies when all clients unbind
  • Complex lifecycle and threading model

Background Service

What it is:
 A Service that runs without user visibility.

Modern reality (post-Oreo):

  • Severely restricted
  • Limited execution window (≈ 1 minute)
  • Killed aggressively by the system

Use case today:

  • Very short, non-UI work only
  • Generally discouraged

Our team’s production policy

We avoid custom Services entirely.

Instead, we use WorkManager, which is:

  • Lifecycle-aware
  • Doze-safe
  • Boot-resilient
  • Retry-capable
  • Battery-optimized

Example: Location sync using WorkManager

@HiltWorker
class LocationSyncWorker @AssistedInject constructor(
@Assisted ctx: Context, @Assisted params: WorkerParameters,
private val locationUseCase: LocationSyncUseCase
) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result = supervisorScope {
try {
locationUseCase.syncLocations(runAttemptCount)
Result.success()
} catch (e: Exception) {
if (runAttemptCount >= BACKOFF_MAX) Result.failure(e) else Result.retry()
}
}
}

val request = OneTimeWorkRequestBuilder<LocationSyncWorker>()
.setConstraints(Constraints(requiresLocation = true, requiresNetwork = true))
.setBackoff(BackoffPolicy.EXPONENTIAL, 1, MINUTES)
.setInputData(workDataOf("priority" to "high"))
.build()
WorkManager.enqueueUniqueWork("location_sync", KEEP, request)

Why this approach works in production

  • Automatic retries with exponential backoff
  • System-managed scheduling
  • Survives reboot and Doze
  • Constraint-based execution saves battery
  • No manual lifecycle handling

Real-world constraints & handling

  • Foreground Service quota
     Android allows ~5 concurrent foreground services per UID
     We monitor usage and maintain 98% success rate
  • Bound Service crashes
     Handled via:
  • onServiceDisconnected()
  • Exponential reconnect logic
  • Battery optimization
     Constraints reduce unnecessary execution, resulting in ~25% battery savings
Foreground Services are for user-visible ongoing work, Bound Services for IPC-style shared functionality, and Background Services are largely obsolete. In modern Android, WorkManager is the safest and most scalable solution for deferred and retryable background work.

5. Application vs Activity Context leak risks?

Interviewer:
 Why do leaks commonly occur with Activity Context, and how do you fix them in production?

Candidate:
 Leaks happen when an Activity Context is retained beyond the Activity’s lifecycle. Since an Activity Context owns the entire view hierarchy, holding a reference to it prevents the Activity and all its Views from being garbage-collected.

Why Activity Context leaks are dangerous

An Activity Context references:

  • The decor view
  • All child Views
  • Drawables, bitmaps, animations
  • Window and theme resources

If an Activity Context is stored in:

  • static fields
  • Singletons
  • Long-lived objects

➡️ The entire UI tree leaks, not just the Activity.

Why Application Context is safer

The Application Context:

  • Is process-wide
  • Lives as long as the app process
  • Does not reference UI or Views

Safe use cases:

  • Retrofit
  • Room
  • WorkManager
  • DownloadManager
  • Analytics
  • Event buses

Production fix: App-scoped Event Bus pattern

In production, I ensure that singletons never hold Activity Contexts. Instead, they use the Application Context and weak references for UI listeners.

@Singleton
class GlobalEventBus @Inject constructor(
@ApplicationContext private val appContext: Context
) {
private val listeners =
ConcurrentHashMap<String, WeakReference<EventListener>>()

fun subscribe(topic: String, listener: EventListener) {
listeners[topic] = WeakReference(listener)
}

fun publish(topic: String, event: Any) {
listeners[topic]?.get()?.onEvent(event)

// Clean up GC’d listeners
listeners.entries.removeIf {
it.value.get() == null
}
}
}
class OrderFragment : Fragment() {

@Inject lateinit var bus: GlobalEventBus // Uses App Context

override fun onViewCreated(
view: View,
savedInstanceState: Bundle?
)
{
bus.subscribe("order_update", ::onOrderUpdate)
}

override fun onDestroyView() {
super.onDestroyView()
bus.unsubscribe("order_update") // Explicit cleanup
}
}

This ensures:

  • No Activity reference leaks
  • Fragments unsubscribe when their View is destroyed
  • Safe reuse across configuration changes

Additional common leak patterns & fixes

Handler leaks

Problem:
 Handlers tied to the main Looper can post delayed Runnables that reference Views or Activities.

Fix:
 Remove callbacks when the view is destroyed.

handler.removeCallbacksAndMessages(null)

Call in onDestroyView() or onDestroy().

Static collections

Problem:
 Static maps holding references to Activities or Views.

Fix:
 Use:

  • WeakReference
  • WeakHashMap for keys

Leak detection & validation

  • LeakCanary enabled in debug builds
  • Catches 99.9% of leaks early
  • Prevents leaks from reaching production
Activity Context leaks occur because it owns the entire view hierarchy. In production, I avoid storing Activity Contexts in long-lived objects, prefer Application Context for singletons, and use weak references with explicit cleanup. LeakCanary helps enforce this continuously.

6. Fragment lifecycle quirks?

Interviewer:
 What are the key Fragment lifecycle quirks compared to Activity, and how do you prevent leaks?

Candidate:
 Fragments have a two-layer lifecycle — they are managed by an Activity, but their View lifecycle is separate from the Fragment lifecycle. Most Fragment bugs and leaks come from misunderstanding this difference.

Fragment lifecycle flow (key callbacks)

  • onAttach()
     Fragment is attached to its host Activity.
     Safe place to communicate with the Activity using requireActivity().
  • onCreate()
     Fragment instance is created.
     Used for:
  • Reading arguments
  • Initializing non-UI state
     No Views exist yet.
  • onCreateView()
     Fragment’s View hierarchy is created.
     Inflate layout and initialize View binding here.
  • onStart() / onResume()
     Mirror the Activity lifecycle.
     Fragment is visible and interactive.
  • onDestroyView()Most critical callback
     Fragment’s View hierarchy is destroyed, but the Fragment instance remains alive.
     This is the most common source of memory leaks.
  • onDestroy()
     Fragment is permanently destroyed.
  • onDetach()
     Fragment is detached from the Activity.

Key Fragment lifecycle quirk

The Fragment can outlive its View.
  • On configuration change or back stack navigation:
  • Fragment instance may survive
  • View hierarchy is destroyed and recreated

If you keep references to Views after onDestroyView(), you leak memory.

Production pattern: View-safe Fragment implementation

class CartFragment : Fragment() {
private var _binding: FragmentCartBinding? = null
private val binding get() = _binding!!
private val vm: CartViewModel by viewLifecycleOwner.viewModels() // View-bound, destroys onDestroyView
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCartBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.recycler.adapter = CartAdapter(vm.cartItems.collectAsState())
viewLifecycleOwner.lifecycleScope.launchWhenStarted { // View-safe
repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.events.collect { binding.root.snack(it) }
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null // Null Views/Listeners
}
}

Why this prevents leaks

  • View binding is nulled in onDestroyView()
  • Coroutines are scoped to viewLifecycleOwner
  • Flow collection stops automatically when the View is destroyed
  • ViewModel is cleared when the View lifecycle ends

Measured production impact

  • Prevents ~95% of Fragment-related leaks
  • Fragment memory allocations drop below 2 KB after onDestroyView()
  • Smooth behavior across rotation and back stack navigation

Common pitfalls and fixes

  • Observing LiveData with Fragment lifecycle
     ❌ this
     ✅ viewLifecycleOwner
  • Global or static adapters
     Clear adapter data in onDestroyView() to release View references
  • Shared state misuse
     Use activityViewModels() only when state must be shared across Fragments

Trade-off decision

  • viewModels() → Fragment-only state
  • viewLifecycleOwner.viewModels() → View-bound state
  • activityViewModels() → Shared across Fragments
Fragments have a separate View lifecycle from their own lifecycle. By releasing View references in onDestroyView() and scoping observers to viewLifecycleOwner, I prevent leaks and ensure correct behavior across configuration changes.

7. Process death survival?

Interviewer:
 How do you survive full process death, like when the user swipe-kills the app?

Candidate:
 When an app process is killed (low memory or swipe-kill), the heap is lost completely. Android recreates Activities/Fragments from the saved instance Bundle, which only contains what was explicitly saved. All in-memory state disappears.

Strategy to survive process death

  1. SavedStateHandle
  • Bundle-backed, injected into ViewModels
  • Holds small transient state (<1MB)
  • Fast restoration for UI that must appear immediately

2. DataStore / Disk storage

  • Persistent, survives full process death
  • Stores critical app state (cart items, totals, preferences)

3. Optimistic UI + delta sync

  • Render cached state immediately
  • Synchronize deltas in the background from the repository

Production example: E-commerce cart

@HiltViewModel
class ProcessDeathVm @Inject constructor(
private val stateHandle: SavedStateHandle,
private val cartStore: DataStore<CartProto>
) : ViewModel() {
private val _totalPrice = MutableStateFlow<BigDecimal>(BigDecimal.ZERO)
val totalPrice: StateFlow<BigDecimal> = _totalPrice.asStateFlow()
val restoredCount: LiveData<Int> = stateHandle.getLiveData("item_count", 0)
fun addItem(item: CartItem) = viewModelScope.launch(Dispatchers.IO) {
val count = restoredCount.value ?: cartStore.data.first().itemCount
stateHandle["item_count"] = (count + 1) // Fast restore
_totalPrice.value += item.price
cartStore.updateData { proto -> proto.copy(itemCount = count + 1, totalPrice = price.toString()) }
}
init {
viewModelScope.launch {
val protoCount = cartStore.data.map { it.itemCount }.first()
stateHandle["item_count"] = protoCount.toInt()
}
}
}

Key points

  • P99 restore < 40ms → almost instant UI recovery
  • SavedStateHandle handles fast, in-memory restore for configuration changes and process death
  • DataStore ensures long-term persistence
  • BigDecimal is not Parcelable → store as String for SavedStateHandle or DataStore
  • Optimistic UI gives immediate feedback while syncing delta changes

Testing process-death survival

adb shell am kill com.app
  • Relaunch app
  • Assert that state restored correctly (cart items, totals, UI)

Closing interview statement

To survive process death, I layer SavedStateHandle for fast UI restore, DataStore for persistent state, and optimistic UI rendering with delta sync. This approach ensures almost instantaneous restoration and avoids user-visible data loss, even after swipe-kill or low-memory termination.

8. Doze mode Service limits?

Interviewer:
 Doze impacts Services — explain the limits and possible workarounds.

Candidate:
 Doze mode (introduced in Android Marshmallow) throttles background work to save battery when the device is idle. Key impacts on Services:

Doze limitations

  • Background Services: Restricted; cannot start freely. Only short execution windows (<1 min) allowed.
  • Alarms: AlarmManager alarms are deferred (“fuzzy”) and only run during maintenance windows.
  • Network access: Limited; partial connectivity allowed.
  • Maintenance windows: System allows work approximately every 15 minutes, with longer windows (up to 2 hours) in deep idle.

Production workaround: WorkManager

Instead of custom Services, we use WorkManager, which is:

  • Doze-aware and automatically reschedules work
  • Retry-capable
  • Lifecycle-safe
  • Constraint-based (network, battery, idle)

Example: Doze-aware periodic sync

@HiltWorker
class DozeAwareSyncWorker @AssistedInject constructor(
@Assisted ctx: Context, @Assisted params: WorkerParameters,
private val syncRepo: SyncRepository
) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
return if (PowerManager.from(applicationContext).isDeviceIdleMode) {
// Light check only
Result.retry() // Next maintenance
} else {
syncRepo.fullSync()
Result.success()
}
}
}

val dozeWork = PeriodicWorkRequestBuilder<DozeAwareSyncWorker>(15, MINUTES)
.setConstraints(Constraints(
requiresBatteryNotLow = true,
requiresDeviceIdle = false, // Avoid doze
requiresNetworkType = NETWORK_TYPE_NOT_ROAMING
))
.setBackoffCriterion(BackoffPolicy.LINEAR, 10, MINUTES)
.build()
WorkManager.enqueueUniquePeriodicWork("doze_sync", KEEP, dozeWork)

Key production outcomes

  • ~98% success even during Doze (tracked via Firebase)
  • Constraints ensure battery-friendly execution
  • Automatic retry handles deferred execution

Optional workarounds / considerations

  • Whitelist / exempt apps: Users can manually allow the app in battery settings (via intent to settings)
  • AlarmManager.setExactAndAllowWhileIdle(): Last-resort option; runs even in Doze but drains battery and should be used sparingly

Closing interview statement

Doze mode restricts background Services and alarms, but using WorkManager with constraints ensures safe, battery-efficient, and retryable execution. Direct foreground or exact alarms are last-resort tools due to battery impact.

9. Multi-window lifecycle?

Interviewer:
 How does multi-window or Picture-in-Picture (PiP) affect the lifecycle of an Activity?

Candidate:
 Split-screen and PiP don’t fully destroy the Activity, but they trigger lifecycle callbacks and a configuration change. Proper handling ensures state persists and layout adapts per mode.

Key callbacks

  1. onMultiWindowModeChanged(isInMultiWindowMode, newConfig
  • Called when the app enters or exits split-screen mode
  • Can be accompanied by a configuration update (screen size, orientation)
  • Use this to adapt layouts and UI for smaller dimensions

2. onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)

  • Called when the app enters or exits PiP mode
  • Use this to adjust video playback, controls, or overlays

Production example: Video playerclass

class VideoPlayerActivity : AppCompatActivity() {

override fun onMultiWindowModeChanged(
isInMultiWindowMode: Boolean,
newConfig: Configuration?
)
{
super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig)

if (isInMultiWindowMode) {
// Switch to compact split-screen layout
setContentView(R.layout.video_split)
player.resizeForSplit()
} else {
// Restore full-screen layout
fullScreenPlayer()
}
}

override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration?
)
{
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
player.enterPipMode(isInPictureInPictureMode)
}

private fun playerResize(width: Int, height: Int) {
// Adjust ExoPlayer view size
}

private fun fullScreenPlayer() {
setContentView(R.layout.video_full)
player.resizeForFullScreen()
}
}
<activity
android:name=".VideoPlayerActivity"
android:supportsPictureInPicture="true"
android:resizeableActivity="true" />

Pitfalls and considerations

  • Window insets
  • WindowInsetsCompat may behave differently in split-screen; consume safely
  • Foldables / hinge posture
  • Use posture sensors or DisplayManager to adjust layout dynamically
  • State persistence per mode
  • Layouts, playback position, and UI state must survive mode switches

Closing interview statement

Multi-window and PiP do not destroy the Activity but trigger callbacks and config changes. By handling onMultiWindowModeChanged and onPictureInPictureModeChanged, and adapting layouts and UI per mode, we ensure seamless state and user experience across split-screen, PiP, and foldable devices.

10. Launcher Activity sticky?

Interviewer:
 How do you make the Launcher Activity “sticky” to handle deep links and proper up/back navigation?

Candidate:
 The goal is to reuse the Launcher Activity instance instead of creating multiple copies, preserving state and improving navigation.

Strategy

  1. launchMode=”singleTop”
  • Reuses the existing top instance if it already exists
  • Ensures onNewIntent() is called for deep links or new intents
  • Safe for up/back navigation

2. alwaysRetainTaskState=”true”

  • Retains the Activity’s state across process kills
  • Useful when the app is backgrounded or recreated

3. taskAffinity

  • Helps manage multiple tasks and ensures your Launcher is the root of its own task
<activity
android:name=".LauncherActivity"
android:launchMode="singleTop"
android:alwaysRetainTaskState="true"
android:exported="true"
android:taskAffinity=".launcher">


<!-- Launcher intent -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<!-- Deep link support -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="app" />
</intent-filter>
</activity>

Handling new intents

override fun onNewIntent(intent: Intent) {  // singleTop callback
super.onNewIntent(intent)
handleDeepLink(intent.dataString)
}
  • onNewIntent() is triggered instead of creating a new Activity
  • Parse and handle deep links here
  • Maintains existing UI state

Production benefits

  • Cold/warm start ratio improved by ~25%
  • Preserves user navigation stack
  • Reduces unnecessary Activity recreation

Pitfalls

  • singleTask vs singleTop
  • singleTask clears any Activities above it → only use for root
  • singleTop preserves stack, safer for deep-link scenarios
  • Testing deep links
adb shell am start -f -a android.intent.action.VIEW -d "app://product/123" com.app

Closing interview statement

Making the Launcher Activity sticky requires singleTop to reuse the existing instance, alwaysRetainTaskState for state retention across kills, and proper onNewIntent() handling for deep links. This ensures a smooth up/back navigation experience and improves warm start performance.

11. ContentProvider IPC?

Interviewer:
 Explain ContentProvider for IPC — including security and performance best practices.

Candidate:
 A ContentProvider enables secure cross-process data sharing (e.g., Contacts, MediaStore). It exposes CRUD operations via URIs, and clients access data through a ContentResolver.

Key considerations for production:

  • Security: Use signature-level or custom permissions.
  • Performance: Throttle queries with LIMIT/OFFSET, parameterize queries to avoid SQL injection.
  • Live updates: Use setNotificationUri() for observers.

Production-ready ContentProvider example

class SharedUserProvider : ContentProvider() {

override fun onCreate(): Boolean = true

override fun query(
uri: Uri,
projection: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?
)
: Cursor? {
val matcher = sUriMatcher.match(uri)
return when (matcher) {
USERS -> {
// Throttle to 20 rows per query
val cursor = db.query(
"users", projection, selection, selectionArgs,
null, null, sortOrder, "20"
)
// Notify observers on changes
cursor.setNotificationUri(contentResolver, uri)
cursor
}
else -> throw IllegalArgumentException("Unknown URI: $uri")
}
}

override fun insert(uri: Uri, values: ContentValues?): Uri? = uri
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int = 0
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
override fun getType(uri: Uri): String? = null

companion object {
private const val AUTHORITY = "com.app.provider.users"
val CONTENT_URI = Uri.parse("content://$AUTHORITY")
private const val USERS = 1

private val sUriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
addURI(AUTHORITY, "users", USERS)
}
}
}
<provider
android:name=".SharedUserProvider"
android:authorities="com.app.provider.users"
android:exported="false"
android:permission="com.app.READ_USERS" />
  • exported=false → prevents unauthorized external access
  • Custom permission ensures only allowed apps can query

Best practices

  1. Security
  • Use signature|privileged permissions
  • Do not export unless necessary

2. Performance

  • Throttle queries (LIMIT/OFFSET)
  • Parameterize selectionArgs to prevent SQL injection
  • Notify observers with notifyChange()

3. Resource management

  • Always close cursors in finally blocks to prevent leaks
  • Avoid returning open cursors to long-lived clients

Closing interview statement

ContentProviders are a secure IPC mechanism for Android. Production usage requires careful permission management, throttled queries for performance, live update notifications, and diligent cursor cleanup to avoid leaks.

12. BroadcastReceiver sticky?

Interviewer:
 When would you use a sticky BroadcastReceiver, and how do you handle long-running work inside it?

Candidate:
 Sticky broadcasts store the last broadcasted event so that late receivers (e.g., on boot) can receive it immediately. Common use case: BOOT_COMPLETED to perform app initialization or sync.

Key considerations:

  • Long-running work (>10s) is not allowed on the main thread in onReceive()
  • Use goAsync() → returns PendingResult
  • Launch work in background coroutine or thread
  • Always call finish() on the PendingResult
  • Unregister receivers in onStop() if dynamic
  • Be Doze-aware; use WorkManager fallback if work is deferred

Production example: Boot sync receiver

class BootStickyReceiver : BroadcastReceiver() {

override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {

val pending = goAsync() // 60s grace to finish work

lifecycleScope.launch(Dispatchers.IO) { // Run off main thread
try {
BootSyncUseCase(context).execute() // Long-running sync
} finally {
pending?.finish() // Mark broadcast as complete
}
}
}
}
}
<receiver
android:name=".BootStickyReceiver"
android:exported="true"
android:permission="RECEIVE_BOOT_COMPLETED">

<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
  • exported=true → system can deliver the broadcast
  • RECEIVE_BOOT_COMPLETED permission ensures only the system can trigger it

Pitfalls & best practices

  1. Doze mode / idle restrictions
  • System may defer broadcasts → enqueue fallback with WorkManager

2. Resource leaks

  • Always call pending.finish() in finally block

3. Threading

  • Never do heavy work on the main thread → crash risk
  1. Dynamic receivers
  • Unregister in onStop() or appropriate lifecycle to prevent leaks

Testing

  • Use shadows or mocks in unit tests:
shadowOf(context).setTime(bootTime)
  • Verify that work executes correctly after system boot event

Closing interview statement

Sticky BroadcastReceivers are useful for delivering important system events to late receivers. For long work, goAsync() ensures the system gives a grace period, and background execution plus WorkManager fallback ensures reliability even under Doze mode or delayed broadcasts.

13. AIDL death recipient?

Interviewer:
 How do you implement robust AIDL IPC to handle service crashes or remote process death?

Candidate:
 AIDL defines a parcelable interface for cross-process RPC. When the remote service crashes, we use IBinder.DeathRecipient to detect the death and reconnect.

Key production patterns:

  • Exponential backoff reconnect
  • Ping/heartbeat to detect unresponsive service
  • Parcel size limits: 1MB per transaction → chunk large data

Bound service client example

class AidlClient {
private var service: IRemoteService? = null
private var connection: ServiceConnection? = null
private var retryCount = 0

fun bind(context: Context) {
connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
service = IRemoteService.Stub.asInterface(binder)
try {
// Listen for remote service death
binder.linkToDeath({ onServiceDied() }, 0)
} catch (e: RemoteException) {
reconnect()
}
}

override fun onServiceDisconnected(name: ComponentName) {
service = null
}
}

val intent = Intent(context, RemoteService::class.java)
context.bindService(intent, connection!!, Context.BIND_AUTO_CREATE)
}

private fun onServiceDied() {
service = null
// Exponential backoff reconnect
val delay = (2_000L shl retryCount).coerceAtMost(60_000L)
handler.postDelayed({ bind(appContext) }, delay)
retryCount++
}
}

Production considerations

  1. Parcel size limits
  • Max ~1MB per transaction
  • Split large data into chunks if needed

2. Threading

  • Avoid blocking main thread
  • Use HandlerThread per client to marshal/unmarshal calls safely

3. DeathRecipient

  • Always link via binder.linkToDeath()
  • Provides reliable callback on remote service termination

4. Reconnect strategy

  • Exponential backoff prevents tight loop
  • Optional heartbeat/ping to preemptively detect unresponsive services

Pitfalls

  • Multi-threaded access to AIDL → always synchronize or use dedicated handler thread
  • Forgetting to unlink death recipient → memory leaks or dangling references
  • Oversized parcels → TransactionTooLargeException

Closing interview statement

For robust AIDL IPC, we combine DeathRecipient callbacks, exponential reconnect, and safe parcel handling. This ensures the client recovers gracefully from service crashes while respecting thread-safety and parcel limits.

14. WakeLock best practices?

Interviewer:
 How do you use WakeLocks safely, what are the risks, and what are modern alternatives?

Candidate:
 WakeLocks keep the CPU or screen awake. Types include:

  • PARTIAL_WAKE_LOCK → CPU only
  • ACQUIRE_CAUSES_WAKEUP → wakes the screen

Risks:

  • Excessive battery drain if held too long
  • Leaks if not released → app or device wake issues

Best practices (production-grade):

  • Acquire for minimal duration only
  • Use acquire(timeout) when possible
  • Always release in finally block
  • Prefer WorkManager / AlarmManager for deferred or periodic tasks instead of holding long WakeLocks

Production example: Location sync

class LocationTracker(private val context: Context) {

private val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
private val wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "location:sync")

suspend fun track(timeoutMs: Long = 5 * 60 * 1000L) {
wakeLock.acquire(timeoutMs) // auto-release after timeout
try {
fusedLocationClient.lastLocation.addOnSuccessListener { location ->
save(location)
}
} finally {
if (wakeLock.isHeld) wakeLock.release()
}
}
}

Production considerations

  1. Battery tax
  • ~20% extra if unconstrained
  • Mitigation: restrict to charging or Wi-Fi conditions

2. Modern alternatives

  • AlarmManager.setExactAndAllowWhileIdle() for timed tasks
  • WorkManager for background deferrable jobs → Doze-aware, battery-friendly

3. Metrics

  • Track usage time → should be <1% of app active time

Closing interview statement

WakeLocks are powerful but dangerous for battery. Acquire only for minimal duration, release in finally, and prefer WorkManager or AlarmManager where possible to stay Doze and battery friendly.

15. StrictMode violations?

Interviewer:
 How do you use StrictMode in production apps to catch violations without crashing users?

Candidate:
 StrictMode detects main-thread violations and resource leaks:

  • Main-thread badness: Disk I/O, network I/O, long SQL (>5ms)
  • VM issues: Leaked SQLite cursors, closables, Activities

Debug vs Production:

  • Debug: penaltyDeath, penaltyFlashScreen → immediate feedback
  • Production: penaltyLog, optional analytics (penaltyDropBox(), Sentry, or Crashlytics)

Production-ready policy 

class MyApplication : Application() {
override fun onCreate() {
super.onCreate()

if (BuildConfig.DEBUG) {
// Thread-level policy
StrictMode.setThreadPolicy(
ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog() // Log violations
.penaltyFlashScreen() // Visual cue for dev
.build()
)

// VM-level policy
StrictMode.setVmPolicy(
VmPolicy.Builder()
.detectLeakedSql()
.detectLeakedClosable()
.detectActivityLeaks()
.penaltyLog() // Log VM issues
.build()
)
}
}
}

Production considerations

  1. Issue detection pre-launch
  • Caught ~40% of performance issues before release

2. Custom reporting

  • penaltyDropBox() or analytics pipeline for production monitoring

3. Feature toggle

  • Use BuildConfig.DEBUG to avoid logging in release builds, or optional prod logging

4. Common violations to monitor

  • Disk/network on main thread
  • Cursor or closable leaks
  • Activity memory leaks

Closing interview statement

StrictMode is a powerful tool for detecting main-thread violations and memory leaks. In production, we log violations to analytics instead of crashing, ensuring users are unaffected while developers can act on issues.

16. ANR detection?

Interviewer:
 How do you detect and prevent ANRs in production Android apps?

Candidate:
 ANR (Application Not Responding) occurs if the main thread blocks >5s for I/O or heavy UI operations.

Production strategies:

  • Choreographer frame watchdog: monitor frame delays (>16ms per frame, detect 5s blocks)
  • StrictMode: detect disk/network on main thread
  • Custom uncaught exception handler: capture blocked main thread crashes or hangs
  • Systrace/profiler: categorize root cause

Production example: Video player 

class FrameWatchdog : Choreographer.FrameCallback {

private var lastFrameNs = 0L
private val thresholdNs = 5 * 1_000_000_000L // 5 seconds

override fun doFrame(frameNs: Long) {
if (lastFrameNs != 0L && frameNs - lastFrameNs > thresholdNs) {
// Report ANR to monitoring
Sentry.captureMessage("ANR detected: ${frameNs - lastFrameNs} ns block")

// Optional: dump HPROF or trace for analysis
StrictMode.incrementExpectedActivityInstanceCount(1)
}
lastFrameNs = frameNs
Choreographer.getInstance().postFrameCallback(this)
}
}

Attaching watchdog in Activity

private lateinit var watchdog: FrameWatchdog

override fun onResume() {
super.onResume()
watchdog = FrameWatchdog()
Choreographer.getInstance().postFrameCallback(watchdog)
}

override fun onPause() {
super.onPause()
Choreographer.getInstance().removeFrameCallback(watchdog)
}

Production considerations

  1. Frame metrics
  • Frame >16ms → potential jank
  • Threshold 5s → ANR detection

2. ANR monitoring

  • Sentry, Firebase Crashlytics for production reporting

3. Alternatives

  • Window.setOnFrameMetricsAvailableListener (API 26+) for frame timing metrics

4. Root cause analysis

  • Use systrace to categorize blocks (e.g., app vs system vs binder delays)

5. Results

  • Post-implementation ANR rate <0.05% in production

Closing interview statement

In production, detecting ANRs early requires a combination of frame-level monitoring, StrictMode policies, and reporting tools. Offending operations can then be traced and deferred, ensuring smooth UI and low ANR rates.

17. WindowInsets foldables?

Interviewer:
 How do you handle WindowInsets for foldable devices and gesture navigation?

Candidate:
 WindowInsets report system UI areas:

  • Status bar, navigation bar, cutouts, and fold hinges
  • Foldables are dynamic → dual-screen mode triggers resize
  • Gesture navigation changes insets dynamically

Production patterns:

  • Use edge-to-edge layouts: fitsSystemWindows=false
  • Consume/apply selectively to avoid overlapping content
  • Handle IME (keyboard) insets to prevent content being hidden
class InsetsHandler {

fun setup(view: View) {
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())

v.updatePadding(
top = systemBars.top,
bottom = systemBars.bottom + ime.bottom
)

// Optional: gesture nav / display cutout
// val cutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout())

insets // Pass-through unconsumed
}
}
}

Activity setup

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

// Edge-to-edge
WindowCompat.setDecorFitsSystemWindows(window, false)

InsetsHandler().setup(findViewById(R.id.root))
}
  • Compat library handles pre-API 30 differences

Pitfalls & considerations

  1. Ignore IME → keyboard can overlap content
  2. Foldable layout → use Configuration.screenLayout to detect dual-mode
  3. Cutouts / gesture nav → always account for displayCutout insets

Closing interview statement

Proper WindowInsets handling ensures edge-to-edge layouts look correct on all devices, including foldables and gesture navigation. Always handle system bars, IME, and hinges, and apply insets selectively to avoid layout overlap.

18. Single Activity arch pros?

Interviewer:
 What are the advantages and disadvantages of using a Single-Activity architecture in modern Android apps?

Candidate:
 Single-Activity architecture is the modern standard (Compose / Navigation Component):

  • One Activity hosts a NavHostFragment
  • Fragments or Compose screens swap inside this host

Pros

  1. Shared ViewModels across screens
  2. Unified backstack → consistent navigation
  3. Dynamic feature delivery via Play Store modules
  4. Cold-start optimized → avoids multiple Activity overhead

E-commerce production example

class SingleActivity : AppCompatActivity(R.layout.activity_single) {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
setupNavGraph(findNavController(R.id.nav_host))
}
}

private fun setupNavGraph(navController: NavController) {
val navGraph = navController.navInflater.inflate(R.navigation.main_graph)

// Dynamic feature module destination
navGraph.addDestination(
NavXmlNavigator.DestinationBuilder(this, R.navigator.feature_cart)
.setId(R.id.cart_destination)
.build()
)

navController.graph = navGraph
}
}
  • Backstack management is flawless with NavController
  • Dynamic features load lazily → modular app

Cons

  1. Heavy host Activity → can be split into modules
  2. Lifecycle handling slightly more complex for deeply nested fragments
  3. Potential longer Activity init → mitigated via modularization and lazy navigation
  4. Observed ~30% faster cold launches with proper modularization

Closing interview statement

Single-Activity architecture simplifies navigation, ViewModel sharing, and modular delivery, while keeping backstack consistent. The trade-off is a heavier host Activity, which we mitigate via modularization and lazy dynamic features.

19. Backstack pruning?

Interviewer:
 How do you efficiently prune the Navigation backstack in Android apps?

Candidate:
 NavController provides popBackStack(id, inclusive) to clear up to a target destination.

Production patterns:

  • Use inclusive = true to reset to root or clear a flow
  • Preserve state via SavedStateHandle or Bundleable arguments
  • Prevent excessive backstack entries → avoid overflow

Checkout flow reset example

val navController = findNavController(R.id.nav_host)

navController.navigate(R.id.action_checkout_complete) {
// Prune backstack up to Home
popUpTo(R.id.home) { inclusive = true }

// Ensure single instance on top
launchSingleTop = true

// Restore state saved in SavedStateHandle
restoreState = true
}
  • Maximum backstack entries observed: 42
  • Pruning prevents memory/performance issues

Pitfalls & best practices

  1. State loss → always pass arguments via Bundleable or SavedStateHandle
  2. Dynamic destinations → ensure IDs match when popping back
  3. TestingNavControllerAssertions.hasDestinationCount validates backstack pruning

Closing interview statement

Efficient backstack pruning ensures flows like checkout complete or login reset don’t leak destinations, preserves state safely, and prevents memory/performance issues.”

20. LifecycleOwner custom?

Interviewer:
 How do you implement a custom LifecycleOwner for non-standard components like dialogs or services?

Candidate:

  • Extend LifecycleOwner and ViewModelStoreOwner for non-standard components
  • Manually dispatch lifecycle events and manage the ViewModel store
  • Useful for foreground Services or custom UI components without an Activity/Fragment

Production example: Observable foreground service

class ObservableService : LifecycleService(), LifecycleOwner, ViewModelStoreOwner {

private val lifecycleRegistry = LifecycleRegistry(this)
private val viewModelStore = ViewModelStore()

override fun getLifecycle(): Lifecycle = lifecycleRegistry
override fun getViewModelStore(): ViewModelStore = viewModelStore

// Custom lifecycle events
fun onForeground() { lifecycleRegistry.currentState = Lifecycle.State.STARTED }
fun onBackground() { lifecycleRegistry.currentState = Lifecycle.State.CREATED }

override fun onDestroy() {
super.onDestroy()
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
viewModelStore.clear() // Prevent leaks
}
}
  • Enables ViewModel scoping and lifecycle-aware observers for services
  • Ideal for managing long-running foreground tasks safely

Pitfalls & best practices

  1. Forgetting lifecycle events → automate with LifecycleObserver
  2. Manual state management → always update LifecycleRegistry.currentState
  3. ViewModel leaks → clear viewModelStore on destroy

Closing interview statement

Custom LifecycleOwners allow ViewModel scoping and lifecycle-aware components for non-standard contexts like services or dialogs. Proper event dispatch and store cleanup prevent leaks and ensure consistency.

EmailId: vikasacsoni9211@gmail.com

LinkedIn: https://www.linkedin.com/in/vikas-soni-052013160/

Happy Learning ❤️ 

Comments

Featured Articles

Optimize Jetpack Compose: Performance & Best Practices

From ‘Master’ to ‘Main’: The Meaning Behind Git’s Naming Shift

JIT vs AOT Compilation | Android Runtime

Play Store Uploads with Fastlane Supply - 4

Managing App Versioning and Changelogs- 6

Mastering Android App Performance: Expert Insights