Notifications are the one UI you can't build in Compose
A notification is rendered by the system UI, not your app, so Compose plays no part in its appearance. What a Compose app owns is the permission flow, the deep link, and the back stack the user lands in.

Day 98 of 100. The surface where Compose genuinely doesn't apply, and the three things around it that do.
The symptom
A notification that drops the user somewhere useless:
val intent = Intent(context, MainActivity::class.java)
val pending = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle("Order #4711 shipped")
.setContentIntent(pending)
.build()
Tapping it opens the app's home screen. The user has to find the order themselves — and worse, if the app was already open on another screen, tapping does nothing visible at all.
The notification's content is correct. Everything about what happens next is wrong.
Why the obvious fix fails
The obvious fix is to pass an extra and read it:
val intent = Intent(context, MainActivity::class.java).putExtra("orderId", "4711")
// in the activity
val orderId = intent.getStringExtra("orderId")
if (orderId != null) navController.navigate("detail/$orderId")
This works for a cold start and misbehaves everywhere else. The activity may already exist,
in which case onCreate doesn't run and the extra arrives in onNewIntent. Navigating from
there races the NavHost's own restoration. And pressing back from the detail screen exits
the app, because nothing was pushed beneath it.
The mechanism that handles all three already exists.

The actual mechanism
A notification is rendered by the system UI process, using RemoteViews or the
system's own templates. Your app supplies data and the system draws it. Compose has no part
in this and won't — a composable can't cross a process boundary.
So the useful framing is that there are three things a Compose app does own.
1. The permission
Since Android 13, posting requires a runtime permission:
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> onPermissionResult(granted) }
Button(onClick = { launcher.launch(Manifest.permission.POST_NOTIFICATIONS) }) {
Text("Enable notifications")
}
rememberLauncherForActivityResult is the Compose-idiomatic form, and it works for any
activity result — permissions, the photo picker, a document. Worth knowing beyond
notifications.
The part that's a product decision rather than an API: ask in context. A permission prompt on first launch, before the user knows what the app does, gets denied — and a denial is nearly permanent. Asking after they enable order tracking gets granted.
2. The deep link
Rather than an extra, use the navigation graph's own deep-link mechanism:
composable(
route = "detail/{orderId}",
deepLinks = listOf(navDeepLink { uriPattern = "app://orders/{orderId}" }),
) { entry -> OrderDetail(entry.arguments?.getString("orderId")) }
val pending = TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(
Intent(Intent.ACTION_VIEW, "app://orders/4711".toUri(), context, MainActivity::class.java)
)
getPendingIntent(0, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
}
Two things this fixes at once. NavController handles the intent whether the app was cold,
warm or already showing another screen — the onNewIntent case included. And
TaskStackBuilder synthesises the parent destinations, so back from the detail screen goes
to the order list rather than out of the app.
That second property is what turns a notification from a dead end into an entry point.
3. The state the user lands in
The screen opened from a notification is usually one the user hasn't visited, so its loading, empty and error states are all reachable directly — Day 85's states, arriving without the happy path in front of them.
Two consequences worth designing for: the screen must handle "this order no longer exists" (the notification may be hours old), and it should not assume any data was cached by an earlier visit. Both are cases a preview can render (Day 92) and a state-driven test can assert (Day 87) — the same states, reachable by a new route.
Channels, and the part users judge
Channels are a permission surface rather than a categorisation detail: users can disable one channel and keep the others, and an app with a single channel gives them a whole-app on/off switch.
val channel = NotificationChannel(
"order-updates",
"Order updates", // shown in system settings — user-facing
NotificationManager.IMPORTANCE_DEFAULT,
).apply { description = "Shipping and delivery notifications" }
The names appear in system settings, so they're product copy. "Order updates" and "Promotions" as separate channels is what lets someone keep the first and mute the second, which is usually better for you than being muted entirely.
Importance is the other lever, and IMPORTANCE_HIGH — which makes a heads-up notification
interrupt whatever the user is doing — should be reserved for things that genuinely can't
wait.
The one part Compose does touch
Worth mentioning as an exception. Custom notification layouts use RemoteViews, and Glance
— tomorrow's subject — can generate those. So a heavily-customised notification can be
written in Compose-shaped code after all.
It's rarely worth it. The system templates handle every form factor, respect the user's font scale, and look consistent with every other notification on the device — which is what users expect from that surface. A custom layout is an opportunity to look wrong on a device you don't own.
The in-app equivalent
Worth naming the boundary. A notification is for when the app isn't in front of the user. When it is, the in-app equivalents are Day 35's snackbar and Day 41's inline states.
Posting a notification for something happening on the screen the user is looking at is a common and irritating bug — a "message sent" notification while the conversation is open. The check is whether the relevant screen is currently visible, and the answer usually lives in the ViewModel that already knows.
How to prove it
The deep link is testable from the command line, which is faster than triggering a real notification:
adb shell am start -W -a android.intent.action.VIEW -d "app://orders/4711"
Run it three ways: with the app killed, with it in the background, and with it open on a different screen. All three should land on the order, and back should reach the list rather than exiting.
The third case is the one that catches the onNewIntent bug, and it's the one manual
testing usually skips.
For the permission, the check worth doing once: deny it, then look at what your app does next. A screen that silently loses a feature with no explanation is the common outcome, and a single line pointing at system settings is the fix — the user cannot re-grant from a prompt after denying.
What this generalizes to
The principle is the boundary of a UI toolkit is a real boundary. Compose stops at your process, so notifications, widgets (tomorrow), Wear complications and Auto templates are all rendered by someone else from data you supply.
What that leaves you owning is the contract: the permission you asked for, the link you provided, and the state the user arrives in. Those are the parts users judge, and they're entirely under your control — which is a more useful framing than lamenting that the notification can't be a composable.
The same reframing works for any cross-process surface. You don't control the rendering; you control what you send and where it leads. Getting those two right is most of what makes a notification feel considered rather than intrusive.
Tomorrow, Day 99: Glance — Compose-shaped code for widgets, and where the resemblance ends.
Day 98 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Notifications.