The notification pipeline
notify() is asynchronous and crosses Binder twice. Between the call and the pixel there is a service that checks permission, resolves a channel, applies Do Not Disturb and ranking, and hands a parcel to SystemUI.
Most developers treat notify() as a function call that puts a row in the shade. It is closer to
posting a letter: it returns before anything has happened, and several offices can refuse it along
the way.
TL;DR#
NotificationManageris a client proxy. The real work happens inNotificationManagerServiceinsidesystem_server.- The call is asynchronous from the app's point of view.
notify()returning does not mean the notification was shown. - Before anything is drawn, the service checks POST_NOTIFICATIONS, resolves the channel, applies Do Not Disturb and ranking, and notifies listeners.
- SystemUI inflates the notification's
RemoteViewsin its own process. Your code does not run there. - Any app with a
NotificationListenerServicegrant receives a copy of every notification. Never put secrets in one.
Mental model#
Think of submitting a press release to a newspaper.
You hand it to the front desk (the NotificationManager proxy), which logs it and passes it to the
editorial office (NotificationManagerService). The editorial office checks whether you are allowed
to publish at all (the permission), which section it belongs in (the channel), whether it is
newsworthy enough to interrupt the reader (importance and DND), and where it should appear (ranking).
Only then does the typesetter (SystemUI) lay it out.
Your copy is not what gets printed. The editorial office may shorten it, reorder it, or hold it.
Where the analogy breaks#
A newspaper has one edition. Android has a live shade that can re-rank notifications as new ones arrive, so a notification's position can change without anything being republished. Ranking is continuous, not a one-time editorial decision.
The big picture#
Text description of this diagram
A ten-step sequence between your app, the NotificationManager client proxy, the NotificationManagerService in system_server, the ranking and policy layer, SystemUI, and any NotificationListenerService. Building the Notification validates the channel and small icon. notify() parcels it over Binder. The call is one-way from the app's point of view. The service checks POST_NOTIFICATIONS; a denial turns the whole call into a silent drop with no exception to the app. It resolves the channel and reads importance, sound, vibration and visibility. It applies Do Not Disturb, bubbles, conversation status and full-screen intent policy. It tells listeners and SystemUI through onNotificationPosted. SystemUI inflates and draws the RemoteViews in its own process. Notification listeners receive a copy. The service may return a sanitised, ranked copy to the app, which is not the object that was sent.
The step that surprises people is the permission check: on API 33+ a denied
POST_NOTIFICATIONS makes the entire call a silent no-op. No exception, no log line the app
sees, no indication that anything was dropped.
Deep dive#
notify() is a Binder call#
val manager = context.getSystemService(NotificationManager::class.java)
manager.notify(TAG, notificationId, notification)NotificationManager is a thin wrapper over INotificationManager, an AIDL interface. The call
marshals the Notification and crosses into system_server.1
Two consequences:
- The Notification is Parcelable and size-limited. A notification carrying a full-resolution
bitmap can fail the Binder transaction.
setLargeIcon()with an un-scaled photo is a common cause of "the notification silently does not appear". - The call is one-way. The service may not have processed the notification when
notify()returns, which is why posting and immediately queryinggetActiveNotifications()can be racy.
The permission gate#
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
// notify() will not show anything. Ask the user first.
}On API 33+, POST_NOTIFICATIONS is a runtime permission.2 A denial
means:
notify()does not throw.- Nothing appears in the shade.
- The foreground service notification is still shown, because it is part of the service contract.
That last exception is important and frequently misremembered. The foreground service notification is shown regardless; the app's own notifications are not.
Channel resolution#
Every notification names a channel:
val notification = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
.setSmallIcon(R.drawable.ic_message)
.setContentTitle("Priya")
.setContentText("Are we still on for 7?")
.build()If the channel does not exist, notify() throws on API 26+.3 This is a validation error at the
builder, not a silent drop, and it is the single most common crash when adding channels to an
existing app.
The channel's importance decides:
| Importance | Behaviour |
|---|---|
IMPORTANCE_HIGH | Sound and heads-up |
IMPORTANCE_DEFAULT | Sound |
IMPORTANCE_LOW | No sound, visible in shade |
IMPORTANCE_MIN | No sound, collapsed in the shade |
Once the user changes a channel's importance, the app cannot raise it back. This is deliberate: the user's decision outranks the app's preference.
Ranking, DND and policy#
Before SystemUI sees anything, the service applies policy:
- Do Not Disturb can suppress sound, or suppress the notification entirely, depending on the interruption filter and the channel's category.
- Conversation ranking promotes notifications that carry a valid
Personand a conversation shortcut to a dedicated section. - Bubbles may convert a notification into a floating window if the user has enabled them for the conversation.
- Full-screen intent policy decides whether a notification may take over the screen, which is restricted to calling and alarm apps on modern releases.
SystemUI renders a description, not your code#
The notification's content is a RemoteViews — a parcel of layout id and method calls — or a
system-drawn layout chosen by the style. SystemUI inflates it in its own process with its own
Context.
That is why:
- Custom layouts only support a small set of views (
TextView,ImageView,Button,ProgressBar,Chronometer, and a few layout containers). - The app's code does not run while the notification is displayed. Only a
PendingIntentfires code, and it fires in the app's process. - Custom views are being progressively restricted because they are the part of the system most exposed to a malicious app's layout.
Listeners see everything#
NotificationListenerService is a privileged capability granted by the user in Settings. A service
with the grant receives onNotificationPosted and onNotificationRemoved for every notification,
including other apps'.4
The security implication is direct: notification content is not private from a determined app the user has granted access to. Do not put tokens, one-time codes or personal data in a notification that does not need it.
The architectural implication is equally interesting: a notification listener can cancel or snooze notifications, which is how smartwatch apps and car head units mirror the shade.
What the pipeline explains about bugs#
Almost every "notification problem" maps to one stage of the pipeline, and knowing the stage tells you where to look.
| Symptom | Likely stage | What to check |
|---|---|---|
| Nothing appears, no crash | Permission gate | POST_NOTIFICATIONS on API 33+ |
| Crash on notify | Channel resolution | The channel was never created |
| Appears but never sounds | Channel importance | The channel is IMPORTANCE_LOW or the user muted it |
| Appears but is not promoted | Ranking and DND | Conversation shortcut missing, or DND active |
| Truncated or blank content | Rendering | RemoteViews too large, or a custom layout using unsupported views |
| Appears on another device | Listeners | A listener app is mirroring it, by design |
That table is worth keeping. It turns a vague "notifications are broken" into a specific check, and in practice most reports land in the first two rows.
The post-and-forget model#
The pipeline's asynchrony has a design consequence that is easy to miss: you cannot use a
notification as a synchronous signal. Posting one and immediately reading getActiveNotifications()
is racy, and a notification posted during onPause may not be visible before the activity is gone.
The supported way to know what happened is getActiveNotifications() after a delay, or a listener
for your own package in a test. In production, the only reliable feedback that a notification was
shown is the user's reaction, which is a poor API but an honest one.
The id is the identity#
The pair of tag and id identifies a notification for its whole life. Posting again with the same
pair updates it in place; posting with a different pair creates a second row. That sounds obvious
and it is the source of two common bugs: using id 0 for everything (so a service cannot update
one notification without replacing another), and generating a new id on every update (so the shade
fills with duplicates).
The pattern that works is a stable id per logical thing — conversationId.hashCode(), or a small
constant per notification category — and a tag when two categories share an id space.
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 3 | 1.5 Cupcake |
|
| 9 | 2.3 Gingerbread |
|
| 11 | 3.0 Honeycomb |
|
| 12 | 3.1 Honeycomb |
|
| 16 | 4.1 Jelly Bean |
|
| 20 | 4.4W KitKat (Wear) |
|
| 21 | 5.0 Lollipop |
|
| 24 | 7.0 Nougat |
|
| 26 | 8.0 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 33 | 13 Tiramisu |
|
| 36 | 16 Baklava |
|
Under the hood#
- NotificationManager.javathe client proxy and its Binder calls
- NotificationManagerService.javavalidation, permission checks and dispatch
- RankingHelper.javahow notifications are ordered and promoted
- NotificationListenerService.javathe listener API other apps use
- where the row views are inflated
Curiosity corner#
notify()with an id of 0 is allowed, and many apps use it, which makes them impossible to update individually. Use distinct ids per conversation or per state.- A notification's
PendingIntentruns with the app's identity, not SystemUI's. That is why mutability flags became mandatory on API 31. - The notification shade is a window like any other, which is why it can be screenshotted and
why its contents appear in screen recordings unless the app sets
FLAG_SECUREon its own window — which does not cover the shade.
Try it#
adb shell dumpsys notification | head -80
Example output
NotificationRecord{... pkg=com.example.app id=1 channel=messages importance=4}adb shell dumpsys notification --noredact | grep -A6 com.example.app
Example output
NotificationRecord{... pkg=com.example.app}adb shell settings get secure enabled_notification_listeners
Example output
enabled listeners:
com.example.watch/.NotificationForwarderBuild it#
Post a notification with a large icon from a full-resolution photo and watch it fail to appear. Then scale the bitmap to a thumbnail and watch it work. The size limit is not documented as a number you can rely on; the failure mode is the lesson.
Check yourself#
Check yourself
Put these notification features in the order they were introduced.
Drag, or use the arrows to reorder.
- 1POST_NOTIFICATIONS as a runtime permission
- 2Direct reply and bundled groups
- 3Mandatory notification channels
- 4Lock screen notifications and heads-up peeks
- 5Expandable styles and actions
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsBinder overviewsource.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 13 or higherdeveloper.android.comaccessed 2026-10-27↩
- docsCreate and manage notification channelsdeveloper.android.comaccessed 2026-10-27↩
- docsNotifications overviewdeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 12developer.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 11developer.android.comaccessed 2026-10-27↩
- docsPerubahan Perilaku Android 8.0developer.android.comaccessed 2026-10-27↩