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 case this post is about
One notification, tapped, dropping the user on the home screen:
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()
The notification is correct. The title names an order, the tap opens the app, and the app opens where it always opens — not on order 4711.
Nothing in that code says which order. The fix that comes first adds an extra and reads it in the activity, which works until the app was already running, or the back stack should contain the list the user came from.
This post follows that one tap, because the notification renders in another process — so Compose stops at the boundary — and the deep link back in is where all the actual work is.
The symptom: a notification that drops the user somewhere useless
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 passing an extra and reading it isn't enough
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 mechanism: notifications are rendered by the system UI process
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.
"Nearly permanent" deserves to be a state machine rather than an adjective, because the terminal state is reachable from a prompt you fired for free:
enum class Perm { NeverAsked, Granted, DeniedOnce, DeniedPermanently }
data class Ask(val allowed: Boolean, val surface: String, val reason: String)
class NotificationGate(private var state: Perm, private val featureEnabled: Boolean) {
fun requestFor(trigger: String): Ask = when (state) {
Perm.Granted -> Ask(false, "none", "already granted")
// The system stops showing the dialog after the second denial. Spending the
// second ask on a cold launch burns the whole capability for the install.
Perm.DeniedPermanently -> Ask(false, "settings deep link", "system will not show the dialog again")
Perm.DeniedOnce ->
if (featureEnabled) Ask(true, "system dialog", "last ask available; user opted into $trigger")
else Ask(false, "in-app rationale", "one ask left — do not spend it on $trigger")
Perm.NeverAsked ->
if (featureEnabled) Ask(true, "system dialog", "user opted into $trigger")
else Ask(false, "in-app rationale", "no demonstrated intent yet")
}
fun record(granted: Boolean) {
state = when {
granted -> Perm.Granted
state == Perm.NeverAsked -> Perm.DeniedOnce
else -> Perm.DeniedPermanently
}
}
}
The two orders in which an app can ask, run against the same user:
println("cold-launch prompt, then the feature:")
NotificationGate(Perm.NeverAsked, featureEnabled = false).let { g ->
println(" app launch -> " + g.requestFor("app launch"))
g.record(granted = false) // user dismissed an unexplained dialog
println(" enable tracking -> " + g.requestFor("order tracking"))
g.record(granted = false)
println(" enable tracking -> " + g.requestFor("order tracking"))
}
cold-launch prompt, then the feature:
app launch -> Ask(allowed=false, surface=in-app rationale, reason=no demonstrated intent yet)
enable tracking -> Ask(allowed=true, surface=system dialog, reason=last ask available; user opted into order tracking)
enable tracking -> Ask(allowed=false, surface=settings deep link, reason=system will not show the dialog again)
The gate refuses the cold-launch ask, which is the whole point — and note the third line, which is where an app without the gate ends up on the first interaction that mattered. Two silent denials and the only remaining surface is a deep link into system settings, which is a thing roughly nobody completes.
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: test the deep link from the command line
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: a toolkit's boundary is a real boundary
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.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".