Under the Hood of Android Notifications — and How to Keep the Important One on Top
A few weeks ago I wrote about why Android notifications scroll away and how I pin the ones that matter. The comments were less "cool app" and more "wait, how does that even work — isn't the notification shade just a list?"
Fair question. So here's the actual mechanism: how a notification gets from an app to your eyeballs, why there's no native "pin," and the ~80 lines of NotificationListenerService you'd write if you wanted to build the pinner yourself.
The pipeline: app → system → shade
When an app calls NotificationManager.notify(), the notification doesn't go straight to the screen. It goes to the system notification manager (a privileged part of the OS). The manager owns:
- the shade (the panel you pull down),
- the status bar icons,
- lock-screen cards,
- and the ranking/ordering of everything.
The shade itself is just a sorted, time-ordered list. Newest on top, oldest at the bottom. "Important" is expressed through interruption — priority channels, DND exceptions — not through position. There is no field on a Notification that says "keep this at the top until dismissed." The OS will happily let a promo from a game push your 2FA code down and out of view.
That's the whole problem in one sentence: the shade optimizes for recency, not importance.
Why the built-ins don't pin
People suggest the usual fixes, but none of them holds a specific notification in place:
- Notification channels — per-app silencing/prioritization. "Priority" still means competes with other priority items. It doesn't pin.
- DND with exceptions — controls what breaks through. Irrelevant once the notification has already arrived and started scrolling.
- Notification history (Android 11+) — great for digging something up after you missed it, useless for the moment you need it.
So the built-in story ends at: quiet the noise, or look back after the fact. There is no "this one stays put."
If you want a pin, you have to step outside the built-ins. Enter NotificationListenerService.
The escape hatch: NotificationListenerService
A NotificationListenerService is a system service your app registers that the OS calls every time a notification is posted or removed (for any app). It's the same hook screen-time and auto-reply apps use. It's also exactly what a notification manager needs.
Granting it is a deliberate user action — Android sends you to Settings and makes you toggle the permission on. That's by design: this service sees everything. Which is the privacy point I'll come back to.
If you want the finer details on what the permission actually grants, I wrote it up here: What Is Notification Access Permission on Android?.
A minimal pinner
Here's the shape of it. This isn't a full app — no UI, no persistence — just the core idea: intercept posted notifications, decide which matter, and hold them in a store that sorts pinned items above everything new.
class PinListener : NotificationListenerService() {
// Called by the OS for EVERY notification posted on the device
override fun onNotificationPosted(sbn: StatusBarNotification) {
val extras = sbn.notification.extras
val title = extras.getCharSequence(EXTRA_TITLE)?.toString().orEmpty()
val text = extras.getCharSequence(EXTRA_TEXT)?.toString().orEmpty()
// Your rule: is this one worth keeping on top?
if (looksImportant(title, text)) {
pinnedStore.add(
PinnedNotification(
key = sbn.key,
title = title,
text = text,
postedAt = sbn.postTime
)
)
// re-rank so pinned items sort above new arrivals
reRender()
}
}
override fun onNotificationRemoved(sbn: StatusBarNotification) {
// Only drop it when the USER unpins — not when the OS auto-dismisses
if (pinnedStore.isPinned(sbn.key) && !userRequestedUnpin(sbn.key)) {
pinnedStore.keep(sbn.key) // stay put
}
}
private fun looksImportant(title: String, text: String): Boolean {
return title.contains("OTP") || text.contains("delivery")
|| isFromContact(title) // your own rules here
}
}
And the manifest side — the service is useless without declaring it and the permission:
<service
android:name=".PinListener"
android:label="Pinned notifications"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
That's the spine. A real app then has to handle: a persistent UI surface for pinned items, re-opening a pinned notification to its source app, search over history, and the thousand small permission/edge cases Android throws at you. The step-by-step for actually pinning one notification covers the user-facing flow if you'd rather not reinvent that.
The catch nobody mentions: it sees everything
A NotificationListenerService sees the entire notification stream. Who texts you. What your bank says. Calendar invites. That's the deal.
Which means the only acceptable place for that data is on the device. The moment you sync it to a server, you've built a profile of someone's life and shipped it off. So if you build this yourself, keep the pinnedStore in local SQLite (or DataStore) and never upload it. If you're evaluating an existing app, "no account, fully on-device" should be a hard requirement in this category — not a nice-to-have.
This is the exact reason the app I use daily does everything locally: no account, no cloud, no "sign in to continue." It's free, it pins, and the unpin is explicit — pinned items don't silently expire. For recovering something you already missed, it keeps a local, searchable history too.
Takeaway
The notification shade is a recency-ordered list with no concept of "important." If you want a pin, NotificationListenerService is the lever — ~80 lines gets you a prototype, and the privacy rule is non-negotiable: on-device only.
If you'd rather not write the UI, ranking, and history-search yourself, the polished version I use is at dingpin.app — free, no account, local-only. I'm still torn on whether optional cloud sync is worth the privacy cost later; if you've thought about that trade-off, I'd genuinely like to hear which way you'd go.
Top comments (0)