Android 12 introduced one of the biggest visual upgrades in Android history: Material You. At the heart of this design shift is a small but powerful feature called Monochrome Icons.
If you’ve ever noticed your app icons changing color to match your wallpaper, that’s Monochrome Icons doing their job.
In this guide, we’ll break down:
What Monochrome Icons are
How they power Android themed icons
Why Android 12+ relies on them
How to implement them correctly
What Are Monochrome Icons in Android?
Monochrome Icons are simplified versions of app icons that use a single color only.
They remove:
Gradients
Shadows
Multiple colors
Decorative details
Instead, they focus on shape and clarity.
Android uses these icons as a base to dynamically apply system colors based on the user’s wallpaper and theme.
In short: Monochrome Icons are the foundation of Android themed icons.
Why Android 12+ Needs Monochrome Icons
Before Android 12, app icons were static. Every icon looked the same regardless of theme.
Android 12 changed this with dynamic theming, where the system extracts colors from the user’s wallpaper and applies them across:
Quick settings
Widgets
System UI
App icons
For this to work cleanly, Android needs icons that are easy to recolor. That’s where Monochrome Icons come in.
Without a proper monochrome layer, Android cannot theme your app icon correctly.
How Themed Icons Work Behind the Scenes
Here’s what happens when a user enables themed icons:
Android checks if your app supports Monochrome Icons
If supported, the system loads the monochrome drawable
Android applies dynamic colors from the Material You palette
The icon adapts instantly when wallpaper or theme changes
If your app does not include a monochrome icon:
The icon stays unchanged
It breaks visual consistency
It looks outdated next to themed apps
Where Monochrome Icons Live in Your App
Monochrome Icons are defined inside your adaptive icon XML.
This file is usually located at:
res/mipmap-anydpi-v26/ic_launcher.xml
This is where Android expects your monochrome icon to be declared.
If your resources are misconfigured, Android won’t find the monochrome drawable.
Checking Android Version (Optional Use Case)
While Android handles themed icons automatically, you may want to check Android version in Kotlin for UI consistency elsewhere.
Kotlin
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {// Android 12 or higher// Themed icons and Material You are supported}
Build.VERSION.SDK_INT gives the device’s Android version
VERSION_CODES.S represents Android 12
This helps you align other UI elements with themed icons
Again, Monochrome Icons themselves do not need Kotlin logic.
Common Mistakes Developers Make
1. Using Colored Monochrome Icons
Monochrome means one solid color only. Shades or gradients will break theming.
2. Overly Detailed Icons
Thin lines disappear when recolored. Bold shapes work best.
3. Forgetting the <monochrome> Tag
Without it, Android ignores themed icons entirely.
4. Relying on PNGs
Vector drawables scale better and theme more reliably.
Why Monochrome Icons Improve User Experience
From a user perspective, Monochrome Icons:
Make the home screen feel cohesive
Reduce visual noise
Adapt naturally to dark and light themes
Feel modern and intentional
From a developer perspective:
Your app looks native on Android 12+
Better alignment with Material You
Improved visual trust and polish
Conclusion
Monochrome Icons may look simple, but they power one of Android’s most advanced design features.
If your app targets Android 12 or higher, supporting Monochrome Icons is no longer optional. It’s part of building a modern, user-first Android experience.
Keep your icons simple. Let the system do the coloring. And embrace Material You the way it was designed.
Jetpack Compose introduces a very different mental model compared to XML-based Android UI. One line that often confuses beginners (and even experienced Android devs at first) is:
Kotlin
onValueChange = { value = it }
Especially when value is defined like this:
Kotlin
varvaluebyremember { mutableStateOf(0) }
At first glance, this line looks almost too simple — and that’s exactly why it’s confusing.
Let’s break down what’s really happening, why it’s written this way, and how it fits into Compose’s state-driven architecture.
The Big Picture: Compose Is State-Driven
Before diving into syntax, it’s important to understand how Compose thinks.
In classic Android:
You updated UI elements directly
UI held its own state
You manually synced UI ↔ data
In Jetpack Compose:
State owns the UI
UI is a function of state
When state changes → UI recomposes automatically
This single line:
Kotlin
onValueChange = { value = it }
is the bridge between user interaction and state updates.
What remember { mutableStateOf(...) } Really Does
Consider this state declaration:
Kotlin
varvaluebyremember { mutableStateOf(0) }
This does three important things:
1. mutableStateOf
Creates an observable state holder. Compose watches this value and tracks where it’s used.
2. remember
Ensures the state survives re-composition. Without remember, the value would reset every time Compose redraws the UI.
3. by keyword
This is Kotlin property delegation. It allows you to write:
Kotlin
value = 5
instead of:
Kotlin
value.value = 5
So value behaves like a normal variable, but Compose is quietly observing it.
What onValueChange Is (Conceptually)
Most interactive Compose components (such as TextField, Slider, Checkbox) follow the same pattern:
If you’ve ever worked with Android’s BroadcastReceiver, you know there’s a golden rule: keep your work quick. The system expects your receiver to finish in about 10 seconds, or it’ll label your app as unresponsive (ANR). But what happens when you need just a bit more time?
That’s exactly where goAsync() comes to the rescue.
In this guide, I’ll walk you through everything you need to know about goAsync() in BroadcastReceiver. We’ll explore how it works, when to use it, common mistakes developers make, and the best practices that’ll keep your Android apps running smoothly.
What Exactly Is goAsync()?
Let’s start with the basics. The goAsync() method is a special tool provided by the BroadcastReceiver class that gives you extra time to complete your work without blocking the main thread.
Normally, when a broadcast arrives, Android expects you to handle it immediately on the main thread. This works great for simple tasks like updating a variable or showing a notification. But what if you need to write to a database, make a quick network call, or perform some computation?
That’s the problem goAsync() solves.
When you call goAsync(), it returns a PendingResult object. This object essentially tells Android: “Hey, I’m not done yet, but I promise I’ll finish soon.” It extends your execution window beyond the typical onReceive() lifecycle.
The Lifecycle: How goAsync() Actually Works
Understanding the lifecycle is crucial to using goAsync() correctly. Let me break it down step by step.
Normal BroadcastReceiver Lifecycle
Here’s what happens in a regular broadcast receiver:
Kotlin
classSimpleBroadcastReceiver : BroadcastReceiver() {overridefunonReceive(context: Context, intent: Intent) {// Your code runs here on the main thread Log.d("Receiver", "Broadcast received!")// When this method exits, the receiver is considered "done" }}
The moment your onReceive() method finishes, Android assumes you’re done. The receiver becomes inactive, and the system may even kill your process if there’s no other component keeping it alive.
With goAsync() in the Picture
Now let’s see how goAsync() changes things:
Kotlin
classAsyncBroadcastReceiver : BroadcastReceiver() {overridefunonReceive(context: Context, intent: Intent) {// Call goAsync() immediately to get a PendingResultval pendingResult: PendingResult = goAsync()// Now you can do work off the main threadCoroutineScope(Dispatchers.IO).launch {try {// Perform your background work hereperformLongRunningTask(context) } finally {// CRITICAL: Always call finish() when done pendingResult.finish() } } }privatesuspendfunperformLongRunningTask(context: Context) {// Simulate some workdelay(3000) Log.d("Receiver", "Task completed!") }}
Here’s what’s happening behind the scenes:
goAsync() is called: This immediately returns a PendingResult object and tells Android the receiver is still working
Work happens off-thread: You move your heavy lifting to a background thread using coroutines or another threading mechanism
finish() is called: When you’re done, calling pendingResult.finish() signals to Android that the receiver has completed its work
The key difference? Your process stays alive even after onReceive() returns, as long as you haven’t called finish() yet.
When Should You Use goAsync()?
The goAsync() method isn’t meant for every situation. Here’s when it makes sense to reach for it:
Perfect Use Cases
Database Operations: Writing user preferences or logging data that takes 2–3 seconds.
Kotlin
classDataSavingReceiver : BroadcastReceiver() {overridefunonReceive(context: Context, intent: Intent) {val pendingResult = goAsync()CoroutineScope(Dispatchers.IO).launch {try {val database = AppDatabase.getInstance(context)valdata = intent.getStringExtra("data") ?: return@launch// This database write might take a few seconds database.userDao().insertData(data) Log.d("Receiver", "Data saved successfully") } finally { pendingResult.finish() } } }}
Quick Network Calls: Sending analytics events or pinging a server (though WorkManager is often better for this).
File I/O: Reading or writing small amounts of data to storage.
When NOT to Use goAsync()
Long-running tasks: Anything taking more than 10 seconds should use WorkManager, JobScheduler, or a foreground service instead.
Complex operations: If your task requires multiple steps that could fail, consider a more robust solution.
Simple tasks: If your work takes less than a millisecond, you don’t need goAsync() at all. Just do it directly in onReceive().
Common Pitfalls and How to Avoid Them
I’ve seen developers stumble over the same issues when using goAsync(). Let me save you from these headaches.
Pitfall 1: Forgetting to Call finish()
This is the number one mistake. If you never call finish(), Android keeps your receiver alive indefinitely, wasting system resources.
Calling goAsync() doesn’t automatically move your work off the main thread. You still need to handle that yourself.
Kotlin
// BAD: Still blocking the main threadclassBlockingReceiver : BroadcastReceiver() {overridefunonReceive(context: Context, intent: Intent) {val pendingResult = goAsync()// This still runs on the main thread! Thread.sleep(5000) // Don't do this! pendingResult.finish() }}
Always explicitly move to a background thread:
Kotlin
// GOOD: Work happens on background threadclassNonBlockingReceiver : BroadcastReceiver() {overridefunonReceive(context: Context, intent: Intent) {val pendingResult = goAsync()// Using Dispatchers.IO for background workCoroutineScope(Dispatchers.IO).launch {try {delay(5000) // This is okay on IO threadprocessData() } finally { pendingResult.finish() } } }}
Pitfall 3: Exceeding the Time Limit
Even with goAsync(), you still have time constraints. Android gives you approximately 10 seconds total. Going beyond that results in an ANR.
Kotlin
classTimeConsciousReceiver : BroadcastReceiver() {overridefunonReceive(context: Context, intent: Intent) {val pendingResult = goAsync()CoroutineScope(Dispatchers.IO).launch {try {withTimeout(8000) { // Set a timeout slightly under 10 secondsperformTaskWithTimeout() } } catch (e: TimeoutCancellationException) { Log.e("Receiver", "Task took too long") } finally { pendingResult.finish() } } }}
Pitfall 4: Memory Leaks with Context
Be careful about holding onto the Context object in your background work. The context passed to onReceive() is short-lived.
Kotlin
// SAFER: Use application context for long-running workclassSafeContextReceiver : BroadcastReceiver() {overridefunonReceive(context: Context, intent: Intent) {val pendingResult = goAsync()val appContext = context.applicationContext // Use app contextCoroutineScope(Dispatchers.IO).launch {try {// Use appContext instead of contextdoWorkWith(appContext) } finally { pendingResult.finish() } } }}
Best Practices for Using goAsync()
After working with goAsync() across multiple projects, here are my recommended best practices.
1. Always Use Structured Concurrency
Kotlin coroutines with proper scope management make your life easier:
Kotlin
classStructuredReceiver : BroadcastReceiver() {overridefunonReceive(context: Context, intent: Intent) {val pendingResult = goAsync()// Create a supervised scopeval scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) scope.launch {try {// All your async work hereval result = performNetworkCall()saveToDatabase(context, result) } catch (e: Exception) {handleError(e) } finally { pendingResult.finish() scope.cancel() // Clean up the scope } } }}
2. Implement Proper Error Handling
Things will go wrong. Handle exceptions gracefully:
Real-World Example: Network Sync on Connectivity Change
Let’s put everything together with a practical example. This receiver syncs data when the device connects to WiFi:
Kotlin
classConnectivitySyncReceiver : BroadcastReceiver() {overridefunonReceive(context: Context, intent: Intent) {// Check if this is a connectivity changeif (intent.action != ConnectivityManager.CONNECTIVITY_ACTION) {return } Log.d(TAG, "Connectivity changed")val pendingResult = goAsync()val appContext = context.applicationContextCoroutineScope(Dispatchers.IO).launch {try {// Check if we're on WiFiif (!isWiFiConnected(appContext)) { Log.d(TAG, "Not on WiFi, skipping sync")return@launch } Log.d(TAG, "WiFi connected, starting sync")// Perform sync with timeoutwithTimeout(8000) {syncDataWithServer(appContext) } Log.d(TAG, "Sync completed successfully") } catch (e: TimeoutCancellationException) { Log.e(TAG, "Sync timed out")scheduleRetryWithWorkManager(appContext) } catch (e: IOException) { Log.e(TAG, "Network error during sync", e) } catch (e: Exception) { Log.e(TAG, "Unexpected error during sync", e) } finally { pendingResult.finish() } } }privatefunisWiFiConnected(context: Context): Boolean {val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManagerval network = cm.activeNetwork ?: returnfalseval capabilities = cm.getNetworkCapabilities(network) ?: returnfalsereturn capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) }privatesuspendfunsyncDataWithServer(context: Context) {// Simulate API calldelay(2000)// In reality, you'd make an actual network call hereval repository = DataRepository.getInstance(context) repository.syncWithServer() }privatefunscheduleRetryWithWorkManager(context: Context) {val retryWork = OneTimeWorkRequestBuilder<SyncWorker>() .setInitialDelay(15, TimeUnit.MINUTES) .build() WorkManager.getInstance(context).enqueue(retryWork) }companionobject {privateconstval TAG = "ConnectivitySync" }}
This example demonstrates several best practices:
Immediate goAsync() call to extend execution time
Application context usage to prevent memory leaks
Proper exception handling for different error scenarios
Timeout protection to avoid ANRs
Fallback to WorkManager for retry logic
Comprehensive logging for debugging
Testing Your goAsync() Implementation
Testing broadcast receivers with goAsync() requires special attention. Here’s a simple approach:
Kotlin
@TestfuntestAsyncBroadcastReceiver() = runBlocking {val context = ApplicationProvider.getApplicationContext<Context>()val intent = Intent("com.softaai.TEST_ACTION")val receiver = AsyncBroadcastReceiver()// Set up a CountDownLatch to wait for async completionval latch = CountDownLatch(1)// Mock the receiver to signal when done receiver.onReceive(context, intent)// Wait for async work to complete (with timeout)val completed = latch.await(5, TimeUnit.SECONDS)assertTrue("Receiver should complete within timeout", completed)}
Conclusion
The goAsync() method is a powerful tool in your Android development toolkit, but it requires careful handling.
Let me recap the key points:
What goAsync() does: Extends the execution window for your BroadcastReceiver beyond the typical onReceive() lifecycle
When to use it: For tasks taking 1–8 seconds, like database writes, quick network calls, or file I/O
Critical rules: Always call finish(), move work off the main thread, respect the 10-second limit, and handle errors gracefully
Better alternatives: For longer tasks or complex workflows, consider WorkManager, JobScheduler, or foreground services
Remember, goAsync() is meant for those in-between moments when your work is too heavy for the main thread but too quick to justify a full background service. Use it wisely, follow the best practices we’ve covered, and your broadcast receivers will run smoothly without causing ANRs or draining battery life.
State management is the backbone of any modern Android app. If state is messy, your UI becomes unpredictable, buggy, and hard to maintain. Jetpack Compose was designed to solve many of these problems, but only if you understand how State Management in Jetpack Compose actually works.
In this post, we will break down modern state management in Jetpack Compose using simple examples. We will cover UI state, Kotlin Flows, side effects, and how they all fit together in a clean, scalable way.
What Does State Mean in Jetpack Compose?
In simple terms, state is data that the UI depends on.
If the state changes, the UI should update automatically.
Examples of state:
A loading flag
A list of items
User input text
An error message
Jetpack Compose is state-driven. This means you do not tell the UI how to update. You just update the state, and Compose handles the rest.
This is the foundation of State Management in Jetpack Compose.
UI State vs Business Logic
A common beginner mistake is mixing UI state with business logic. Modern Compose apps separate these responsibilities.
collectAsStateWithLifecycle() converts Flow into Compose state
Compose automatically recomposes when state changes
UI stays in sync with data
This is declarative UI in action.
Understanding Side Effects in Jetpack Compose
Side effects are operations that happen outside the scope of a composable function. Think network calls, database writes, or analytics events. Compose provides several side effect handlers, each with specific use cases.
LaunchedEffect: For Coroutine-Based Side Effects
Use LaunchedEffect when you need to run suspend functions:
Exhaustive when expressions — the compiler ensures you handle all cases
Type-safe data access — state.data only exists in Success
Clear state representation
Easy to test each state
State Hoisting: Keeping Composables Reusable
State hoisting is a pattern where you move state up to make composables stateless and reusable. This is crucial for good state management in Jetpack Compose.
Let me save you some headaches by pointing out common mistakes:
Pitfall 1: Unnecessary Recompositions
Kotlin
// Bad: Creates a new list on every recomposition@ComposablefunBadExample() {val items = listOf("A", "B", "C") // New list every time!}// Good: Remember the list@ComposablefunGoodExample() {val items = remember { listOf("A", "B", "C") }}
Pitfall 2: Calling Suspend Functions Directly
Kotlin
// Bad: This will crash@ComposablefunBadNetworkCall() {valdata = repository.getData() // Suspend function!}// Good: Use LaunchedEffect@ComposablefunGoodNetworkCall(viewModel: MyViewModel) {valdataby viewModel.data.collectAsStateWithLifecycle()LaunchedEffect(Unit) { viewModel.loadData() }}
Pitfall 3: Modifying State Outside Composition
Kotlin
// Bad: State update in initialization@ComposablefunBadStateUpdate(viewModel: MyViewModel) { viewModel.updateState() // Don't do this..!}// Good: Use side effects@ComposablefunGoodStateUpdate(viewModel: MyViewModel) {LaunchedEffect(Unit) { viewModel.updateState() }}
Best Practices for State Management in Jetpack Compose
Here are proven guidelines used in real production apps:
Use a single UI state per screen
Keep state immutable
Expose state as StateFlow
Handle side effects explicitly
Avoid mutable state in Composables
Let ViewModel own the logic
Following these keeps your app stable and testable.
Conclusion
Modern State Management in Jetpack Compose is not complicated once you understand the core ideas. State flows down. Events flow up. Side effects are handled explicitly.
Jetpack Compose rewards clean thinking. If your state is simple and predictable, your UI will be too.
Start small. Keep state clear. Let Compose do the heavy lifting.
Have you ever opened an app on your Android phone, switched to another app for a while, and then returned only to find everything reset? Your form data gone, your scroll position lost, or the app back at the home screen?
Understanding Process Death in Android is crucial for building apps that feel reliable and professional. In this guide, we’ll explore what it is, why it happens, and most importantly, how to handle it properly in your Android apps.
What Exactly Is Process Death in Android?
Process Death in Android occurs when the Android operating system kills your app’s process to free up memory for other apps. Think of it like your phone doing some housekeeping — when memory gets tight, Android decides which apps to close to keep everything running smoothly.
Here’s the tricky part: when your app’s process dies, your activities might still appear to be in the back stack. When the user returns, Android recreates the activity, but all your runtime data is gone unless you’ve saved it properly.
This is different from the normal activity lifecycle. Your app doesn’t just pause or stop — it’s completely terminated and then brought back to life.
Why Does Process Death Happen?
Android needs to manage limited device resources efficiently. Here are the main reasons Process Death in Android occurs:
Low Memory Situations
When your device runs low on RAM, Android starts killing background processes. Apps you haven’t used recently are the first to go.
Developer Options Testing
During development, you can enable “Don’t keep activities” in Developer Options. This immediately destroys activities when they leave the foreground, making it easier to test Process Death scenarios.
Long Background Duration
If your app stays in the background for an extended period while the user uses other memory-intensive apps, there’s a higher chance your process will be killed.
System Updates or Crashes
Sometimes system events or crashes can trigger process termination across multiple apps.
Real-World Example: The Shopping Cart Problem
Let me share a common scenario that illustrates Process Death in Android perfectly.
Imagine you’re building a shopping app. A user adds five items to their cart, then switches to their messaging app to check a friend’s recommendation. They spend 10 minutes chatting, during which Android kills your shopping app’s process to free memory.
When they return to your app, if you haven’t handled Process Death properly, their cart is empty.
Frustrating, right?
This is exactly why understanding and handling Process Death in Android matters for user experience.
How to Detect Process Death in Android
Before we fix it, let’s learn how to detect it. Add this code to your activity:
Kotlin
classMainActivity : AppCompatActivity() {overridefunonCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_main)if (savedInstanceState != null) {// Activity was recreated after process death Log.d("ProcessDeath", "Activity restored after process death") } else {// Normal first-time creation Log.d("ProcessDeath", "Activity created normally") } }}
The onCreate() method receives a savedInstanceState parameter. When this parameter is not null, it means Android is restoring your activity after Process Death. If it’s null, your activity is being created for the first time or normally resumed.
This simple check helps you understand when restoration is happening.
Saving State with onSaveInstanceState
The primary way to handle Process Death in Android is by overriding onSaveInstanceState(). This method is called before your activity might be destroyed, giving you a chance to save important data.
Kotlin
classShoppingCartActivity : AppCompatActivity() {privatevar cartItems = mutableListOf<String>()privatevar totalPrice = 0.0privatevar userName = ""overridefunonCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_shopping_cart)// Restore saved state if available savedInstanceState?.let { cartItems = it.getStringArrayList("CART_ITEMS")?.toMutableList() ?: mutableListOf() totalPrice = it.getDouble("TOTAL_PRICE", 0.0) userName = it.getString("USER_NAME", "") Log.d("ProcessDeath", "Restored ${cartItems.size} items") }updateUI() }overridefunonSaveInstanceState(outState: Bundle) {super.onSaveInstanceState(outState)// Save critical data before potential process death outState.putStringArrayList("CART_ITEMS", ArrayList(cartItems)) outState.putDouble("TOTAL_PRICE", totalPrice) outState.putString("USER_NAME", userName) Log.d("ProcessDeath", "Saved ${cartItems.size} items to bundle") }privatefunupdateUI() {// Update your UI with restored datafindViewById<TextView>(R.id.itemCount).text = "Items: ${cartItems.size}"findViewById<TextView>(R.id.totalPrice).text = "Total: $$totalPrice" }}
Here, we override two key methods:
onSaveInstanceState(): This is where we save our data to a Bundle. Think of a Bundle as a container that Android keeps safe even during Process Death. We use methods like putStringArrayList(), putDouble(), and putString() to store different data types.
onCreate(): We check if savedInstanceState exists. If it does, we restore our data using corresponding get methods like getStringArrayList() and getDouble().
The ?.let syntax is Kotlin’s safe call operator, ensuring we only access the bundle if it’s not null.
What Data Can You Save in a Bundle?
Bundles support many common data types, but there are limitations. Here’s what you can save:
Important Limitation: Bundles have a size limit (typically around 500KB to 1MB). Don’t try to save large images, videos, or extensive datasets. For large data, use other persistence methods like databases or files.
Using ViewModel to Survive Configuration Changes
While onSaveInstanceState() handles Process Death in Android, ViewModels help with configuration changes like screen rotation. However, ViewModels alone don’t survive process death.
Here’s how to combine both approaches:
Kotlin
classUserProfileViewModel : ViewModel() {// This survives configuration changes but NOT process deathvar userName = MutableLiveData<String>()var userAge = MutableLiveData<Int>()var profileImageUrl = MutableLiveData<String>()funupdateUserData(name: String, age: Int, imageUrl: String) { userName.value = name userAge.value = age profileImageUrl.value = imageUrl }}classUserProfileActivity : AppCompatActivity() {privatelateinitvar viewModel: UserProfileViewModeloverridefunonCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_user_profile) viewModel = ViewModelProvider(this).get(UserProfileViewModel::class.java)// If recovering from process death, restore to ViewModel savedInstanceState?.let {val name = it.getString("USER_NAME", "")val age = it.getInt("USER_AGE", 0)val imageUrl = it.getString("PROFILE_IMAGE_URL", "") viewModel.updateUserData(name, age, imageUrl) }observeViewModel() }overridefunonSaveInstanceState(outState: Bundle) {super.onSaveInstanceState(outState)// Save ViewModel data to survive process death outState.putString("USER_NAME", viewModel.userName.value ?: "") outState.putInt("USER_AGE", viewModel.userAge.value ?: 0) outState.putString("PROFILE_IMAGE_URL", viewModel.profileImageUrl.value ?: "") }privatefunobserveViewModel() { viewModel.userName.observe(this) { name ->findViewById<TextView>(R.id.nameText).text = name } viewModel.userAge.observe(this) { age ->findViewById<TextView>(R.id.ageText).text = "Age: $age" } }}
The ViewModel holds our UI data and survives configuration changes automatically. However, to survive Process Death in Android, we still need to:
Save ViewModel data in onSaveInstanceState()
Restore that data back to the ViewModel in onCreate()
This gives us the best of both worlds: automatic configuration change handling from ViewModel, plus process death recovery from saved instance state.
SavedStateHandle: The Modern Approach
Android Jetpack provides SavedStateHandle, which combines ViewModel benefits with automatic state saving. This is the recommended approach for handling Process Death in Android:
Kotlin
classShoppingViewModel(privateval savedStateHandle: SavedStateHandle) : ViewModel() {// Automatically saved and restored across process deathvar cartItems: MutableLiveData<List<String>> = savedStateHandle.getLiveData("cart_items", emptyList())var totalPrice: MutableLiveData<Double> = savedStateHandle.getLiveData("total_price", 0.0)funaddItem(item: String, price: Double) {val currentItems = cartItems.value?.toMutableList() ?: mutableListOf() currentItems.add(item) cartItems.value = currentItemsval currentTotal = totalPrice.value ?: 0.0 totalPrice.value = currentTotal + price// Automatically saved to SavedStateHandle }funclearCart() { cartItems.value = emptyList() totalPrice.value = 0.0 }}classShoppingActivity : AppCompatActivity() {privateval viewModel: ShoppingViewModelbyviewModels()overridefunonCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_shopping)// No manual state restoration needed!// SavedStateHandle does it automatically viewModel.cartItems.observe(this) { items ->updateCartUI(items) } viewModel.totalPrice.observe(this) { total ->findViewById<TextView>(R.id.totalText).text = "Total: $$total" }findViewById<Button>(R.id.addButton).setOnClickListener { viewModel.addItem("Product ${System.currentTimeMillis()}", 29.99) } }privatefunupdateCartUI(items: List<String>) {// Update RecyclerView or ListView with items }}
SavedStateHandle is magical for handling Process Death in Android. Here’s why:
getLiveData(): This method creates LiveData that’s automatically backed by saved state. When process death occurs, the data is saved. When the process restarts, the data is restored automatically.
No manual saving: Unlike the previous examples, we don’t need to override onSaveInstanceState(). The SavedStateHandle does it for us.
Type-safe: We can store various types, and they’re automatically serialized and deserialized.
The by viewModels() delegate is a Kotlin property delegate that creates or retrieves the ViewModel with SavedStateHandle automatically injected.
Testing Process Death in Android
Testing is crucial to ensure your app handles Process Death in Android correctly. Here are practical ways to test:
Method 1: Developer Options
Go to Settings → Developer Options
Enable “Don’t keep activities”
Navigate through your app, switching between activities
Every time an activity goes to the background, it’s destroyed
Method 2: Using ADB Command
Force your app’s process to be killed using Android Debug Bridge:
adb shell am kill com.yourapp.package
Then return to your app from the recent apps menu.
Method 3: Memory Pressure Testing
Use Android Studio’s Profiler to simulate low memory conditions:
Open Android Profiler
Select Memory
Click “Force garbage collection” multiple times
Android may kill your app’s process naturally
Common Mistakes to Avoid
When dealing with Process Death in Android, developers often make these mistakes:
Mistake 1: Saving Large Objects
Kotlin
// DON'T DO THISoverridefunonSaveInstanceState(outState: Bundle) {super.onSaveInstanceState(outState) outState.putSerializable("LARGE_IMAGE", largeImageBitmap) // Too big!}
Why it’s wrong: Bundles have size limits. Saving large objects causes TransactionTooLargeException.
Better approach: Save only a reference or ID, then reload the data from a persistent source.
Kotlin
// DO THIS INSTEADoverridefunonSaveInstanceState(outState: Bundle) {super.onSaveInstanceState(outState) outState.putString("IMAGE_URL", imageUrl) // Save URL, not bitmap}
Mistake 2: Assuming ViewModel Survives Process Death
Kotlin
// INCORRECT ASSUMPTIONclassMyActivity : AppCompatActivity() {privatelateinitvar viewModel: MyViewModeloverridefunonCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState) viewModel = ViewModelProvider(this).get(MyViewModel::class.java)// Assuming viewModel.userData is always available after process death// This is WRONG - ViewModel doesn't survive process death without SavedStateHandle }}
Fix: Use SavedStateHandle or manually save/restore ViewModel data.
Mistake 3: Not Testing Thoroughly
Many developers never test Process Death scenarios until users report bugs. Always enable “Don’t keep activities” during development.
Best Practices for Handling Process Death in Android
1. Use SavedStateHandle for Simple Data
For primitive types and small objects, SavedStateHandle is your best friend:
For critical data that users can’t afford to lose, use Room database or other persistent storage:
Kotlin
classFormActivity : AppCompatActivity() {privatelateinitvar database: AppDatabaseoverridefunonCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState) database = Room.databaseBuilder( applicationContext, AppDatabase::class.java,"form-database" ).build()// Restore form from database if exists lifecycleScope.launch {val savedForm = database.formDao().getUnsubmittedForm() savedForm?.let { restoreForm(it) } } }overridefunonPause() {super.onPause()// Save form to database when leaving activity lifecycleScope.launch {val formData = collectFormData() database.formDao().saveForm(formData) } }}
3. Combine Multiple Approaches
Use the right tool for each type of data:
SavedStateHandle: UI state (scroll position, selected tab, form inputs)
ViewModel: Temporary runtime data that survives configuration changes
Database/SharedPreferences: Persistent user data
Memory cache: Easily re-fetchable data
4. Keep Bundles Small
Only save essential state information. Calculate or reload other data:
Kotlin
overridefunonSaveInstanceState(outState: Bundle) {super.onSaveInstanceState(outState)// Save only IDs, not entire objects outState.putInt("SELECTED_ITEM_ID", selectedItem.id) outState.putString("SEARCH_QUERY", searchQuery)// Don't save the entire search results list// Reload it using the search query instead}
Advanced: Handling Process Death with Compose
If you’re using Jetpack Compose, handling Process Death in Android looks a bit different:
With Compose, you still use ViewModel with SavedStateHandle. The difference is:
observeAsState(): Converts LiveData from ViewModel into Compose State
Automatic recomposition: When the ViewModel data changes (including after process death restoration), Compose automatically updates the UI
No manual lifecycle management: Compose handles the observation lifecycle for you
The underlying Process Death handling still uses SavedStateHandle in the ViewModel, but the UI layer becomes much simpler.
Monitoring Process Death in Production
To understand how often Process Death in Android affects your users, implement analytics:
Kotlin
classAnalyticsHelper(privateval context: Context) {funtrackProcessDeath(activityName: String) {// Log to your analytics service FirebaseAnalytics.getInstance(context).logEvent("process_death_recovery") {param("activity_name", activityName)param("timestamp", System.currentTimeMillis()) } }}classMainActivity : AppCompatActivity() {overridefunonCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_main)if (savedInstanceState != null) {AnalyticsHelper(this).trackProcessDeath("MainActivity") } }}
This helps you understand:
How frequently users experience process death
Which activities are most affected
Whether users successfully recover their state
Conclusion
Process Death in Android is an essential concept every Android developer must master. It’s not just about preventing crashes — it’s about creating a seamless user experience that feels reliable and polished.
Remember these key takeaways:
For simple UI state: Use SavedStateHandle with ViewModel. It automatically handles Process Death in Android with minimal code.
For important user data: Persist to a database. Don’t rely solely on saved instance state for data users can’t afford to lose.
Always test: Enable “Don’t keep activities” during development. Test your app thoroughly by simulating process death scenarios.
Keep bundles small: Only save essential state information. Reload complex data when the activity restores.
By properly handling Process Death in Android, you’ll build apps that feel professional, reliable, and respectful of your users’ time and data. Your users might never know about the complexity you’ve handled behind the scenes — and that’s exactly the point.
Start implementing these patterns in your next project, and you’ll be amazed at how much more robust your apps become.
If you’ve been writing object-oriented code for a while, you’ve probably used inheritance a lot. It feels natural. You create a base class, extend it, override a few methods, and move on.
But as projects grow, inheritance often becomes hard to manage. Classes get tightly coupled. Changes ripple through the codebase. Small tweaks break unexpected things.
In this post, we’ll break down what Composition Over Inheritance really means, why it matters, and how it’s used naturally in modern Kotlin development, especially with Jetpack Compose.
What Does “Composition Over Inheritance” Mean?
Composition Over Inheritance is a design principle that says:
Prefer building classes by combining smaller, reusable components instead of extending base classes.
In simpler terms:
Inheritance says “is a”
Composition says “has a”
Instead of forcing behavior through class hierarchies, you compose behavior by using other objects.
A Simple Real-World Example
Think of a smartphone.
A smartphone has a camera, battery, speaker, and screen.
It does not inherit from Camera, Battery, or Speaker.
That’s composition.
If you used inheritance here, the design would fall apart fast.
The Problem With Inheritance
Inheritance looks clean at first, but it comes with hidden costs.
Example Using Inheritance (Problematic)
Kotlin
openclassVehicle {openfunmove() {println("Vehicle is moving") }}openclassCar : Vehicle() {overridefunmove() {println("Car is driving") }}classElectricCar : Car() {overridefunmove() {println("Electric car is driving silently") }}
At first glance, this seems fine.
But now imagine:
You want a flying car
You want a boat-car
You want a self-driving electric truck
Your inheritance tree explodes.
Changes to Vehicleaffect every subclass. You’re locked into decisions you made early, often before requirements were clear.
This is exactly what Composition Over Inheritance helps you avoid.
Composition Over Inheritance Explained With Kotlin
Let’s rewrite the same idea using composition.
Create Small, Focused Behaviors
Kotlin
interfaceEngine {funmove()}
Kotlin
classGasEngine : Engine {overridefunmove() {println("Driving using gas engine") }}
Kotlin
classElectricEngine : Engine {overridefunmove() {println("Driving silently using electric engine") }}
Alarms, reminders, and notifications are some of the most deceptively complex features in Android development.
On the surface, they look simple: “Schedule something at a time and show a notification.”
In reality, you’re fighting with:
Doze mode
OEM background restrictions
Process death
App restarts
Device reboots
Android 12+ background execution limits
Android 13+ notification permissions
Android 14+ stricter alarms policies
After more than a decade building Android apps at scale, one thing is clear:
Most alarm and notification bugs are architectural bugs, not API bugs.
This article walks through how to architect alarms and notifications in a real production app using MVVM + Clean Architecture, feature modules, and a hybrid AlarmManager + WorkManager approach that survives modern Android constraints.
This is not theory. This is how you ship reliable reminder systems.
@Module@InstallIn(SingletonComponent::class)objectSystemServiceModule {@ProvidesfunprovideAlarmManager(@ApplicationContext context: Context ): AlarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager}
Domain remains DI-agnostic.
AlarmManager vs WorkManager: The Real Decision Matrix
Requirement
AlarmManager
WorkManager
Exact timing
Provides precise, exact-time execution and is suitable for alarms and reminders that must fire at a specific moment
Does not guarantee exact timing; execution can be delayed based on system conditions
Doze compatibility
Can fire during Doze mode only when using exact alarms with allow-while-idle, and even then execution is constrained
Does not run during Doze at an exact time and may be deferred until the system exits idle mode
Long background work
Not suitable for long-running background tasks; execution time must be very short
Designed specifically for longer background work with proper lifecycle handling
Survives device reboot
Does not automatically survive a device reboot and requires manual rescheduling
Automatically survives device reboot and restores scheduled work
Battery efficiency
Less battery efficient because it bypasses system optimizations for exact timing
More battery efficient because it respects system scheduling and optimization policies
User-visible alerts
Well suited for user-visible actions such as alarms, reminders, and time-critical notifications
Less reliable for user-visible alerts that must appear at an exact time
Use AlarmManager when the user expects something to happen at an exact moment, such as reminders, alarms, or medication alerts.
Use WorkManager when the task involves background processing, network calls, database work, or any operation that does not require exact timing.
Combine both for production-grade systems: let AlarmManager wake the app, and let WorkManager perform the actual work.
The Correct Hybrid Approach
AlarmManager wakes the app at the exact time
WorkManager executes background work
Worker shows notification and reschedules
This combination works across:
Android 8 → 15
OEM restrictions
Play Store policies
Conclusion
Reliable alarms and notifications are not about clever tricks or undocumented APIs. They are about respecting architectural boundaries and accepting Android’s reality.
If you:
Keep domain pure
Treat scheduling as a business concern
Let infra absorb platform volatility
Reschedule explicitly
Your reminder system will survive every Android release.
And you’ll sleep better knowing your users won’t miss their alarms.
The Factory Pattern is a creational design pattern that handles object creation for you.
Instead of creating objects directly using constructors, you delegate that responsibility to a factory.
In simple terms:
A factory decides which object to create and how to create it.
Your main code just asks for an object and uses it. It doesn’t care about the details.
This separation is what makes the Factory Pattern Simplify Complex Object Creation so effective.
Why Object Creation Becomes Complex
In real-world applications, object creation often depends on:
User input
Configuration files
API responses
Environment (development, testing, production)
Example without a factory:
Kotlin
val paymentProcessor = when (paymentType) {"CARD"->CardPaymentProcessor()"UPI"->UpiPaymentProcessor()"WALLET"->WalletPaymentProcessor()else->throwIllegalArgumentException("Invalid payment type")}
Now imagine this logic repeated across multiple files.
Problems appear quickly:
Code duplication
Hard-to-maintain logic
Difficult testing
Violations of the Single Responsibility Principle
This is why developers rely on patterns that simplify complex object creation.
How the Factory Pattern Helps
The Factory Pattern solves these issues by:
Centralizing object creation
Reducing tight coupling
Making code easier to extend
Improving testability
Most importantly, it lets your business logic focus on what to do, not how objects are created.
That’s the real power behind Factory Pattern Simplify Complex Object Creation.
Modern software design isn’t just about writing code that works; it’s about writing code that’s maintainable, scalable, and easy to understand. One way to achieve this is by using Use Case Patterns — a structured approach to modeling software functionality based on real-world user interactions. In this guide, we’ll break down everything you need to know about use case patterns, provide practical Kotlin examples, and show how to apply them effectively in your projects.
What Are Use Case Patterns?
A use case pattern is a reusable template that describes how a system should respond to specific user actions or events. Think of them as building blocks for designing your software’s functionality. Instead of starting from scratch each time, you can rely on patterns to standardize workflows, reduce errors, and speed up development.
For example, common use case patterns include:
Authentication — logging in and out
CRUD Operations — create, read, update, delete
Notification Handling — sending emails or push notifications
These patterns provide a blueprint for solving recurring problems, making your code more predictable and maintainable.
Why Use Use Case Patterns in Modern Software Design?
Consistency: Patterns ensure that similar functionalities follow a consistent approach across your project.
Reusability: Once you define a pattern, you can reuse it in multiple parts of your app without rewriting code.
Clarity: Clear use case patterns make it easier for new developers to understand your system.
Scalability: Patterns help design systems that can grow without becoming messy or unmanageable.
Core Principles of Use Case Patterns
To master use case patterns, keep these principles in mind:
Single Responsibility: Each pattern should handle one type of use case.
Clear Actors: Define who or what interacts with the system.
Explicit Steps: Document each step the system performs in response to an action.
Reusability: Design patterns so they can be applied in multiple scenarios.
Implementing Use Case Patterns in Kotlin
Kotlin is a modern, concise programming language that’s perfect for demonstrating use case patterns. Let’s go through a simple example: a user registration system.
Define the Use Case Interface
Start by creating an interface that represents the use case:
This simple example shows how use case patterns create a clear, reusable structure. You can now create other use cases, like LoginUserUseCase, following the same template.
Best Practices for Use Case Patterns
Keep Use Cases Small: Avoid overloading a single use case with too many responsibilities.
Focus on Business Logic: Use case patterns should contain only business logic — not UI or database code.
Combine With Repositories: Use repositories or services for data access while keeping the use case focused.
Document Clearly: Add descriptions for each use case to improve maintainability.
Advanced Tip: Chaining Use Cases
Sometimes, a single user action involves multiple steps. Kotlin’s flexibility allows chaining use cases:
Here, the CompleteUserOnboardingUseCase combines registration and email notification, keeping each use case modular and reusable.
Conclusion
Mastering use case patterns is a game-changer for modern software design. They help you write cleaner, maintainable code that is easy to understand and scale. Using Kotlin, you can implement these patterns with minimal boilerplate, keeping your focus on business logic.
Start small, focus on clarity, and gradually build a library of reusable patterns. Before long, your software architecture will be robust, consistent, and much easier to maintain.
By embracing use case patterns, you not only improve your code today — you future-proof your projects for tomorrow.
Software projects rarely stay small. Features grow, requirements change, and teams expand. When data access logic is tightly coupled with business logic, even a simple update can break multiple parts of the system.
In this blog, we’ll explain the Repository Pattern in a clear and beginner-friendly way using Kotlin examples. You’ll learn what it is, why it matters, and how it makes your code easier to test, maintain, and scale over time.
What Is the Repository Pattern?
The Repository Pattern is a design pattern that separates data access logic from business logic.
Instead of letting your services or view models talk directly to a database, API, or data source, all data operations are handled by a repository. Your business logic interacts only with the repository interface.
You can think of the repository as a middle layer that hides all the details of how data is stored or retrieved.
This separation leads to cleaner, safer, and more flexible code.
Why the Repository Pattern Is Important
Without the Repository Pattern, applications often suffer from:
Database queries scattered across the codebase
Business logic tightly tied to a specific database or framework
Difficult and slow unit testing
High risk when changing data sources
The Repository Pattern solves these problems by creating a single, consistent place for data access.
Core Structure of the Repository Pattern
A typical Repository Pattern implementation includes:
A repository interface that defines allowed operations
A repository implementation that handles actual data access
Business logic that depends only on the interface
Let’s walk through a simple Kotlin example.
Repository Pattern in Kotlin
Step 1: Define a Data Model
Kotlin
dataclassUser(val id: Int,val name: String)
This is a simple data class that represents a user in the system.
This class contains all the data access logic. Whether the data comes from Room, SQL, an API, or another source, the rest of the app does not need to know.