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()orsetContent {}
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, theonResume()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 callingonDestroy(), critical state is persisted using: SavedStateHandleDataStore- Improvement trade-off
repeatOnLifecycle(Lifecycle.State.STARTED)is often preferred overlaunchWhenResumedbecause it handles configuration changes more safely. - Testing approach
Lifecycle behavior is validated using: ActivityScenariofor lifecycle testingTurbinefor 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 usingDisplayManagerand 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 calledWhat 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 anIBinder- 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:
staticfields- 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:
WeakReferenceWeakHashMapfor 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 usingrequireActivity(). - 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 inonDestroyView()to release View references - Shared state misuse
UseactivityViewModels()only when state must be shared across Fragments
Trade-off decision
viewModels()→ Fragment-only stateviewLifecycleOwner.viewModels()→ View-bound stateactivityViewModels()→ Shared across Fragments
Fragments have a separate View lifecycle from their own lifecycle. By releasing View references inonDestroyView()and scoping observers toviewLifecycleOwner, 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
- 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:
AlarmManageralarms 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
- 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
WindowInsetsCompatmay behave differently in split-screen; consume safely- Foldables / hinge posture
- Use posture sensors or
DisplayManagerto 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 handlingonMultiWindowModeChangedandonPictureInPictureModeChanged, 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
- 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
singleTaskclears any Activities above it → only use for rootsingleToppreserves stack, safer for deep-link scenarios- Testing deep links
adb shell am start -f -a android.intent.action.VIEW -d "app://product/123" com.appClosing interview statement
Making the Launcher Activity sticky requiressingleTopto reuse the existing instance,alwaysRetainTaskStatefor state retention across kills, and properonNewIntent()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
- Security
- Use
signature|privilegedpermissions - Do not export unless necessary
2. Performance
- Throttle queries (LIMIT/OFFSET)
- Parameterize
selectionArgsto prevent SQL injection - Notify observers with
notifyChange()
3. Resource management
- Always close cursors in
finallyblocks 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()→ returnsPendingResult - Launch work in background coroutine or thread
- Always call
finish()on thePendingResult - 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 broadcastRECEIVE_BOOT_COMPLETEDpermission ensures only the system can trigger it
Pitfalls & best practices
- Doze mode / idle restrictions
- System may defer broadcasts → enqueue fallback with WorkManager
2. Resource leaks
- Always call
pending.finish()infinallyblock
3. Threading
- Never do heavy work on the main thread → crash risk
- 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
- 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 onlyACQUIRE_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
finallyblock - 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
- Battery tax
- ~20% extra if unconstrained
- Mitigation: restrict to charging or Wi-Fi conditions
2. Modern alternatives
AlarmManager.setExactAndAllowWhileIdle()for timed tasksWorkManagerfor 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
- 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.DEBUGto 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
- 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
- Ignore IME → keyboard can overlap content
- Foldable layout → use
Configuration.screenLayoutto detect dual-mode - Cutouts / gesture nav → always account for
displayCutoutinsets
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
Activityhosts aNavHostFragment - Fragments or Compose screens swap inside this host
Pros
- Shared ViewModels across screens
- Unified backstack → consistent navigation
- Dynamic feature delivery via Play Store modules
- 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
- Heavy host Activity → can be split into modules
- Lifecycle handling slightly more complex for deeply nested fragments
- Potential longer Activity init → mitigated via modularization and lazy navigation
- 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 = trueto reset to root or clear a flow - Preserve state via SavedStateHandle or
Bundleablearguments - 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
- State loss → always pass arguments via
Bundleableor SavedStateHandle - Dynamic destinations → ensure IDs match when popping back
- Testing →
NavControllerAssertions.hasDestinationCountvalidates 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
LifecycleOwnerandViewModelStoreOwnerfor 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
- Forgetting lifecycle events → automate with
LifecycleObserver - Manual state management → always update
LifecycleRegistry.currentState - ViewModel leaks → clear
viewModelStoreon 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 ❤️
.png)
Comments
Post a Comment