Android

Compose Preview

Compose Preview Explained: How It Works, Why It Matters, and Where It Falls Short

If you’re building Android apps with Jetpack Compose, chances are you’ve already used Compose Preview. Or at least clicked the little Preview tab in Android Studio and hoped it would magically show your UI.

Sometimes it does.
Sometimes it doesn’t.

In this blog, we’ll break down Compose Preview, covering everything from core mechanics to practical tips. You’ll learn:

  • What Compose Preview actually is
  • How it works under the hood
  • Why it matters for real-world development
  • Where it struggles and why
  • When to trust it and when not to

Let’s start with the basics.

What Is Compose Preview?

Compose Preview is a design-time tool in Android Studio that lets you see your Jetpack Compose UI without running the app on a device or emulator.

It renders composable functions directly inside the IDE.

That means:

  • Faster feedback
  • No APK install
  • No waiting for Gradle every time you tweak padding or text size

In short, Compose Preview helps you design UI faster.

A Simple Compose Preview Example

Let’s start with a basic example.

Kotlin
@Composable
fun Greeting(name: String) {
    Text(text = "Hello, $name!")
}

This composable works, but Android Studio can’t preview it yet. Why?

Because Greeting needs a parameter.

That’s where Compose Preview comes in.

Adding a Preview Function

Kotlin
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    Greeting(name = "Android")
}
  • @Preview tells Android Studio: Render this composable
  • showBackground = true adds a white background so text is readable
  • GreetingPreview() supplies sample data ("Android")

This preview function is not used in production.
It exists only for design-time visualization.

That’s an important detail many beginners miss.

How Compose Preview Works Behind the Scenes

Compose Preview does not run your full app.

Instead, Android Studio:

  1. Compiles the composable function
  2. Runs it in a special design-time environment
  3. Skips most Android framework components
  4. Renders the UI using sample data

That’s why previews are fast.

And that’s also why they’re limited.

Why Compose Preview Matters So Much

1. Faster UI Iteration

With Compose Preview, you can:

  • Adjust spacing
  • Change colors
  • Try different text styles
  • Experiment with layouts

All without touching an emulator.

For UI-heavy screens, this saves hours over time.

2. Encourages Smaller, Cleaner Composables

Compose Preview works best with small, focused composables.

That naturally pushes you toward:

  • Better separation of concerns
  • Reusable UI components
  • Clearer code structure

This directly improves long-term maintainability.

3. Better Design Collaboration

Designers and developers can:

  • Review UI changes quickly
  • Compare states side by side
  • Validate layouts early

Compose Preview becomes a shared visual language.

Advanced Compose Preview Features You Should Know

Beyond basic previews, several advanced features make Compose Preview even more powerful.

Preview with Different Device Configurations

The @Preview annotation accepts parameters that let you simulate different devices, screen sizes, and system settings.

Kotlin
@Preview(
    name = "Small phone",
    device = Devices.PIXEL_3A,
    showSystemUi = true
)
@Preview(
    name = "Large phone",
    device = Devices.PIXEL_7_PRO,
    showSystemUi = true
)
@Preview(
    name = "Tablet",
    device = Devices.PIXEL_TABLET,
    showSystemUi = true
)
@Preview(
    name = "Foldable",
    device = Devices.FOLDABLE,
    showSystemUi = true
)
@Preview(
    name = "Landscape",
    device = Devices.PIXEL_7_PRO,
    widthDp = 891,
    heightDp = 411
)
@Preview(
    name = "Dark Theme",
    uiMode = Configuration.UI_MODE_NIGHT_YES,
    showBackground = true
)
@Preview(showBackground = true)
@Composable
fun ResponsiveLayoutPreview() {
    MaterialTheme {
        Surface(
            modifier = Modifier.fillMaxSize(),
            tonalElevation = 4.dp
        ) {
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(24.dp),
                verticalArrangement = Arrangement.spacedBy(20.dp)
            ) {

                // Header
                Text(
                    text = "Responsive UI",
                    style = MaterialTheme.typography.headlineMedium,
                    fontWeight = FontWeight.Bold
                )

                Text(
                    text = "Adaptive layouts across form factors",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )

                Divider()

                Column(
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    FeatureRow("Phones", "Compact & large screens")
                    FeatureRow("Tablets", "Expanded content layouts")
                    FeatureRow("Foldables", "Posture-aware UI")
                    FeatureRow("Themes", "Light & Dark mode ready")
                }
            }
        }
    }
}

@Composable
private fun FeatureRow(
    title: String,
    subtitle: String
) {
    Column {
        Text(
            text = title,
            style = MaterialTheme.typography.titleMedium,
            fontWeight = FontWeight.SemiBold
        )
        Text(
            text = subtitle,
            style = MaterialTheme.typography.bodySmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
    }
}

Let me break down what’s happening here:

  • device = Devices.PIXEL_7_PRO: This tells Compose Preview to render your composable as if it’s running on a Pixel 7 Pro device, matching that specific screen size and dimensions.
  • showSystemUi = true: This parameter displays the system UI elements like the status bar and navigation bar, giving you a more realistic preview of how your app will look.
  • uiMode = Configuration.UI_MODE_NIGHT_YES: This simulates dark mode, letting you verify that your colors and themes work properly in both light and dark settings.

You can stack multiple @Preview annotations on the same function to see all these variations simultaneously.

Preview Parameters for Dynamic Content

Sometimes you want to test your composables with different data sets. The @PreviewParameter annotation helps with this.

Kotlin
class UserStateProvider : PreviewParameterProvider<Boolean> {
    override val values = sequenceOf(true, false)
}

@Preview(showBackground = true)
@Composable
fun StatusBadgePreview(
    @PreviewParameter(UserStateProvider::class) isActive: Boolean
) {
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(
                color = if (isActive) Color.Green else Color.Red,
                shape = CircleShape
            ),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = if (isActive) "Active" else "Inactive",
            color = Color.White,
            fontWeight = FontWeight.Bold
        )
    }
}

Here,

The UserStateProvider class implements PreviewParameterProvider<Boolean>, which means it provides a sequence of Boolean values for previewing. The values property returns both true and false.

When you use @PreviewParameter(UserStateProvider::class) on the isActive parameter, Compose Preview automatically generates two separate previews—one for each value in the sequence. You get both the active and inactive states without writing separate preview functions.

This approach is incredibly useful when testing with lists of data, different user types, or various configuration options.

Interactive Preview Mode

Recent versions of Android Studio introduced interactive preview mode, which lets you click buttons, scroll lists, and interact with your UI directly in the preview pane. This feature brings you even closer to the actual app experience without leaving the IDE.

To enable it, look for the interactive mode toggle in the preview pane toolbar. Keep in mind that interactions are limited to the composable being previewed — you can’t navigate to other screens or trigger real network calls.

Where Compose Preview Falls Short

Compose Preview is helpful, but it’s not perfect.

Let’s talk honestly about its limitations.

1. No Real Runtime Logic

Compose Preview does not handle:

  • Network calls
  • Database access
  • ViewModel state from real sources
  • Dependency injection (Hilt, Koin)

If your composable depends on runtime data, preview will break.

That’s why preview-friendly composables should take simple, deterministic parameters that can be easily mocked in previews, rather than ViewModels.

2. Limited Interaction Support

You can’t:

  • Click buttons meaningfully
  • Trigger navigation
  • Test animations properly
  • Simulate gestures accurately

Compose Preview shows how things look, not how they behave.

For behavior, you still need:

  • Emulators
  • Physical devices
  • UI tests

3. Can Be Slow in Large Projects

As your project grows:

  • Previews may take longer to render
  • IDE memory usage increases
  • Sometimes previews just refuse to refresh

This isn’t your fault. It’s a known trade-off.

4. Not a Replacement for Testing

Compose Preview is not a test.

It won’t catch:

  • Crashes
  • Logic bugs
  • Edge-case states
  • Performance issues

Think of it as a design aid, not a quality gate.

Best Practices for Using Compose Preview

To get the most out of Compose Preview:

Keep Preview Functions Simple and Focused

Your preview functions should be straightforward and serve a single purpose. Don’t overcomplicate them with business logic or complex data transformations.

Kotlin
// Good: Simple and clear
@Preview(showBackground = true)
@Composable
fun LoadingButtonPreview() {
    LoadingButton(
        text = "Loading",
        isLoading = true,
        onClick = { }
    )
}

// Avoid: Too much logic in preview
@Preview(showBackground = true)
@Composable
fun ComplicatedPreview() {
    val viewModel = remember { MyViewModel() }
    val state by viewModel.uiState.collectAsState()
    // This won't work well in preview..!
}

The first preview is clean and predictable. The second tries to instantiate a ViewModel, which likely depends on dependency injection, context, or other resources that aren’t available in preview mode.

Use Preview Groups for Organization

When you have many related previews, organize them into preview groups for better navigation.

Kotlin
annotation class ComponentPreviews

@ComponentPreviews
@Preview(name = "Small Button", widthDp = 100)
@Preview(name = "Medium Button", widthDp = 200)
@Preview(name = "Large Button", widthDp = 300)
@Composable
fun ButtonSizePreview() {
    Button(onClick = { }) {
        Text("Click Me")
    }
}

By creating a custom annotation like @ComponentPreviews and applying it alongside your @Preview annotations, you can filter and group previews in Android Studio. This becomes invaluable when working on large projects with hundreds of composables.

Create Preview Fixtures for Common Data

Maintain a separate file with preview fixtures — sample data objects you can reuse across multiple previews.

Kotlin
// PreviewFixtures.kt
object PreviewFixtures {
    val sampleUser = UserData(
        name = "Amol Pawar",
        email = "[email protected]",
        joinDate = "March 2022"
    )
    
    val sampleMessages = listOf(
        MessageData("Hello there!", "Amol", "9:00 AM"),
        MessageData("How are you?", "Rutuja", "9:05 AM"),
        MessageData("Doing great!", "Amol", "9:10 AM")
    )
    
    val longText = """
        This is a longer text sample that helps us test how our UI
        handles content that spans multiple lines. It's useful for
        checking text wrapping, overflow behavior, and spacing.
    """.trimIndent()
}

Then use these fixtures in your previews:

Kotlin
@Preview(showBackground = true)
@Composable
fun UserProfileWithFixturePreview() {
    UserProfile(
        userId = "sample",
        getUserData = { PreviewFixtures.sampleUser }
    )
}

This approach keeps your preview code DRY (Don’t Repeat Yourself) and makes it easier to maintain consistency across previews.

Test Edge Cases in Previews

Don’t just preview your happy path. Create previews for edge cases like empty states, error states, and extreme data conditions.

Kotlin
@Preview(name = "Empty List", showBackground = true)
@Composable
fun EmptyListPreview() {
    MessageList(messages = emptyList())
}

@Preview(name = "Very Long Name", showBackground = true)
@Composable
fun LongNamePreview() {
    ProfileCard(
        name = "Soundarya Bhagayalaxmi Venkateshwari Basapa Rao",
        isOnline = true,
        profileImageUrl = null
    )
}

@Preview(name = "Single Character", showBackground = true)
@Composable
fun SingleCharPreview() {
    ProfileCard(
        name = "X",
        isOnline = false,
        profileImageUrl = null
    )
}

These edge case previews help you catch layout issues before they reach production. Does your text truncate properly? Do your empty states look intentional rather than broken? 

The Future of Compose Preview

The Compose Preview tool continues to evolve with each Android Studio release. Recent improvements include better performance, enhanced animation support, and more sophisticated interactive capabilities.

Looking ahead, we can expect:

  • Deeper integration with design tools: Better collaboration between designers and developers through improved Figma integration and design token support.
  • AI-assisted previews: Automated generation of preview functions based on your composable parameters and common usage patterns.
  • Enhanced debugging: More powerful inspection tools for understanding why your UI renders the way it does.
  • Cloud-based previews: The ability to share interactive previews with team members without requiring them to open Android Studio.

The Android development community actively shapes these improvements through feedback, so don’t hesitate to file feature requests or bug reports.

Conclusion

Despite its limitations, Compose Preview is an essential part of modern Android development. The speed and convenience it offers make it ideal for rapid UI iteration and component-level design work.

The key is knowing when to use it. Compose Preview works best for visual validation and layout refinement, while emulators or real devices are still necessary for testing interactions, animations, and real data flows.

When used with preview-friendly composables and best practices, Compose Preview significantly improves development speed and feedback. It turns UI work into a more iterative, design-driven process rather than a cycle of long builds and guesswork.

Happy previewing..!

Responsive and Adaptive UI in Jetpack Compose

How to Build Responsive and Adaptive UI in Jetpack Compose for Every Screen Size

If you’ve ever opened your beautifully designed app on a tablet only to see it looking like a stretched-out phone screen, you know the pain. Or maybe you’ve watched your carefully crafted layout break apart on a foldable device. Trust me, I’ve been there. The good news..? Responsive and Adaptive UI in Jetpack Compose makes...

Membership Required

You must be a member to access this content.

View Membership Levels

Already a member? Log in here
Monochrome Icons

How Monochrome Icons Power Android Themed Icons (Android 12+)

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:

  1. Android checks if your app supports Monochrome Icons
  2. If supported, the system loads the monochrome drawable
  3. Android applies dynamic colors from the Material You palette
  4. 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.

Sample Adaptive Icon with Monochrome Support

Here’s a valid adaptive icon configuration:

XML
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
    <background android:drawable="@color/ic_launcher_background" />
    <foreground android:drawable="@drawable/ic_launcher_foreground" />
    <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

What Each Part Means

  • background
    Used for legacy launchers and non-themed states
  • foreground
    The full-color icon shown when themed icons are disabled
  • monochrome
    This is the key part
    Android uses this drawable for themed icons

If the <monochrome> tag is missing, themed icons won’t work for your app.

Designing a Proper Monochrome Icon

A good Monochrome Icon should:

  • Use solid white or black only
  • Avoid thin lines
  • Avoid transparency gradients
  • Focus on a recognizable shape

Recommended Format

  • Vector Drawable (.xml)
  • Single path
  • android:fillColor set to white or black
XML
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="108dp"
    android:height="108dp"
    android:viewportWidth="108"
    android:viewportHeight="108">
    
<path
    android:fillColor="#FFFFFFFF"
    android:pathData="M20,20h68v68h-68z" />
</vector>

This simplicity is what allows Android to recolor it cleanly.

App Icon Configuration

Your launcher icon is referenced in AndroidManifest.xml:

XML
<application
    android:icon="@mipmap/ic_launcher"
    android:roundIcon="@mipmap/ic_launcher_round">

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.

onValueChange = { value = it }

Understanding onValueChange = { value = it } in Jetpack Compose

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
var value by remember { 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
var value by remember { 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:

Kotlin
Component(
    value = currentState,
    onValueChange = { /* update state */ }
)

This is intentional and consistent.

onValueChange is:

  • A callback function
  • Triggered every time the user interacts
  • Passed the new value as a parameter

Compose itself does not store the value internally.
You are responsible for updating the state.

Breaking Down { value = it }

Let’s rewrite the lambda in a more explicit way:

Kotlin
onValueChange = { newValue ->
    value = newValue
}

Now it’s clearer.

  • it (or newValue) is the latest value from the UI
  • value = it updates your state
  • Updating state triggers recomposition

This is not assigning a random variable — it’s updating the single source of truth.

How the Data Flow Actually Works

Here’s the real flow behind the scenes:

  1. User interacts with the UI (types text, drags slider, etc.)
  2. Compose calls onValueChange(newValue)
  3. You update state (value = newValue)
  4. Compose detects the state change
  5. Any composables reading value recompose automatically

This is called unidirectional data flow, and it’s a core Compose principle.

Kotlin
State → UI<br>UI interaction → Callback → State update → Recomposition

Simple Example with TextField

Kotlin
@Composable
fun NameInput() {
    var name by remember { mutableStateOf("") }

    TextField(
        value = name,
        onValueChange = { name = it },
        label = { Text("Enter your name") }
    )
}

Here,

  • name controls what the TextField displays
  • Typing triggers onValueChange
  • The new text is assigned to name
  • The TextField redraws with updated text

If you remove onValueChange, the field becomes read-only.

Why Compose Doesn’t Update the Value Automatically

This design is intentional.

Compose avoids hidden internal state because:

  • It prevents bugs
  • It makes UI predictable
  • It improves testability
  • It aligns with modern architecture (MVI, Redux-style patterns)

You always know where your data lives.

Common Beginner Mistakes

Forgetting to update state

Kotlin
onValueChange = { }

Result: UI never changes.

Not using remember

Kotlin
var value by mutableStateOf(0)

Result: Value resets on every recomposition.

Expecting Compose to “save” the value

Compose renders, it doesn’t store business state. That’s your job (or ViewModel’s).

Why This Pattern Is So Powerful

Once you understand this line, you understand 50% of Compose.

It enables:

  • Clean separation of UI and state
  • Easy state hoisting
  • Predictable recomposition
  • Seamless ViewModel integration

Example with state hoisting:

Kotlin
@Composable
fun Counter(value: Int, onValueChange: (Int) -> Unit) {
    Slider(
        value = value.toFloat(),
        onValueChange = { onValueChange(it.toInt()) }
    )
}

Now the parent owns the state — not the UI.

Final Mental Model (Remember This)

Compose does not mutate UI.
Compose reacts to state changes.

And this line:

JavaScript
onValueChange = { value = it }

is simply saying:

“When the user changes something, update my state — and let Compose handle the rest.”

Once this clicks, Jetpack Compose stops feeling confusing and starts feeling refreshingly simple..!

goAsync()

How goAsync() Works in BroadcastReceiver: Lifecycle, Pitfalls, and Best Practices

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
class SimpleBroadcastReceiver : BroadcastReceiver() {
    override fun onReceive(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
class AsyncBroadcastReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        // Call goAsync() immediately to get a PendingResult
        val pendingResult: PendingResult = goAsync()
        
        // Now you can do work off the main thread
        CoroutineScope(Dispatchers.IO).launch {
            try {
                // Perform your background work here
                performLongRunningTask(context)
            } finally {
                // CRITICAL: Always call finish() when done
                pendingResult.finish()
            }
        }
    }
    
    private suspend fun performLongRunningTask(context: Context) {
        // Simulate some work
        delay(3000)
        Log.d("Receiver", "Task completed!")
    }
}

Here’s what’s happening behind the scenes:

  1. goAsync() is called: This immediately returns a PendingResult object and tells Android the receiver is still working
  2. Work happens off-thread: You move your heavy lifting to a background thread using coroutines or another threading mechanism
  3. 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
class DataSavingReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val pendingResult = goAsync()
        
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val database = AppDatabase.getInstance(context)
                val data = 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.

Kotlin
// BAD: Missing finish() call
class BadReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val pendingResult = goAsync()
        
        CoroutineScope(Dispatchers.IO).launch {
            performTask()
            // Oops..! Forgot to call pendingResult.finish()
        }
    }
}

Always use a try-finally block or Kotlin’s use pattern to ensure finish() gets called:

Kotlin
// GOOD: Guaranteed finish() call
class GoodReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val pendingResult = goAsync()
        
        CoroutineScope(Dispatchers.IO).launch {
            try {
                performTask()
            } catch (e: Exception) {
                Log.e("Receiver", "Error: ${e.message}")
            } finally {
                pendingResult.finish()
            }
        }
    }
}

Pitfall 2: Running on the Main Thread

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 thread
class BlockingReceiver : BroadcastReceiver() {
    override fun onReceive(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 thread
class NonBlockingReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val pendingResult = goAsync()
        
        // Using Dispatchers.IO for background work
        CoroutineScope(Dispatchers.IO).launch {
            try {
                delay(5000) // This is okay on IO thread
                processData()
            } 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
class TimeConsciousReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val pendingResult = goAsync()
        
        CoroutineScope(Dispatchers.IO).launch {
            try {
                withTimeout(8000) { // Set a timeout slightly under 10 seconds
                    performTaskWithTimeout()
                }
            } 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 work
class SafeContextReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val pendingResult = goAsync()
        val appContext = context.applicationContext // Use app context
        
        CoroutineScope(Dispatchers.IO).launch {
            try {
                // Use appContext instead of context
                doWorkWith(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
class StructuredReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val pendingResult = goAsync()
        
        // Create a supervised scope
        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
        
        scope.launch {
            try {
                // All your async work here
                val 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:

Kotlin
class RobustReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val pendingResult = goAsync()
        
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val data = intent.getStringExtra("key") 
                    ?: throw IllegalArgumentException("Missing data")
                
                processData(data)
                
            } catch (e: IllegalArgumentException) {
                Log.e("Receiver", "Invalid input: ${e.message}")
            } catch (e: IOException) {
                Log.e("Receiver", "Network error: ${e.message}")
            } catch (e: Exception) {
                Log.e("Receiver", "Unexpected error: ${e.message}")
            } finally {
                pendingResult.finish()
            }
        }
    }
}

3. Add Logging for Debugging

When things go wrong, good logs are your best friend:

Kotlin
class LoggingReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        Log.d(TAG, "Broadcast received: ${intent.action}")
        val startTime = System.currentTimeMillis()
        val pendingResult = goAsync()
        
        CoroutineScope(Dispatchers.IO).launch {
            try {
                Log.d(TAG, "Starting background work")
                performWork()
                Log.d(TAG, "Work completed successfully")
            } catch (e: Exception) {
                Log.e(TAG, "Work failed", e)
            } finally {
                val duration = System.currentTimeMillis() - startTime
                Log.d(TAG, "Total execution time: ${duration}ms")
                pendingResult.finish()
            }
        }
    }
    
    companion object {
        private const val TAG = "LoggingReceiver"
    }
}

4. Consider WorkManager for Complex Tasks

If your task is getting complicated, it might be time to switch to WorkManager:

Kotlin
class SmartReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val taskComplexity = estimateComplexity(intent)
        
        when {
            taskComplexity < 3 -> {
                // Simple task: use goAsync()
                val pendingResult = goAsync()
                CoroutineScope(Dispatchers.IO).launch {
                    try {
                        quickTask()
                    } finally {
                        pendingResult.finish()
                    }
                }
            }
            else -> {
                // Complex task: delegate to WorkManager
                val workRequest = OneTimeWorkRequestBuilder<DataSyncWorker>()
                    .setInputData(workDataOf("data" to intent.getStringExtra("data")))
                    .build()
                WorkManager.getInstance(context).enqueue(workRequest)
            }
        }
    }
}

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
class ConnectivitySyncReceiver : BroadcastReceiver() {
    
    override fun onReceive(context: Context, intent: Intent) {
        // Check if this is a connectivity change
        if (intent.action != ConnectivityManager.CONNECTIVITY_ACTION) {
            return
        }
        
        Log.d(TAG, "Connectivity changed")

        val pendingResult = goAsync()
        val appContext = context.applicationContext
        
        CoroutineScope(Dispatchers.IO).launch {
            try {
                // Check if we're on WiFi
                if (!isWiFiConnected(appContext)) {
                    Log.d(TAG, "Not on WiFi, skipping sync")
                    return@launch
                }
                
                Log.d(TAG, "WiFi connected, starting sync")
                
                // Perform sync with timeout
                withTimeout(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()
            }
        }
    }
    
    private fun isWiFiConnected(context: Context): Boolean {
        val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        val network = cm.activeNetwork ?: return false
        val capabilities = cm.getNetworkCapabilities(network) ?: return false
        return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
    }
    
    private suspend fun syncDataWithServer(context: Context) {
        // Simulate API call
        delay(2000)
        
        // In reality, you'd make an actual network call here
        val repository = DataRepository.getInstance(context)
        repository.syncWithServer()
    }
    
    private fun scheduleRetryWithWorkManager(context: Context) {
        val retryWork = OneTimeWorkRequestBuilder<SyncWorker>()
            .setInitialDelay(15, TimeUnit.MINUTES)
            .build()
        WorkManager.getInstance(context).enqueue(retryWork)
    }
    
    companion object {
        private const val 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
@Test
fun testAsyncBroadcastReceiver() = runBlocking {
    val context = ApplicationProvider.getApplicationContext<Context>()
    val intent = Intent("com.softaai.TEST_ACTION")
    val receiver = AsyncBroadcastReceiver()
    
    // Set up a CountDownLatch to wait for async completion
    val 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 in Jetpack Compose

Modern State Management in Jetpack Compose: Flows, Side Effects, and UI State

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...

Membership Required

You must be a member to access this content.

View Membership Levels

Already a member? Log in here
Process Death in Android

What Is Process Death in Android? Causes, Examples, and How to Handle It

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?

That’s Process Death in Android.

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
class MainActivity : AppCompatActivity() {
    
    override fun onCreate(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
class ShoppingCartActivity : AppCompatActivity() {
    
    private var cartItems = mutableListOf<String>()
    private var totalPrice = 0.0
    private var userName = ""
    
    override fun onCreate(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()
    }
    
    override fun onSaveInstanceState(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")
    }
    
    private fun updateUI() {
        // Update your UI with restored data
        findViewById<TextView>(R.id.itemCount).text = "Items: ${cartItems.size}"
        findViewById<TextView>(R.id.totalPrice).text = "Total: $$totalPrice"
    }
}

Here, we override two key methods:

  1. 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.
  2. 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:

Supported Types:

  • Primitive types (Int, Long, Float, Double, Boolean)
  • Strings and CharSequences
  • Parcelable objects
  • Serializable objects
  • Arrays and ArrayLists of supported types

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
class UserProfileViewModel : ViewModel() {
    
    // This survives configuration changes but NOT process death
    var userName = MutableLiveData<String>()
    var userAge = MutableLiveData<Int>()
    var profileImageUrl = MutableLiveData<String>()
    
    fun updateUserData(name: String, age: Int, imageUrl: String) {
        userName.value = name
        userAge.value = age
        profileImageUrl.value = imageUrl
    }
}

class UserProfileActivity : AppCompatActivity() {
    
    private lateinit var viewModel: UserProfileViewModel
    
    override fun onCreate(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()
    }
    
    override fun onSaveInstanceState(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 ?: "")
    }
    
    private fun observeViewModel() {
        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:

  1. Save ViewModel data in onSaveInstanceState()
  2. 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
class ShoppingViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    
    // Automatically saved and restored across process death
    var cartItems: MutableLiveData<List<String>> = savedStateHandle.getLiveData("cart_items", emptyList())
    var totalPrice: MutableLiveData<Double> = savedStateHandle.getLiveData("total_price", 0.0)
    
    fun addItem(item: String, price: Double) {
        val currentItems = cartItems.value?.toMutableList() ?: mutableListOf()
        currentItems.add(item)
        cartItems.value = currentItems
        
        val currentTotal = totalPrice.value ?: 0.0
        totalPrice.value = currentTotal + price
        
        // Automatically saved to SavedStateHandle
    }
    
    fun clearCart() {
        cartItems.value = emptyList()
        totalPrice.value = 0.0
    }
}


class ShoppingActivity : AppCompatActivity() {
    
    private val viewModel: ShoppingViewModel by viewModels()
    
    override fun onCreate(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)
        }
    }
    
    private fun updateCartUI(items: List<String>) {
        // Update RecyclerView or ListView with items
    }
}

SavedStateHandle is magical for handling Process Death in Android. Here’s why:

  1. 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.
  2. No manual saving: Unlike the previous examples, we don’t need to override onSaveInstanceState(). The SavedStateHandle does it for us.
  3. 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

  1. Go to Settings → Developer Options
  2. Enable “Don’t keep activities”
  3. Navigate through your app, switching between activities
  4. 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:

  1. Open Android Profiler
  2. Select Memory
  3. Click “Force garbage collection” multiple times
  4. 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 THIS
override fun onSaveInstanceState(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 INSTEAD
override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putString("IMAGE_URL", imageUrl) // Save URL, not bitmap
}

Mistake 2: Assuming ViewModel Survives Process Death

Kotlin
// INCORRECT ASSUMPTION
class MyActivity : AppCompatActivity() {
    private lateinit var viewModel: MyViewModel
    
    override fun onCreate(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:

Kotlin
class MyViewModel(private val state: SavedStateHandle) : ViewModel() {
    val username: LiveData<String> = state.getLiveData("username", "")
    val score: LiveData<Int> = state.getLiveData("score", 0)
}

2. Persist Important Data to Database

For critical data that users can’t afford to lose, use Room database or other persistent storage:

Kotlin
class FormActivity : AppCompatActivity() {
    private lateinit var database: AppDatabase
    
    override fun onCreate(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) }
        }
    }
    
    override fun onPause() {
        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
override fun onSaveInstanceState(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:

Kotlin
@Composable
fun ShoppingCartScreen(viewModel: ShoppingViewModel = viewModel()) {
    
    val cartItems by viewModel.cartItems.observeAsState(emptyList())
    val totalPrice by viewModel.totalPrice.observeAsState(0.0)
    
    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        Text(
            text = "Shopping Cart",
            style = MaterialTheme.typography.h5
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        LazyColumn(modifier = Modifier.weight(1f)) {
            items(cartItems) { item ->
                CartItemRow(item = item)
            }
        }
        
        Divider()
        
        Text(
            text = "Total: $${"%.2f".format(totalPrice)}",
            style = MaterialTheme.typography.h6,
            modifier = Modifier.padding(vertical = 16.dp)
        )
        
        Button(
            onClick = { viewModel.addItem("New Product", 29.99) },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("Add Item")
        }
    }
}

@Composable
fun CartItemRow(item: String) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 8.dp),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Text(text = item)
        Text(text = "$29.99")
    }
}

With Compose, you still use ViewModel with SavedStateHandle. The difference is:

  1. observeAsState(): Converts LiveData from ViewModel into Compose State
  2. Automatic recomposition: When the ViewModel data changes (including after process death restoration), Compose automatically updates the UI
  3. 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
class AnalyticsHelper(private val context: Context) {
    
    fun trackProcessDeath(activityName: String) {
        // Log to your analytics service
        FirebaseAnalytics.getInstance(context).logEvent("process_death_recovery") {
            param("activity_name", activityName)
            param("timestamp", System.currentTimeMillis())
        }
    }
}

class MainActivity : AppCompatActivity() {
    
    override fun onCreate(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. 

Composition Over Inheritance

What Is Composition Over Inheritance? The Built-In Compose Way Explained

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.

This is where Composition Over Inheritance comes in.

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
open class Vehicle {
    open fun move() {
        println("Vehicle is moving")
    }
}

open class Car : Vehicle() {
    override fun move() {
        println("Car is driving")
    }
}

class ElectricCar : Car() {
    override fun move() {
        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 Vehicle affect 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
interface Engine {
    fun move()
}
Kotlin
class GasEngine : Engine {
    override fun move() {
        println("Driving using gas engine")
    }
}
Kotlin
class ElectricEngine : Engine {
    override fun move() {
        println("Driving silently using electric engine")
    }
}

Each class has one clear responsibility.

Compose the Behavior

Kotlin
class Car(private val engine: Engine) {

    fun drive() {
        engine.move()
    }
}

Now the Car has an engine, instead of being forced into a rigid hierarchy.

Use It

Kotlin
fun main() {
    val electricCar = Car(ElectricEngine())
    electricCar.drive()

    val gasCar = Car(GasEngine())
    gasCar.drive()
}

Output:

Driving silently using electric engine
Driving using gas engine

This is Composition Over Inheritance in action.

Why Composition Over Inheritance Is Better

Here’s why modern Kotlin developers strongly prefer this approach.

1. Less Coupling

Your classes depend on interfaces, not concrete implementations.

2. Easier Changes

You can swap behaviors without rewriting class hierarchies.

3. Better Testability

You can inject fake or mock implementations easily.

4. Cleaner Code

Smaller classes. Clear responsibilities. Fewer surprises.

Composition Over Inheritance in Jetpack Compose

Jetpack Compose is built almost entirely on Composition Over Inheritance.

That’s not an accident.

Traditional UI (Inheritance-Heavy)

Kotlin
class CustomButton : Button {
    // override styles, behavior, states
}

This leads to rigid UI components that are hard to reuse.

Compose Way (Composition First)

Kotlin
@Composable
fun MyButton(
    text: String,
    onClick: () -> Unit
) {
    Button(onClick = onClick) {
        Text(text)
    }
}

Here’s what’s happening:

  • MyButton is not extending Button
  • It uses Button
  • Behavior is passed in, not inherited

This is Composition Over Inheritance at the UI level.

Why Compose Feels Easier to Work With

Compose avoids deep inheritance trees entirely.

Instead:

  • UI is built from small composable functions
  • Each function does one thing
  • You combine them like building blocks

That’s composition by design.

Delegation: Kotlin’s Built-In Support for Composition

Kotlin makes Composition Over Inheritance even easier with delegation.

Example Using Delegation

Kotlin
interface Logger {
    fun log(message: String)
}

class ConsoleLogger : Logger {
    override fun log(message: String) {
        println(message)
    }
}

class UserService(private val logger: Logger) : Logger by logger

Now UserService automatically uses ConsoleLogger’s implementation without inheritance.

This keeps your code flexible and clean.

When Should You Still Use Inheritance?

Inheritance is not evil. It’s just often overused.

Inheritance works best when:

  • There is a true “is-a” relationship
  • The base class is stable
  • You control both parent and child classes

If those conditions are missing, Composition Over Inheritance is usually the safer choice.

Conclusion

Let’s wrap it up.

  • Composition Over Inheritance means building behavior using objects, not class hierarchies
  • Kotlin makes composition easy with interfaces and delegation
  • Jetpack Compose is a real-world example of this principle done right
  • Composition leads to flexible, testable, and maintainable code

If you’re writing Kotlin today, especially with Compose, you’re already using Composition Over Inheritance whether you realized it or not.

And once you start designing with it intentionally, your code gets simpler, not harder.

Architecting Alarms & Notifications in Android

Architecting Alarms & Notifications in Android: The Clean Architecture Way

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...

Membership Required

You must be a member to access this content.

View Membership Levels

Already a member? Log in here
@ApplicationContext

How @ApplicationContext Works in Jetpack Compose with Hilt : A Practical, Clean-Architecture Guide (With Runtime Explanation)

If you’ve ever used Hilt in a Jetpack Compose app, you’ve probably written code like this:

Kotlin
class MyRepository @Inject constructor(
    @ApplicationContext private val context: Context
)

And then paused for a second and thought:

“Okay… but where is this @ApplicationContext coming from?”
 
“Who creates it?”
 
“And how does Hilt magically inject it at runtime?”

This article answers those questions deeply and practically — without buzzwords, without hand-waving, and without unsafe patterns.

We’ll cover:

  • Why ViewModels should not receive Context
  • How Hilt resolves @ApplicationContext at runtime
  • The exact dependency flow from Compose → ViewModel → Repository
  • Why this approach is clean, safe, and testable
  • What code Hilt generates behind the scenes (conceptually)
  • Common mistakes and how to avoid them

Why Passing Context to ViewModel Is a Bad Idea

Let’s start with the mistake most Android developers make at least once:

Kotlin
class MyViewModel(private val context: Context) : ViewModel()

This looks harmless — until it isn’t.

Jetpack ViewModels are designed to outlive UI components like Activities and Fragments. An Activity context, however, is tied to the Activity lifecycle.

If a ViewModel holds an Activity context:

  • The Activity cannot be garbage collected
  • Memory leaks occur
  • Configuration changes become dangerous
  • Testing becomes harder

This is why Android’s architecture guidelines are very clear:

ViewModels should not hold a Context.

But what if you need access to:

  • SharedPreferences
  • DataStore
  • ConnectivityManager
  • Location services
  • File system APIs

You do need a Context — just not in the ViewModel. This is where repositories and Application context come in.

The Clean Architecture Rule

Here’s the mental model that solves this cleanly:

LayerResponsibilityContext Allowed
UI (Compose)Rendering, user inputYes (UI-only)
ViewModelState & business logicNo
RepositoryData & system accessYes
ApplicationApp lifecycleYes

So the rule is simple:
 If something needs a Context, it belongs below the ViewModel layer.

The Correct Dependency Flow

In a modern Compose app using Hilt, the flow looks like this:

Kotlin
Compose Screen
   ↓
ViewModel (no Context)
   ↓
Repository (Application Context)
   ↓
Android System Services

The ViewModel never touches Context.
 The Repository owns it.
 The Application provides it.

So… Where Does @ApplicationContext Come From?

This is the part that feels like magic — but isn’t.

@HiltAndroidApp Creates the Root Component

Kotlin
@HiltAndroidApp
class MyApp : Application()

When you add this annotation, Hilt:

  • Generates a base class for your Application
  • Creates a singleton Application-level component
  • Stores the Application instance inside it

At runtime, Android creates your Application before anything else.

That Application instance is a Context.

Hilt Has a Built-In Context Provider

Inside Hilt’s internal codebase (not yours), there is a binding equivalent to:

Kotlin
@Provides
@Singleton
@ApplicationContext
fun provideApplicationContext(app: Application): Context = app

You never write this.
 You never import it.
 But it exists and is always available once @HiltAndroidApp is present.

So when Hilt sees:

Kotlin
@ApplicationContext Context

It knows exactly what to inject:
 ➡ the Application instance

Repository Requests the Context

Kotlin
class UserRepository @Inject constructor(
    @ApplicationContext private val context: Context
)

At compile time:

  • Hilt validates that a binding exists
  • It generates a factory class for MyRepository

Conceptually, the generated code looks like:

Kotlin
class MyRepository_Factory(
    private val contextProvider: Provider<Context>
) {
    fun get(): MyRepository {
        return MyRepository(contextProvider.get())
    }
}

At runtime:

  • contextProvider.get() returns the Application
  • The repository receives a safe, long-lived context

ViewModel Receives the Repository

Kotlin
@HiltViewModel
class UserViewModel @Inject constructor(
    private val repository: UserRepository
) : ViewModel()

The ViewModel:

  • Has no idea where the context comes from
  • Has no Android dependency
  • Is fully testable with fake repositories

Compose retrieves it like this:

Kotlin
val viewModel = hiltViewModel<UserViewModel>()

Hilt handles everything else.

A Real Example: SharedPreferences

Repository:

Kotlin
class UserPreferencesRepository @Inject constructor(
    @ApplicationContext context: Context
) {
    private val prefs =
        context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)

    fun saveUsername(name: String) {
        prefs.edit().putString("username", name).apply()
    }

    fun loadUsername(): String =
        prefs.getString("username", "Guest") ?: "Guest"
}

The ViewModel remains clean:

Kotlin
@HiltViewModel
class UserViewModel @Inject constructor(
    private val repository: UserPreferencesRepository
) : ViewModel() {

    val username = MutableStateFlow("")

    fun load() {
        username.value = repository.loadUsername()
    }
}

Notice what’s missing?

No Context in the ViewModel.
 That’s the whole point.

Why This Is Safe (And Recommended)

Let’s address the usual concerns.

Will this leak memory?
 No. Application context lives as long as the app process.

Will this break on rotation?
 No. ViewModels are lifecycle-aware; repositories aren’t tied to UI.

Is this officially recommended?
 Yes. This matches Google’s own Compose + Hilt samples.

Is this future-proof?
 Yes. This is the architecture Android is moving toward, not away from.

What If You Forget @HiltAndroidApp?

Your app will crash early with a clear error:

Kotlin
Hilt Activity must be attached to an @HiltAndroidApp Application

This happens because:

  • No ApplicationComponent is created
  • No Context binding exists
  • Dependency graph cannot be resolved

This is Hilt protecting you — not failing silently.

The One Rule to Remember

Context belongs to the data layer, not the state layer.

If you follow this rule:

  • Your architecture scales
  • Your code stays testable
  • Your app avoids subtle lifecycle bugs

Conclusion

@ApplicationContext isn’t magic.
 It’s a well-defined dependency provided by Hilt at the Application level, injected safely into the data layer, and kept far away from your UI state.

Once you understand this flow, Compose + Hilt stops feeling mysterious — and starts feeling predictable.

If this helped you, consider sharing it with the next developer who asks:
 “But where does the Context come from?”

error: Content is protected !!