Android Engineering
Reference Guide
A comprehensive reference covering Android fundamentals through Staff and Architect level concepts — with diagrams, code examples, and Staff interview Q&A.
Android Fundamentals
What is Android?
Android is a Linux-based operating system. Every app runs in its own Linux process with its own memory space — apps cannot directly access each other's memory.
┌─────────────────────────────────────────────────────┐ │ Android OS (Linux kernel) │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────── │ │ │ App A │ │ App B │ │ App C │ │ │ (Process 1) │ │ (Process 2) │ │ (Process 3) │ │ │ │ │ │ │ │ │ │ ART Runtime │ │ ART Runtime │ │ ART Runtime │ │ │ Own memory │ │ Own memory │ │ Own memory │ │ └──────────────┘ └──────────────┘ └─────────────│ │ │ │ OS manages: memory, CPU, battery │ │ CAN KILL your process at ANY TIME when backgrounded│ └─────────────────────────────────────────────────────┘
Each app gets its own instance of the Android Runtime (ART) — ART compiles your Kotlin/Java bytecode into native machine code. This is why Android apps feel native despite being written in JVM languages.
Everything in Android is a component — Activities, Fragments, Services, BroadcastReceivers, ContentProviders. The OS knows about these components and can start or stop them independently.
Application Class
The entry point of your app that most tutorials skip. Created before any Activity, Service, or BroadcastReceiver.
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// Called when the app process is first created
// Initialize app-wide singletons: analytics, crash reporting, DI
// DO NOT do heavy work here — blocks app startup
}
}
<application android:name=".MyApplication" ... >
- Created before any Activity, Service, or BroadcastReceiver
- Lives for the entire lifetime of the app process
- One Application instance per process
- If you use multiple processes, each gets its own Application.onCreate() call
- Use App Startup library for lazy initialization — avoids slowing cold start
Activity Lifecycle
An Activity represents one screen with a user interface. It is the entry point for user interaction.
App launched
│
▼
onCreate() — Set up UI, initialize ViewModel, restore saved state.
Called once per Activity instance.
│
▼
onStart() — Activity becoming visible but NOT yet interactive.
Start animations, register receivers that need visibility.
│
▼
onResume() — Foreground and interactive. User can touch/type.
Resume camera, video playback.
│
▼
[User Interacting]
│
├── Another activity comes to foreground
│ │
│ ▼
│ onPause() — MINIMAL WORK ONLY. Blocks new Activity from showing.
│ │ Save draft data, pause animations, release camera.
│ │
│ ├── [User returns] → onResume()
│ │
│ └── [Goes away completely]
│ │
│ ▼
│ onStop() — Not visible. Safe for heavier work:
│ save to database, stop heavy animations,
│ unregister receivers.
│ │
│ ├── [User returns] → onRestart() → onStart()
│ │
│ └── [Finished/killed]
│ │
│ ▼
│ onDestroy() — NEVER rely on this for
│ critical cleanup. May not run.
│
└── [Back button / finish()]
→ onPause() → onStop() → onDestroy()
Key Distinctions
| Method | Activity State | Safe For | Avoid |
|---|---|---|---|
onPause() | Still partially visible | Minimal work only — save draft state | Heavy operations — blocks next Activity |
onStop() | Completely invisible | Database writes, stop animations, unregister | UI updates (view may be null) |
onDestroy() | Being destroyed | Final cleanup of long-lived resources | Relying on it — may not be called on process death |
launchMode — Controls Back Stack Behavior
<activity android:name=".MainActivity" android:launchMode="singleTask" />
| Mode | Behavior | Stack Example | Use Case |
|---|---|---|---|
standard | New instance every time | [A, B] + start B → [A, B, B] | Default — most activities |
singleTop | Reuse if already on top; calls onNewIntent() | [A, B] + start B → [A, B] (reused) | Search results, notifications |
singleTask | One instance; clears everything above it | [A, B, C, D] + start B → [A, B] | Main/home screen |
singleInstance | Own task, alone in it | Task1:[A, B] Task2:[C] | Incoming call screen, alarms |
// onNewIntent() — called when singleTop/singleTask Activity is reused
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent) // MUST call this to update getIntent()
handleIntent(intent)
}
Intent Flags — Runtime Equivalent of launchMode
// Clear back stack and start fresh (logout flow)
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
startActivity(intent)
// Equivalent to singleTop behavior
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
// Clear everything above target in back stack
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
Process Death — The Most Important Concept
When your app goes to the background, Android may kill your process to free memory. The user sees nothing. When they return, Android tries to restore the app to exactly the state it was in.
User returns to app after process death:
│
▼
Android creates a NEW process
│
▼
Application.onCreate() called
│
▼
The Activity the user was on is RECREATED
│
▼
onCreate(savedInstanceState) called
— savedInstanceState is NON-NULL
— contains everything saved in onSaveInstanceState()
│
▼
ViewModel is RECREATED (fresh instance)
← ViewModel does NOT survive process death
← Only savedInstanceState does
State storage tiers:
┌──────────────────────────────────────────────────────┐
│ In-memory (ViewModel, instance variables) │
│ Lost on: rotation AND process death │
│ Size: unlimited │
├──────────────────────────────────────────────────────┤
│ savedInstanceState (Bundle) │
│ Survives: rotation AND process death │
│ Lost on: user explicitly closes app │
│ Size: limited (~1MB — keep it small) │
├──────────────────────────────────────────────────────┤
│ Persistent storage (Room, DataStore, file) │
│ Survives: everything │
│ Size: unlimited │
└──────────────────────────────────────────────────────┘
// savedInstanceState — small serializable data
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString("search_query", searchQuery)
outState.putInt("selected_tab", selectedTabIndex)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
searchQuery = savedInstanceState?.getString("search_query") ?: ""
}
// onRestoreInstanceState — only called when there IS state to restore
// Guaranteed non-null — no null check needed
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
searchQuery = savedInstanceState.getString("search_query") ?: ""
}
// SavedStateHandle — modern approach, bridges ViewModel + savedInstanceState
@HiltViewModel
class SearchViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
// Survives BOTH rotation AND process death
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
val query: StateFlow<String> = savedStateHandle.getStateFlow("query", "")
fun onQueryChanged(newQuery: String) {
savedStateHandle["query"] = newQuery
}
}
adb shell am kill com.your.package.name
Or enable "Don't keep activities" in Developer Options.
Configuration Changes
<!-- Handle rotation without recreating Activity (e.g. ExoPlayer screen) -->
<activity
android:name=".VideoActivity"
android:configChanges="orientation|screenSize|keyboardHidden" />
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {
// Switch to landscape layout manually
}
}
Use @Parcelize for passing complex objects between Activities:
@Parcelize
data class User(val id: String, val name: String, val age: Int) : Parcelable
intent.putExtra("user", user)
val user = intent.getParcelableExtra<User>("user")
Staff Q&A
- onPause() — Activity is still partially visible (e.g., a dialog is on top). Called immediately, must be fast — it blocks the new Activity from showing.
- onStop() — Activity is completely invisible. Safe for heavier operations like database writes.
- The key rule: do MINIMAL work in onPause() because it directly blocks the user from seeing the next screen.
- When the system kills the process due to memory pressure — onStop() may have been called earlier, but onDestroy() might not run at all.
- This is why you must never rely on onDestroy() for critical cleanup like closing database connections or saving user data.
- Design your cleanup strategy around onStop() or earlier lifecycle methods instead.
- That ViewModel survives process death — it does NOT. ViewModel survives configuration changes (rotation) only.
- Only savedInstanceState (Bundle) survives process death.
- SavedStateHandle bridges both: it gives ViewModel access to savedInstanceState data, so ViewModel can restore itself after process death.
- Use
savedStateHandle.saveable { mutableStateOf("") }for values that must survive both rotation and process death.
- A Task is a collection of Activities the user interacts with for one job — like a stack of cards.
- singleTask — one instance per task; clears everything above it when reused (onNewIntent called). Breaks if mixed with FLAG_ACTIVITY_NEW_TASK.
- singleInstance — one instance globally, lives in its own task alone; no other Activities can join its task.
- Use TaskStackBuilder for notifications to synthesize a proper back stack so pressing back from a notification deep link feels natural.
Fragments
Fragment Lifecycle — Two Lifecycles
Fragments have TWO separate lifecycles: the Fragment itself AND its View. The view can be destroyed while the Fragment object stays alive (e.g., when on the back stack).
Fragment added to back stack (view destroyed, fragment survives):
onAttach() → onCreate() → onCreateView() → onViewCreated()
→ onViewStateRestored() → onStart() → onResume()
│
[Another Fragment added on top]
│
onPause() → onStop() → onDestroyView() ◄──── VIEW destroyed
Fragment object stays alive in memory
User presses back (fragment returns):
onCreateView() → onViewCreated() → onStart() → onResume()
Fragment fully destroyed:
onPause() → onStop() → onDestroyView() → onDestroy() → onDetach()
Key callbacks:
onAttach() — Context available
onCreate() — Non-UI initialization (DO NOT access views here)
onCreateView() — Inflate and RETURN the Fragment's layout
onViewCreated() — SET UP UI HERE — find views, set listeners, observe LiveData
onDestroyView() — CLEAR VIEW REFERENCES HERE — set binding = null
onDestroy() — Fragment being destroyed
The _binding Null Pattern — Preventing Memory Leaks
// ❌ WRONG — memory leak
class MyFragment : Fragment() {
private var binding: FragmentMyBinding? = null
override fun onCreateView(...): View {
binding = FragmentMyBinding.inflate(inflater)
return binding!!.root
// binding holds reference to views
// Fragment object stays on back stack
// views hold reference to Context → MEMORY LEAK
}
}
// ✅ CORRECT
class MyFragment : Fragment() {
private var _binding: FragmentMyBinding? = null
private val binding get() = _binding!! // throws if accessed after onDestroyView
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentMyBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Set up UI here — binding is guaranteed non-null
binding.textView.text = "Hello"
// Observe LiveData/Flow with viewLifecycleOwner (NOT this)
viewModel.data.observe(viewLifecycleOwner) { data ->
binding.textView.text = data
}
// Collect Flow with viewLifecycleOwner
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state -> render(state) }
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null // ← CRITICAL: break the reference so GC can collect views
}
}
viewLifecycleOwner when observing in a Fragment. Using this (the Fragment) means the observer stays active even when the view is destroyed (Fragment on back stack), potentially causing updates to a null binding.
Fragment Transactions
// add() — keeps existing Fragment, adds new one on top (both in layout simultaneously)
supportFragmentManager.commit {
add(R.id.container, FragmentB())
addToBackStack("fragment_b")
}
// replace() — removes existing Fragment, adds new one (only one in container)
supportFragmentManager.commit {
replace(R.id.container, FragmentB())
addToBackStack("fragment_b")
}
// commit variants:
// commit() — async, schedules after onResume. Safe for most cases.
// commitNow() — synchronous. Can't be added to back stack.
// commitAllowingStateLoss() — like commit() but allowed after onSaveInstanceState.
// Use carefully — can lose back stack state.
supportFragmentManager.commit(allowStateLoss = true) { replace(...) }
// Pop back stack
supportFragmentManager.popBackStack()
supportFragmentManager.popBackStack("fragment_b", FragmentManager.POP_BACK_STACK_INCLUSIVE)
val count = supportFragmentManager.backStackEntryCount
Passing Data to Fragments
// ❌ WRONG — constructor arguments
class MyFragment(private val userId: String) : Fragment()
// System recreates Fragments using no-arg constructor → crashes on recreation
// ✅ CORRECT — use arguments Bundle
class MyFragment : Fragment() {
companion object {
fun newInstance(userId: String) = MyFragment().apply {
arguments = bundleOf("user_id" to userId)
}
}
override fun onViewCreated(...) {
val userId = arguments?.getString("user_id")
}
}
// ✅ BEST — Safe Args (Navigation Component) or type-safe routes (Compose Navigation)
private val args: DetailFragmentArgs by navArgs()
val userId = args.userId // type-safe, no casting
Fragment Communication — Modern Approaches
// Shared ViewModel — for sibling fragments sharing state
class ListFragment : Fragment() {
private val viewModel: SharedViewModel by activityViewModels()
fun onItemClicked(id: String) { viewModel.selectItem(id) }
}
class DetailFragment : Fragment() {
private val viewModel: SharedViewModel by activityViewModels()
override fun onViewCreated(...) {
viewModel.selectedItem.observe(viewLifecycleOwner) { item ->
showItemDetail(item)
}
}
}
// Fragment Result API — for simple one-way results (e.g., dialog returning selection)
// Sender (e.g., dialog or child fragment):
parentFragmentManager.setFragmentResult(
"request_key",
bundleOf("result" to "some_value")
)
// Receiver (parent fragment):
parentFragmentManager.setFragmentResultListener("request_key", viewLifecycleOwner) { key, bundle ->
val result = bundle.getString("result")
// handle result
}
childFragmentManager vs parentFragmentManager
| Property | What it manages | When to use |
|---|---|---|
parentFragmentManager | Fragments at the same level as this one (sibling Fragments) | Communicating with siblings or host Activity. Fragment Result API between siblings. |
childFragmentManager | Fragments NESTED INSIDE this Fragment | Adding Fragments inside a Fragment's own layout (e.g. ViewPager inside a Fragment). Using wrong one causes transactions attached to wrong lifecycle. |
DialogFragment — The Right Way for Dialogs
class ConfirmDialog : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return AlertDialog.Builder(requireContext())
.setTitle("Confirm")
.setMessage("Are you sure?")
.setPositiveButton("Yes") { _, _ ->
parentFragmentManager.setFragmentResult(
"confirm_result",
bundleOf("confirmed" to true)
)
}
.setNegativeButton("No", null)
.create()
}
}
// Show it
ConfirmDialog().show(parentFragmentManager, "confirm_dialog")
// Why DialogFragment: dialog survives rotation, state management handled automatically.
// AlertDialog.Builder directly in Activity/Fragment = dialog lost on rotation.
Staff Q&A
- The system recreates Fragments using reflection and the no-arg constructor.
- If you add constructor parameters, the system can't recreate the Fragment after process death or configuration change — it crashes with InstantiationException.
- Pass data via
argumentsBundle, which is automatically saved/restored by the Fragment framework.
- add() — adds a new Fragment on top. Both exist simultaneously in the container. The one below may still be visible (useful for overlays, transparent dialogs). Both are "active" — both receive lifecycle events.
- replace() — removes the existing Fragment and adds the new one. Only one Fragment in the container at a time. The removed Fragment goes through onDestroyView() (and onDestroy() if not on back stack).
- Use
replace()for main navigation. Useadd()for overlays where you want to see the Fragment below.
- commit() — asynchronous, scheduled after current frame. Safe for most cases, works with back stack. The Fragment appears in the next layout pass.
- commitNow() — synchronous, executes immediately. Use when you need the Fragment to be in the FragmentManager before the next line of code (e.g., finding it by tag immediately after adding). Cannot be added to back stack.
- Prefer commit() — commitNow() can cause issues when called during certain lifecycle states.
BroadcastReceiver & Permissions
BroadcastReceiver
A BroadcastReceiver listens for system-wide or app-specific broadcast messages. Think of it as a pub/sub system — anyone can publish a broadcast, registered receivers receive it. onReceive() runs on the main thread and must be fast (under 10 seconds or ANR).
Two types of broadcasts: System Broadcasts App Broadcasts ───────────────────────────── ──────────────────────────────── ACTION_BOOT_COMPLETED Custom actions your app defines ACTION_BATTERY_LOW Used for inter-component ACTION_CONNECTIVITY_CHANGE communication within or between apps ACTION_AIRPLANE_MODE_CHANGED ACTION_SCREEN_ON / OFF ACTION_PACKAGE_ADDED ACTION_TIMEZONE_CHANGED Two ways to register: 1. Static (manifest) — system can start your app to deliver 2. Dynamic (in code) — only while your component is alive
Static Registration (Manifest)
<receiver
android:name=".BootReceiver"
android:exported="true"> <!-- Android 12+ requires explicit exported -->
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
// Reschedule WorkManager jobs after reboot
WorkManager.getInstance(context).enqueue(syncRequest)
}
}
}
Dynamic Registration (In Code)
class MainActivity : AppCompatActivity() {
private val networkReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val isConnected = isNetworkConnected(context)
updateNetworkStatus(isConnected)
}
}
override fun onStart() {
super.onStart()
// Register — receive while Activity is visible
val filter = IntentFilter().apply {
addAction(ConnectivityManager.CONNECTIVITY_ACTION)
addAction(Intent.ACTION_AIRPLANE_MODE_CHANGED)
}
registerReceiver(networkReceiver, filter)
}
override fun onStop() {
super.onStop()
// Always unregister — memory leak if forgotten
unregisterReceiver(networkReceiver)
}
}
// Register/unregister pairs:
// onCreate() ↔ onDestroy() — entire lifetime
// onStart() ↔ onStop() — while visible (most common)
// onResume() ↔ onPause() — while interactive
Doing Async Work in onReceive()
class SyncReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// ❌ WRONG — coroutine may not complete before onReceive() returns
// CoroutineScope(Dispatchers.IO).launch { doWork() }
// ✅ ACCEPTABLE for short work (<30s) — extends window
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
doWork()
} finally {
pendingResult.finish() // must call or ANR
}
}
// ✅ BEST for longer work — hand off to WorkManager
WorkManager.getInstance(context).enqueue(
OneTimeWorkRequestBuilder<SyncWorker>().build()
)
}
}
Sending Broadcasts
// Send to all registered receivers
sendBroadcast(Intent("com.example.MY_ACTION").apply {
putExtra("data", "some_value")
})
// Send only to receivers in your app (more secure)
sendBroadcast(Intent("com.example.MY_ACTION").apply {
setPackage(packageName)
})
// Ordered broadcast — receivers process one at a time, can abort or modify
sendOrderedBroadcast(intent, null)
// LocalBroadcastManager is DEPRECATED
// Modern replacement: LiveData, StateFlow, or explicit package broadcasts
Security
// exported=false — only your app can send to this receiver (Android 12+)
// <receiver android:name=".MyReceiver" android:exported="false">
// Send with permission — only receivers holding this permission receive it
sendBroadcast(Intent("com.example.MY_ACTION"), "com.example.MY_PERMISSION")
// Register with permission — only senders holding this permission can deliver
registerReceiver(myReceiver, IntentFilter("com.example.MY_ACTION"),
"com.example.MY_PERMISSION", null)
Permissions
Two categories of permissions:
Normal Permissions Dangerous Permissions
──────────────────────── ──────────────────────────────
Granted at install time Must be requested at RUNTIME
User never sees a dialog User sees a permission dialog
Low risk to privacy Direct access to sensitive data
Examples: Examples:
INTERNET READ_CONTACTS, WRITE_CONTACTS
VIBRATE CAMERA
BLUETOOTH ACCESS_FINE_LOCATION
RECEIVE_BOOT_COMPLETED READ_CALL_LOG
NFC RECORD_AUDIO
READ_EXTERNAL_STORAGE / media
Runtime Permission Flow — Full Implementation
class CameraActivity : AppCompatActivity() {
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
openCamera()
} else {
if (shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
// User denied but did NOT check "Don't ask again"
// Show explanation, then ask again
showRationaleDialog()
} else {
// User checked "Don't ask again" OR first denial on some devices
// Must send user to app settings
showSettingsDialog()
}
}
}
private val requestMultiplePermissions = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val cameraGranted = permissions[Manifest.permission.CAMERA] ?: false
val audioGranted = permissions[Manifest.permission.RECORD_AUDIO] ?: false
if (cameraGranted && audioGranted) startVideoRecording()
}
fun checkAndRequestCamera() {
when {
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED -> openCamera()
shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) ->
showRationaleDialog()
else -> requestPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
private fun showRationaleDialog() {
AlertDialog.Builder(this)
.setTitle("Camera Permission Required")
.setMessage("We need camera access to scan QR codes.")
.setPositiveButton("Grant") { _, _ ->
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
}
.setNegativeButton("Cancel", null)
.show()
}
private fun showSettingsDialog() {
AlertDialog.Builder(this)
.setTitle("Permission Denied")
.setMessage("Please enable camera permission in Settings.")
.setPositiveButton("Settings") { _, _ ->
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null)))
}
.setNegativeButton("Cancel", null)
.show()
}
}
shouldShowRequestPermissionRationale — The Key Method
Permission state machine via shouldShowRequestPermissionRationale(): Never asked before: → shouldShowRequestPermissionRationale() = false → Ask directly User denied once: → shouldShowRequestPermissionRationale() = true → Show explanation first, then ask again User checked "Don't ask again": → shouldShowRequestPermissionRationale() = false → Send to Settings (can't show dialog anymore) Permission granted: → checkSelfPermission() = PERMISSION_GRANTED → Proceed directly
Storage Permission Evolution (Android 6 → 13+)
| Android Version | Storage Approach |
|---|---|
| Android 9 and below | READ_EXTERNAL_STORAGE + WRITE_EXTERNAL_STORAGE |
| Android 10 (Scoped Storage) | Apps get isolated sandbox. READ_EXTERNAL_STORAGE still needed for other apps' media. |
| Android 11 | MANAGE_EXTERNAL_STORAGE for full access (Play Store justification required). Use MediaStore API for media. |
| Android 13+ | READ_EXTERNAL_STORAGE replaced by granular: READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO |
// Handle storage permission across versions
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestPermissionLauncher.launch(Manifest.permission.READ_MEDIA_IMAGES)
} else {
requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
Location Permissions
// Coarse — approximate (city level)
Manifest.permission.ACCESS_COARSE_LOCATION
// Fine — precise (GPS)
Manifest.permission.ACCESS_FINE_LOCATION
// Background — Android 10+ — must request SEPARATELY after foreground granted
// Cannot request foreground + background at the same time (system rejects)
Manifest.permission.ACCESS_BACKGROUND_LOCATION
Special Permissions
// Draw over other apps (overlay)
if (!Settings.canDrawOverlays(this)) {
startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$packageName")))
}
// Schedule exact alarms (Android 12+)
if (!alarmManager.canScheduleExactAlarms()) {
startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
}
// Install unknown apps (Android 8+)
if (!packageManager.canRequestPackageInstalls()) {
startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:$packageName")))
}
Staff Q&A
- Before Android 6, all permissions were granted at install time. Users rarely read the permission list and had no granular control.
- Runtime permissions give users context — they see the request at the exact moment it's needed, with the app's current action making the reason obvious.
- Users can grant permissions conditionally (only while using the app for location) and revoke them later without uninstalling.
- This significantly improved privacy and user trust — a core Android design principle since Marshmallow.
- Nothing visible — the system silently ignores the request and immediately calls the result callback with PERMISSION_DENIED.
shouldShowRequestPermissionRationale()returnsfalsein this state — this is how you distinguish "permanently denied" from "never asked".- The only recourse is sending the user to app Settings where they can manually re-enable the permission.
- Scoped Storage (Android 10+) gives each app an isolated storage sandbox — apps can freely read/write their own files without any permission.
- Accessing other apps' files requires either the MediaStore API (for media) or the Storage Access Framework (for documents).
- Google introduced it to prevent apps from accessing arbitrary files — a major privacy improvement. Before this, any app with READ_EXTERNAL_STORAGE could read all photos, documents, and sensitive files.
- Implication: most apps no longer need READ_EXTERNAL_STORAGE or WRITE_EXTERNAL_STORAGE if they only work with their own files or use MediaStore.
- android:exported=false — OS-level enforcement. Other apps cannot send broadcasts to this receiver even if they try. The system rejects delivery.
- LocalBroadcastManager — process-level isolation. Broadcasts stay within your process. Other apps never see them. More efficient (in-process, no IPC overhead). But now deprecated.
- Modern replacement for LocalBroadcastManager: use LiveData, StateFlow, or explicit package broadcasts (
setPackage(packageName)).
Services & Background Work
Services Overview
A Service is an Android component that runs without a user interface. It runs on the main thread by default — you must manage your own threading inside a Service.
Three types of Services: ┌─────────────────────────────────────────────────────┐ │ Foreground Service │ │ • User-visible (persistent notification required) │ │ • Music, navigation, fitness, file upload │ │ • High priority — system rarely kills │ │ • Must declare foregroundServiceType (Android 10+) │ ├─────────────────────────────────────────────────────┤ │ Background Service │ │ • User unaware │ │ • SEVERELY restricted on Android 8+ │ │ • Apps in background can only run for a few minutes │ │ • Use WorkManager instead for almost everything │ ├─────────────────────────────────────────────────────┤ │ Bound Service │ │ • Components bind and interact via an interface │ │ • Lives as long as something is bound │ │ • IPC, exposing APIs to other components │ └─────────────────────────────────────────────────────┘
Foreground Service
class MusicPlayerService : Service() {
private val binder = MusicBinder()
override fun onCreate() {
super.onCreate()
// Service created — initialize player
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_PLAY -> startPlayback()
ACTION_PAUSE -> pausePlayback()
ACTION_STOP -> stopSelf()
}
// Must call startForeground() within 5 seconds of startForegroundService()
startForeground(NOTIFICATION_ID, buildNotification())
// Return value tells system what to do if Service is killed:
return START_STICKY // recreate with null intent (music player)
// START_NOT_STICKY — don't recreate (one-time operations)
// START_REDELIVER_INTENT — recreate with last intent (file download)
}
private fun buildNotification(): Notification {
// Must create notification channel on Android 8+
val channel = NotificationChannel(CHANNEL_ID, "Music Playback",
NotificationManager.IMPORTANCE_LOW)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Now Playing")
.setContentText("Song Title")
.setSmallIcon(R.drawable.ic_music)
.setOngoing(true) // can't be dismissed by user
.addAction(R.drawable.ic_pause, "Pause", buildPendingIntent(ACTION_PAUSE))
.addAction(R.drawable.ic_stop, "Stop", buildPendingIntent(ACTION_STOP))
.build()
}
override fun onBind(intent: Intent): IBinder = binder
inner class MusicBinder : Binder() {
fun getService(): MusicPlayerService = this@MusicPlayerService
}
companion object {
const val ACTION_PLAY = "action_play"
const val ACTION_PAUSE = "action_pause"
const val ACTION_STOP = "action_stop"
const val NOTIFICATION_ID = 1
const val CHANNEL_ID = "music_channel"
}
}
<!-- Manifest -->
<service
android:name=".MusicPlayerService"
android:foregroundServiceType="mediaPlayback" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
// Starting the foreground service
val intent = Intent(this, MusicPlayerService::class.java).apply {
action = MusicPlayerService.ACTION_PLAY
}
// Must use startForegroundService() on Android 8+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
Foreground Service Types (Android 10+)
android:foregroundServiceType="mediaPlayback" <!-- music/video -->
android:foregroundServiceType="location" <!-- GPS tracking -->
android:foregroundServiceType="camera" <!-- video recording -->
android:foregroundServiceType="microphone" <!-- audio recording -->
android:foregroundServiceType="dataSync" <!-- upload/download -->
android:foregroundServiceType="connectedDevice" <!-- Bluetooth/USB -->
<!-- Multiple: "mediaPlayback|location" -->
Bound Service
class DownloadService : Service() {
private val binder = DownloadBinder()
inner class DownloadBinder : Binder() {
fun getService(): DownloadService = this@DownloadService
}
override fun onBind(intent: Intent): IBinder = binder
override fun onUnbind(intent: Intent): Boolean = true // true = onRebind() called
// Public API exposed to bound clients
fun startDownload(url: String) { /* ... */ }
fun getProgress(): Int = currentProgress
fun cancelDownload() { /* ... */ }
}
// Binding from Activity
class MainActivity : AppCompatActivity() {
private var downloadService: DownloadService? = null
private var isBound = false
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
downloadService = (binder as DownloadService.DownloadBinder).getService()
isBound = true
}
override fun onServiceDisconnected(name: ComponentName) {
// Called on unexpected disconnection (crash, killed)
downloadService = null
isBound = false
}
}
override fun onStart() {
super.onStart()
Intent(this, DownloadService::class.java).also { intent ->
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
}
override fun onStop() {
super.onStop()
if (isBound) { unbindService(serviceConnection); isBound = false }
}
}
WorkManager
WorkManager is the recommended solution for all deferrable background work that must complete even if the app exits or the device restarts.
WorkManager internally uses the best available mechanism per OS version: Android 8+ → JobScheduler Android 5-7 → JobScheduler or Firebase JobDispatcher Below 5 → AlarmManager + BroadcastReceiver WorkManager handles: ✓ Doze mode — defers work correctly ✓ App Standby buckets ✓ Device restarts — work persists ✓ Constraints — network, battery, storage ✓ Retry with backoff ✓ Chaining — sequential and parallel ✓ Observing work status
// CoroutineWorker — preferred for Kotlin
class SyncWorker(context: Context, params: WorkerParameters)
: CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
val userId = inputData.getString("user_id") ?: return Result.failure()
val data = syncRepository.syncUserData(userId)
Result.success(workDataOf("synced_count" to data.size))
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
}
// One-time work with constraints
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf("user_id" to "123"))
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true)
.build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS)
.addTag("sync_work")
.build()
WorkManager.getInstance(context).enqueue(syncRequest)
// Periodic work (minimum 15 minutes)
val periodicRequest = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(constraints).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"periodic_sync",
ExistingPeriodicWorkPolicy.KEEP,
periodicRequest
)
// Chaining — sequential: A → B → C
WorkManager.getInstance(context).beginWith(workA).then(workB).then(workC).enqueue()
// Parallel then sequential: (A + B) → C
WorkManager.getInstance(context)
.beginWith(listOf(workA, workB)) // A and B run in parallel
.then(workC) // C runs after both complete
.enqueue()
// Observing work status
WorkManager.getInstance(context)
.getWorkInfoByIdLiveData(syncRequest.id)
.observe(this) { workInfo ->
when (workInfo.state) {
WorkInfo.State.RUNNING -> showProgress()
WorkInfo.State.SUCCEEDED -> {
val count = workInfo.outputData.getInt("synced_count", 0)
showSuccess("Synced $count items")
}
WorkInfo.State.FAILED -> showError()
else -> {}
}
}
// Cancel work
WorkManager.getInstance(context).cancelWorkById(syncRequest.id)
WorkManager.getInstance(context).cancelAllWorkByTag("sync_work")
AlarmManager & Doze Mode
AlarmManager is for work that needs to happen at a specific time — not deferrable. Use WorkManager for most cases. AlarmManager for user-visible reminders only.
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val pendingIntent = PendingIntent.getBroadcast(
this, 0,
Intent(this, ReminderReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// setExactAndAllowWhileIdle — fires even during Doze mode
// Use for user-visible alarms (calendar reminders, timers)
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerTimeMillis,
pendingIntent
)
// Android 12+ requires permission check
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(...)
} else {
startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
}
| Alarm Type | Clock Base | Wakes Device? | Use For |
|---|---|---|---|
| RTC | Wall clock (real time) | No | Specific date/time, device already awake |
| RTC_WAKEUP | Wall clock (real time) | Yes | Specific date/time, must fire even if asleep |
| ELAPSED_REALTIME | Since boot | No | Interval-based, device already awake |
| ELAPSED_REALTIME_WAKEUP | Since boot | Yes | Interval-based, must fire even if asleep |
Doze Mode & App Standby
Doze mode (Android 6+) — when screen is off, stationary, unplugged: Network access suspended Wake locks ignored AlarmManager deferred (EXCEPT setExactAndAllowWhileIdle) WorkManager jobs deferred GPS/sensors restricted System provides maintenance windows periodically App Standby Buckets (Android 6+): ACTIVE → Currently in use — no restrictions WORKING_SET → Used regularly — minimal restrictions FREQUENT → Used often — moderate restrictions RARE → Rarely used — significant restrictions RESTRICTED → Almost never (Android 11+) — severe restrictions Lower buckets = fewer background execution opportunities per day WorkManager handles both Doze and Standby correctly. Use setExactAndAllowWhileIdle() ONLY for user-visible alarms.
Staff Q&A
- START_STICKY — system recreates Service after being killed, with null Intent. Use for music players that should restart automatically. Service re-enters a running state but without the original command.
- START_NOT_STICKY — system does NOT recreate Service. Use for one-time operations that can be safely retried later when the user triggers them again.
- START_REDELIVER_INTENT — system recreates Service AND redelivers the last Intent. Use for file downloads or operations where you need to know what was being processed when the service was killed.
- Android 8+ background execution limits mean background Services are killed after a few minutes when the app is in the background.
- Android enforces this aggressively — apps that try to start background Services while backgrounded throw IllegalStateException.
- WorkManager is the correct replacement — it uses JobScheduler (Android 8+) under the hood, which respects Doze mode and App Standby and survives device restarts.
- Foreground Services (with a visible notification) are exempt from background limits — use them for user-visible ongoing work.
- WorkManager — for deferrable work. Will eventually run even if delayed by Doze mode, App Standby, or device restarts. Best for sync, uploads, periodic reports — anything where "soon" is acceptable.
- AlarmManager — for time-critical work that must happen at a specific moment.
setExactAndAllowWhileIdle()fires even during Doze — use for user-visible reminders like calendar events, medication reminders. - Rule: if the user would be annoyed by a 15-minute delay → AlarmManager. If "within the next hour" is fine → WorkManager.
ViewModel & LiveData
ViewModel Internals & Survival
ViewModel survival across rotation:
Rotation happens
│
▼
onSaveInstanceState() called on Activity
│
▼
Activity DESTROYED (onDestroy called)
│
▼ ← ViewModel stored in ViewModelStore (RETAINED by system)
← ViewModelStore is NOT destroyed with Activity
│
▼
New Activity instance created (onCreate called)
│
▼
New Activity gets the SAME ViewModelStore
│
▼
viewModels() delegate retrieves EXISTING ViewModel
│
▼
ViewModel.onCleared() NOT called yet
When user truly leaves (back button, finish()):
ViewModelStore cleared → ViewModel.onCleared() IS called
KEY MISCONCEPTION: ViewModel does NOT survive process death.
Only savedInstanceState (Bundle) survives process death.
ViewModel survives ROTATION ONLY.
@HiltViewModel
class UserViewModel @Inject constructor(
private val getUsersUseCase: GetUsersUseCase,
private val deleteUserUseCase: DeleteUserUseCase,
private val savedStateHandle: SavedStateHandle // bridges ViewModel + savedInstanceState
) : ViewModel() {
// Single UI state — single source of truth for UI
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
// One-time events — SharedFlow with replay=0 so rotation doesn't re-deliver them
private val _events = MutableSharedFlow<UserEvent>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val events: SharedFlow<UserEvent> = _events.asSharedFlow()
// viewModelScope — SupervisorJob + Dispatchers.Main.immediate
// Automatically cancelled when ViewModel.onCleared() is called
init { loadUsers() }
private fun loadUsers() {
viewModelScope.launch {
getUsersUseCase()
.catch { e -> _uiState.value = UserUiState.Error(e.message ?: "") }
.collect { users ->
_uiState.value = if (users.isEmpty()) UserUiState.Empty
else UserUiState.Success(users)
}
}
}
fun refreshUsers() {
viewModelScope.launch {
_uiState.update { current ->
if (current is UserUiState.Success) current.copy(isRefreshing = true)
else UserUiState.Loading
}
// ... refresh logic
}
}
fun onUserClicked(userId: String) {
viewModelScope.launch {
_events.emit(UserEvent.NavigateToDetail(userId))
}
}
override fun onCleared() {
super.onCleared()
// viewModelScope auto-cancelled — no manual cleanup needed
// Release non-coroutine resources here
}
}
sealed class UserUiState {
object Loading : UserUiState()
object Empty : UserUiState()
data class Success(val users: List<User>, val isRefreshing: Boolean = false) : UserUiState()
data class Error(val message: String) : UserUiState()
}
sealed class UserEvent {
data class NavigateToDetail(val userId: String) : UserEvent()
data class ShowSnackbar(val message: String) : UserEvent()
}
ViewModel Scopes
// Fragment-scoped (default) — destroyed when Fragment is destroyed
private val viewModel: MyViewModel by viewModels()
// Activity-scoped from Fragment — shared with other Fragments in same Activity
private val sharedViewModel: SharedViewModel by activityViewModels()
// Navigation graph-scoped — shared within a flow, destroyed when graph is popped
private val checkoutViewModel: CheckoutViewModel
by navGraphViewModels(R.id.checkout_graph) { defaultViewModelProviderFactory }
// Custom factory — when ViewModel needs constructor params not provided by Hilt
private val viewModel: UserViewModel by viewModels {
UserViewModelFactory(userRepository, userId)
}
SavedStateHandle — Process Death Survival
@HiltViewModel
class SearchViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
// Automatically saved to Bundle and restored after process death
// Survives BOTH rotation AND process death
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
// As StateFlow — observe like any other flow
val query: StateFlow<String> = savedStateHandle.getStateFlow(
key = "search_query",
initialValue = ""
)
fun onQueryChanged(newQuery: String) {
savedStateHandle["search_query"] = newQuery
}
// In Navigation Compose — get route args from SavedStateHandle
private val productId: String = savedStateHandle.toRoute<ProductRoute>().productId
}
LiveData, StateFlow & Transformations
LiveData Basics
// MutableLiveData — can be changed
private val _count = MutableLiveData<Int>(0)
val count: LiveData<Int> = _count // read-only exposed to UI
// From main thread
_count.value = 42
// From background thread — posts to main thread
_count.postValue(42)
LiveData Transformations
class UserViewModel(private val repository: UserRepository) : ViewModel() {
private val userId = MutableLiveData<String>()
// map — transform the value (1:1 transformation)
val userDisplayName: LiveData<String> = userId.map { id -> "User #$id" }
// switchMap — switch to a new LiveData source based on value
// Used when the value determines WHICH LiveData to observe
val user: LiveData<User> = userId.switchMap { id ->
repository.getUserById(id) // returns LiveData<User>
}
// MediatorLiveData — merge multiple LiveData sources
val combinedData = MediatorLiveData<String>().apply {
addSource(liveDataA) { value = "A: $it" }
addSource(liveDataB) { value = "B: $it" }
}
fun loadUser(id: String) {
userId.value = id // triggers switchMap → user LiveData updates
}
}
StateFlow vs LiveData — Full Comparison
| Feature | LiveData | StateFlow |
|---|---|---|
| Lifecycle-aware | ✅ Built in | ❌ Needs repeatOnLifecycle |
| Always has a value | ❌ Can be null initially | ✅ Requires initial value |
| Thread safety | postValue() for background | Coroutine-safe by default |
| Kotlin-first | ❌ Java-based | ✅ |
| Transformation operators | map, switchMap only | Full Flow operators |
| Testing | Needs InstantTaskExecutorRule | Easier with Turbine |
| Conflation | No | Yes — only latest kept if collector is slow |
| Google recommendation | Still valid, legacy | ✅ Preferred for new Kotlin code |
Collecting StateFlow Safely
// Fragment — repeatOnLifecycle is REQUIRED
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
// repeatOnLifecycle(STARTED):
// • Starts collecting when lifecycle reaches STARTED
// • CANCELS collection when lifecycle drops below STARTED (app backgrounded)
// • RESTARTS collection when lifecycle returns to STARTED
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.uiState.collect { state ->
when (state) {
is UserUiState.Loading -> showLoading()
is UserUiState.Success -> showUsers(state.users)
is UserUiState.Error -> showError(state.message)
is UserUiState.Empty -> showEmpty()
}
}
}
launch {
viewModel.events.collect { event ->
when (event) {
is UserEvent.NavigateToDetail ->
findNavController().navigate(...)
is UserEvent.ShowSnackbar ->
Snackbar.make(binding.root, event.message, LENGTH_SHORT).show()
}
}
}
}
}
}
// ❌ WRONG — keeps collecting even when app is backgrounded
// viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState.collect { } }
Staff Q&A
- Encapsulation — only the ViewModel should change state. The UI should only observe and react.
- Enforces unidirectional data flow — state changes come from one place (ViewModel), making them traceable.
- A common mistake is exposing MutableLiveData directly — any component could mutate state, making bugs very hard to track down.
_uiState(private MutableStateFlow) vsuiState(public StateFlow) is the standard pattern.
- StateFlow is a SharedFlow with replay=1 and a required initial value. Always holds and replays the latest value to new collectors. Use for UI state — always needs a current value.
- SharedFlow is more flexible: configurable replay count (0 for one-time events), no required initial value, configurable buffer overflow. Use for events that should be consumed once (navigation, snackbars).
- If you use StateFlow for navigation events, rotation re-delivers the navigation — the user navigates again. SharedFlow with replay=0 prevents this.
- viewModelScope is a CoroutineScope with
Dispatchers.Main.immediate + SupervisorJob(). - SupervisorJob means child coroutine failures don't cancel sibling coroutines — one failing network call won't cancel all other running coroutines.
- The scope is cancelled in ViewModel.onCleared() — automatically cancels all running coroutines, no manual cleanup needed.
- Never use GlobalScope in a ViewModel — it lives for the entire app lifetime, causes memory leaks, and can't be cancelled when the user leaves the screen.
Room Database
Core Components
Room Architecture: ┌─────────────────────────────────────────────────────┐ │ @Database — the database itself │ │ One singleton per app │ │ Lists all @Entity classes and @Dao interfaces │ ├─────────────────────────────────────────────────────┤ │ @Entity — a table in the database │ │ One data class = one table │ │ One instance = one row │ ├─────────────────────────────────────────────────────┤ │ @Dao — Data Access Object │ │ Interface defining all database operations │ │ Room generates UserDao_Impl at compile time │ │ SQL errors become BUILD errors, not runtime crashes│ └─────────────────────────────────────────────────────┘
// @Entity
@Entity(
tableName = "users",
indices = [
Index(value = ["email"], unique = true),
Index(value = ["last_name", "first_name"])
]
)
data class UserEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "first_name") val firstName: String,
@ColumnInfo(name = "last_name") val lastName: String,
@ColumnInfo(name = "email") val email: String,
@ColumnInfo(name = "is_active") val isActive: Boolean = true,
@Ignore val formattedName: String = "$firstName $lastName" // not stored
)
// Embedded objects — Address fields become columns in users table
data class Address(val street: String, val city: String, val country: String)
@Entity
data class UserEntity(
@PrimaryKey val id: String,
val name: String,
@Embedded val address: Address,
@Embedded(prefix = "work_") val workAddress: Address // prefix avoids column conflicts
)
// TypeConverters — for types Room doesn't understand natively
class Converters {
@TypeConverter fun fromTimestamp(value: Long?): Date? = value?.let { Date(it) }
@TypeConverter fun dateToTimestamp(date: Date?): Long? = date?.time
@TypeConverter fun fromStringList(value: String?): List<String>? = value?.split(",")
@TypeConverter fun toStringList(list: List<String>?): String? = list?.joinToString(",")
}
DAO — Data Access Objects
@Dao
interface UserDao {
// INSERT
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: UserEntity)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertUsers(users: List<UserEntity>)
// UPSERT — Room 2.5+ (insert or update)
@Upsert
suspend fun upsertUser(user: UserEntity)
// UPDATE
@Update
suspend fun updateUser(user: UserEntity)
// DELETE
@Delete
suspend fun deleteUser(user: UserEntity)
@Query("DELETE FROM users WHERE id = :userId")
suspend fun deleteUserById(userId: String)
// SELECT — Flow for reactive queries (emits on every change)
@Query("SELECT * FROM users ORDER BY last_name ASC")
fun getAllUsers(): Flow<List<UserEntity>>
// SELECT — suspend for one-shot queries
@Query("SELECT * FROM users WHERE id = :userId")
suspend fun getUserById(userId: String): UserEntity?
// SELECT with search
@Query("SELECT * FROM users WHERE first_name LIKE '%' || :query || '%' OR last_name LIKE '%' || :query || '%'")
fun searchUsers(query: String): Flow<List<UserEntity>>
// COUNT
@Query("SELECT COUNT(*) FROM users")
fun getUserCount(): Flow<Int>
// Partial update — only specific columns
@Query("UPDATE users SET email = :email WHERE id = :userId")
suspend fun updateEmail(userId: String, email: String)
// @Transaction required for relationships — ensures consistent data snapshot
@Transaction
@Query("SELECT * FROM users")
fun getUsersWithOrders(): Flow<List<UserWithOrders>>
// RawQuery — for truly dynamic SQL
@RawQuery(observedEntities = [UserEntity::class])
fun getUsersByRawQuery(query: SupportSQLiteQuery): Flow<List<UserEntity>>
}
// OnConflictStrategy options:
// REPLACE — delete existing row, insert new (changes rowId, triggers unnecessary invalidation)
// IGNORE — keep existing row, discard new
// ABORT — roll back transaction (default)
// FAIL — throw exception
// Prefer @Upsert over REPLACE — safer for foreign keys
Relationships
// One-to-Many: User has many Orders
@Entity
data class Order(
@PrimaryKey val orderId: String,
val userId: String, // foreign key
val total: Double
)
data class UserWithOrders(
@Embedded val user: UserEntity,
@Relation(parentColumn = "id", entityColumn = "userId")
val orders: List<Order>
)
// @Transaction is REQUIRED for relationships
// Without it: Room makes two separate queries that could see inconsistent data
@Dao
interface UserDao {
@Transaction
@Query("SELECT * FROM users WHERE id = :userId")
suspend fun getUserWithOrders(userId: String): UserWithOrders?
}
// Many-to-Many: Users can have many Courses, Courses can have many Users
@Entity(primaryKeys = ["userId", "courseId"])
data class UserCourseCrossRef(val userId: String, val courseId: String)
data class UserWithCourses(
@Embedded val user: UserEntity,
@Relation(
parentColumn = "id",
entityColumn = "courseId",
associateBy = Junction(UserCourseCrossRef::class)
)
val courses: List<Course>
)
Advanced Room — Transactions, Migrations, Testing
Transactions
// @Transaction on DAO — atomic operation
@Transaction
suspend fun transferBalance(fromId: String, toId: String, amount: Double) {
decreaseBalance(fromId, amount) // if this fails...
increaseBalance(toId, amount) // ...this is rolled back automatically
}
// Manual transaction for multi-DAO operations
suspend fun syncAllData(users: List<UserEntity>, orders: List<Order>) {
database.withTransaction {
userDao.deleteAllUsers()
userDao.insertUsers(users)
orderDao.deleteAllOrders()
orderDao.insertOrders(orders)
// If ANY step fails → ALL are rolled back
}
}
How InvalidationTracker Works
When any write happens to a table:
1. InvalidationTracker marks that table as "dirty"
2. All active Flow queries referencing that table are re-run
3. New results emitted to collectors automatically
userDao.insertUser(newUser) → triggers re-run of:
getAllUsers() Flow ✓ observes "users" table
searchUsers() Flow ✓ observes "users" table
getOrdersForUser() Flow ✗ observes "orders" table — NOT triggered
Performance tip: use .distinctUntilChanged() to skip identical emissions
after a write that doesn't actually change the query result.
FTS — Full Text Search
// FTS4 table for fast text search across large datasets
@Fts4(contentEntity = UserEntity::class)
@Entity(tableName = "users_fts")
data class UserFts(
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Int,
val firstName: String,
val lastName: String
)
@Dao
interface UserDao {
// FTS query — much faster than LIKE for large datasets
@Query("SELECT * FROM users WHERE rowid IN (SELECT rowid FROM users_fts WHERE users_fts MATCH :query)")
fun searchUsersFts(query: String): Flow<List<UserEntity>>
}
N+1 Problem
// ❌ N+1 queries — one for users + one per user for orders
suspend fun getUsersWithOrders(): List<UserWithOrders> {
val users = userDao.getAllUsersOnce()
return users.map { user ->
val orders = orderDao.getOrdersForUser(user.id) // N extra queries!
UserWithOrders(user, orders)
}
}
// ✅ 2 queries total — Room handles the JOIN efficiently
@Transaction
@Query("SELECT * FROM users")
fun getUsersWithOrders(): Flow<List<UserWithOrders>>
Migrations
// Version 1 → 2: Add phone column
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE users ADD COLUMN phone TEXT DEFAULT '' NOT NULL")
}
}
// Version 2 → 3: Rename column (SQLite doesn't support RENAME COLUMN before 3.25)
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE users_new (id TEXT PRIMARY KEY NOT NULL, first_name TEXT NOT NULL, email_address TEXT NOT NULL)")
database.execSQL("INSERT INTO users_new SELECT id, first_name, email FROM users")
database.execSQL("DROP TABLE users")
database.execSQL("ALTER TABLE users_new RENAME TO users")
}
}
// Auto-migration (Room 2.4+) for simple changes
@Database(entities = [UserEntity::class], version = 3,
autoMigrations = [
AutoMigration(from = 1, to = 2), // simple — handles automatically
AutoMigration(from = 2, to = 3, spec = Migration2To3::class) // rename
])
abstract class AppDatabase : RoomDatabase()
@RenameColumn(tableName = "users", fromColumnName = "email", toColumnName = "email_address")
class Migration2To3 : AutoMigrationSpec
// Register in builder
Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
Testing Room
@RunWith(AndroidJUnit4::class)
class UserDaoTest {
private lateinit var database: AppDatabase
private lateinit var userDao: UserDao
@Before fun setup() {
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(), AppDatabase::class.java
).allowMainThreadQueries().build()
userDao = database.userDao()
}
@After fun teardown() { database.close() }
@Test fun insertAndRetrieve() = runTest {
val user = UserEntity(id = "1", firstName = "John", lastName = "Doe", email = "j@t.com")
userDao.insertUser(user)
assertThat(userDao.getUserById("1")).isEqualTo(user)
}
@Test fun getAllUsers_emitsOnInsert() = runTest {
userDao.getAllUsers().test {
assertThat(awaitItem()).isEmpty()
userDao.insertUser(testUser)
assertThat(awaitItem()).hasSize(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test fun migration_1_to_2_preservesData() {
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(), AppDatabase::class.java)
helper.createDatabase("test_db", 1).apply {
execSQL("INSERT INTO users VALUES ('1', 'John', 'Doe', 'john@test.com', 1, 0)")
close()
}
val db = helper.runMigrationsAndValidate("test_db", 2, true, MIGRATION_1_2)
val cursor = db.query("SELECT * FROM users WHERE id = '1'")
assertThat(cursor.count).isEqualTo(1)
cursor.close(); db.close()
}
}
Staff Q&A
- Database operations can take unpredictable time — milliseconds to seconds depending on data size and query complexity.
- Running on the main thread would block UI updates and cause ANRs (Application Not Responding).
- Room enforces this at runtime by checking the current thread and throwing IllegalStateException if you try to run a query on the main thread.
- Exception: allowMainThreadQueries() exists for tests only — never use in production.
- Flow — stays active and emits a new value every time the underlying data changes. Use for queries the UI should always reflect the latest state of. Never call these from a coroutine directly — collect them.
- suspend — one-shot operation. Run once, return result, done. Use for mutations (INSERT, UPDATE, DELETE) and one-time reads where you don't need reactive updates.
- Rule: if you need "live" data that updates automatically → Flow. If you need to do something once and get a result → suspend.
- Room executes relationship queries as multiple separate SQL statements (one for the parent, one for children).
- Without @Transaction, another thread could modify data between those two queries, giving you a UserWithOrders where the orders don't match the user.
- @Transaction wraps all queries in a single database transaction — you get a consistent snapshot of the data at one point in time.
- It deletes the entire database and recreates it when no migration path is found — ALL user data is lost.
- Only acceptable during development when the schema is changing rapidly and test data doesn't matter.
- Never use in production unless you have a way to re-sync all data from a server and users understand local data will be cleared.
- Better alternative: always write proper migrations, even for early development, so you build the habit.
Dependency Injection (Hilt)
Why DI & Hilt Basics
Without DI (tight coupling):
class UserRepository {
private val api = UserApi(OkHttpClient(), "https://api.example.com")
private val db = Room.databaseBuilder(...).build()
// ❌ Can't test (can't swap real network for fake)
// ❌ Can't control lifecycle (who creates? who owns? who destroys?)
// ❌ Multiple instances created uncontrolled
}
With DI (loose coupling):
class UserRepository(
private val api: UserApi, // injected from outside
private val userDao: UserDao // injected from outside
) {
// ✅ Inject FakeUserApi in tests
// ✅ Lifecycle controlled by the DI framework
// ✅ Single instance guaranteed by scope annotation
}
The Four Core Annotations
// 1. @HiltAndroidApp — marks Application, triggers code generation
@HiltAndroidApp
class MyApplication : Application()
// 2. @AndroidEntryPoint — marks Android class that receives injection
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject lateinit var analyticsTracker: AnalyticsTracker
private val viewModel: UserViewModel by viewModels()
}
@AndroidEntryPoint // Fragment, View, Service, BroadcastReceiver also supported
class UserFragment : Fragment()
// 3. @Inject on constructor — Hilt can auto-create this class
class UserRepository @Inject constructor(
private val userApi: UserApi,
private val userDao: UserDao
)
// 4. @HiltViewModel — for ViewModels
@HiltViewModel
class UserViewModel @Inject constructor(
private val repository: UserRepository,
private val savedStateHandle: SavedStateHandle // automatically provided by Hilt
) : ViewModel()
@Module + @Provides vs @Binds
// @Provides — for third-party classes or classes needing special construction
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun provideOkHttpClient(): OkHttpClient =
OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).build()
@Provides @Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit =
Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.build()
@Provides @Singleton
fun provideUserApi(retrofit: Retrofit): UserApi = retrofit.create(UserApi::class.java)
}
// @Binds — for binding an interface to its implementation (more efficient)
// Must be in abstract class, must be abstract function
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}
// @Binds vs @Provides:
// @Binds — no object instantiation in generated code. Only for interface → implementation.
// @Provides — full flexibility. For third-party, classes needing config, factory logic.
Advanced Hilt — Scopes, Qualifiers, Assisted Injection, Multi-bindings
Scope Hierarchy
Hilt Component Hierarchy:
SingletonComponent (@Singleton) — App lifetime
│
├── ActivityRetainedComponent (@ActivityRetainedScoped)
│ — Survives rotation! Where ViewModels live.
│ — Destroyed only when Activity is truly finished.
│
└── ActivityComponent (@ActivityScoped)
— Activity lifetime (destroyed on rotation)
│
├── FragmentComponent (@FragmentScoped)
│ │
│ └── ViewWithFragmentComponent (@ViewScoped)
│
└── ViewComponent (@ViewScoped)
ServiceComponent (@ServiceScoped) — Service lifetime
Rule: a component can ONLY access dependencies from itself and its PARENTS.
SingletonComponent cannot use ActivityComponent dependencies (would outlive them → leak).
Hilt enforces this at compile time.
No scope annotation = NEW INSTANCE every time it is requested (transient).
// Correct scoping examples
@Provides @Singleton // one instance for entire app lifetime
fun provideDatabase(...): AppDatabase
@Provides @ActivityScoped // new instance per Activity, destroyed on rotation
fun provideAnalyticsTracker(): AnalyticsTracker
@Provides // new instance every time it's requested (no annotation)
fun provideUserFormatter(): UserFormatter
Qualifiers — Multiple Bindings of the Same Type
// Define qualifiers
@Qualifier @Retention(AnnotationRetention.BINARY)
annotation class AuthOkHttpClient
@Qualifier @Retention(AnnotationRetention.BINARY)
annotation class NoAuthOkHttpClient
// Provide both
@Module @InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton @NoAuthOkHttpClient
fun provideNoAuthClient(): OkHttpClient = OkHttpClient.Builder().build()
@Provides @Singleton @AuthOkHttpClient
fun provideAuthClient(interceptor: AuthInterceptor): OkHttpClient =
OkHttpClient.Builder().addInterceptor(interceptor).build()
// Use qualifier to select which one
@Provides @Singleton
fun provideUserApi(@AuthOkHttpClient client: OkHttpClient): UserApi =
Retrofit.Builder().client(client).build().create(UserApi::class.java)
}
// Built-in Hilt qualifiers
class UserRepository @Inject constructor(
@ApplicationContext private val context: Context, // Application context
// @ActivityContext — provides Activity context (use in @ActivityScoped or smaller)
)
Assisted Injection — Runtime + DI Parameters
// Problem: ViewModel needs DI deps AND a runtime value (product ID from navigation)
// Solution: @AssistedInject
class ProductViewModel @AssistedInject constructor(
private val productRepository: ProductRepository, // from Hilt
@Assisted private val productId: String // provided at runtime
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(productId: String): ProductViewModel
}
}
// In Fragment
@AndroidEntryPoint
class ProductFragment : Fragment() {
private val args: ProductFragmentArgs by navArgs()
@Inject
lateinit var viewModelFactory: ProductViewModel.Factory
private val viewModel: ProductViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return viewModelFactory.create(args.productId) as T
}
}
}
}
Multi-bindings — Plugin Architecture
// Contribute to a Set — add implementations without modifying existing code
@Module @InstallIn(SingletonComponent::class)
object AnalyticsModule {
@Provides @IntoSet
fun provideFirebaseTracker(): AnalyticsTracker = FirebaseTracker()
@Provides @IntoSet
fun provideMixpanelTracker(): AnalyticsTracker = MixpanelTracker()
}
// Another module can add more without touching AnalyticsModule
@Module @InstallIn(SingletonComponent::class)
object DebugModule {
@Provides @IntoSet
fun provideLoggingTracker(): AnalyticsTracker = LoggingTracker()
}
// Composite tracker receives all implementations
class CompositeTracker @Inject constructor(
private val trackers: Set<@JvmSuppressWildcards AnalyticsTracker>
) : AnalyticsTracker {
override fun track(event: AnalyticsEvent) { trackers.forEach { it.track(event) } }
}
// Map bindings — key-value pairs
@Provides @IntoMap @StringKey("firebase")
fun provideFirebase(): AnalyticsTracker = FirebaseTracker()
class TrackerManager @Inject constructor(
private val trackers: Map<String, @JvmSuppressWildcards AnalyticsTracker>
) {
fun getTracker(name: String) = trackers[name]
}
@EntryPoint — Injecting Into Non-Hilt Classes
// For classes that can't use @AndroidEntryPoint (ContentProvider, legacy code)
@EntryPoint
@InstallIn(SingletonComponent::class)
interface UserRepositoryEntryPoint {
fun userRepository(): UserRepository
}
class LegacyHelper(private val context: Context) {
fun doSomething() {
val entryPoint = EntryPointAccessors.fromApplication(
context, UserRepositoryEntryPoint::class.java)
val userRepository = entryPoint.userRepository()
}
}
Testing with Hilt
// Replace production module with test module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
@Module
abstract class FakeRepositoryModule {
@Binds @Singleton
abstract fun bindUserRepository(fake: FakeUserRepository): UserRepository
}
// Fake repository — reusable across tests
class FakeUserRepository @Inject constructor() : UserRepository {
private val users = MutableStateFlow<List<User>>(emptyList())
private var error: Exception? = null
fun setUsers(list: List<User>) { users.value = list }
fun setError(e: Exception) { error = e }
override fun getUsers(): Flow<List<User>> = flow {
error?.let { throw it }
emitAll(users)
}
override suspend fun deleteUser(id: String) {
error?.let { throw it }
users.value = users.value.filter { it.id != id }
}
}
@HiltAndroidTest
class UserScreenTest {
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<MainActivity>()
@Inject lateinit var fakeUserRepository: FakeUserRepository
@Before fun setup() { hiltRule.inject() }
}
Staff Q&A
- They are synonyms — both scope a dependency to SingletonComponent, meaning one instance for the entire application lifetime.
- @Singleton comes from Dagger (Hilt's underlying framework). @ApplicationScoped is Hilt's own annotation for the same thing.
- Use either — most Android developers use @Singleton since it's more familiar from Dagger history.
- The component hierarchy is a tree — parents don't know about children.
- SingletonComponent lives longer than ActivityComponent. If it held a reference to an Activity-scoped object, that object would outlive its scope — memory leak (holding a reference to a destroyed Activity).
- Hilt enforces this at compile time — you get a build error if you try, not a runtime crash.
- A new instance is created every time that dependency is requested.
- For expensive objects like OkHttpClient, Retrofit, or AppDatabase, this means multiple instances — wasted memory, potentially incorrect behavior (multiple database connections, multiple network clients with different states).
- Always scope expensive objects appropriately. When in doubt about whether to use @Singleton, ask "would having two instances of this cause bugs?" If yes, scope it.
- Constructor injection — preferred. Dependencies are clearly declared, the object can't exist without them, easy to test (just call the constructor with fakes). No lateinit, no null risk.
- Field injection (@Inject lateinit var) — only for Android components (Activity, Fragment, Service) that the system instantiates using a no-arg constructor. Can't use constructor injection there.
- Never use field injection in non-Android classes (ViewModels, Repositories, UseCases) — it hides dependencies and makes testing harder.
Coroutines & Flow
Coroutines Fundamentals
Threads vs Coroutines: Thread.sleep(1000) → Thread is BLOCKED. Can't run other code. delay(1000) → Coroutine SUSPENDED. Thread is FREE for other work. Coroutine = suspendable computation • Can pause at suspension points (suspend functions, delay, yield) • Thread is freed while paused • May resume on a DIFFERENT thread • Thousands of coroutines run on just a few threads • Structured concurrency — parent/child relationships enforce lifetime
Builders
// launch — fire and forget, returns Job
val job = viewModelScope.launch {
uploadFile(file) // result not needed
}
job.cancel() // cancellable
// async — concurrent work with result, returns Deferred<T>
val userDeferred = async { api.getUser(userId) }
val ordersDeferred = async { api.getOrders(userId) }
// Both run CONCURRENTLY — total time = max(user, orders) NOT sum
val user = userDeferred.await()
val orders = ordersDeferred.await()
// coroutineScope — suspends until all children complete or any child fails
// If one child throws → ALL children are cancelled
coroutineScope {
launch { fetchUsers() }
launch { fetchProducts() }
}
// supervisorScope — child failures don't cancel siblings
supervisorScope {
launch { fetchUsers() }
launch { throw NetworkException() } // only THIS one fails
// fetchUsers() continues normally
}
Dispatchers
Dispatchers.Main // Main/UI thread only. UI updates, view operations.
Dispatchers.IO // IO-optimized pool (up to 64 threads). Network, database, files.
Dispatchers.Default // CPU-optimized (= CPU core count). JSON parsing, sorting, computation.
Dispatchers.Unconfined // Not confined to any thread — rarely used in practice.
// withContext — switch dispatchers inside a coroutine
suspend fun getUser(id: String): User {
return withContext(Dispatchers.IO) {
api.fetchUser(id) // runs on IO thread
} // automatically returns to caller's dispatcher
}
// DO NOT use withContext(Dispatchers.IO) inside a suspend function in a Repository
// if the caller already uses Dispatchers.IO — unnecessary context switch.
// Room and Retrofit handle their own threading internally.
// DO use it when calling blocking (non-suspend) Java IO APIs.
Structured Concurrency
Job hierarchy:
viewModelScope (SupervisorJob)
│
├── launch { fetchUsers() } ← Job A
└── launch { fetchProducts() } ← Job B
If Job A fails:
With regular Job: Job B is CANCELLED too
With SupervisorJob: Job B CONTINUES
viewModelScope uses SupervisorJob (correct behavior for independent operations)
coroutineScope uses regular Job (correct for dependent operations — if one fails, all fail)
Parent Job cancelled → ALL children cancelled (always, regardless of Job type)
viewModelScope.cancel() → ALL launched coroutines cancelled
Cancellation — Cooperative Model
// Cancellation is COOPERATIVE — code between suspension points runs to completion
// Code CAN ONLY be cancelled at suspension points
// ❌ Cannot be cancelled — no suspension points
launch {
while (true) { heavyCpuWork() } // infinite loop, never checks cancellation
}
// ✅ Can be cancelled — checks cancellation state
launch {
while (isActive) { // isActive becomes false when cancelled
heavyCpuWork()
yield() // suspension point — cancellation happens here
ensureActive() // throws CancellationException if cancelled
}
}
// CRITICAL: NEVER swallow CancellationException
try {
delay(1000)
} catch (e: CancellationException) {
throw e // ← ALWAYS re-throw so cancellation propagates correctly
} catch (e: IOException) {
handleNetworkError(e)
}
// Why: if you swallow CancellationException, structured concurrency breaks.
// The parent coroutine thinks this child is still running.
Exception Handling
// CoroutineExceptionHandler — handles UNCAUGHT exceptions in launch coroutines
// (NOT in async — those are caught at await())
val handler = CoroutineExceptionHandler { context, exception ->
Log.e("Coroutine", "Uncaught: ${exception.message}")
}
viewModelScope.launch(handler) {
throw RuntimeException("Oops") // caught by handler
}
// async — exception is deferred until await()
val deferred = viewModelScope.async { throw RuntimeException("Oops") }
try {
deferred.await() // exception thrown here
} catch (e: RuntimeException) {
handleError(e)
}
// withTimeout — cancel coroutine if it takes too long
try {
withTimeout(5000L) {
api.uploadLargeFile(file) // cancelled if takes > 5 seconds
}
} catch (e: TimeoutCancellationException) {
showTimeoutError()
}
// withTimeoutOrNull — returns null on timeout (no exception)
val result = withTimeoutOrNull(5000L) {
api.fetchData()
}
if (result == null) showTimeoutError()
Flow — Deep Dive
Flow characteristics:
• COLD — doesn't start until collected (lazy)
• SEQUENTIAL — emits one value at a time (unless using parallel operators)
• CANCELLABLE — cancelled when collector's scope is cancelled
• Each collector gets its own independent stream (cold)
suspend fun → ONE value, then done
Flow<T> → MULTIPLE values over time
Types:
flow {} — cold, cancellable
callbackFlow{} — bridge callback APIs to Flow
stateIn() — convert cold Flow to hot StateFlow
shareIn() — convert cold Flow to hot SharedFlow
// Creating Flows
val numberFlow: Flow<Int> = flow {
emit(1)
delay(100)
emit(2)
emit(3)
}
// callbackFlow — wrapping callback-based APIs
val locationFlow: Flow<Location> = callbackFlow {
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { trySend(it) } // trySend — non-suspending
}
}
fusedLocationClient.requestLocationUpdates(request, callback, Looper.getMainLooper())
awaitClose { fusedLocationClient.removeLocationUpdates(callback) } // cleanup when cancelled
}
// Key operators
searchQueryFlow
.debounce(300) // wait 300ms after last emission
.filter { it.length > 2 } // only emit if query is meaningful
.distinctUntilChanged() // skip duplicate queries
.flatMapLatest { query -> // cancel previous search on new query
searchRepository.search(query)
}
.catch { e -> emit(emptyList()) } // handle errors in the stream
.onEach { results -> logSearchResults(results) } // side effects
// Combining multiple flows
combine(usersFlow, filtersFlow) { users, filters ->
users.filter { user -> filters.matches(user) }
} // emits on EVERY update from either source
zip(flowA, flowB) { a, b -> Pair(a, b) } // waits for both before emitting
merge(flowA, flowB) // interleaves emissions from both
// stateIn — convert cold Flow to hot StateFlow for ViewModel
val users: StateFlow<List<User>> = repository.getUsers()
.map { entities -> entities.map { it.toDomain() } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
// stopTimeout = 5000ms:
// When 0 collectors remain, wait 5000ms before stopping upstream
// Handles rotation: new Fragment subscribes within 5 seconds, no restart
// After 5 seconds with no subscribers → upstream STOPS (saves resources)
initialValue = emptyList()
)
// snapshotFlow — convert Compose State to Flow
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { index ->
if (index >= items.size - 5) loadMoreItems()
}
}
Testing Flows with Turbine
// Turbine makes Flow testing readable and reliable
@Test
fun `users flow emits loading then data`() = runTest {
viewModel.uiState.test {
assertThat(awaitItem()).isEqualTo(UiState.Loading)
fakeRepository.setUsers(listOf(User("1", "John")))
val successState = awaitItem()
assertThat(successState).isInstanceOf(UiState.Success::class.java)
assertThat((successState as UiState.Success).users).hasSize(1)
cancelAndIgnoreRemainingEvents()
}
}
// Test time-based operators with runTest + advanceTimeBy
@Test
fun `search debounces rapid typing`() = runTest {
viewModel.onQueryChanged("k")
viewModel.onQueryChanged("ko")
viewModel.onQueryChanged("kot")
advanceTimeBy(200) // advance 200ms — less than debounce threshold
coVerify(exactly = 0) { searchRepository.search(any()) }
advanceTimeBy(200) // total 400ms — past 300ms debounce
coVerify(exactly = 1) { searchRepository.search("kot") }
}
Staff Q&A
- flatMapLatest — when a new value arrives, CANCELS the previous inner flow and starts a new one. Use for search — only care about the latest query's results.
- flatMapMerge — runs all inner flows CONCURRENTLY. All inner flows active simultaneously. Use when order doesn't matter and you want maximum parallelism.
- flatMapConcat — runs inner flows SEQUENTIALLY. Waits for each to complete before starting the next. Use when order matters.
- Default for search: always flatMapLatest — cancels stale network requests automatically.
- GlobalScope is not tied to any lifecycle — coroutines launched in it run for the entire app lifetime.
- If a ViewModel is destroyed (user leaves the screen), GlobalScope coroutines keep running, wasting resources and potentially causing memory leaks (holding references to destroyed objects).
- GlobalScope coroutines can't be cancelled by the framework — if the user navigates away, stale updates can arrive and try to update destroyed Views.
- Always use a scoped scope: viewModelScope, lifecycleScope, or a manually managed CoroutineScope with a tied lifecycle.
- Structured concurrency enforces that every coroutine has a parent scope. A parent scope cannot complete until all its children complete or are cancelled.
- This means: if you cancel viewModelScope, ALL coroutines launched from it are automatically cancelled — no leaked work, no "fire and forget" coroutines lingering after the ViewModel is destroyed.
- Without structured concurrency (GlobalScope), you have to manually track and cancel every coroutine — easy to forget, leads to leaks.
- Practical impact: viewModelScope.cancel() in onCleared() cancels all network calls, database queries, and any other work the ViewModel launched. Automatic cleanup with no manual tracking.
MVVM & Clean Architecture
Clean Architecture — Three Layers
Clean Architecture Dependency Rule: ┌─────────────────────────────────────────────────────┐ │ Presentation Layer (UI) │ │ ViewModel, UI State, Events │ │ Composables, Activities, Fragments │ │ ↓ Depends on: Domain only │ ├─────────────────────────────────────────────────────┤ │ Domain Layer ← THE CORE (innermost ring) │ │ Use Cases (business rules) │ │ Domain Models (pure Kotlin data classes) │ │ Repository INTERFACES (not implementations!) │ │ ↓ Depends on: NOTHING (no Android, no Room, no Retrofit) │ ├─────────────────────────────────────────────────────┤ │ Data Layer │ │ Repository Implementations │ │ Remote sources (Retrofit API calls) │ │ Local sources (Room DAOs, DataStore) │ │ DTOs, Entities, Mappers │ │ ↑ Depends on: Domain layer │ └─────────────────────────────────────────────────────┘ Key rules: 1. Presentation → Domain ← Data (Data and Presentation don't depend on each other) 2. Domain is pure Kotlin — can be moved to KMP shared module 3. Domain never imports android.* packages
Project Folder Structure
app/
└── src/main/java/com/example/app/
│
├── di/ ← Hilt modules
│ ├── NetworkModule.kt
│ ├── DatabaseModule.kt
│ └── RepositoryModule.kt
│
├── domain/ ← Pure Kotlin, zero Android deps
│ ├── model/
│ │ ├── User.kt
│ │ └── Order.kt
│ ├── repository/
│ │ ├── UserRepository.kt ← Interface only
│ │ └── OrderRepository.kt
│ └── usecase/
│ ├── GetUsersUseCase.kt
│ └── DeleteUserUseCase.kt
│
├── data/ ← Android + third-party deps allowed
│ ├── remote/
│ │ ├── api/UserApi.kt
│ │ └── dto/UserDto.kt
│ ├── local/
│ │ ├── dao/UserDao.kt
│ │ └── entity/UserEntity.kt
│ ├── mapper/
│ │ └── UserMapper.kt
│ └── repository/
│ └── UserRepositoryImpl.kt
│
└── presentation/ ← Android UI
├── user/
│ ├── UserViewModel.kt
│ ├── UserScreen.kt
│ └── UserUiState.kt
└── navigation/
└── AppNavigation.kt
// Domain model — pure Kotlin, no framework dependencies
data class User(
val id: String,
val firstName: String,
val lastName: String,
val email: String,
val isActive: Boolean,
val createdAt: Instant
) {
val fullName: String get() = "$firstName $lastName" // business logic on model
fun isEmailValid(): Boolean = email.contains("@")
}
// Repository interface — in Domain
interface UserRepository {
fun getUsers(): Flow<List<User>>
suspend fun getUserById(id: String): User?
suspend fun createUser(user: User): User
suspend fun deleteUser(id: String)
}
// Use Case — does ONE thing, testable independently
class GetActiveUsersUseCase @Inject constructor(
private val userRepository: UserRepository
) {
operator fun invoke(): Flow<List<User>> { // invoke() = clean call site: useCase()
return userRepository.getUsers()
.map { users ->
users
.filter { it.isActive }
.sortedBy { it.lastName }
}
}
}
// Mappers as extension functions — data layer only
fun UserDto.toDomain(): User = User(
id = id, firstName = firstName, lastName = lastName,
email = email, isActive = isActive,
createdAt = Instant.ofEpochMilli(createdAt)
)
fun UserEntity.toDomain(): User = User(
id = id, firstName = firstName, lastName = lastName,
email = email, isActive = isActive,
createdAt = Instant.ofEpochMilli(createdAt)
)
fun User.toEntity(): UserEntity = UserEntity(
id = id, firstName = firstName, lastName = lastName,
email = email, isActive = isActive,
createdAt = createdAt.toEpochMilli()
)
Repository Implementation
class UserRepositoryImpl @Inject constructor(
private val userApi: UserApi,
private val userDao: UserDao,
private val networkMonitor: NetworkMonitor
) : UserRepository {
// Database is single source of truth — UI always reads from Room
override fun getUsers(): Flow<List<User>> {
return userDao.getAllUsers().map { entities -> entities.map { it.toDomain() } }
}
// Network writes to database, database notifies observers
override suspend fun createUser(user: User): User {
if (!networkMonitor.isConnected) throw NoNetworkException()
val dto = userApi.createUser(user.toDto())
val entity = dto.toEntity()
userDao.insertUser(entity)
return entity.toDomain()
}
// Refresh — fetch from API, store in database
suspend fun refreshUsers() {
val users = userApi.getUsers()
userDao.deleteAllUsers() // in a transaction
userDao.insertUsers(users.map { it.toEntity() })
}
}
MVI Pattern
MVI — Model View Intent: User View ViewModel Model │ │ │ │ │──[taps button]──► │ │ │ │ │──[UserIntent]────► │ │ │ │ │──[load data]──────► │ │ │ │◄──[Result]───────── │ │ │◄──[UiState]───── │ │ │◄────[renders UI]── │ │ │ Single immutable state — ONE object describes ENTIRE screen state Predictable — given state + intent → predictable new state Time-travel debugging — log all intents and replay them
// Single immutable UI state
data class UserState(
val users: List<User> = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val error: String? = null,
val searchQuery: String = "",
val selectedUserId: String? = null
) {
val filteredUsers: List<User> get() =
if (searchQuery.isEmpty()) users
else users.filter { it.fullName.contains(searchQuery, ignoreCase = true) }
}
// User intentions — sealed class of all possible user actions
sealed class UserIntent {
object LoadUsers : UserIntent()
object RefreshUsers : UserIntent()
data class SearchUsers(val query: String) : UserIntent()
data class DeleteUser(val userId: String) : UserIntent()
data class SelectUser(val userId: String) : UserIntent()
}
// ViewModel as state reducer — single processIntent() entry point
class UserViewModel @Inject constructor(
private val getUsersUseCase: GetActiveUsersUseCase,
private val deleteUserUseCase: DeleteUserUseCase
) : ViewModel() {
private val _state = MutableStateFlow(UserState())
val state: StateFlow<UserState> = _state.asStateFlow()
init { processIntent(UserIntent.LoadUsers) }
fun processIntent(intent: UserIntent) {
when (intent) {
is UserIntent.LoadUsers -> loadUsers()
is UserIntent.RefreshUsers -> refreshUsers()
is UserIntent.SearchUsers -> updateSearch(intent.query)
is UserIntent.DeleteUser -> deleteUser(intent.userId)
is UserIntent.SelectUser ->
_state.update { it.copy(selectedUserId = intent.userId) }
}
}
private fun loadUsers() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
getUsersUseCase()
.catch { e -> _state.update { it.copy(isLoading = false, error = e.message) } }
.collect { users -> _state.update { it.copy(isLoading = false, users = users) } }
}
}
private fun updateSearch(query: String) {
_state.update { it.copy(searchQuery = query) }
}
}
// UI — single state collector
@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
UserContent(
state = state,
onIntent = viewModel::processIntent
)
}
@Composable
fun UserContent(state: UserState, onIntent: (UserIntent) -> Unit) {
Column {
SearchBar(
query = state.searchQuery,
onQueryChange = { onIntent(UserIntent.SearchUsers(it)) }
)
when {
state.isLoading -> LoadingIndicator()
state.error != null -> ErrorMessage(state.error)
else -> UserList(
users = state.filteredUsers,
onDeleteUser = { onIntent(UserIntent.DeleteUser(it)) }
)
}
}
}
Testing Each Layer
// Use Case test — pure Kotlin, no Android deps
class GetActiveUsersUseCaseTest {
private val repository = mockk<UserRepository>()
private val useCase = GetActiveUsersUseCase(repository)
@Test fun `filters inactive users and sorts by last name`() = runTest {
every { repository.getUsers() } returns flowOf(listOf(
User("1", "John", "Zebra", isActive = true),
User("2", "Jane", "Apple", isActive = true),
User("3", "Bob", "Mango", isActive = false)
))
val result = useCase().first()
assertThat(result).hasSize(2)
assertThat(result.map { it.lastName }).containsExactly("Apple", "Zebra").inOrder()
}
}
// ViewModel test
class UserViewModelTest {
private val useCase = mockk<GetActiveUsersUseCase>()
private val deleteUseCase = mockk<DeleteUserUseCase>()
private lateinit var viewModel: UserViewModel
@Before fun setup() { viewModel = UserViewModel(useCase, deleteUseCase) }
@Test fun `processIntent LoadUsers emits loading then success`() = runTest {
val users = listOf(User("1", "John", "Doe"))
every { useCase() } returns flowOf(users)
viewModel.state.test {
viewModel.processIntent(UserIntent.LoadUsers)
assertThat(awaitItem().isLoading).isTrue()
val successState = awaitItem()
assertThat(successState.isLoading).isFalse()
assertThat(successState.users).hasSize(1)
cancelAndIgnoreRemainingEvents()
}
}
}
// Repository test
class UserRepositoryTest {
private val api = mockk<UserApi>()
private val dao = mockk<UserDao>()
private val repository = UserRepositoryImpl(api, dao, networkMonitor)
@Test fun `getUsers maps entity to domain`() = runTest {
every { dao.getAllUsers() } returns flowOf(listOf(testEntity))
val result = repository.getUsers().first()
assertThat(result[0].id).isEqualTo(testEntity.id)
}
}
Staff Q&A
- MVVM — ViewModel exposes multiple state/event streams. UI observes them and calls ViewModel methods directly. Simpler for straightforward screens. More flexible but can lead to scattered state across multiple StateFlows.
- MVI — single immutable state object. User actions are wrapped in Intent sealed class and sent through one processIntent() function. State is only updated in one place. More boilerplate but much easier to reason about for complex screens.
- When to use MVI: screens with complex interdependent state (search + filter + sort + pagination + selection), screens that need time-travel debugging, or teams that want strict unidirectional data flow enforced by the type system.
- Testability — domain layer tests run as pure JVM unit tests (fast, no Android emulator needed). No need to mock Context, Resources, or Android framework classes.
- Portability — domain layer can be shared with other platforms via Kotlin Multiplatform without modification.
- Longevity — Android APIs change. If your business logic is tied to Android APIs, you have to rewrite it when those APIs change. Pure Kotlin business logic is stable.
- Mental model — domain is "what the app does," presentation/data is "how it does it on Android."
- Yes, for most teams — the discipline prevents logic creep into ViewModels and Repositories.
- A Use Case doing ONE thing is easily testable, easily named, and clearly responsible for one behavior.
- The cost is verbosity — GetUsersUseCase.kt, GetUserByIdUseCase.kt, etc.
- Some teams group related use cases into a single class with multiple methods — acceptable if each method remains simple and focused. The key is that business logic never lives in ViewModel or Repository.
Jetpack Compose Basics
Declarative vs Imperative
Imperative (XML + Views) — describe HOW to change UI:
nameTextView.text = user.name // mutate specific view
emailTextView.text = user.email // mutate specific view
if (user.isAdmin) badge.show() else badge.hide()
When state changes → YOU figure out which views to update
Declarative (Compose) — describe WHAT the UI should look like:
@Composable
fun UserCard(user: User) {
Column {
Text(text = user.name)
Text(text = user.email)
if (user.isAdmin) AdminBadge()
}
}
When state changes → Compose reruns the function with new state
Compose figures out WHAT changed and updates ONLY that
// @Composable rules:
// • Named with uppercase (by convention)
// • Side-effect free — don't modify external state directly
// • Can be called multiple times — don't rely on call count
// • Only callable from other @Composable functions
// • Inputs are the ONLY thing that should determine the output
@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!") // always the same output for same input
}
Layouts
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("First"); Text("Second"); Text("Third")
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Left"); Spacer(Modifier.weight(1f)); Text("Right")
}
Box(modifier = Modifier.fillMaxSize()) {
Image(...) // drawn first (bottom)
Text("Overlay", modifier = Modifier.align(Alignment.Center)) // drawn on top
Badge(modifier = Modifier.align(Alignment.TopEnd))
}
Modifier — Order Matters!
// Modifiers apply LEFT TO RIGHT — order changes the result
// padding applied BEFORE background → blue area does NOT include padding
Box(modifier = Modifier
.padding(16.dp)
.background(Color.Blue))
// background applied BEFORE padding → blue area INCLUDES padding space
Box(modifier = Modifier
.background(Color.Blue)
.padding(16.dp))
// Size, clip, border
Modifier
.size(100.dp)
.clip(CircleShape)
.border(2.dp, Color.Red, CircleShape)
.clickable { onClick() }
.padding(8.dp)
.background(MaterialTheme.colorScheme.surface)
// Weight (inside Row/Column — like layout_weight in XML)
Row {
Box(modifier = Modifier.weight(1f)) // takes 1/3 of available space
Box(modifier = Modifier.weight(2f)) // takes 2/3 of available space
}
Previews with PreviewParameter
// Multiple preview configurations
@Preview(name = "Light Mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Large font", fontScale = 1.5f)
@Preview(name = "Small screen", widthDp = 320)
@Composable
fun UserCardPreviews() {
MyAppTheme { UserCard(user = previewUser) }
}
// PreviewParameterProvider — test multiple data states at once
class UserPreviewProvider : PreviewParameterProvider<User> {
override val values = sequenceOf(
User("1", "John", "Doe", isActive = true),
User("2", "Jane", "Smith", isActive = false),
User("3", "Bob", "VeryLongLastNameThatMightOverflow", isActive = true)
)
}
@Preview
@Composable
fun UserCardWithParameters(
@PreviewParameter(UserPreviewProvider::class) user: User
) {
MyAppTheme { UserCard(user = user) }
}
// Generates 3 separate previews in Android Studio automatically
Staff Q&A
- Recomposition is Compose re-running a @Composable function when its inputs change.
- Triggered when a State<T> object that was READ during the previous composition changes its value.
- Compose tracks which composables read which state objects during composition — when state changes, ONLY the composables that read it are recomposed, not the whole tree.
- Key insight: Compose uses a Snapshot system internally to track all state reads during composition. When any read state changes, affected composables are scheduled for recomposition.
- remember — stores value in the composition. Survives recomposition. Lost on rotation, process death, or when composable leaves composition (navigation).
- rememberSaveable — saves value to savedInstanceState mechanism. Survives recomposition AND rotation AND process death. Lost only when user explicitly dismisses the screen.
- Use rememberSaveable for any UI state the user would be frustrated to lose on rotation: scroll position, typed text, expanded state, selected tab.
- Composables can recompose many times — any logic inside runs multiple times. Side effects in the body cause bugs: multiple analytics events, multiple network calls.
- Composables are untestable without Compose test infrastructure — business logic in a composable can't be unit-tested cheaply.
- Composables can't be previewed if they have side effects — previews would trigger network calls.
- Business logic belongs in ViewModels and Use Cases — composables should be pure functions of state.
State, Stability & Recomposition
// State types
var name by remember { mutableStateOf("") } // general purpose
val items = remember { mutableStateListOf<String>() } // observable list
val map = remember { mutableStateMapOf<String, Int>() } // observable map
// rememberSaveable with custom Saver for non-primitive types
var selectedDate by rememberSaveable(
stateSaver = object : Saver<LocalDate, String> {
override fun restore(value: String) = LocalDate.parse(value)
override fun SaverScope.save(value: LocalDate) = value.toString()
}
) { mutableStateOf(LocalDate.now()) }
// Parcelable — automatically supported by rememberSaveable
@Parcelize
data class FormState(val name: String, val email: String) : Parcelable
var formState by rememberSaveable { mutableStateOf(FormState("", "")) }
// derivedStateOf — only recomposes when derived value changes
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
// Without derivedStateOf: recomposes on EVERY scroll offset change (every pixel)
// With derivedStateOf: recomposes only when true/false changes
Stability System
Stable type → Compose CAN skip recomposition if params unchanged Unstable type → Compose ALWAYS recomposes (even if value looks same) Stable: All primitives: Int, Long, Float, String, Boolean @Immutable data class with all-stable properties @Stable class with observable mutableStateOf properties Kotlin object, enum, sealed class Unstable (even if read-only): Regular class (assumed mutable by compiler) List<T>, Map<K,V>, Set<T> — interface, could be mutable Data class containing any unstable property Fix: List<T> → ImmutableList<T> from kotlinx-collections-immutable data class → @Immutable data class Stability config file (no code changes needed) Diagnose: Enable Compose compiler reports Look for "unstable" params and non-"skippable" composables
State Hoisting
// ❌ Stateful — hard to reuse, hard to test, hard to preview
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { Text("Count: $count") }
}
// ✅ Stateless — reusable, testable, previewable
@Composable
fun Counter(
count: Int, // state flows DOWN
onIncrement: () -> Unit // events flow UP
) {
Button(onClick = onIncrement) { Text("Count: $count") }
}
// State holder class for complex screens
class SearchScreenState(
val listState: LazyListState,
private val coroutineScope: CoroutineScope
) {
var searchQuery by mutableStateOf("")
private set
var isFilterVisible by mutableStateOf(false)
private set
val showScrollToTop by derivedStateOf {
listState.firstVisibleItemIndex > 0
}
fun onQueryChanged(query: String) { searchQuery = query }
fun toggleFilter() { isFilterVisible = !isFilterVisible }
fun scrollToTop() {
coroutineScope.launch { listState.animateScrollToItem(0) }
}
}
@Composable
fun rememberSearchScreenState(...): SearchScreenState = remember { SearchScreenState(...) }
Staff Q&A
- List<T> is an interface — its implementation could be a MutableList that changes without Compose knowing.
- Since Compose can't verify whether contents changed without checking every element (expensive), it conservatively treats List as unstable and always recomposes.
- ImmutableList from kotlinx-collections-immutable solves this — it's a concrete type that guarantees immutability, so Compose can trust it won't change.
- Alternative: annotate your data class with @Immutable — you promise Compose the object and its properties won't change outside of State observation.
- remember(key) — recomputes when key changes and replaces the value. No State tracking. Used when you want to recalculate something whenever a specific value changes.
- derivedStateOf — recomputes when any read State inside the lambda changes, but only triggers recomposition when the DERIVED VALUE changes. Used when source state changes frequently but derived value changes rarely.
- Classic use case: scroll position (changes every pixel) → showScrollToTopButton (only changes true/false). Without derivedStateOf, every pixel of scroll recomposes the whole screen.
Testing
Testing Strategy & Pyramid
Testing Pyramid:
╱‾‾‾‾‾‾‾‾‾‾‾‾‾╲
╱ E2E Tests ╲ 10% — Full user journeys, slow (minutes)
╱ Espresso, UI ╲ Expensive to write and maintain
╱────────────────────╲
╱ Integration Tests ╲ 20% — Multiple real components
╱ DAO, Repository, ╲ Moderate speed, real database/network
╱ ViewModel + UseCase ╲
╱────────────────────────── ╲
╱ Unit Tests ╲ 70% — Single class, pure JVM
╱ Use Cases, Domain Models ╲ Milliseconds per test
What to test at each layer:
Domain: Business logic, validation, transformations, edge cases
Data: DAO ops (in-memory Room), Repository caching, data mapping
Presentation: State transitions, events, user actions, debouncing
UI: Rendering states, interactions, accessibility semantics, screenshot tests
Architect-level responsibilities:
• Define minimum coverage thresholds (80% overall, 90% domain)
• Set up quality gates in CI (no PR merged with failing tests)
• Choose the right strategy per layer
• Decide between Fakes vs Mocks per use case
• Set up screenshot testing for design system components
MockK in Depth
// Basic mocking
val repository = mockk<UserRepository>()
// Stubbing regular functions
every { repository.getUsers() } returns flowOf(emptyList())
// Stubbing suspend functions
coEvery { repository.getUserById("1") } returns User("1", "John")
// Argument matchers
every { repository.search(any()) } returns flowOf(emptyList())
every { repository.search("kotlin") } returns flowOf(listOf(result))
coEvery { repository.getUser(match { it.length > 3 }) } returns User("1", "John")
// Dynamic responses
coEvery { repository.getUserById(any()) } answers {
val id = firstArg<String>()
User(id, "User $id")
}
// Exceptions
coEvery { repository.deleteUser(any()) } throws NetworkException("No connection")
// Just Runs — for Unit-returning suspend functions
coEvery { repository.deleteUser(any()) } just Runs
// Verification
verify { repository.getUsers() }
coVerify { repository.getUserById("1") }
coVerify(exactly = 2) { repository.search(any()) }
coVerify(exactly = 0) { repository.deleteUser(any()) }
// Argument capture
val slot = slot<String>()
coEvery { repository.getUserById(capture(slot)) } returns User("1", "John")
// After the call:
assertThat(slot.captured).isEqualTo("expected_id")
// Capture list — for multiple calls
val captured = mutableListOf<String>()
coEvery { repository.getUserById(captureNullable(captured)) } returns null
// Relaxed mock — returns defaults for unstubbed calls
val relaxedMock = mockk<UserRepository>(relaxed = true)
// Spy — wrap real object, override specific methods
val realRepo = UserRepositoryImpl(api, dao)
val spy = spyk(realRepo)
every { spy.getUsers() } returns flowOf(emptyList())
// Other methods call REAL implementation
// Verify order
verifyOrder {
repository.getUsers()
repository.getUserById("1")
}
// Confirm no unexpected calls
confirmVerified(repository)
Compose UI Testing
// Basic test setup
@get:Rule val composeTestRule = createComposeRule()
@Test fun userCard_displaysName() {
composeTestRule.setContent {
MyAppTheme { UserCard(user = testUser, onClick = {}) }
}
composeTestRule.onNodeWithText("John Doe").assertIsDisplayed()
}
// Finders
composeTestRule.onNodeWithText("Submit")
composeTestRule.onNodeWithTag("submit_button")
composeTestRule.onNodeWithContentDescription("Close")
composeTestRule.onAllNodesWithTag("list_item")
composeTestRule.onNode(hasText("Submit") and isEnabled())
// Actions
.performClick()
.performScrollTo()
.performScrollToIndex(10)
.performTextInput("Hello World")
.performTextClearance()
.performImeAction()
// Assertions
.assertIsDisplayed()
.assertDoesNotExist()
.assertIsEnabled()
.assertIsNotEnabled()
.assertIsFocused()
.assertTextEquals("Expected")
.assertContentDescriptionEquals("Description")
// Semantic matchers
hasText("Submit")
hasTestTag("button")
isEnabled()
hasClickAction()
hasAnyChild(hasText("Child"))
hasAnyAncestor(hasTestTag("parent"))
// Waiting for async operations
composeTestRule.waitForIdle()
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule.onAllNodesWithText("Loaded")
.fetchSemanticsNodes().isNotEmpty()
}
// Add semantics for testability
Modifier.semantics {
contentDescription = "User card for ${user.fullName}"
testTag = "user_card_${user.id}"
role = Role.Button
}
// Testing animations
composeTestRule.mainClock.autoAdvance = false
// trigger visibility change
composeTestRule.mainClock.advanceTimeBy(500)
composeTestRule.onNodeWithText("Content").assertIsDisplayed()
Screenshot Testing with Paparazzi
// Fast screenshot tests — no device or emulator needed
class UserCardScreenshotTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_5,
theme = "android:Theme.Material.Light.NoActionBar"
)
@Test fun userCard_lightTheme() {
paparazzi.snapshot {
MyAppTheme(darkTheme = false) {
UserCard(user = User("1", "John", "Doe", isActive = true))
}
}
}
@Test fun userCard_inactive() {
paparazzi.snapshot {
MyAppTheme {
UserCard(user = User("1", "John", "Doe", isActive = false))
}
}
}
}
// First run: generates golden images
// Subsequent runs: diffs against golden images
// Fails CI if pixel difference detected
// Record new goldens: ./gradlew recordPaparazziDebug
Fake vs Mock — When to Use Each
// MOCK — quick stubs and interaction verification
val repository = mockk<UserRepository>()
every { repository.getUsers() } returns flowOf(emptyList())
// Good for: simple one-off stubs, verifying interactions happened
// FAKE — a real implementation for testing (handwritten)
class FakeUserRepository @Inject constructor() : UserRepository {
private val users = MutableStateFlow<List<User>>(emptyList())
private var error: Exception? = null
fun setUsers(list: List<User>) { users.value = list }
fun setError(e: Exception) { error = e }
fun clearError() { error = null }
override fun getUsers(): Flow<List<User>> = flow {
error?.let { throw it }
emitAll(users)
}
override suspend fun getUserById(id: String): User? {
error?.let { throw it }
return users.value.find { it.id == id }
}
override suspend fun deleteUser(id: String) {
error?.let { throw it }
users.value = users.value.filter { it.id != id }
}
}
// Good for: complex stateful behavior, reuse across many tests,
// integration tests where multiple classes interact with the repository
Staff Q&A
- createComposeRule() — no Activity needed. Creates a minimal host for Compose. Faster, less setup. Use for pure composable unit tests where you don't need navigation, Hilt, or system UI.
- createAndroidComposeRule<MainActivity>() — launches a real Activity. Use for integration tests needing real Activity lifecycle, Hilt injection, navigation, or back stack behavior. Slower but more realistic.
- Mock — when you need to verify specific interactions occurred, or for simple one-off stubs in a single test. MockK generates the implementation at runtime.
- Fake — when the behavior is complex (stateful, multiple methods interact), when the same test double is used across many tests, or when you want to test realistic behavior. Fakes are handwritten and reusable.
- Rule: prefer Fakes for repositories and data sources (they have complex stateful behavior). Prefer Mocks for simple collaborators and when you want to verify that a specific method was called.
- Use JaCoCo with a coverage verification task in Gradle.
- Add to CI pipeline — fail the build if coverage drops below threshold.
- Set different thresholds per layer: domain 90%+, data 80%+, presentation 70%+.
- Sonar can track coverage trends over time — easier to see regressions than absolute thresholds.
- Coverage is a proxy metric — 80% coverage with shallow tests is worse than 60% coverage with meaningful tests. Always review what's being tested, not just the number.
Networking
OkHttp & Retrofit
Networking Stack: ┌─────────────────────────────────────────────────────┐ │ Retrofit — turns HTTP API into Kotlin interface │ │ Handles serialization/deserialization │ │ Coroutine support out of the box │ ├─────────────────────────────────────────────────────┤ │ OkHttp — the actual HTTP client │ │ Connection pooling, caching, interceptors │ │ Handles raw TCP/HTTP communication │ ├─────────────────────────────────────────────────────┤ │ kotlinx.serialization / Moshi / Gson │ │ JSON ↔ Kotlin object conversion │ └─────────────────────────────────────────────────────┘ OkHttp Interceptor Types: Application interceptors (addInterceptor): • Run before request reaches network layer • Called once even for cached responses • Can short-circuit the chain (return early) • Good for: auth headers, logging, connectivity checks Network interceptors (addNetworkInterceptor): • Run after request reaches the network layer • NOT called for cached responses • See redirects and retries individually • Have access to Connection object (TLS info, IP) • Good for: cache control headers, network-level logging
// Auth interceptor
class AuthInterceptor(private val tokenProvider: TokenProvider) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (request.header("No-Auth") != null) return chain.proceed(request)
return chain.proceed(
request.newBuilder()
.header("Authorization", "Bearer ${tokenProvider.getToken()}")
.build()
)
}
}
// Token refresh — with race condition prevention
class TokenRefreshInterceptor(
private val tokenProvider: TokenProvider,
private val authRepository: AuthRepository
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.code == 401) {
synchronized(this) {
val newToken = runBlocking { authRepository.refreshToken() }
if (newToken != null) {
tokenProvider.saveToken(newToken)
response.close()
return chain.proceed(
chain.request().newBuilder()
.header("Authorization", "Bearer $newToken")
.build()
)
}
}
}
return response
}
}
// Full Retrofit API interface
interface UserApi {
@GET("users") suspend fun getUsers(): List<UserDto>
@GET("users/{id}") suspend fun getUserById(@Path("id") id: String): UserDto
@GET("users") suspend fun searchUsers(
@Query("q") query: String, @Query("page") page: Int = 1
): PagedResponse<UserDto>
@POST("users") suspend fun createUser(@Body user: CreateUserRequest): UserDto
@PUT("users/{id}") suspend fun updateUser(@Path("id") id: String, @Body user: UpdateUserRequest): UserDto
@DELETE("users/{id}") suspend fun deleteUser(@Path("id") id: String): Response<Unit>
@Multipart @POST("users/{id}/avatar")
suspend fun uploadAvatar(@Path("id") id: String, @Part avatar: MultipartBody.Part): UserDto
// Raw Response — access status code and headers
@GET("users/{id}") suspend fun getUserResponse(@Path("id") id: String): Response<UserDto>
}
kotlinx.serialization & Error Handling
// kotlinx.serialization vs Gson:
// Gson: reflection-based, slow, doesn't respect Kotlin null safety, ignores defaults
// kotlinx.serialization: compile-time code generation, fast, fully null-safe,
// respects Kotlin defaults, excellent R8/ProGuard support
@Serializable
data class UserDto(
val id: String,
@SerialName("first_name") val firstName: String,
@SerialName("last_name") val lastName: String,
@SerialName("is_active") val isActive: Boolean = true, // default respected
val avatar: String? = null
)
// Nested objects
@Serializable
data class OrderDto(
val id: String,
val user: UserDto,
val items: List<OrderItemDto>,
@SerialName("created_at") val createdAt: String
)
// Sealed class serialization — polymorphic
@Serializable
sealed class PaymentMethodDto {
@Serializable @SerialName("card")
data class Card(val last4: String, val brand: String) : PaymentMethodDto()
@Serializable @SerialName("upi")
data class Upi(val vpa: String) : PaymentMethodDto()
}
// Custom serializer for non-standard types
object InstantSerializer : KSerializer<Instant> {
override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) { encoder.encodeString(value.toString()) }
override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())
}
// Json configuration
val json = Json {
ignoreUnknownKeys = true // safe for API versioning — new fields ignored
coerceInputValues = true // null to default instead of throwing
prettyPrint = BuildConfig.DEBUG
encodeDefaults = true
}
// Retrofit converter
Retrofit.Builder()
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
Robust Error Handling
// Sealed error hierarchy
sealed class AppError : Exception() {
data class NetworkError(override val message: String) : AppError()
data class ServerError(val code: Int, val apiError: ApiError?) : AppError()
data class AuthError(override val message: String = "Unauthorized") : AppError()
data class ParseError(override val message: String) : AppError()
object NoConnectivityError : AppError()
object TimeoutError : AppError()
}
// Safe API call wrapper
suspend fun <T> safeApiCall(apiCall: suspend () -> T): Result<T> = runCatching {
apiCall()
}.mapFailure { throwable ->
when (throwable) {
is HttpException -> AppError.ServerError(throwable.code(), parseApiError(throwable))
is IOException -> AppError.NetworkError(throwable.message ?: "Network error")
is SerializationException -> AppError.ParseError(throwable.message ?: "Parse error")
else -> throwable
}
}
// Repository using Result
class UserRepositoryImpl @Inject constructor(
private val userApi: UserApi,
private val userDao: UserDao
) : UserRepository {
override suspend fun getUser(id: String): Result<User> = runCatching {
val response = userApi.getUserResponse(id)
if (response.isSuccessful) {
response.body()?.toDomain() ?: throw AppError.ParseError("Empty response")
} else {
throw when (response.code()) {
401 -> AppError.AuthError()
404 -> AppError.ServerError(404, null)
else -> AppError.ServerError(response.code(), parseApiError(response.errorBody()))
}
}
}
}
Image Loading with Coil
// Basic usage
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://example.com/image.jpg")
.crossfade(true)
.placeholder(R.drawable.placeholder)
.error(R.drawable.error_image)
.transformations(CircleCropTransformation())
.build(),
contentDescription = "User avatar",
contentScale = ContentScale.Crop,
modifier = Modifier.size(48.dp).clip(CircleShape)
)
// SubcomposeAsyncImage — access loading state
SubcomposeAsyncImage(model = imageUrl, contentDescription = null) {
when (painter.state) {
is AsyncImagePainter.State.Loading -> CircularProgressIndicator()
is AsyncImagePainter.State.Error -> Icon(Icons.Default.BrokenImage, null)
else -> SubcomposeAsyncImageContent()
}
}
// Global configuration in Application
setSingletonImageLoaderFactory { context ->
ImageLoader.Builder(context)
.memoryCache { MemoryCache.Builder(context).maxSizePercent(0.25).build() }
.diskCache { DiskCache.Builder()
.directory(context.cacheDir.resolve("image_cache"))
.maxSizeBytes(50L * 1024 * 1024).build() }
.okHttpClient {
OkHttpClient.Builder().addInterceptor(AuthInterceptor(tokenProvider)).build()
}
.components {
add(GifDecoder.Factory())
add(SvgDecoder.Factory(context))
}
.build()
}
Testing Networking with MockWebServer
class UserApiTest {
private val mockWebServer = MockWebServer()
private lateinit var userApi: UserApi
@Before fun setup() {
mockWebServer.start()
val retrofit = Retrofit.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
userApi = retrofit.create(UserApi::class.java)
}
@After fun teardown() { mockWebServer.shutdown() }
@Test fun `getUsers parses response correctly`() = runTest {
mockWebServer.enqueue(
MockResponse().setResponseCode(200)
.setBody("[{id:1, first_name:John, last_name:Doe}]")
.addHeader("Content-Type", "application/json")
)
val result = userApi.getUsers()
assertThat(result).hasSize(1)
assertThat(result[0].firstName).isEqualTo("John")
val request = mockWebServer.takeRequest()
assertThat(request.path).isEqualTo("/users")
assertThat(request.method).isEqualTo("GET")
}
@Test fun `auth interceptor adds token`() = runTest {
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
val tokenProvider = mockk<TokenProvider>()
every { tokenProvider.getToken() } returns "test_token_123"
// build client with interceptor and make request
val request = mockWebServer.takeRequest()
assertThat(request.getHeader("Authorization")).isEqualTo("Bearer test_token_123")
}
}
Staff Q&A
- Null safety — Gson ignores Kotlin null safety annotations. A non-nullable field can receive null from JSON without throwing, causing NullPointerException later. kotlinx.serialization throws at parse time.
- Default values — Gson ignores Kotlin default parameter values. kotlinx.serialization respects them — missing JSON fields use the Kotlin default.
- Performance — compile-time code generation vs reflection at runtime. Faster parsing, especially on first invocation.
- R8/ProGuard — Gson requires keeping all serialized class members explicitly in ProGuard rules (easy to miss, causes subtle runtime bugs). kotlinx.serialization works cleanly with R8.
- Use synchronized() around the refresh block so only ONE thread executes the refresh at a time.
- Better: store the in-flight refresh as a Deferred. Other requests that hit 401 simultaneously wait for the same Deferred result instead of each triggering a new refresh call.
- After refresh: retry ALL queued requests with the new token, not just the first one.
- Handle the case where refresh itself returns 401 — force logout, clear tokens, navigate to login.
- OkHttp maintains a pool of open TCP connections to reuse across multiple requests to the same host.
- Without pooling: every request opens a new TCP connection (3-way handshake + TLS handshake for HTTPS) — significant latency overhead.
- With pooling: connections are reused sequentially (HTTP/1.1) or multiplexed simultaneously (HTTP/2 — single connection, multiple parallel requests).
- Default pool: 5 idle connections, 5 minute keep-alive. Tune maxIdleConnections based on app's concurrent request patterns.
Compose Side Effects
Effect Handlers — Complete Reference
A side effect is anything that happens outside the composable's scope: network calls, database writes, analytics, registering listeners. Composables should be side-effect free. Effect handlers give you controlled, lifecycle-aware ways to run side effects.
Effect Decision Tree:
Need a side effect?
│
├── Need cleanup (register/unregister)? → DisposableEffect
│
├── Need a coroutine?
│ ├── Triggered by user event (onClick, button)? → rememberCoroutineScope + .launch{}
│ └── Triggered by composition or key change? → LaunchedEffect(key)
│
├── Run after EVERY successful recomposition? → SideEffect
│
├── Convert Compose State → Flow? → snapshotFlow (inside LaunchedEffect)
│
└── Convert async API → State? → produceState
// LaunchedEffect — coroutine tied to composition/key
LaunchedEffect(userId) {
// Launches when composable enters composition
// CANCELS current and RE-LAUNCHES when userId changes
// Cancels when composable leaves composition
val user = repository.getUser(userId)
}
LaunchedEffect(Unit) { analytics.trackScreen("Home") } // runs exactly once
// DisposableEffect — register AND cleanup
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> player.play()
Lifecycle.Event.ON_STOP -> player.pause()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
// onDispose guaranteed to run on: composable leaving, key change
// NOT guaranteed during normal recomposition
}
// SideEffect — runs after every successful recomposition
SideEffect {
analyticsTracker.setCurrentScreen(screenName) // sync non-Compose system
player.playWhenReady = shouldPlay // sync ExoPlayer state
}
// rememberCoroutineScope — for coroutines in event handlers
val scope = rememberCoroutineScope()
FloatingActionButton(onClick = {
scope.launch { listState.animateScrollToItem(0) } // can't call LaunchedEffect here
})
// snapshotFlow — Compose State → Flow
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { index -> if (index >= items.size - 5) loadMoreItems() }
}
// produceState — async API → State
val userState by produceState<Result<User>>(
initialValue = Result.Loading, key1 = userId
) {
value = try { Result.Success(repository.getUser(userId)) }
catch (e: Exception) { Result.Error(e.message ?: "") }
}
Common Patterns
// Pattern: Debounced search
LaunchedEffect(query) {
delay(300) // wait for typing to stop
viewModel.search(query)
}
// Pattern: One-time navigation event from ViewModel
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is LoginEvent.NavigateToHome -> onNavigateToHome()
is LoginEvent.ShowError -> snackbarHostState.showSnackbar(event.msg)
}
}
}
// Pattern: Analytics enter/exit tracking
DisposableEffect(productId) {
val startTime = System.currentTimeMillis()
analytics.trackProductView(productId)
onDispose {
val duration = System.currentTimeMillis() - startTime
analytics.trackProductExit(productId, duration)
}
}
// Pattern: BackHandler for unsaved changes
BackHandler(enabled = hasUnsavedChanges) {
showConfirmDialog = true
}
Staff Q&A
- LaunchedEffect(Unit) — Unit never changes, so launches once on composition entry and never re-launches. Use for: analytics tracking, initial one-time setup, subscribing to a ViewModel's event stream.
- LaunchedEffect(key) — re-launches (cancels current, starts new) whenever key changes. Use when effect depends on a parameter: loading data for a specific userId, playing audio for a specific trackId.
- Common mistake: using LaunchedEffect(Unit) for something that should re-run when a parameter changes — the effect runs once and never updates.
- Composables can recompose many times — unpredictably and potentially frequently. A side effect in the body runs on every recomposition.
- Multiple analytics events, multiple network calls, multiple database writes for one user action.
- Compose may recompose the same composable multiple times before showing the result on screen — side effects would fire for each intermediate recomposition.
- Effect handlers guarantee controlled execution: once, on key change, or after each completed recomposition. Never on arbitrary recompositions.
- Runs when composable permanently leaves the composition (navigated away, condition became false, parent removed it).
- Runs when the DisposableEffect's key changes (old effect cleaned up before new one starts).
- Does NOT run during recomposition — only on actual removal. This makes it safe for resource cleanup like closing connections, unregistering listeners, releasing hardware.
- If the composable is removed and re-added (not same instance), a new DisposableEffect is set up from scratch — not resumed.
Lazy Lists & Grids
LazyColumn, LazyRow & Performance
Column vs LazyColumn: Column + verticalScroll: LazyColumn: All items composed upfront Only visible items composed All held in memory Items recycled on scroll OK for <20 items Required for large/dynamic lists Simple, no keys needed Needs stable keys for correctness
val listState = rememberLazyListState()
LazyColumn(
state = listState,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize()
) {
item(key = "header") { ListHeader() }
stickyHeader(key = "section_a") { SectionHeader("A") } // stays visible while section scrolls
items(
items = users,
key = { user -> user.id }, // ← CRITICAL: stable, unique identity
contentType = { user -> user::class } // hint: same-type items reuse compositions
) { user ->
UserCard(user = user, modifier = Modifier.animateItemPlacement())
}
if (isLoadingMore) { item(key = "loader") { CircularProgressIndicator() } }
item(key = "footer_space") { Spacer(Modifier.height(80.dp)) } // FAB clearance
}
// Without key: position-based identity
// Insert at top → all positions shift → ALL items recompose → state lost → no animation
// With key: stable identity
// Insert at top → Compose knows each existing item by ID → only new item composes
// Existing items keep expanded/scroll state → animateItemPlacement() works correctly
// Detect end of list for pagination
val shouldLoadMore by remember {
derivedStateOf {
val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()
last != null && last.index >= listState.layoutInfo.totalItemsCount - 5
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore && !isLoadingMore) onLoadMore()
}
// Scroll to top
val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
val scope = rememberCoroutineScope()
FloatingActionButton(onClick = { scope.launch { listState.animateScrollToItem(0) } }) {
Icon(Icons.Default.KeyboardArrowUp, "Top")
}
LazyVerticalGrid & Staggered
// Adaptive grid
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 128.dp),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item(span = { GridItemSpan(maxLineSpan) }) { FullWidthHeader() } // full-width item
items(products, key = { it.id },
span = { p -> if (p.isFeatured) GridItemSpan(2) else GridItemSpan(1) }
) { product -> ProductCard(product) }
}
// Staggered grid (Pinterest-style)
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(150.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalItemSpacing = 4.dp
) {
items(photos, key = { it.id }) { photo ->
AsyncImage(model = photo.url, contentDescription = null,
modifier = Modifier.fillMaxWidth().height(photo.displayHeight.dp).clip(RoundedCornerShape(8.dp)))
}
}
Paging 3 Integration
// ViewModel
val products = repository.pagedProducts.cachedIn(viewModelScope)
// cachedIn: survives rotation, no re-fetch from page 1
// Composable
val products = viewModel.products.collectAsLazyPagingItems()
LazyColumn {
items(
count = products.itemCount,
key = products.itemKey { it.id },
contentType = products.itemContentType { "product" }
) { index ->
products[index]?.let { ProductCard(it) } ?: ProductPlaceholder()
}
when (val state = products.loadState.append) {
is LoadState.Loading -> item { CircularProgressIndicator(Modifier.fillMaxWidth().padding(16.dp).wrapContentWidth()) }
is LoadState.Error -> item { ErrorItem(state.error.message ?: "", onRetry = { products.retry() }) }
else -> {}
}
}
Staff Q&A
- contentType is a hint telling Compose which items can share composition node pools for reuse.
- Items of the same contentType can reuse each other's compositions when they scroll in/out of the viewport — like RecyclerView's view type.
- Without contentType: all items share one pool. A Post item might try to reuse an Ad item's composition — mismatch, worse performance.
- With contentType: Posts reuse Post compositions, Ads reuse Ad compositions — correct and efficient.
- Only matters when your list has heterogeneous item types (feeds mixing posts, ads, stories, headers).
- Without cachedIn: every new collector (recomposition, rotation) triggers a new PagingSource starting from page 1. User loses scroll position, sees loading again.
- With cachedIn(viewModelScope): PagingData is cached in the ViewModel. Rotation gives same PagingData to new collector — no re-fetch, scroll position preserved.
- Multiple composables can collect the same cached PagingData without each triggering new fetches.
- Cache lives as long as the ViewModel — cleared when ViewModel.onCleared() is called.
Theming & Material 3
Material 3 Color, Typography & Shapes
Material 3 Color System — 30 roles in pair pattern: primary/onPrimary/primaryContainer/onPrimaryContainer secondary/onSecondary/secondaryContainer/onSecondaryContainer tertiary/onTertiary/tertiaryContainer/onTertiaryContainer error/onError/errorContainer/onErrorContainer background/onBackground surface/onSurface/surfaceVariant/onSurfaceVariant/surfaceTint outline/outlineVariant Rule: "on" prefix = content drawn ON that color Text on primary background → use onPrimary Text on surfaceVariant → use onSurfaceVariant NEVER hardcode Color.Black or Color.White: Black text is invisible in dark mode Always use colorScheme roles
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, // Material You — Android 12+ only
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
// Sync status bar color with theme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(colorScheme = colorScheme, typography = AppTypography, shapes = AppShapes, content = content)
}
// Custom typography
val AppTypography = Typography(
titleLarge = TextStyle(fontFamily = Montserrat, fontWeight = FontWeight.Medium, fontSize = 22.sp),
bodyMedium = TextStyle(fontFamily = Montserrat, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp),
labelSmall = TextStyle(fontFamily = Montserrat, fontWeight = FontWeight.Medium, fontSize = 11.sp, letterSpacing = 0.5.sp)
)
// Custom shapes
val AppShapes = Shapes(
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(28.dp)
)
// CompositionLocal for custom theme extensions
val LocalAppDimensions = compositionLocalOf { AppDimensions() }
object AppTheme {
val dimensions: AppDimensions @Composable get() = LocalAppDimensions.current
}
// Usage
MaterialTheme(colorScheme = colorScheme, content = {
CompositionLocalProvider(LocalAppDimensions provides AppDimensions(screenPadding = 16.dp)) {
content()
}
})
Dark Theme & Persisting Preference
// Persisting theme preference with DataStore
enum class ThemeConfig { SYSTEM, LIGHT, DARK }
class ThemeRepository @Inject constructor(private val dataStore: DataStore<Preferences>) {
private val THEME_KEY = stringPreferencesKey("theme")
val themeConfig = dataStore.data.map { ThemeConfig.valueOf(it[THEME_KEY] ?: ThemeConfig.SYSTEM.name) }
suspend fun setTheme(config: ThemeConfig) { dataStore.edit { it[THEME_KEY] = config.name } }
}
// In Activity
val themeConfig by themeRepository.themeConfig.collectAsStateWithLifecycle(ThemeConfig.SYSTEM)
val darkTheme = when (themeConfig) {
ThemeConfig.SYSTEM -> isSystemInDarkTheme()
ThemeConfig.LIGHT -> false
ThemeConfig.DARK -> true
}
MyAppTheme(darkTheme = darkTheme) { AppNavigation() }
Font Scale Accessibility
// ❌ WRONG — fixed height breaks at large font scales
Box(modifier = Modifier.height(48.dp)) { Text("Button label") }
// Text truncated at fontScale = 1.5
// ✅ CORRECT — minimum height, can expand
Box(modifier = Modifier.heightIn(min = 48.dp)) { Text("Button label") }
// Always use sp for text sizes — scales with user accessibility preference
// Use dp for everything else (padding, icon size, layout dimensions)
// Test in preview
@Preview(name = "Large font", fontScale = 1.5f)
@Composable fun UserCardLargeFont() { MyAppTheme { UserCard(user = previewUser) } }
Staff Q&A
- Dynamic color (Material You, Android 12+) extracts colors from the user's wallpaper and generates a complete color scheme. The app matches the user's personal style.
- Don't use it when: brand identity is critical (banking apps, food delivery — users expect Swiggy orange, Zomato red), when brand recognition is the core value, or when your design team has specific color requirements that cannot flex.
- Always provide a static fallback for Android 11 and below — dynamicColor only works on API 31+.
- Meesho example: e-commerce apps often want consistent brand colors for trust. Dynamic color would make the app look different on every phone — poor for brand identity.
- CompositionLocal provides implicit value passing down the composition tree — consumers read the value without the provider explicitly passing it as a parameter.
- Built-in examples: LocalContext, LocalLifecycleOwner, MaterialTheme itself (which is just a CompositionLocal for color, typography, shapes).
- Use for: theme values, navigation controllers, analytics trackers — things genuinely ambient to a large subtree.
- Avoid for: business data, ViewModel references, screen-specific state — creates hidden dependencies that make composables hard to test and preview.
- Rule: if you'd need to pass it through every composable in a tree as a parameter, CompositionLocal makes sense. If only a few composables deep in the tree need it, still pass as parameter — explicit is better.
Interop: XML ↔ Compose
XML → Compose (ComposeView)
Two directions of interop: XML → Compose (ComposeView): Embed a Composable inside an existing XML layout Gradual migration: one screen or component at a time No need to rewrite existing Fragment/Activity shell Compose → XML (AndroidView / AndroidViewBinding): Embed a traditional View inside a Composable Required for: maps, ExoPlayer's PlayerView, AdMob banners, legacy custom views, third-party views
// In XML layout:
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
// In Fragment:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.composeView.apply {
// CRITICAL — must set before setContent
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
MyAppTheme {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
UserCard(uiState = uiState, onAction = viewModel::onAction)
}
}
}
}
ViewCompositionStrategy
| Strategy | Disposes When | Use For |
|---|---|---|
| DisposeOnViewTreeLifecycleDestroyed | Host View's lifecycle owner destroyed | Fragments — handles back stack correctly |
| DisposeOnDetachedFromWindow | ComposeView detached from window | Activities, custom ViewGroups |
| DisposeOnLifecycleDestroyed(lifecycle) | Provided Lifecycle destroyed | Full manual control |
Phase-Based Migration Strategy
Phase 1 — Leaf nodes (lowest risk): Replace individual views inside XML with ComposeView e.g. Replace a complex custom TextView with a Composable Phase 2 — Screens as ComposeView: Fragment.onCreateView() returns ComposeView Fragment shell remains (Navigation Component back stack) ViewModel unchanged — Compose observes same StateFlow Phase 3 — Full Compose Navigation: Migrate NavGraph from Fragment to Compose NavHost Fragment shells replaced by composable destinations Phase 4 — Remove XML layer: Delete all Fragment shells and XML layouts Activity minimal — just hosts NavHost
Compose → XML (AndroidView)
// AndroidView — embedding a View inside a Composable
@Composable
fun VideoPlayer(player: ExoPlayer, modifier: Modifier = Modifier) {
AndroidView(
factory = { ctx ->
// Called ONCE when composable enters composition
// Create and configure the View here
PlayerView(ctx).apply {
this.player = player
useController = true
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
}
},
update = { playerView ->
// Called on EVERY recomposition — keep this CHEAP
playerView.player = player // update if player reference changed
},
onRelease = { playerView ->
// Called when AndroidView leaves composition — cleanup
playerView.player = null // detach without releasing (ViewModel owns player)
},
modifier = modifier
)
// Handle player lifecycle with DisposableEffect
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, player) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> player.play()
Lifecycle.Event.ON_STOP -> player.pause()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}
// AndroidViewBinding — use entire XML layout inside Compose
@Composable
fun LegacyItemView(data: SomeData, modifier: Modifier = Modifier) {
AndroidViewBinding(
factory = ItemComplexBinding::inflate,
modifier = modifier
) {
// 'this' is the binding — update on every recomposition
titleTextView.text = data.title
subtitleTextView.text = data.subtitle
iconImageView.load(data.iconUrl)
}
}
Custom Canvas Drawing
// Migrating View.onDraw() to Compose Canvas
@Composable
fun CustomProgressRing(
progress: Float,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary,
strokeWidth: Dp = 8.dp
) {
val stroke = with(LocalDensity.current) { strokeWidth.toPx() }
Canvas(modifier = modifier) {
// Background track
drawArc(color = color.copy(alpha = 0.2f), startAngle = -90f, sweepAngle = 360f,
useCenter = false, style = Stroke(width = stroke, cap = StrokeCap.Round))
// Progress arc
drawArc(color = color, startAngle = -90f, sweepAngle = 360f * progress,
useCenter = false, style = Stroke(width = stroke, cap = StrokeCap.Round))
}
}
Staff Q&A
- Fragment view lifecycle != Fragment lifecycle. The view is destroyed in onDestroyView() but the Fragment stays alive on the back stack.
- If Composition disposed when Fragment goes on back stack (wrong strategy): state is lost when user navigates back — bad UX.
- If Composition never disposed (tied to Fragment lifetime): views leak when the Fragment's view is gone — memory leak.
- DisposeOnViewTreeLifecycleDestroyed ties disposal exactly to the view lifecycle — disposes when the view is truly gone, not when the Fragment is temporarily on the back stack.
- factory runs once — expensive view creation is not repeated.
- update runs on EVERY recomposition — must be cheap (just update data, no view creation).
- AndroidView breaks Compose's optimization — the embedded View is a black box, Compose can't skip its drawing or know if it changed.
- Keep AndroidView as low in the composition tree as possible. Pass only the exact parameters it needs — state changes higher in the tree trigger update even if the View's data didn't change.
- Theme inconsistency — Compose MaterialTheme and XML Material theme can diverge, producing different colors/typography on the same screen or even the same view.
- Use Accompanist's Mdc3Theme wrapper to inherit XML theme in Compose, OR migrate theme to Compose first before migrating screens.
- Lifecycle bugs from wrong ViewCompositionStrategy — the most common crash in interop. Always set it explicitly.
- Navigation conflicts — never use both NavHostFragment and Compose NavHost in the same app for the same navigation graph. Choose one system per navigation scope.
Animations in Compose
Animation APIs — From Simple to Advanced
Animation Layers (high → low level):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
High Level — simple, automatic, less control
AnimatedVisibility show/hide composables with animation
AnimatedContent animate between different content/state
Crossfade simple fade between composables
animateXxxAsState() animate a single value (Float, Color, Dp)
Mid Level — coordinate multiple, imperative control
updateTransition multiple properties animated in sync
Animatable control animation imperatively
Low Level — maximum performance
graphicsLayer {} skip composition + layout, drawing phase only
rememberInfiniteTransition continuous looping animations
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// animateXxxAsState — simplest animation
val backgroundColor by animateColorAsState(
targetValue = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surface,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
label = "card_bg"
)
val alpha by animateFloatAsState(targetValue = if (isVisible) 1f else 0f, label = "alpha")
val height by animateDpAsState(targetValue = if (isExpanded) 300.dp else 100.dp, label = "height")
// AnimatedVisibility
AnimatedVisibility(
visible = isVisible,
enter = fadeIn(tween(300)) + slideInVertically { -it },
exit = fadeOut(tween(300)) + shrinkVertically()
) { Card { Text("Animated card") } }
// AnimatedContent — animate between different states
AnimatedContent(
targetState = uiState,
transitionSpec = { fadeIn(tween(300)) togetherWith fadeOut(tween(300)) },
label = "state_content"
) { state ->
when (state) {
is UiState.Loading -> LoadingScreen()
is UiState.Success -> SuccessScreen(state.data)
is UiState.Error -> ErrorScreen(state.message)
}
}
// updateTransition — coordinate multiple animations in sync
val transition = updateTransition(targetState = isExpanded, label = "card")
val cardHeight by transition.animateDp(label = "height") { if (it) 300.dp else 100.dp }
val contentAlpha by transition.animateFloat(label = "alpha") { if (it) 1f else 0f }
val bgColor by transition.animateColor(label = "bg") {
if (it) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface
}
// All three animate together, in sync, on the same timeline
// Animation specs
tween(durationMillis = 300, easing = FastOutSlowInEasing) // fixed duration, eased
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMedium) // physics, no fixed duration
keyframes {
durationMillis = 500
0f at 0 with LinearEasing
1.2f at 200 with FastOutSlowInEasing // overshoot
1f at 500
}
infiniteRepeatable(animation = tween(1000), repeatMode = RepeatMode.Reverse)
snap(delayMillis = 50) // instant
graphicsLayer — Performance-Critical Animations
// Modifier.alpha() reads state in COMPOSITION phase → triggers recomposition
// graphicsLayer { alpha = } reads state in DRAWING phase only → skips composition + layout
// ❌ Triggers recomposition on every frame
Box(modifier = Modifier.alpha(animatedAlpha))
// ✅ Only drawing phase — cheaper, smoother for high-frequency animations
Box(modifier = Modifier.graphicsLayer {
this.alpha = animatedAlpha
this.scaleX = animatedScale
this.scaleY = animatedScale
this.rotationZ = animatedRotation
this.translationY = animatedOffset
this.transformOrigin = TransformOrigin(0.5f, 0.5f)
})
Shimmer Loading Effect
@Composable
fun ShimmerCard(modifier: Modifier = Modifier) {
val shimmer = rememberInfiniteTransition(label = "shimmer")
val translateX by shimmer.animateFloat(
initialValue = -300f, targetValue = 1000f,
animationSpec = infiniteRepeatable(animation = tween(1200, easing = LinearEasing)),
label = "shimmer_translate"
)
Box(
modifier = modifier.fillMaxWidth().height(100.dp)
.clip(RoundedCornerShape(8.dp))
.background(Brush.linearGradient(
colors = listOf(
Color.LightGray.copy(alpha = 0.6f),
Color.LightGray.copy(alpha = 0.2f),
Color.LightGray.copy(alpha = 0.6f)
),
start = Offset(translateX, 0f),
end = Offset(translateX + 300f, 0f)
))
)
}
Respecting reduceMotion
@Composable
fun AccessibleAnimation(isExpanded: Boolean) {
val reduceMotion = LocalReduceMotion.current.value
val animSpec: AnimationSpec<Dp> = if (reduceMotion) snap() else spring()
val height by animateDpAsState(
targetValue = if (isExpanded) 300.dp else 100.dp,
animationSpec = animSpec, label = "height"
)
Box(modifier = Modifier.height(height))
}
Staff Q&A
- Modifier.alpha() etc. reads state in the composition phase — on every value change, Compose schedules a recomposition. For animations running at 60fps, that's recomposing 60 times per second.
- graphicsLayer reads state in the drawing phase only — skips composition and layout entirely. The composable "looks" the same to the composition but draws differently. Much cheaper.
- Rule: for animations that update every frame (scroll-driven, infinite, value animations), always use graphicsLayer. For one-time static changes, Modifier.alpha() is fine.
- Crossfade — simple fade only. No size animation. One line of code. Perfect when you just want to fade between two composables.
- AnimatedContent — full control: any enter/exit combination (slide, scale, fade), size change animation via SizeTransform, directional animations based on which state you're transitioning to/from.
- Use Crossfade for simple loading → content transitions. Use AnimatedContent for counter animations (slide up when incrementing, slide down when decrementing), tab content transitions, or any animation where direction matters.
- Use animateItemPlacement() modifier on each item — requires stable key on items().
- For items entering/leaving: wrap item content in AnimatedVisibility inside the items block.
- The stable key is critical — without it, Compose uses position identity and can't tell if an item moved or was replaced. animateItemPlacement() would animate incorrectly.
- Key insight: animateItemPlacement() animates POSITION changes (items moving up/down when others are inserted/removed). AnimatedVisibility inside the item handles the item's own enter/exit animation.
Custom Compose Layouts
Modifier.layout{}, Layout{} and SubcomposeLayout{}
Three APIs for custom layouts:
1. Modifier.layout{} — modify a SINGLE composable's measurement/placement
2. Layout{} — arrange MULTIPLE children in a custom way
3. SubcomposeLayout{} — measure some children BASED ON other children's sizes
Compose Layout Protocol (two phases per child):
1. MEASURE — "how big do you want to be?" within given constraints
2. PLACE — "you go here" at x,y coordinates
Rules:
• Each child measured EXACTLY ONCE (twice throws exception)
• Constraints: minWidth ≤ width ≤ maxWidth
• Tight: minWidth == maxWidth (child has no choice)
• Loose: minWidth == 0 (child chooses its size)
• Children report size via Placeable
• Parent places children via Placeable.placeAt()
// Modifier.layout{} — single composable modification
fun Modifier.firstBaselineToTop(topPadding: Dp) = layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val firstBaseline = placeable[FirstBaseline]
val placeableY = topPadding.roundToPx() - firstBaseline
layout(placeable.width, placeable.height + placeableY) {
placeable.placeAt(0, placeableY)
}
}
// Layout{} — flow layout (wrap items to next line)
@Composable
fun FlowRow(horizontalSpacing: Dp = 8.dp, verticalSpacing: Dp = 8.dp,
modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val hSpacing = horizontalSpacing.roundToPx()
val vSpacing = verticalSpacing.roundToPx()
val placeables = measurables.map { it.measure(constraints.copy(minWidth = 0)) }
val rows = mutableListOf<List<Placeable>>()
var currentRow = mutableListOf<Placeable>()
var rowWidth = 0
placeables.forEach { p ->
val newWidth = rowWidth + (if (currentRow.isEmpty()) 0 else hSpacing) + p.width
if (newWidth > constraints.maxWidth && currentRow.isNotEmpty()) {
rows.add(currentRow); currentRow = mutableListOf(p); rowWidth = p.width
} else { currentRow.add(p); rowWidth = newWidth }
}
if (currentRow.isNotEmpty()) rows.add(currentRow)
val rowHeights = rows.map { row -> row.maxOf { it.height } }
val totalHeight = rowHeights.sum() + vSpacing * (rows.size - 1).coerceAtLeast(0)
layout(constraints.maxWidth, totalHeight) {
var y = 0
rows.forEachIndexed { i, row ->
var x = 0
row.forEach { p -> p.placeAt(x, y); x += p.width + hSpacing }
y += rowHeights[i] + vSpacing
}
}
}
}
// SubcomposeLayout — tab indicator matching tab width
@Composable
fun CustomTabRow(selectedTabIndex: Int, tabs: @Composable () -> Unit) {
SubcomposeLayout { constraints ->
val tabsPlaceables = subcompose("tabs", tabs).map { it.measure(constraints) }
val tabWidths = tabsPlaceables.map { it.width }
val totalWidth = tabWidths.sum()
val tabHeight = tabsPlaceables.maxOfOrNull { it.height } ?: 0
val indicatorWidth = tabWidths.getOrElse(selectedTabIndex) { 0 }
val indicatorX = tabWidths.take(selectedTabIndex).sum()
val indicatorPlaceables = subcompose("indicator") {
Box(Modifier.width(indicatorWidth.toDp()).height(3.dp)
.background(MaterialTheme.colorScheme.primary))
}.map { it.measure(Constraints.fixed(indicatorWidth, 3.dp.roundToPx())) }
layout(totalWidth, tabHeight + 3.dp.roundToPx()) {
var x = 0
tabsPlaceables.forEach { it.placeAt(x, 0); x += it.width }
indicatorPlaceables.forEach { it.placeAt(indicatorX, tabHeight) }
}
}
}
// Overlapping avatar stack
@Composable
fun AvatarStack(avatarUrls: List<String>, avatarSize: Dp = 40.dp, overlapRatio: Float = 0.4f) {
Layout(content = {
avatarUrls.take(4).forEach { url ->
AsyncImage(model = url, contentDescription = null,
modifier = Modifier.size(avatarSize).clip(CircleShape)
.border(2.dp, MaterialTheme.colorScheme.surface, CircleShape))
}
}) { measurables, _ ->
val avatarPx = avatarSize.roundToPx()
val step = avatarPx - (avatarPx * overlapRatio).toInt()
val placeables = measurables.map { it.measure(Constraints.fixed(avatarPx, avatarPx)) }
val totalWidth = if (placeables.isEmpty()) 0 else avatarPx + step * (placeables.size - 1)
layout(totalWidth, avatarPx) {
placeables.forEachIndexed { i, p -> p.placeAt(step * i, 0, zIndex = i.toFloat()) }
}
}
}
Intrinsic Measurements
// height(IntrinsicSize.Min) — makes all siblings the same height
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
Text("Short", modifier = Modifier.weight(1f))
Divider(modifier = Modifier.fillMaxHeight().width(1.dp)) // fills Row's intrinsic height
Text("This is much longer text
that spans two lines", modifier = Modifier.weight(1f))
}
// Without IntrinsicSize.Min: Divider has no natural height → renders invisible
// With IntrinsicSize.Min: Row height = tallest child → Divider fills correctly
Staff Q&A
- Layout{} — measures ALL children upfront, then places them. Children are measured independently — no child can see another child's measured size. Use for 99% of custom layouts.
- SubcomposeLayout{} — composes children lazily on demand. You can compose and measure Child B AFTER seeing Child A's measured size. Use only when children genuinely depend on sibling sizes: tab indicator matching tab width, adaptive content filling remaining space.
- SubcomposeLayout is more expensive — composition happens during layout phase. Don't use it unless you truly need one child's size to determine another's composition.
- placeAt(x, y) — simple placement at coordinates. No graphics layer. No animation support. Cheaper for static layouts.
- placeWithLayer(x, y) { } — places with a dedicated graphics layer. Lambda can apply alpha, rotation, scale at drawing phase (not composition). Required for animateItemPlacement() to work in custom layouts. Slightly more memory overhead per item.
- Rule: use placeAt() by default. Switch to placeWithLayer() when items need position or visual animations.
DataStore
What is DataStore and Why Does It Replace SharedPreferences?
Every app needs a place to save small pieces of user preferences — dark mode on or off, the user's name, their last selected language. For years Android developers used SharedPreferences for this. But SharedPreferences has serious problems that led Google to build DataStore as its replacement.
SharedPreferences problems: ✗ Synchronous reads on the main thread → can cause ANR ✗ apply() appears async but flushes synchronously on certain lifecycle events ✗ No type safety — getString(), getInt(), getBoolean() can crash at runtime ✗ No error handling — no way to know if a write failed ✗ Not safe to use from multiple threads ✗ No support for complex data types DataStore fixes all of these: ✓ Fully asynchronous — built on Kotlin Coroutines + Flow ✓ Never blocks the main thread ✓ Safe to use from multiple coroutines simultaneously ✓ Error handling via Flow exceptions ✓ Two variants: Preferences DataStore (key-value) and Proto DataStore (typed schema)
Two Variants of DataStore
| Feature | Preferences DataStore | Proto DataStore |
|---|---|---|
| Schema | Key-value pairs (like SharedPreferences) | Defined via Protocol Buffers (.proto file) |
| Type safety | Partial — uses typed keys | Full — compile-time guaranteed types |
| Setup complexity | Simple | More setup (proto file + generated code) |
| Best for | Simple flags, strings, numbers | Complex structured data that must be typed |
For most apps, Preferences DataStore is the right choice. Use Proto DataStore when you have complex nested data structures that need strict typing.
Preferences DataStore — Complete Implementation
// build.gradle
implementation "androidx.datastore:datastore-preferences:1.1.x"
// Step 1: Create the DataStore instance — one per file, one per app
// The 'by preferencesDataStore' delegate creates a singleton
val Context.userPreferencesDataStore by preferencesDataStore(name = "user_preferences")
// "user_preferences" becomes the filename: user_preferences.preferences_pb
// Always use a singleton — multiple instances for the same file = data corruption
// Step 2: Define typed keys — replaces magic strings
object PreferencesKeys {
val DARK_MODE = booleanPreferencesKey("dark_mode")
val USER_NAME = stringPreferencesKey("user_name")
val FONT_SIZE = intPreferencesKey("font_size")
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
val NOTIFICATIONS_ON = booleanPreferencesKey("notifications_enabled")
val THEME_CONFIG = stringPreferencesKey("theme_config")
}
// Step 3: Repository wrapping DataStore — the right pattern
class UserPreferencesRepository @Inject constructor(
private val dataStore: DataStore<Preferences>
) {
// READ — returns a Flow that emits whenever the value changes
val isDarkMode: Flow<Boolean> = dataStore.data
.catch { exception ->
if (exception is IOException) emit(emptyPreferences()) // handle corruption
else throw exception
}
.map { preferences ->
preferences[PreferencesKeys.DARK_MODE] ?: false // default if not set
}
val userPreferences: Flow<UserPreferences> = dataStore.data
.catch { if (it is IOException) emit(emptyPreferences()) else throw it }
.map { preferences ->
UserPreferences(
isDarkMode = preferences[PreferencesKeys.DARK_MODE] ?: false,
userName = preferences[PreferencesKeys.USER_NAME] ?: "",
fontSize = preferences[PreferencesKeys.FONT_SIZE] ?: 16,
notificationsOn = preferences[PreferencesKeys.NOTIFICATIONS_ON] ?: true
)
}
// WRITE — suspend function, safe to call from any coroutine
suspend fun setDarkMode(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.DARK_MODE] = enabled
}
}
suspend fun setUserName(name: String) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.USER_NAME] = name
}
}
// ATOMIC UPDATE — edit() is transactional: if it throws, no changes are made
suspend fun resetAllPreferences() {
dataStore.edit { preferences ->
preferences.clear()
}
}
// Update multiple values atomically in one transaction
suspend fun updateTheme(isDark: Boolean, fontSize: Int) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.DARK_MODE] = isDark
preferences[PreferencesKeys.FONT_SIZE] = fontSize
// Both saved together — never a state where one is saved and the other isn't
}
}
}
// Data class for structured preferences
data class UserPreferences(
val isDarkMode: Boolean,
val userName: String,
val fontSize: Int,
val notificationsOn: Boolean
)
Hilt Setup for DataStore
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
@Provides
@Singleton
fun provideUserPreferencesDataStore(
@ApplicationContext context: Context
): DataStore<Preferences> = context.userPreferencesDataStore
@Provides
@Singleton
fun provideUserPreferencesRepository(
dataStore: DataStore<Preferences>
): UserPreferencesRepository = UserPreferencesRepository(dataStore)
}
Using DataStore in ViewModel
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val preferencesRepository: UserPreferencesRepository
) : ViewModel() {
val userPreferences: StateFlow<UserPreferences> = preferencesRepository.userPreferences
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UserPreferences(false, "", 16, true)
)
fun onDarkModeToggled(enabled: Boolean) {
viewModelScope.launch {
preferencesRepository.setDarkMode(enabled)
}
}
fun onFontSizeChanged(size: Int) {
viewModelScope.launch {
preferencesRepository.updateTheme(
isDark = userPreferences.value.isDarkMode,
fontSize = size
)
}
}
}
Migrating from SharedPreferences
// DataStore can automatically migrate from SharedPreferences
val Context.userPreferencesDataStore by preferencesDataStore(
name = "user_preferences",
produceMigrations = { context ->
listOf(
SharedPreferencesMigration(
context,
"old_shared_prefs_name" // the name you used in SharedPreferences
)
)
}
)
// On first access, DataStore reads all values from SharedPreferences
// and writes them to DataStore. SharedPreferences file is then deleted.
Staff Q&A
- SharedPreferences.edit().apply() posts a write to a background thread but the same SharedPreferences object can be read on another thread simultaneously — no synchronization guarantee.
- getSharedPreferences() on the main thread parses the XML file synchronously on the first call — this can take hundreds of milliseconds for large files, directly causing jank or ANR.
- commit() (as opposed to apply()) blocks the calling thread entirely until the write is complete — using commit() on the main thread is a direct ANR risk.
- DataStore's Flow-based API makes it impossible to accidentally block the main thread — you're forced into coroutines.
- SharedPreferences: data corruption is possible. apply() writes to an in-memory map immediately, then flushes to disk asynchronously. A crash between these two steps can result in a partially written or corrupted XML file.
- DataStore: uses atomic file replacement. It writes to a temporary file first, then atomically renames it. If the app crashes mid-write, the original file is untouched — no corruption. This is why DataStore uses a .preferences_pb file (Protocol Buffer format) which is more robust than XML.
- When you have complex nested data that must be type-safe at the schema level, not just at the key level.
- When you need to evolve the schema over time with guaranteed backward compatibility (proto supports field additions without breaking old data).
- When your preferences have relationships between fields (e.g., a settings object with nested display settings and notification settings).
- For most apps, Preferences DataStore is sufficient. Proto DataStore adds build complexity (proto plugin, generated code) that's only worth it for complex data structures.
Performance & Optimization
What is ANR and How Does Android Detect It?
ANR stands for Application Not Responding. It's Android's way of telling the user "this app is frozen." When your app blocks its main thread for too long, Android shows an ANR dialog and may kill your app.
ANR triggers: Input dispatch timeout → User taps/touches, no response within 5 seconds Service timeout → Started Service does not complete within 20 seconds Broadcast timeout → BroadcastReceiver onReceive() takes more than 10 seconds Content Provider ANR → Provider not responding within 10 seconds The main thread (UI thread) must: ✓ Process touch events ✓ Draw frames (16ms budget at 60fps, 8ms at 120fps) ✓ Run Activity/Fragment lifecycle callbacks ✓ Run all Handler/Looper messages Any blocking work on main thread = frozen UI: ✗ Network calls (can take seconds) ✗ Database queries (can take hundreds of ms) ✗ Large file reads/writes ✗ Heavy computation (sorting 10,000 items, parsing large JSON) ✗ Thread.sleep() — the classic accidental block ✗ Synchronous SharedPreferences reads on first access
Diagnosing ANRs
// ANR traces are written to:
// /data/anr/anr_TIMESTAMP.txt (requires root or adb)
// adb pull /data/anr/
// In Android Studio — use Android Vitals in Play Console for production ANR reports
// Or: Logcat filter for "ANR" during development
// StrictMode — catch main thread violations in development
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads() // disk reads on main thread
.detectDiskWrites() // disk writes on main thread
.detectNetwork() // network on main thread
.detectCustomSlowCalls() // custom slow operations
.penaltyLog() // log to Logcat (use in dev)
// .penaltyDeath() // crash the app (use in CI)
.build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectLeakedSqlLiteObjects()
.detectLeakedClosableObjects()
.detectActivityLeaks()
.penaltyLog()
.build()
)
}
}
}
Jank — Dropped Frames and Rendering Performance
Jank is when your app skips frames, causing visible stuttering. A smooth app renders every frame in under 16 milliseconds (at 60fps). If the main thread is busy for longer than that, a frame is dropped and the user sees a stutter.
The 16ms budget (at 60fps):
CPU: measure + layout + draw into display list ← your code runs here
GPU: render the display list to screen
If your code takes 30ms → frame is skipped → user sees stutter
Common jank causes:
RecyclerView/LazyColumn:
• No stable keys → all items recompose on any change
• Heavy onBindViewHolder/item composable → takes too long per item
• Nested RecyclerViews/ScrollViews → double measurement
Compose:
• Unstable parameters → unnecessary recompositions
• Missing derivedStateOf → recomposing on every scroll pixel
• Reading state in wrong phase → forces extra layout passes
General:
• Inflation of complex layouts (XML) during scroll
• Loading images on main thread
• Large allocations during scroll → triggering GC pauses
Profiling Tools
// 1. Android Studio Profiler — built in
// Run → Profile → CPU → Record a System Trace
// Shows: frame rendering, main thread work, binder calls
// 2. Layout Inspector (Compose)
// View → Tool Windows → Layout Inspector
// Enable "Show recomposition counts" — see which composables recompose most
// 3. Systrace / Perfetto — low-level tracing
// $ adb shell perfetto -o /tmp/trace.pftrace -t 10s ...
// Open at ui.perfetto.dev
// 4. Add trace sections to your own code
class UserRepository @Inject constructor(...) {
suspend fun getUsers(): List<User> {
return withContext(Dispatchers.IO) {
trace("UserRepository.getUsers") { // shows up in Perfetto
userDao.getAllUsersOnce().map { it.toDomain() }
}
}
}
}
// 5. Compose performance tracking
@Composable
fun PerformantList(items: List<Item>) {
// Use remember + derivedStateOf to avoid unnecessary recompositions
val expensiveValue by remember { derivedStateOf { computeExpensiveThing(items) } }
LazyColumn {
items(
items = items,
key = { it.id }, // stable key = avoid position confusion
contentType = { it::class } // helps Compose reuse compositions
) { item ->
ItemCard(item) // mark as stable with @Immutable on data class
}
}
}
Memory Leaks — What They Are and How to Fix Them
A memory leak happens when an object is no longer needed but is still referenced by something else, preventing the garbage collector from freeing its memory. In Android, the most common leak is holding a reference to an Activity or Context that has already been destroyed.
Classic Android memory leaks:
1. Static reference to Activity/Context
static Activity sActivity; // Activity destroyed, but static field holds it → leak
2. Inner class holding outer class reference
class MyActivity extends Activity {
void startWork() {
new AsyncTask<Void, Void, Void>() { // anonymous inner class
// implicitly holds reference to MyActivity
// if activity is destroyed before doInBackground finishes → leak
}.execute();
}
}
3. Fragment view binding not cleared
private var binding: FragmentMyBinding? = null
// If _binding never set to null in onDestroyView() → view tree leaked
4. Context stored in ViewModel
class UserViewModel : ViewModel() {
val context: Context // ← NEVER do this
// ViewModel survives rotation → holds destroyed Activity context
5. Callback/listener not unregistered
locationManager.requestLocationUpdates(provider, listener)
// If removeUpdates() never called → listener holds Activity reference
LeakCanary — Automatic Leak Detection
// build.gradle — automatically detects and reports leaks in debug builds
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.x.x'
// No code needed — LeakCanary auto-installs via ContentProvider
// Shows a notification with the leak trace when a leak is detected
// What LeakCanary monitors by default:
// • Activity instances that are not garbage collected after onDestroy
// • Fragment instances not GC'd after onDestroy
// • Fragment views not GC'd after onDestroyView
// • ViewModel instances not GC'd after onCleared
// • Service instances after onDestroy
// Reading a LeakCanary report:
// RETAINED → object is alive but should be dead
// LEAKING → confirmed leak (activity destroyed but still retained)
// The trace shows the shortest reference chain keeping it alive
Memory Profiler
// Android Studio Profiler → Memory tab
// Force GC → if memory doesn't drop → potential leak
// Dump Java Heap → see all live objects and what holds them
// Common fixes:
// 1. Use Application context for long-lived objects
class Repository @Inject constructor(
@ApplicationContext private val context: Context // ✓ safe, lives as long as app
)
// 2. WeakReference for listeners that outlive their owners
class LocationManager {
private val listeners = WeakHashMap<LocationListener, Unit>()
fun addListener(listener: LocationListener) { listeners[listener] = Unit }
}
// 3. Clear references in lifecycle callbacks
override fun onDestroyView() {
super.onDestroyView()
_binding = null // break reference to view tree
recyclerView.adapter = null // RecyclerView holds adapter which holds views
}
// 4. Cancel coroutines properly — use viewModelScope, lifecycleScope
// Never use GlobalScope for work tied to UI components
Staff Q&A
- Play Console → Android Vitals → ANR rate. This shows ANR traces from real users — filter by OS version, device, ANR type.
- The ANR trace shows a thread dump at the moment of the ANR — look for "main" thread: if it shows waiting on a lock, database cursor, network socket, or sleeping → that's the cause.
- Common patterns in traces: "waiting to lock" means deadlock; "sqlite.SQLiteDatabase.execSQL" on main thread means a database query blocked UI; "getSharedPreferences" on main thread means legacy SharedPreferences access.
- Prevention strategy: StrictMode in debug/CI builds catches violations before they reach production.
- A memory leak is the cause — objects accumulate over time because GC can't collect them. OOM is the effect — the app runs out of heap space and crashes.
- Not all OOMs are from leaks: loading a very large Bitmap that exceeds heap can cause OOM without any leak. But repeated leaks over time deplete the heap and eventually cause OOM.
- Leaks are insidious because they don't immediately crash — the app just gets slower and slower (GC runs more frequently to reclaim the shrinking free space) until it eventually OOMs or the OS kills it.
- Android apps run on the ART runtime which uses a JIT (Just-In-Time) compiler. On first run, code is interpreted byte by byte — very slow. JIT compiles hot code to native over time.
- Baseline Profiles pre-compile critical code paths to native at install time (via AOT compilation). The first launch is as fast as subsequent launches.
- This dramatically reduces cold start time and eliminates jank on first scroll (which is often slow because RecyclerView/LazyColumn code hasn't been JIT-compiled yet).
- Result: typically 30-40% improvement in startup time, 20-30% reduction in first-scroll jank. Google recommends them for all production apps.
Accessibility
What is Accessibility and Why Does It Matter?
Accessibility means making your app usable by everyone — including people with visual, motor, hearing, or cognitive disabilities. In India, over 40 million people have visual impairments. Accessibility is not optional; Google Play penalizes apps with poor accessibility scores, and many enterprise clients require WCAG compliance.
TalkBack (Android's screen reader): • Reads UI elements aloud using text-to-speech • User swipes to move between elements • Double-taps to activate • Users who are blind or have low vision rely on this entirely Switch Access: • For users who can't use touch (motor disabilities) • Uses external switches or the volume buttons to navigate Magnification: • Zoom in on parts of the screen • Works on top of your UI Your app must work correctly with ALL of these. If an element has no content description → TalkBack reads nothing → unusable. If a button is too small → Switch Access can't target it. If font doesn't scale → large text users can't read it.
Accessibility in Compose — Semantics
In Compose, accessibility is handled through the semantics system. Every composable has a semantics node that describes what it is and what it does. TalkBack reads these semantics nodes.
// Basic content description — what TalkBack reads aloud
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = "Add to favorites" // NEVER null for interactive icons
// contentDescription = null // only for purely decorative icons
)
Image(
painter = painterResource(R.drawable.user_photo),
contentDescription = "Profile photo of ${user.name}" // descriptive, not "image"
)
// Adding semantics to any composable
Box(
modifier = Modifier.semantics {
contentDescription = "Shopping cart with ${itemCount} items"
role = Role.Button
}
)
// Merging semantics — treat a group of elements as one
// e.g. a card that contains an image + title + subtitle
Card(
modifier = Modifier
.clickable { onClick() }
.semantics(mergeDescendants = true) {
// TalkBack reads: "John Doe, Software Engineer, Double tap to view profile"
// Instead of reading each text separately
}
) {
Row {
AsyncImage(model = user.avatarUrl, contentDescription = null) // decorative here
Column {
Text(user.name)
Text(user.jobTitle)
}
}
}
Roles — Telling TalkBack What an Element Is
// Role tells TalkBack how to describe and interact with an element
Modifier.semantics { role = Role.Button } // "Button" — reads as "Double tap to activate"
Modifier.semantics { role = Role.Checkbox } // "Checkbox" — reads checked/unchecked state
Modifier.semantics { role = Role.Switch } // "Switch" — on/off
Modifier.semantics { role = Role.Tab } // "Tab"
Modifier.semantics { role = Role.RadioButton } // "Radio button"
Modifier.semantics { role = Role.Image } // "Image"
// State descriptions
Modifier.semantics {
stateDescription = if (isExpanded) "Expanded" else "Collapsed"
}
// Custom action — add an action TalkBack users can trigger
Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction("Share post") { sharePost(); true },
CustomAccessibilityAction("Delete post") { deletePost(); true }
)
}
Touch Target Size
// Minimum touch target: 48dp x 48dp (Material Design guidelines)
// Even if the visual is smaller, expand the touch target
IconButton(
onClick = { },
modifier = Modifier.size(48.dp) // minimum 48dp
) {
Icon(
Icons.Default.Close, contentDescription = "Close",
modifier = Modifier.size(24.dp) // visual size can be smaller
)
}
// For smaller visual elements, use minimumInteractiveComponentSize
Icon(
imageVector = Icons.Default.Info,
contentDescription = "More information",
modifier = Modifier
.minimumInteractiveComponentSize() // enforces 48x48 touch target
.size(16.dp) // 16dp visual
.clickable { }
)
Testing Accessibility
// 1. Enable TalkBack on your device/emulator and navigate your app manually
// Settings → Accessibility → TalkBack → On
// Navigate with: swipe right (next), swipe left (previous), double tap (activate)
// 2. Accessibility Scanner — Google Play app that scans your UI for issues
// Reports: missing content descriptions, small touch targets, low contrast
// 3. Compose Semantics in tests
@Test
fun profileCard_hasCorrectAccessibility() {
composeTestRule.setContent {
ProfileCard(user = User("John Doe", "Engineer"))
}
// Verify content description is meaningful
composeTestRule
.onNodeWithContentDescription("Profile photo of John Doe")
.assertExists()
// Verify interactive elements have the right role
composeTestRule
.onNode(hasText("Follow") and hasClickAction())
.assertHasClickAction()
// Verify merged semantics
composeTestRule
.onNode(hasText("John Doe") and hasText("Engineer"))
.assertIsDisplayed()
}
// 4. Check contrast ratios
// Normal text: minimum 4.5:1 contrast ratio
// Large text (18sp+): minimum 3:1
// Use: https://webaim.org/resources/contrastchecker/
Common Mistakes
- Icons without contentDescription (or with contentDescription = null) when they're interactive — TalkBack says nothing
- Custom buttons built from Box+clickable without setting role = Role.Button — TalkBack doesn't know it's a button
- Text color that's too light on white background — low contrast, unreadable for low-vision users
- Fixed text sizes in dp instead of sp — doesn't scale with user's font size preference
- Touch targets smaller than 48dp — impossible to tap for motor-impaired users
- Modals/dialogs that don't trap focus — TalkBack users can navigate outside the dialog
Staff Q&A
- Setting contentDescription = null explicitly tells Compose/Android: "this element has no meaningful description for accessibility — it's decorative." TalkBack skips it entirely.
- Omitting it (for interactive elements like buttons and icons) means the system will try to infer a description — often poorly, reading "Button" or the icon's resource name like "ic_baseline_favorite_24."
- Always set contentDescription on interactive icons to something meaningful. Only use null for purely decorative elements that add no information (like a divider line or a background pattern).
- Without merging: TalkBack navigates to each element separately — it would read "John Doe" (stop), "Software Engineer" (stop), "San Francisco" (stop) for one card. The user has to swipe multiple times per card in a list.
- With mergeDescendants = true: TalkBack treats the whole card as one unit and reads all the text together. Much faster to navigate a list.
- The clickable modifier applies mergeDescendants automatically for Buttons — you only need to set it manually for custom card-like components.
Modularization at Scale
What is Modularization and Why Does It Matter?
By default, an Android app is one module — the :app module. All your code lives there. When you first start, this is fine. But as the app grows to 100,000+ lines of code with 10+ engineers, a single module creates serious problems.
Single-module problems at scale:
Build time:
One change anywhere → recompile EVERYTHING
A typo in a utility class → rebuilds the entire app (5-15 minutes)
Engineers waiting for builds → massive productivity loss
Code organization:
No enforced boundaries — any class can import any other class
Business logic leaks into UI, UI leaks into data layer
"Spaghetti architecture" — everything depends on everything
Team scalability:
Team A's change conflicts with Team B's change constantly
One engineer's bad code can break another team's feature
Impossible to assign ownership clearly
Multi-module solves all of these:
Build time:
Change feature A → only feature A is recompiled
Unchanged modules use their cached compiled output
5-15 minute builds → 30-60 second incremental builds
Code organization:
Modules can only see what they declare as dependencies
The compiler enforces architecture boundaries
Clean Architecture becomes enforced, not just aspirational
Team scalability:
Each team owns their modules
Changes are isolated by module boundaries
Feature flags and A/B testing are module-level decisions
Module Structure — How to Organize
Recommended module structure:
:app ← thin shell: DI wiring, navigation, Application class
│
├── :core:designsystem ← colors, typography, shapes, reusable UI components
├── :core:network ← OkHttp, Retrofit setup, network monitoring
├── :core:database ← Room database, DAOs
├── :core:datastore ← DataStore setup
├── :core:domain ← domain models, repository interfaces, use cases
├── :core:testing ← shared test utilities: fakes, test rules
│
├── :feature:home ← home screen: ViewModel, Screen composable
├── :feature:product ← product listing and detail
├── :feature:cart ← cart and checkout
├── :feature:profile ← user profile and settings
├── :feature:search ← search and filters
│
└── :data:product ← ProductRepositoryImpl, ProductApi, ProductDao
:data:user ← UserRepositoryImpl, UserApi, UserDao
Dependency rules:
:feature → :core:domain, :core:designsystem
:data → :core:domain, :core:network, :core:database
:core:domain → nothing (pure Kotlin, no Android)
:app → all modules (wires everything together)
Features NEVER depend on each other (prevents coupling)
// :core:domain/build.gradle.kts — pure Kotlin library
plugins {
id("java-library") // NOT android-library — no Android deps allowed
id("org.jetbrains.kotlin.jvm")
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.x")
// No Room, no Retrofit, no Android — forces pure domain code
}
// :feature:home/build.gradle.kts
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
}
android {
namespace = "com.example.feature.home"
}
dependencies {
implementation(project(":core:domain")) // business logic
implementation(project(":core:designsystem")) // UI components
// Feature modules do NOT depend on :data modules directly
// They depend on interfaces from :core:domain
// :app wires the actual implementations via Hilt
}
Convention Plugins — Avoid Gradle Duplication
// Problem: every module has identical build.gradle boilerplate
// Solution: convention plugins in build-logic/
// build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.library")
pluginManager.apply("org.jetbrains.kotlin.android")
pluginManager.apply("com.google.dagger.hilt.android")
extensions.configure<LibraryExtension> {
compileSdk = 35
defaultConfig.minSdk = 24
buildFeatures.compose = true
}
dependencies {
"implementation"(project(":core:domain"))
"implementation"(project(":core:designsystem"))
"testImplementation"(project(":core:testing"))
}
}
}
}
// Now each feature module's build file is just:
// :feature:home/build.gradle.kts
plugins {
id("myapp.android.feature") // one line instead of 50 lines
}
android { namespace = "com.example.feature.home" }
Navigation Between Feature Modules
// Problem: :feature:home can't import :feature:product (they're independent)
// Solution: shared :core:navigation module with all route definitions
// :core:navigation
@Serializable object HomeRoute
@Serializable data class ProductDetailRoute(val productId: String)
// :feature:home — navigates to product, but doesn't know product module exists
@Composable
fun HomeScreen(
onNavigateToProduct: (String) -> Unit // passed as lambda from :app
) {
Button(onClick = { onNavigateToProduct("product_123") }) {
Text("View Product")
}
}
// :app — wires everything together, knows all feature modules
NavHost(navController, startDestination = HomeRoute) {
homeGraph(
navController = navController,
onNavigateToProduct = { productId ->
navController.navigate(ProductDetailRoute(productId))
}
)
productGraph(navController = navController)
}
Staff Q&A
- Create a module when: there's a clear ownership boundary (one team owns it), when it needs different build configuration (pure Kotlin library vs Android library), when it's reused by multiple features, or when you want to enforce architectural boundaries at compile time.
- Don't create a module for every tiny thing — 50 modules with 3 files each creates overhead without benefit. A rough guide: a module should be at least 500-1000 lines of production code.
- Feature modules map to user-facing flows (home, search, checkout). Core modules map to cross-cutting concerns (network, database, design system).
- If :feature:home depends on :feature:product, you create a chain — changes in product's internals require rebuilding home. You also can't develop/test home independently.
- More importantly: circular dependencies become possible (home → product → home → ...) which Gradle won't build at all.
- The correct pattern: features communicate via :core:navigation routes (compile-time contracts) and via shared :core:domain use cases. The :app module wires the actual navigation responses.
Gradle Build Optimization
Understanding Gradle — How Android Builds Work
Gradle is the build system that compiles your Kotlin code, processes resources, and packages your APK/AAB. Understanding how it works helps you make builds faster — slow builds kill developer productivity.
Android build steps (simplified): 1. Compile Kotlin → .class files (JVM bytecode) 2. D8/R8: .class files → .dex files (Dalvik bytecode for Android) 3. AAPT2: process resources, generate R.java 4. Link: combine dex + resources → APK/AAB 5. Sign: add signing certificate Build types: Debug: skips shrinking/obfuscation, faster Release: runs R8 (minify + obfuscate), slower but smaller APK Three things that slow builds: 1. Annotation processing (KAPT) — runs at compile time, slow 2. Cache misses — recompiling things that haven't changed 3. Too many dependencies in the dependency graph
KSP, Configuration Cache & Build Cache
KSP vs KAPT — The Single Biggest Build Speed Win
| Feature | KAPT (old) | KSP (new) |
|---|---|---|
| Speed | Slow — runs a full Java stub generation pass | 2x faster — understands Kotlin natively |
| Incremental | Limited incremental processing | Full incremental processing |
| Kotlin multiplatform | No | Yes |
| Used by | Old libraries | Room 2.5+, Hilt 2.48+, Moshi, Glide |
// Migrate from KAPT to KSP
// build.gradle.kts (module level)
// BEFORE (KAPT)
plugins {
id("kotlin-kapt")
}
dependencies {
kapt("com.google.dagger:hilt-android-compiler:2.48")
kapt("androidx.room:room-compiler:2.6.x")
}
// AFTER (KSP) — significantly faster builds
plugins {
id("com.google.devtools.ksp")
}
dependencies {
ksp("com.google.dagger:hilt-android-compiler:2.48")
ksp("androidx.room:room-compiler:2.6.x")
}
Configuration Cache
// What the configuration phase does:
// Gradle evaluates ALL build.gradle files before running any task
// For a 20-module project: evaluates 20+ build files every build
// This takes 5-30 seconds even for a simple code change
// Configuration cache: Gradle snapshots the task graph after configuration
// Subsequent builds SKIP the configuration phase entirely
// Savings: 5-30 seconds per build
// Enable in gradle.properties:
// org.gradle.configuration-cache=true
// org.gradle.configuration-cache.problems=warn ← use 'warn' while fixing issues
// Check compliance:
// ./gradlew assembleDebug --configuration-cache
// Gradle reports any incompatible tasks
// Common issues:
// • Referencing project.rootDir at execution time (capture in configuration)
// • Using Task.project at execution time
// • Non-serializable objects in task inputs/outputs
Build Cache
// Build cache stores task outputs (compiled .class files, processed resources)
// If inputs haven't changed → Gradle reuses cached output instead of recompiling
// Works across machine wipes (remote cache) and local clean builds
// gradle.properties — enable local build cache
org.gradle.caching=true
// Remote build cache — share cache across the team (Jenkins, GitHub Actions)
// gradle.properties
org.gradle.caching=true
// settings.gradle.kts — configure remote cache
buildCache {
local { isEnabled = true }
remote<HttpBuildCache> {
url = uri("https://your-build-cache-server/cache/")
isPush = System.getenv("CI") == "true" // only push from CI
credentials {
username = System.getenv("CACHE_USERNAME") ?: ""
password = System.getenv("CACHE_PASSWORD") ?: ""
}
}
}
Other Gradle Optimizations
// gradle.properties — tune JVM for faster builds
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC
// -Xmx4g: 4GB heap for Gradle daemon (prevents GC pauses during build)
// -XX:+UseParallelGC: parallel garbage collector (faster for short-lived build objects)
// Parallel project execution
org.gradle.parallel=true
// Gradle daemon — keep the JVM warm between builds (enabled by default)
org.gradle.daemon=true
// Build scan — identify slow tasks
// ./gradlew assembleDebug --scan
// Opens a web UI showing exactly which tasks are slow and why
// Avoid dynamic versions — they force Gradle to check for updates every build
// ❌ implementation "androidx.compose.ui:ui:+" (checks for latest every build)
// ✅ implementation "androidx.compose.ui:ui:1.6.0" (locked version, cacheable)
// Reduce coupling between modules — fewer dependencies = faster incremental builds
// Use api() vs implementation() correctly:
// implementation() — dependency is NOT exposed to consumers (faster builds)
// api() — dependency IS exposed (use sparingly — forces consumers to recompile)
Staff Q&A
- Configuration cache — caches the result of Gradle's configuration phase (evaluating all build.gradle files and constructing the task graph). Saves 5-30 seconds by skipping configuration on subsequent builds where the build scripts haven't changed.
- Build cache — caches the output of individual build tasks (compiled classes, processed resources). If a task's inputs haven't changed (same source files, same dependencies), Gradle reuses the cached output instead of re-running the task.
- They work at different levels: configuration cache speeds up Gradle's startup, build cache speeds up the actual compilation tasks. Both can be enabled together for maximum benefit.
- With implementation(): if library A changes, only module A needs to recompile. Modules that depend on A don't see A's transitive dependencies — they're encapsulated.
- With api(): if library A changes, ALL modules that depend on A must also recompile, because they might be using A's transitive dependencies in their own code.
- In a large multi-module app, one api() dependency at the core level can cascade recompilation through the entire module graph. Use api() only when consumers genuinely need to use types from the dependency in their own public API.
Baseline Profiles & Macrobenchmark
What is ART, JIT, and Why First Launch is Slow
To understand Baseline Profiles, you need to understand how Android runs your code. When you install an APK, Android doesn't compile it to native machine code immediately — that would take too long at install time. Instead, it uses a two-phase approach.
Without Baseline Profiles — first launch is slow:
Install APK
│
▼
First Launch:
ART interprets bytecode one instruction at a time
Very slow — roughly 10-50x slower than native code
As user navigates, JIT compiler identifies "hot" code
JIT compiles hot code to native in background (profile-guided)
│
▼
Subsequent Launches:
ART uses the JIT-compiled native code from previous run
Fast! But only after the user has run the code once.
Problems:
• First launch is painfully slow
• First scroll in a RecyclerView/LazyColumn is janky
(LazyColumn code hasn't been JIT-compiled yet)
• Every fresh install = slow experience
With Baseline Profiles:
AOT (Ahead-Of-Time) compilation of critical code at INSTALL time
First launch = same speed as 10th launch
Cold start improvement: typically 30-40%
First scroll improvement: 20-30% jank reduction
Creating and Applying Baseline Profiles
// Step 1: Add dependencies
// app/build.gradle.kts
androidComponents {
onVariants(selector().all()) { variant ->
if (variant.buildType == "release") {
variant.enableAndroidTestCoverage = false
}
}
}
// macrobenchmark module/build.gradle.kts
plugins { id("com.android.test") }
android {
targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true
}
dependencies {
implementation("androidx.benchmark:benchmark-macro-junit4:1.2.x")
implementation("androidx.test.ext:junit:1.1.x")
implementation("androidx.test.uiautomator:uiautomator:2.3.x")
}
// Step 2: Write the Baseline Profile generator
// macrobenchmark/src/androidTest/java/BaselineProfileGenerator.kt
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(
packageName = "com.example.myapp"
) {
// These are the critical user journeys to pre-compile
// Simulate what users do on first launch
// 1. Launch the app
pressHome()
startActivityAndWait()
// 2. Scroll the home feed (critical for LazyColumn performance)
device.findObject(By.res("home_list")).scroll(Direction.DOWN, 5)
// 3. Open a product detail
device.findObject(By.text("Popular Products")).click()
device.waitForIdle()
// 4. Open search
device.pressBack()
device.findObject(By.res("search_bar")).click()
device.waitForIdle()
}
}
// Step 3: Generate the profile
// ./gradlew :macrobenchmark:generateBaselineProfile
// This runs the test on a device and writes HumanReadableProfile.txt
// Step 4: Apply the profile
// The generated file goes to: app/src/main/baselineProfiles/baseline-prof.txt
// It contains method signatures like:
// Lcom/example/myapp/ui/home/HomeScreen;
// Lcom/example/myapp/data/UserRepositoryImpl;->getUsers()
// At install time, ART pre-compiles all these methods to native code
Macrobenchmark — Measuring Real Performance
// Macrobenchmark measures your app's performance as a real user experiences it
// (not unit-level microbenchmarks — those use the benchmark library instead)
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun coldStartup() = benchmarkRule.measureRepeated(
packageName = "com.example.myapp",
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD // kill process before each iteration
) {
pressHome()
startActivityAndWait()
}
@Test
fun scrollHomeFeed() = benchmarkRule.measureRepeated(
packageName = "com.example.myapp",
metrics = listOf(FrameTimingMetric()), // measures dropped frames
iterations = 5,
startupMode = StartupMode.WARM
) {
startActivityAndWait()
val list = device.findObject(By.res("home_feed"))
list.scroll(Direction.DOWN, 5)
}
}
// Results show:
// timeToFullDisplayMs: 1234 [min=1100, median=1234, max=1400]
// frameOverrunMs P50: 2ms, P90: 8ms, P99: 45ms (high P99 = occasional jank)
App Startup Library
// Problem: multiple libraries each register their own ContentProvider for initialization
// ContentProvider.onCreate() runs before Application.onCreate()
// Each ContentProvider adds ~50-100ms to cold start
// 10 libraries = 500ms-1000ms just in ContentProvider startup
// App Startup: one ContentProvider initializes all libraries in dependency order
// Dramatically reduces cold start time
// build.gradle
implementation "androidx.startup:startup-runtime:1.1.x"
// Create an Initializer for each library
class WorkManagerInitializer : Initializer<WorkManager> {
override fun create(context: Context): WorkManager {
val config = Configuration.Builder().build()
WorkManager.initialize(context, config)
return WorkManager.getInstance(context)
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}
class AnalyticsInitializer : Initializer<Analytics> {
override fun create(context: Context): Analytics {
return Analytics.init(context)
}
// Dependencies are initialized first
override fun dependencies() = listOf(WorkManagerInitializer::class.java)
}
// AndroidManifest.xml — one ContentProvider for all initializers
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="com.example.AnalyticsInitializer"
android:value="androidx.startup" />
</provider>
// Lazy initialization — only initialize when first needed
// (not at startup, only when actually required)
class MyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialize analytics only when user navigates to analytics screen
AppInitializer.getInstance(this)
.initializeComponent(AnalyticsInitializer::class.java)
}
}
Staff Q&A
- Cold start — the app's process doesn't exist. Android creates a new process, initializes Application, creates the first Activity. The slowest — typically 500ms-2000ms.
- Warm start — the app's process exists but the Activity was destroyed (user pressed back or was killed from background). Android recreates the Activity but reuses the process and Application. Medium speed — typically 200ms-500ms.
- Hot start — the app's process and Activity both exist (user swiped to another app and came back). Android just brings it to the foreground. Fastest — typically 50-150ms.
- Baseline Profiles primarily help cold starts. App Startup library also helps cold starts by reducing ContentProvider overhead.
- Debug builds have: no R8 optimization, no code shrinking, debuggable flag enabled (which slows ART's JIT), and extra assertion/logging code.
- Benchmarking a debug build gives numbers that are 2-5x slower than what real users experience on the Play Store release build.
- The whole point of Macrobenchmark is to measure real-world performance — that requires a configuration as close to production as possible.
R8 & ProGuard
What is R8 and What Does It Do?
R8 is Android's code shrinker, obfuscator, and optimizer. It runs on release builds and does three things to your code: removes unused code (shrinking), renames classes and methods to short names (obfuscation), and applies compile-time optimizations. This produces a smaller, faster, harder-to-reverse-engineer APK.
R8 three-step pipeline: 1. SHRINKING (Tree Shaking) Analyzes which classes/methods are reachable from your entry points Removes everything that's never called A library with 100,000 methods → only 3,000 used → removes 97,000 Result: 60-80% smaller DEX files 2. OBFUSCATION Renames all classes, methods, fields to short names com.example.userprofile.UserProfileViewModel → a.b.c.d getUser() → a(), deleteUser() → b() Result: harder to reverse-engineer, slightly smaller DEX 3. OPTIMIZATION Inlines short methods, removes redundant null checks Converts virtual calls to direct calls where safe Result: faster code at runtime D8 vs R8: D8: converts .class (JVM bytecode) to .dex (Dalvik bytecode). Used in debug builds. R8: does everything D8 does PLUS shrinking + obfuscation + optimization. R8 is always used in release builds.
// build.gradle.kts — R8 is enabled automatically for release
android {
buildTypes {
release {
isMinifyEnabled = true // enable R8 shrinking + obfuscation
isShrinkResources = true // also remove unused resources (images, layouts)
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
isMinifyEnabled = false // debug builds skip R8 — faster builds
}
}
}
ProGuard Rules — Keeping What R8 Must Not Remove
R8 is aggressive — it removes or renames anything it can't prove is needed. But some things must be kept: classes accessed via reflection, serialization models, JNI entry points, and anything the AndroidManifest references. ProGuard rules tell R8 what to leave alone.
// proguard-rules.pro
// -keep: don't shrink or obfuscate
// -keepnames: don't obfuscate (but still shrink)
// -dontwarn: suppress warnings about missing classes
// -keepclassmembers: keep members but class name can change
// Keep all serializable DTOs (kotlinx.serialization needs class names intact)
// Note: usually not needed with kotlinx.serialization (it generates adapters at compile time)
// But if using Gson (reflection-based):
-keep class com.example.data.remote.dto.** { *; }
// Keep Room entities (R8 might remove fields it thinks are unused)
-keep class com.example.data.local.entity.** { *; }
// Keep Parcelable implementations
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
// Keep Enum values (reflection is used to get enum values by name)
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
// Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
// Retrofit — keep service interfaces
-keep,allowobfuscation interface com.example.data.remote.api.**
// OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
// Most modern libraries ship their own ProGuard rules via the AAR file
// Check: unzip library.aar → proguard.txt
// You usually DON'T need to write rules for: Hilt, Room, Compose, Retrofit, OkHttp
// They include their own rules. Write rules only for YOUR code and libraries that don't.
Testing Your Release Build
// CRITICAL: Always test your release build before publishing
// R8 can break your app in ways debug builds won't reveal
// Build a release APK locally
// ./gradlew assembleRelease
// Install on device (sign with debug key for testing)
// ./gradlew installRelease
// Common R8-induced bugs:
// 1. Gson/Jackson serialization broken:
// Data class fields renamed by obfuscation → JSON field names don't match
// Fix: use @SerializedName or keep rules, or switch to kotlinx.serialization
// 2. Reflection broken:
// Class.forName("com.example.MyClass") → ClassNotFoundException
// Fix: -keep class com.example.MyClass
// 3. Missing interface implementation:
// R8 removed a class it thought was unused, but it's registered in AndroidManifest
// Fix: AndroidManifest references are automatically kept — check your manifest
// Retrace — decode obfuscated stack traces
// mapping.txt is generated at: app/build/outputs/mapping/release/mapping.txt
// Upload to Play Console (automatic), or:
// retrace mapping.txt crash_stacktrace.txt
Staff Q&A
- Gson uses reflection at runtime — it reads class names and field names from Java's reflection API to map JSON keys to fields. If R8 renames the field, Gson can't find it.
- kotlinx.serialization generates serialization code at compile time. The generated code uses direct property access (no reflection). R8 can rename things freely — the generated code references properties directly, not by name.
- This is one of the strongest reasons to use kotlinx.serialization in new projects — it works correctly with R8 without any ProGuard rules, while Gson requires extensive keep rules to avoid subtle bugs.
- mapping.txt is the dictionary R8 creates during obfuscation — it maps original class/method names to the obfuscated short names.
- When users report crashes from your release build, the stack trace shows obfuscated names: "a.b.c.d() line 42." Useless without mapping.txt.
- With mapping.txt (retrace), you can decode it back to your original class/method names. Play Console does this automatically if you upload the mapping file with each release.
- Archive mapping.txt for every release you publish. Without it, crash reports from that release are permanently unreadable.
Security
Android Security — What You Need to Protect
Security in Android apps means protecting three things: data in transit (network), data at rest (storage), and access to sensitive features (authentication). Most security vulnerabilities in Android apps come from developers not thinking about security as a first-class requirement.
Android security threat model:
Network threats:
Man-in-the-middle attacks — intercepting HTTPS traffic
DNS spoofing — redirecting to a fake server
→ Defend with: Certificate Pinning, Network Security Config
Storage threats:
Device theft — reading files from unencrypted storage
Backup extraction — adb backup can expose data
→ Defend with: EncryptedSharedPreferences, EncryptedFile, FLAG_SECURE
Authentication threats:
Credential theft — stored passwords in SharedPreferences (never do this!)
Brute force — no lockout on PIN entry
→ Defend with: BiometricPrompt, AccountManager, Android Keystore
Code threats:
Reverse engineering — decompiling your APK
→ Defend with: R8 obfuscation, code attestation, Play Integrity API
Certificate Pinning, Biometric & Encrypted Storage
Network Security Config — Prevent Cleartext & Pin Certificates
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<!-- Block all cleartext (HTTP) traffic -->
<base-config cleartextTrafficPermitted="false" />
<!-- Certificate pinning for your API -->
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2025-12-31">
<!-- SHA-256 hash of the SubjectPublicKeyInfo of your certificate -->
<pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<!-- Backup pin — use when rotating certificates -->
<pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
</domain-config>
</network-security-config>
<!-- AndroidManifest.xml -->
<application android:networkSecurityConfig="@xml/network_security_config" />
// Certificate pinning via OkHttp (alternative to Network Security Config)
val certificatePinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
EncryptedSharedPreferences & EncryptedFile
// EncryptedSharedPreferences — store sensitive data encrypted on disk
// Uses AES-256-GCM for values, AES-256-SIV for keys
// Keys are stored in Android Keystore (hardware-backed on modern devices)
// build.gradle
implementation "androidx.security:security-crypto:1.1.x"
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedPrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs", // filename
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// Use exactly like regular SharedPreferences
encryptedPrefs.edit().putString("auth_token", token).apply()
val token = encryptedPrefs.getString("auth_token", null)
// EncryptedFile — for encrypting larger files
val encryptedFile = EncryptedFile.Builder(
context,
File(context.filesDir, "sensitive_data.txt"),
masterKey,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()
encryptedFile.openFileOutput().use { it.write("secret data".toByteArray()) }
encryptedFile.openFileInput().use { data = it.readBytes() }
Biometric Authentication
// BiometricPrompt — fingerprint, face, or PIN
class AuthViewModel @Inject constructor(
private val biometricManager: BiometricManager
) : ViewModel() {
fun checkBiometricAvailability(): BiometricStatus {
return when (biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)) {
BiometricManager.BIOMETRIC_SUCCESS -> BiometricStatus.Available
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> BiometricStatus.NoHardware
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricStatus.NotEnrolled
else -> BiometricStatus.Unavailable
}
}
}
// In Activity/Fragment
private val biometricPrompt by lazy {
BiometricPrompt(
this, // FragmentActivity
ContextCompat.getMainExecutor(this),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
// User authenticated successfully
unlockApp()
}
override fun onAuthenticationFailed() {
// Biometric didn't match (but user can try again)
showAuthFailed()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
// Permanent error (too many attempts, hardware error, etc.)
showAuthError(errString.toString())
}
}
)
}
private val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Authenticate to Continue")
.setSubtitle("Use your fingerprint or device credentials")
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL // allows PIN/pattern/password as fallback
)
.build()
fun authenticate() {
biometricPrompt.authenticate(promptInfo)
}
Other Security Best Practices
// 1. Prevent screenshots of sensitive screens (banking, health data)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
// Prevents screenshots AND prevents screen from appearing in recent apps thumbnail
}
// 2. Don't log sensitive data
// ❌ Log.d("Auth", "Token: $token") — visible in logcat to any app with READ_LOGS
// ✅ Log only in debug builds, never log credentials/tokens/personal data
if (BuildConfig.DEBUG) { Timber.d("Request sent") } // message only, never the token
// 3. Disable backup for sensitive files
// AndroidManifest.xml
// android:allowBackup="false" — prevents adb backup from extracting your data
// Or use android:fullBackupContent to specify what's excluded
// 4. Play Integrity API — verify app hasn't been tampered with
// Detects: rooted devices, modified APKs, unofficial app stores
// Use for sensitive operations: payments, credential submission
Staff Q&A
- Android Keystore is a secure hardware-backed system for storing cryptographic keys. On modern devices, keys are stored in a dedicated security chip (Trusted Execution Environment or StrongBox) where they can't be extracted even with root access.
- A hardcoded encryption key in your code: any attacker who decompiles your APK has the key. All encrypted data is compromised. There's no "secure" way to store a hardcoded key in an APK.
- Android Keystore: the key never leaves the secure hardware. Code can use the key for encrypt/decrypt operations through the Keystore API, but can never extract the raw key bytes.
- EncryptedSharedPreferences uses Android Keystore under the hood — that's what makes it genuinely secure, not just "encrypted with a key stored near the data."
- Certificate pinning tells your app to only accept HTTPS connections from a server with a specific certificate (or certificate authority). Even if an attacker intercepts the connection with a valid certificate from another CA, your app rejects it.
- Risk 1: Certificate expiry. If you pin to a specific certificate and it expires, your app stops working until users update. Always pin to at least two certificates (current + next rotation).
- Risk 2: Emergency rotation. If your server's private key is compromised, you need to rotate the certificate immediately — but your app is pinned to the old one. Users who haven't updated will be locked out.
- Mitigations: set an expiration date in Network Security Config, always include a backup pin, and have a server-side mechanism to invalidate pinning if needed.
CI/CD for Android
What is CI/CD and Why Android Needs It
CI (Continuous Integration) means automatically building and testing your code on every push. CD (Continuous Delivery) means automatically delivering the tested build to testers or users. Without CI/CD, "it works on my machine" becomes a recurring crisis.
Without CI/CD:
Developer pushes code
No automatic build check
Tests only run if developer remembers
"Works on my machine" problems discovered in production
Release process: manual steps, easy to forget signing, forgot to update version
One bad commit can break everyone's development for hours
With CI/CD:
Developer opens PR
CI pipeline triggers automatically:
1. Compile (catches syntax errors, missing dependencies)
2. Unit tests (catches logic bugs)
3. Lint (catches code quality issues)
4. Static analysis (Detekt, SonarQube)
PR can only merge if all checks pass
On merge to main:
5. Integration tests
6. Build release APK/AAB
7. Upload to Firebase App Distribution (for testers)
8. Or: upload to Play Store internal track (for wider testing)
GitHub Actions — Complete Android Pipeline
// .github/workflows/android.yml
name: Android CI
on:
pull_request:
branches: [ main, develop ]
push:
branches: [ main ]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
# 1. Check out code
- name: Checkout
uses: actions/checkout@v4
# 2. Set up JDK 17
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
# 3. Cache Gradle to avoid re-downloading on every run
- name: Cache Gradle packages
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-gradle-
# 4. Run unit tests
- name: Run unit tests
run: ./gradlew testDebugUnitTest --build-cache
# 5. Run lint
- name: Run Lint
run: ./gradlew lintDebug
# 6. Build debug APK
- name: Build debug APK
run: ./gradlew assembleDebug --build-cache
# 7. Upload test results (visible in GitHub UI even if tests fail)
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: '**/build/reports/tests/'
release:
needs: build-and-test
if: github.ref == 'refs/heads/main' # only on main branch
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '17', distribution: 'temurin' }
# Decode keystore from secret
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore
# Build signed release AAB
- name: Build signed release AAB
run: ./gradlew bundleRelease
env:
KEYSTORE_PATH: release.keystore
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
# Upload to Play Store internal track
- name: Upload to Play Store
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: com.example.myapp
releaseFiles: app/build/outputs/bundle/release/*.aab
track: internal
Signing Configuration — Store Keys Safely
// app/build.gradle.kts
// Never hardcode keystore credentials — read from environment variables
android {
signingConfigs {
create("release") {
storeFile = file(System.getenv("KEYSTORE_PATH") ?: "release.keystore")
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: ""
keyAlias = System.getenv("KEY_ALIAS") ?: ""
keyPassword = System.getenv("KEY_PASSWORD") ?: ""
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
// Store the keystore as a GitHub Secret (base64 encoded)
// NEVER commit the keystore file to the repository
// NEVER commit keystore passwords to the repository
Staff Q&A
- Never commit the keystore file or passwords to the repository. Once committed, it's in git history forever — even if you delete it later.
- Store the keystore as a base64-encoded GitHub Secret (Settings → Secrets). In the CI workflow, decode it: echo "$KEYSTORE_SECRET" | base64 -d > release.keystore
- Store passwords separately as additional GitHub Secrets — never in the workflow YAML file itself.
- Rotate secrets periodically and if anyone with access leaves the team. The keystore's private key cannot be replaced without uploading a new app to the Play Store — protect it like a production database password.
- Firebase App Distribution — share APKs with testers without going through Play Store review. Testers download and install directly. Fast feedback loop, no review delay. Best for early builds and internal QA.
- Play Store internal track — publish to a small group of testers through Play Store infrastructure. Requires the app to be set up on Play Console. Slower (Play Store processes the AAB) but tests the actual Play Store delivery mechanism, including app bundle splits.
- Typical pipeline: Firebase App Distribution for daily builds → Play Store internal track for release candidates → Play Store alpha/beta for wider testing → Play Store production for release.
ExoPlayer & Media
What is ExoPlayer and When to Use It
ExoPlayer (now part of Media3) is Android's extensible media player library. Android has a built-in MediaPlayer, but it's limited: no DASH/HLS adaptive streaming, no DRM support, no fine-grained control. ExoPlayer handles all of these and is used by YouTube, Netflix, and virtually every serious Android video app.
| Feature | MediaPlayer | ExoPlayer (Media3) |
|---|---|---|
| HLS/DASH streaming | Limited | Full support |
| DRM (Widevine) | No | Yes |
| Custom video renderers | No | Yes |
| Playlist support | No | Yes |
| Subtitle tracks | Limited | Full support |
| Background audio | Manual | MediaSession integration |
| Track selection | No | Full (quality, language, subtitles) |
ExoPlayer — Complete Implementation
// build.gradle
implementation "androidx.media3:media3-exoplayer:1.x.x"
implementation "androidx.media3:media3-exoplayer-hls:1.x.x" // HLS support
implementation "androidx.media3:media3-exoplayer-dash:1.x.x" // DASH support
implementation "androidx.media3:media3-ui:1.x.x" // PlayerView
implementation "androidx.media3:media3-session:1.x.x" // MediaSession
// ViewModel owns the ExoPlayer instance
// Why ViewModel? Player survives rotation, expensive to recreate
@HiltViewModel
class VideoViewModel @Inject constructor(
@ApplicationContext private val context: Context
) : ViewModel() {
val player: ExoPlayer = ExoPlayer.Builder(context)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build(),
true // handle audio focus automatically
)
.setHandleAudioBecomingNoisy(true) // pause on headphone disconnect
.build()
init {
player.prepare()
}
fun loadVideo(url: String) {
val mediaItem = MediaItem.Builder()
.setUri(url)
.build()
player.setMediaItem(mediaItem)
player.playWhenReady = true
}
fun loadPlaylist(urls: List<String>) {
val mediaItems = urls.map { MediaItem.fromUri(it) }
player.setMediaItems(mediaItems)
player.playWhenReady = true
}
override fun onCleared() {
super.onCleared()
player.release() // release when ViewModel destroyed (user truly left)
}
}
// UI — AndroidView for ExoPlayer's PlayerView in Compose
@Composable
fun VideoPlayer(
player: ExoPlayer,
modifier: Modifier = Modifier
) {
val lifecycleOwner = LocalLifecycleOwner.current
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
this.player = player
useController = true
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
// PlayerControlView appearance
setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING)
}
},
update = { playerView ->
playerView.player = player
},
modifier = modifier
)
// Handle lifecycle — pause when app goes to background
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> player.pause()
Lifecycle.Event.ON_RESUME -> player.play()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}
// Player state observation — update UI based on playback state
@Composable
fun VideoControls(player: ExoPlayer) {
var isPlaying by remember { mutableStateOf(player.isPlaying) }
var currentPosition by remember { mutableStateOf(player.currentPosition) }
var duration by remember { mutableStateOf(player.duration) }
var bufferedPosition by remember { mutableStateOf(player.bufferedPosition) }
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
}
override fun onPlaybackStateChanged(state: Int) {
when (state) {
Player.STATE_BUFFERING -> { /* show loading */ }
Player.STATE_READY -> duration = player.duration
Player.STATE_ENDED -> { /* show replay button */ }
Player.STATE_IDLE -> { /* player not started */ }
}
}
}
player.addListener(listener)
onDispose { player.removeListener(listener) }
}
// Update position every second while playing
LaunchedEffect(isPlaying) {
while (isPlaying) {
currentPosition = player.currentPosition
bufferedPosition = player.bufferedPosition
delay(1000L)
}
}
Column {
LinearProgressIndicator(
progress = if (duration > 0) currentPosition.toFloat() / duration else 0f,
modifier = Modifier.fillMaxWidth()
)
Row {
IconButton(onClick = { if (isPlaying) player.pause() else player.play() }) {
Icon(if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, null)
}
Text("${formatTime(currentPosition)} / ${formatTime(duration)}")
}
}
}
Auto-play in Video Feeds (like Instagram Reels)
// Auto-play the video whose thumbnail is most visible on screen
@Composable
fun VideoFeed(videos: List<Video>) {
val listState = rememberLazyListState()
val viewModel: FeedViewModel = hiltViewModel()
// Find the most visible item
val mostVisibleIndex by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val viewportCenter = (layoutInfo.viewportStartOffset + layoutInfo.viewportEndOffset) / 2
layoutInfo.visibleItemsInfo
.minByOrNull { abs((it.offset + it.size / 2) - viewportCenter) }
?.index ?: 0
}
}
LaunchedEffect(mostVisibleIndex) {
viewModel.onVideoFocused(mostVisibleIndex)
}
LazyColumn(state = listState) {
itemsIndexed(videos, key = { _, v -> v.id }) { index, video ->
VideoFeedItem(
video = video,
isFocused = index == mostVisibleIndex
)
}
}
}
Staff Q&A
- ExoPlayer initialization is expensive — it creates threads, codecs, and network connections. Recreating it on every rotation causes a visible playback reset (video restarts, position lost).
- ViewModel survives rotation. Player is created once in init, survives the configuration change, and the new Activity/Fragment simply attaches its PlayerView to the same existing player.
- ViewModel.onCleared() is the correct place to call player.release() — called when the user truly leaves the screen (presses back), not on rotation.
- STATE_IDLE — player just created or stopped. No media loaded. Call prepare() to move to buffering.
- STATE_BUFFERING — media loaded, buffering data. Show loading indicator. Player will start automatically when enough data is buffered if playWhenReady = true.
- STATE_READY — enough data buffered to play. If playWhenReady = true, playback starts automatically. If false, player is paused but ready.
- STATE_ENDED — reached the end of the media item (or playlist). To replay: call seekTo(0) and player.play(). Show a "replay" button in your UI.
Kotlin Multiplatform (KMP)
What is KMP and What Does It Share?
Kotlin Multiplatform (KMP) lets you write Kotlin code once and use it on multiple platforms: Android, iOS, web, and desktop. Unlike Flutter or React Native (which replace the UI layer), KMP only shares business logic — each platform keeps its native UI. This means Android uses Compose, iOS uses SwiftUI, and they share the same repositories, use cases, and domain models.
KMP architecture:
Android App iOS App
(Compose UI) (SwiftUI)
│ │
└──────┬─────────────┘
│
Shared Kotlin Module
(commonMain source set)
┌─────────────────────┐
│ Domain Models │ ← Pure Kotlin data classes
│ Repository Interfaces │
│ Use Cases │ ← Business logic
│ Repository Impls │ ← Ktor for networking
│ Data Mapping │ ← kotlinx.serialization
└─────────────────────┘
NOT shared (platform-specific):
Android: Compose UI, Room (or SQLDelight Android), Android Context
iOS: SwiftUI, CoreData (or SQLDelight iOS)
Each platform provides its own UI and platform-specific implementations
KMP Project Structure and Setup
// shared/build.gradle.kts — the shared module
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.multiplatform")
id("org.jetbrains.kotlin.plugin.serialization")
}
kotlin {
androidTarget() // Android target
iosX64() // iOS simulator (Intel)
iosArm64() // iOS device
iosSimulatorArm64() // iOS simulator (Apple Silicon)
sourceSets {
// Common code — runs on ALL platforms
val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:2.x.x")
implementation("io.ktor:ktor-client-content-negotiation:2.x.x")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.x.x")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.x")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.x")
}
}
// Android-specific code
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-android:2.x.x") // Android HTTP engine
}
}
// iOS-specific code
val iosMain by creating {
dependencies {
implementation("io.ktor:ktor-client-darwin:2.x.x") // iOS HTTP engine (NSURLSession)
}
}
}
}
// shared/src/commonMain/kotlin/UserRepository.kt
// This code compiles for BOTH Android and iOS
class UserRepositoryImpl(
private val httpClient: HttpClient, // Ktor client — works on both platforms
private val userDao: UserDao // expect/actual pattern for database
) : UserRepository {
override suspend fun getUsers(): List<User> {
val response = httpClient.get("https://api.example.com/users")
return response.body<List<UserDto>>().map { it.toDomain() }
}
}
// expect/actual — platform-specific implementations
// In commonMain:
expect class DatabaseDriver {
fun createDriver(schema: SqlSchema): SqlDriver
}
// In androidMain:
actual class DatabaseDriver actual constructor(private val context: Context) {
actual fun createDriver(schema: SqlSchema): SqlDriver =
AndroidSqliteDriver(schema, context, "user.db")
}
// In iosMain:
actual class DatabaseDriver {
actual fun createDriver(schema: SqlSchema): SqlDriver =
NativeSqliteDriver(schema, "user.db")
}
// Android app module uses shared module normally
// androidApp/build.gradle.kts
dependencies {
implementation(project(":shared"))
}
// AndroidMain just provides UI and platform implementations
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val viewModel: UserViewModel = hiltViewModel()
// ViewModel uses GetUsersUseCase from shared module
UserScreen(viewModel)
}
}
}
Staff Q&A
- Business logic bugs fixed once propagate to both platforms automatically. Without KMP, a fix in Android UserRepository must be manually reimplemented in Swift.
- Feature parity is enforced by the type system — if a Use Case exists in shared code, both Android and iOS have it. Platform drift (where one platform is 6 months behind) is eliminated for shared logic.
- Significantly less total code to maintain — the domain layer and data layer don't need to be written twice. Teams report 40-60% reduction in backend-interaction code.
- iOS developers contribute to the same domain logic as Android developers, reducing silos. Business rules are reviewed once, not twice.
- Should be shared: domain models, repository interfaces, use cases, networking (Ktor), serialization, data mapping, core business logic.
- Should NOT be shared: UI code (each platform uses its native framework), platform-specific APIs (camera, location, push notifications use platform SDKs), anything that depends on Android Context or iOS UIKit.
- Rule: if it's business logic that would need to be identically reimplemented on the other platform → share it. If it's fundamentally tied to one platform's APIs → keep it platform-specific with an interface in shared code and a platform implementation.
On-Device ML: TFLite & ML Kit
Why On-Device ML? Cloud vs On-Device
Machine learning doesn't have to live in the cloud. Running ML models directly on the user's device offers significant advantages — and comes with real constraints you need to understand.
| Aspect | Cloud Inference | On-Device (TFLite) |
|---|---|---|
| Latency | 50-500ms (network round trip) | 1-50ms (no network) |
| Privacy | Data sent to server | Data never leaves device |
| Offline | Requires internet | Works fully offline |
| Cost | Server costs per inference | Free (device computes) |
| Model size | Unlimited (on server) | Limited (~10-50MB in APK) |
| Model updates | Instant (change server) | Requires app update or download |
| Battery | Only network cost | Neural network computation drains battery |
When to choose on-device ML: ✓ Real-time processing (image filters, AR, face detection) ✓ Privacy-sensitive data (health metrics, private photos) ✓ Offline-first apps ✓ High-frequency inference (every camera frame) ✓ Low-latency requirements (<50ms) When to choose cloud ML: ✓ Large models that don't fit on device ✓ Infrequent inference (occasional document OCR) ✓ Results need server-side aggregation ✓ Model needs to be updated frequently without app updates
TensorFlow Lite — Running Custom Models
TFLite lets you run any TensorFlow model on-device. You train a model (or download a pre-trained one), convert it to .tflite format, bundle it in your app, and run inference on device.
// build.gradle
implementation "org.tensorflow:tensorflow-lite:2.14.x"
implementation "org.tensorflow:tensorflow-lite-support:0.4.x"
implementation "org.tensorflow:tensorflow-lite-gpu:2.14.x" // GPU acceleration (optional)
// Place your model at: app/src/main/assets/model.tflite
class ImageClassifier(private val context: Context) {
private val interpreter: Interpreter by lazy {
val model = loadModelFile()
val options = Interpreter.Options().apply {
numThreads = 4 // use 4 CPU threads for inference
// For GPU acceleration (faster but not all models support it):
// addDelegate(GpuDelegate())
}
Interpreter(model, options)
}
private fun loadModelFile(): MappedByteBuffer {
val fileDescriptor = context.assets.openFd("model.tflite")
val inputStream = FileInputStream(fileDescriptor.fileDescriptor)
val fileChannel = inputStream.channel
return fileChannel.map(FileChannel.MapMode.READ_ONLY,
fileDescriptor.startOffset, fileDescriptor.declaredLength)
}
fun classify(bitmap: Bitmap): List<Classification> {
// Preprocess: resize to model input size (e.g., 224x224)
val resized = Bitmap.createScaledBitmap(bitmap, 224, 224, true)
// Create input tensor [1, 224, 224, 3] — batch=1, height=224, width=224, channels=3(RGB)
val inputBuffer = TensorBuffer.createFixedSize(intArrayOf(1, 224, 224, 3), DataType.FLOAT32)
// Normalize pixels to [0, 1]
val imageProcessor = ImageProcessor.Builder()
.add(ResizeOp(224, 224, ResizeOp.ResizeMethod.BILINEAR))
.add(NormalizeOp(0f, 255f)) // divide by 255 to get 0-1 range
.build()
inputBuffer.loadBitmap(resized)
// Output tensor — shape depends on number of classes
val outputBuffer = TensorBuffer.createFixedSize(intArrayOf(1, NUM_CLASSES), DataType.FLOAT32)
// Run inference — this is the expensive call, run on IO/Default dispatcher
interpreter.run(inputBuffer.buffer, outputBuffer.buffer)
// Post-process: map probabilities to class labels
val probabilities = outputBuffer.floatArray
return probabilities.mapIndexed { index, probability ->
Classification(label = LABELS[index], confidence = probability)
}.sortedByDescending { it.confidence }.take(5)
}
fun close() { interpreter.close() }
}
data class Classification(val label: String, val confidence: Float)
// ViewModel usage — run inference off main thread
@HiltViewModel
class ClassifierViewModel @Inject constructor(
@ApplicationContext context: Context
) : ViewModel() {
private val classifier = ImageClassifier(context)
private val _results = MutableStateFlow<List<Classification>>(emptyList())
val results: StateFlow<List<Classification>> = _results.asStateFlow()
fun classifyImage(bitmap: Bitmap) {
viewModelScope.launch(Dispatchers.Default) { // CPU-intensive work
val classifications = classifier.classify(bitmap)
_results.value = classifications
}
}
override fun onCleared() { classifier.close() }
}
ML Kit — Ready-Made On-Device ML
ML Kit provides pre-built, pre-trained on-device ML models for common tasks. You don't need to train anything — just pass in data and get results. Ideal when you need text recognition, face detection, barcode scanning, or language translation without building custom models.
// ML Kit ready-made APIs:
// Text Recognition (OCR)
// Face Detection
// Barcode Scanning
// Object Detection & Tracking
// Pose Detection
// Image Labeling
// Language Identification
// On-device Translation
// Smart Reply
// Barcode scanning — camera-based QR/barcode reader
implementation "com.google.mlkit:barcode-scanning:17.x.x"
implementation "androidx.camera:camera-camera2:1.x.x"
implementation "androidx.camera:camera-lifecycle:1.x.x"
implementation "androidx.camera:camera-view:1.x.x"
class QrScannerActivity : AppCompatActivity() {
private val barcodeScanner = BarcodeScanning.getClient()
private val imageAnalysis = ImageAnalysis.Builder()
.setTargetResolution(Size(1280, 720))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { analysis ->
analysis.setAnalyzer(ContextCompat.getMainExecutor(this)) { imageProxy ->
val mediaImage = imageProxy.image ?: run { imageProxy.close(); return@setAnalyzer }
val inputImage = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
barcodeScanner.process(inputImage)
.addOnSuccessListener { barcodes ->
barcodes.firstOrNull()?.rawValue?.let { value ->
onBarcodeDetected(value)
}
}
.addOnCompleteListener { imageProxy.close() } // always close!
}
}
}
// Text Recognition (OCR)
implementation "com.google.mlkit:text-recognition:16.x.x"
fun recognizeText(bitmap: Bitmap, onResult: (String) -> Unit) {
val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
val inputImage = InputImage.fromBitmap(bitmap, 0)
recognizer.process(inputImage)
.addOnSuccessListener { visionText ->
val fullText = visionText.textBlocks
.joinToString("
") { block -> block.text }
onResult(fullText)
}
.addOnFailureListener { e -> Log.e("MLKit", "Text recognition failed", e) }
}
// On-device Translation (no internet needed after model download)
implementation "com.google.mlkit:translate:17.x.x"
class TranslationViewModel : ViewModel() {
private val translator = Translation.getClient(
TranslatorOptions.Builder()
.setSourceLanguage(TranslateLanguage.ENGLISH)
.setTargetLanguage(TranslateLanguage.HINDI)
.build()
)
init {
// Download model if not already cached (~30MB)
val conditions = DownloadConditions.Builder().requireWifi().build()
translator.downloadModelIfNeeded(conditions)
.addOnSuccessListener { /* model ready */ }
}
fun translate(text: String, onResult: (String) -> Unit) {
translator.translate(text)
.addOnSuccessListener { translatedText -> onResult(translatedText) }
}
override fun onCleared() { translator.close() }
}
On-Device LLM — Gemini Nano
// Gemini Nano runs entirely on-device on Pixel 8+ and other supported devices
// No internet required for inference — fully private
implementation "com.google.ai.client.generativeai:generativeai:0.x.x"
// Check device support first
val generativeModel = GenerativeModel(
modelName = "gemini-nano",
// No API key needed for on-device model
)
// Structured summarization
viewModelScope.launch {
val response = generativeModel.generateContent(
content { text("Summarize this product review in 2 sentences: $reviewText") }
)
_summary.value = response.text ?: ""
}
// MediaPipe LLM Inference — for other on-device models (Gemma, Phi-2, etc.)
implementation "com.google.mediapipe:tasks-genai:0.x.x"
val llmInference = LlmInference.createFromOptions(
context,
LlmInference.LlmInferenceOptions.builder()
.setModelPath("/data/local/tmp/gemma-2b-it-gpu-int4.bin")
.setMaxTokens(512)
.setPreferredBackend(LlmInference.Backend.GPU) // use GPU for faster inference
.build()
)
val response = llmInference.generateResponse("What are the features of this product?")
AI Feature Architecture — Where ML Fits in Clean Architecture
// ML inference belongs in the DATA layer — it's a data source
// Domain layer defines the interface, data layer provides the ML implementation
// Domain layer — interface (no ML deps)
interface ImageAnalysisRepository {
suspend fun classifyImage(imagePath: String): List<ImageLabel>
suspend fun detectText(imagePath: String): String
}
data class ImageLabel(val label: String, val confidence: Float)
// Use Case — business logic using the ML result
class GetProductFromImageUseCase @Inject constructor(
private val imageAnalysis: ImageAnalysisRepository,
private val productRepository: ProductRepository
) {
suspend operator fun invoke(imagePath: String): Product? {
val labels = imageAnalysis.classifyImage(imagePath)
val topLabel = labels.firstOrNull { it.confidence > 0.7f } ?: return null
return productRepository.searchByCategory(topLabel.label).firstOrNull()
}
}
// Data layer — ML Kit implementation
class MLKitImageAnalysisRepository @Inject constructor(
@ApplicationContext private val context: Context
) : ImageAnalysisRepository {
private val labeler = ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS)
override suspend fun classifyImage(imagePath: String): List<ImageLabel> {
val bitmap = BitmapFactory.decodeFile(imagePath)
val inputImage = InputImage.fromBitmap(bitmap, 0)
return labeler.process(inputImage).await() // await() extension on Task
.map { label -> ImageLabel(label.text, label.confidence) }
}
}
// Testing — fake the ML repository
class FakeImageAnalysisRepository : ImageAnalysisRepository {
private var fakeLabels = listOf<ImageLabel>()
fun setLabels(labels: List<ImageLabel>) { fakeLabels = labels }
override suspend fun classifyImage(imagePath: String) = fakeLabels
override suspend fun detectText(imagePath: String) = "fake text"
}
Staff Q&A
- For models bundled in the APK: updating the model requires a new app release. Keep models small (<5MB ideally) to minimize APK size impact. Use R8 to ensure the model file is compressed in the APK.
- For dynamically downloaded models (Firebase ML Custom Models, Play Feature Delivery): the model can be updated server-side without an app update. Check model version on startup, download in background if a newer version exists.
- Always define the model input/output format as a versioned contract. A model update that changes input shape will crash inference — validate compatibility before switching.
- Use model quantization (INT8 instead of FLOAT32) to reduce model size by 4x with minimal accuracy loss — critical for APK size budgets.
- ML models natively use 32-bit floating point numbers (FLOAT32). A 100MB FLOAT32 model has 25 million parameters.
- Quantization converts weights to 8-bit integers (INT8) or even 4-bit (INT4). Same 25 million parameters → 25MB model (INT8) or 12.5MB (INT4). 4x-8x smaller.
- INT8 inference is also faster on device — modern mobile processors have dedicated INT8 hardware acceleration (Neural Processing Units).
- Accuracy trade-off: quantized models are slightly less accurate than FLOAT32 models. For most tasks, INT8 accuracy is within 1-2% of FLOAT32. INT4 has more accuracy loss but enables models like Gemma-2B to run on consumer devices.
Architect-Level Practices
Architecture Decision Records (ADRs)
An Architecture Decision Record (ADR) is a short document that captures an important architectural decision: what was decided, why it was decided, and what the consequences are. At Staff/Architect level, you're expected to document architectural decisions — not just make them.
Why ADRs matter:
Without ADRs:
6 months later: "Why did we choose Hilt over manual DI?"
Nobody remembers the context. The original decision-maker left.
Team debates reopening the decision without knowing why it was made.
New engineers make decisions that contradict past decisions unknowingly.
With ADRs:
All context preserved: the options considered, the trade-offs, who was involved
New engineers understand the reasoning without bothering senior engineers
Decisions can be consciously revisited with full context
Demonstrates technical leadership — you can articulate why, not just what
# ADR-001: Adopt Jetpack Compose as primary UI framework
## Status
Accepted — 2024-03-15
## Context
The current codebase uses XML layouts and the View system. As we scale to
10+ engineers and add complex animations and adaptive layouts, the XML
approach is causing: long build times (full recompilation on any layout
change), difficulty in creating adaptive layouts for foldables, and
inability to preview complex states.
We evaluated:
1. Continue with XML + View Binding
2. Adopt Jetpack Compose
3. Adopt Flutter (cross-platform)
## Decision
Adopt Jetpack Compose as the primary UI framework for all new screens.
Existing XML screens to be migrated incrementally over 6 months.
## Consequences
### Positive
- Declarative UI reduces state management bugs
- Live preview of UI states speeds up design iteration
- State hoisting pattern makes UI easier to test
- Better support for adaptive layouts (phones, tablets, foldables)
- Younger engineers are more familiar with Compose than XML
### Negative
- Learning curve for existing team (3-month transition period estimated)
- Third-party library support incomplete (MapView, ExoPlayer need AndroidView)
- Some existing XML-based third-party components will need wrappers
- Performance profiling tools (Layout Inspector for Compose) less mature
## Alternatives Rejected
### Flutter
Rejected because: adds Dart language to the team's stack, loses access to
Android-native APIs without platform channels, performance overhead at
the Dart/native boundary for complex interactions.
### Stay with XML
Rejected because: growing complexity of adaptive layouts can't be
addressed without fundamental changes to the layout system.
## Review Date
2024-09-15 — review Compose adoption progress and any blocking issues.
ADR Template
# ADR-NNN: [Short Title of Decision]
## Status
[Proposed | Accepted | Deprecated | Superseded by ADR-NNN]
## Context
What is the problem we're solving? What constraints exist?
What options did we consider?
## Decision
What was decided? Be specific and actionable.
## Consequences
### Positive
Benefits of this decision.
### Negative
Trade-offs, risks, and mitigations.
## Alternatives Rejected
Why other options were not chosen.
## Review Date
When to revisit this decision.
Staff Q&A
- Mark the old ADR as "Superseded by ADR-NNN" and write a new ADR explaining what changed and why the original decision is no longer the right one.
- Never edit or delete the old ADR — the historical record of why you made the original decision is valuable context. Someone might want to know "why did we migrate FROM this to THAT?"
- The fact that you made a decision, documented it, and then consciously changed it is a sign of a healthy engineering culture — not a failure. Undocumented reversals are the actual problem.
AI-Assisted Development Workflows
AI tools are now a real part of the senior Android engineer's toolkit. Understanding when and how to use them effectively — and where they fail — is an expected skill at Staff level.
AI tools in the Android developer workflow: Claude / ChatGPT: ✓ Code generation for boilerplate (DAOs, Hilt modules, mappers) ✓ Code review assistance — "what's wrong with this?" ✓ Explaining unfamiliar code quickly ✓ Architecture planning — sounding board for tradeoffs ✗ Current API versions (training cutoff — always verify) ✗ Project-specific context (doesn't know your codebase) Claude Code / Cursor / GitHub Copilot: ✓ In-editor completion — generates method implementations ✓ Refactoring with context of surrounding code ✓ Test generation — "write a test for this function" ✓ Terminal command suggestions ✗ Can produce plausible but wrong code — always review output Custom AI workflows (like your Mermaid architecture tool): ✓ Convert git diffs to architecture diagrams automatically ✓ Generate ADR drafts from commit messages ✓ Automated code review comments for common patterns ✓ PR description generation from changed files
// Example: AI-assisted Mermaid architecture diagram generation
// This is the kind of workflow you built at Walmart
// Script that runs on every PR:
// 1. Get the git diff of changed files
// 2. Send to Claude API with a system prompt
// 3. Generate a Mermaid diagram of what changed
// 4. Post as a PR comment
// The prompt structure:
val systemPrompt = ''' // triple-quoted string
You are an Android architecture diagram generator.
Given a git diff, produce a Mermaid class diagram showing:
- New classes and their relationships
- Modified interfaces
- Dependencies added or removed
Output ONLY the Mermaid diagram, no explanation.
''' // end of string
// Claude generates something like:
// classDiagram
// UserViewModel --> GetUsersUseCase
// GetUsersUseCase --> UserRepository
// UserRepositoryImpl ..|> UserRepository
// This runs automatically on every PR — reviewers immediately see the architecture impact
// Result: PR review time reduced because reviewers understand impact without reading code
Effective Prompting for Android Development
// Principles for getting better AI output for Android:
// 1. Provide context about your architecture
// ❌ "Generate a ViewModel for my user screen"
// ✅ "Generate a ViewModel using Hilt, StateFlow for UI state (sealed class),
// SharedFlow for one-time events, and our GetUsersUseCase.
// The ViewModel should handle loading/success/error states."
// 2. Specify versions — AI may generate deprecated APIs
// ❌ "How do I navigate in Compose?"
// ✅ "How do I navigate in Compose using Navigation 2.8 type-safe routes
// with @Serializable objects?"
// 3. Ask for alternatives and trade-offs — not just one answer
// "What are the trade-offs between using StateFlow vs LiveData for this use case?"
// 4. Use AI to review your own code
// "Review this coroutine code for cancellation handling issues:
// [paste your code]"
// 5. Generate tests, not just implementation
// "Write unit tests for this ViewModel using MockK and Turbine:
// [paste ViewModel code]"
Internal SDK Design Principles
At Architect level, you may be asked to design a library or SDK that other engineers in your company consume — a design system, a network library, an analytics SDK, or an internal platform component. Designing a good SDK is harder than writing application code because your API is a contract that's difficult to change once adopted.
What makes a good SDK vs a bad SDK: Bad SDK: Exposes internal implementation details Forces callers to manage complex state Requires 20 lines of setup for simple use cases Crashes if called in wrong order No versioning strategy Good SDK: Hides implementation behind clean interfaces Handles complexity internally Simple default case, advanced options available Defensive — validates inputs, provides clear error messages Semantic versioning — backwards-compatible minor versions
// SDK API design principles illustrated
// ❌ BAD: exposes internals, forces caller to manage state
class AnalyticsSDK {
var httpClient: OkHttpClient? = null // caller must set this
var isInitialized = false
var retryCount = 0
fun sendRawEvent(json: String) { /* ... */ } // caller must format JSON
}
// ✅ GOOD: clean interface, implementation hidden
class Analytics private constructor(private val config: Config) {
data class Config(
val apiKey: String,
val endpoint: String = "https://analytics.example.com",
val batchSize: Int = 20,
val debugMode: Boolean = false
)
// Simple use case — one line
fun track(event: AnalyticsEvent) { /* ... */ }
// Advanced use case — still clean
fun trackWithProperties(event: AnalyticsEvent, properties: Map<String, Any>) { /* ... */ }
companion object {
@Volatile private var instance: Analytics? = null
fun getInstance(): Analytics =
instance ?: throw IllegalStateException("Call Analytics.initialize() first")
fun initialize(context: Context, config: Config): Analytics {
return instance ?: synchronized(this) {
instance ?: Analytics(config).also { instance = it }
}
}
}
}
// Caller experience:
// One-time setup (Application.onCreate()):
Analytics.initialize(context, Analytics.Config(apiKey = "my_key"))
// Usage anywhere:
Analytics.getInstance().track(AnalyticsEvent.ButtonClicked("buy_now"))
// Versioning — semantic versioning for your SDK
// MAJOR.MINOR.PATCH
// MAJOR: breaking change (callers must update their code)
// MINOR: new features, backwards compatible
// PATCH: bug fixes, backwards compatible
// What's a breaking change in an SDK:
// • Removing a public method
// • Changing a method signature (adding required parameters)
// • Changing return types
// • Renaming a public class
// How to add a parameter without breaking callers:
// ❌ BREAKING — adds required parameter, all callers must update
fun track(event: AnalyticsEvent, userId: String)
// ✅ NON-BREAKING — adds optional parameter with default
fun track(event: AnalyticsEvent, userId: String? = null)
// Old code: track(event) — still compiles
// New code: track(event, userId) — also works
// @Deprecated — migrate callers gradually
@Deprecated(
message = "Use track(event, userId) instead",
replaceWith = ReplaceWith("track(event, userId)"),
level = DeprecationLevel.WARNING // WARNING → ERROR → (remove in next major)
)
fun track(event: AnalyticsEvent) = track(event, null)
Staff Q&A
- A library is a collection of utilities or functions with no strong opinion about how they're used. Callers use what they need, ignore the rest.
- An SDK (Software Development Kit) is a complete set of tools for a specific purpose — it often includes a client, configuration, authentication, error handling, and retry logic. An SDK has opinions: there's a right way to use it.
- Most internal platform components are SDKs — they abstract an entire domain (analytics, feature flags, payments) behind a clean interface. Callers shouldn't need to know about HTTP, retries, or batching — the SDK handles all of that.
- Even when you own both sides, treat breaking changes seriously — other teams may depend on the SDK and need time to migrate.
- Deprecate first (with @Deprecated and a replacement). Give teams at least one release cycle to migrate before removing the deprecated API.
- Provide a migration guide in the SDK's CHANGELOG or README — what changed, why, and how to update.
- Use Android Studio's "Find Usages" or grep to proactively identify all callers of a deprecated API and create tickets for migration before the major version bump.
App Bundle & Dynamic Delivery
The Android App Bundle (AAB) is the modern way to publish to the Play Store. Instead of shipping one APK for all devices, you ship a bundle and Google Play generates optimized APKs for each device configuration.
APK vs App Bundle:
Traditional APK:
Your APK contains: arm64 code + x86 code + xxxhdpi images + all languages
A Pixel 8 user (arm64, xhdpi, English) downloads:
ALL architectures, ALL screen densities, ALL languages
Typical size: 100MB download
App Bundle (AAB):
You upload the bundle, Play Store splits it into:
Base APK: code needed for all devices
Architecture splits: arm64.apk, x86.apk, armeabi.apk
Density splits: xhdpi.apk, xxhdpi.apk, xxxhdpi.apk
Language splits: en.apk, hi.apk, fr.apk
Pixel 8 user downloads: arm64 + xxxhdpi + en only
Typical size: 40-60MB download (40-50% smaller)
Dynamic Feature Modules:
Some features only needed by some users
e.g., AR features, full map experience, premium tools
Not downloaded at install — downloaded on demand when needed
// Building AAB
// ./gradlew bundleRelease
// Dynamic Feature Module — feature downloaded on demand
// :feature:ar-scanner/build.gradle.kts
plugins { id("com.android.dynamic-feature") }
android {
// Dynamic features don't have their own versionCode/versionName
}
dependencies {
implementation(project(":app")) // dynamic features depend on :app
}
// AndroidManifest.xml of the dynamic feature
<manifest xmlns:dist="http://schemas.android.com/apk/distribution">
<dist:module
dist:instant="false"
dist:title="@string/feature_ar_scanner">
<dist:delivery>
<dist:on-demand/> <!-- downloaded when user first needs it -->
</dist:delivery>
<dist:fusing dist:include="true"/>
</dist:module>
</manifest>
// Request download in app code
val splitInstallManager = SplitInstallManagerFactory.create(context)
val request = SplitInstallRequest.newBuilder()
.addModule("feature_ar_scanner")
.build()
splitInstallManager.startInstall(request)
.addOnSuccessListener { sessionId ->
// Monitor download progress
splitInstallManager.registerListener({ state ->
when (state.status()) {
SplitInstallSessionStatus.DOWNLOADING -> showDownloadProgress(state)
SplitInstallSessionStatus.INSTALLED -> launchArScanner()
SplitInstallSessionStatus.FAILED -> showError()
}
}, splitInstallManager.sessionStates)
}