A channel is a category every notification must belong to on Android 8+; posting to a channel that was never created is silently dropped, and its importance, sound and vibration freeze at creation because from then on they belong to the user.
Create before posting: in Application.onCreate. createNotificationChannel is idempotent, so calling it on every launch is free. A missing channel throws nothing; logcat only prints No Channel found for pkg=....
What the user owns: importance, sound, vibration, badge, lock-screen visibility and blocking, per channel. That is the point: the user mutes "Promotions" and keeps "Delivery updates".
What you can change: name, description and group update on a repeat call; raising importance or changing sound and vibration is ignored. To really change behaviour, ship a new id (delivery_v2) and delete the old one; re-creating a deleted id restores the user's old settings.
AndroidMiddle
Notifications & Push
NotificationCompat, channels, POST_NOTIFICATIONS, FCM, data vs notification messages, deep links
Questions closed
0 / 19
- 0Review
Questions
A channel id and a small icon are the hard minimum; a title, text and a content intent for the tap make it useful, and notify(id, notification) posts it.
**setSmallIcon**: the one field whose absence throws (IllegalArgumentException at notify()). Use a white-on-transparent 24dp vector: the system keeps only the alpha channel, so a full-colour launcher icon turns into a white square.
**setContentIntent: without it a tap does nothing.
notify(id, notification)**: the id is the notification's identity. On Android 13+ it needs POST_NOTIFICATIONS.
BigTextStyle, BigPictureStyle and InboxStyle only change the expanded view and keep the collapsed row from setContentTitle and setContentText; MessagingStyle and CallStyle also replace the collapsed layout.
**BigTextStyle: a long paragraph. Cheap; add it whenever the text can be longer than one line.
BigPictureStyle: one image. Decode a scaled bitmap off the main thread: the whole notification travels to the system over Binder.
InboxStyle: a few short lines, typical for a group summary.
MessagingStyle**: a chat, each message with its own Person; required for the Conversations section. The collapsed row shows the latest message and ignores the content title and text.
**CallStyle**: incoming and ongoing calls with the standard answer and decline buttons, built from a Person.
Progress is normally setProgress(max, current, indeterminate), not a style; Android 16 adds the platform Notification.ProgressStyle for step-by-step journeys such as a ride or delivery.
Call notify() again with the same id (and tag) to update it in place, and cancel(id) or cancelAll() to remove it; the id is the notification's identity, so keep it stable per thing you show.
Update quietly: add setOnlyAlertOnce(true), otherwise every update sounds and vibrates again. With a tag, notify(tag, id, n) and cancel(tag, id) use the pair as the identity.
Automatic removal: setAutoCancel(true) removes it on tap, setTimeoutAfter(ms) expires it, which suits a one-time code or a ride offer.
Ongoing: setOngoing(true) no longer guarantees the row stays: since Android 14 users can swipe most ongoing notifications away (CallStyle and media are exempt). Use setDeleteIntent to learn about the swipe.
Since Android 13 posting notifications needs a runtime permission that is off by default on new installs, so you declare it, ask for it in context with the Activity Result API, and give the content another home when the user says no.
Who has it: apps that were installed and allowed to notify before the upgrade are pre-granted; fresh installs start denied. That is why only new users go missing.
Your target matters: target 33+ and nothing is asked until you ask. Target 32 or lower and the system shows the dialog itself when you create the first channel, usually on a cold first launch.
Ask in context: when the user turns on price alerts, not on the splash screen.
Two refusals: the dialog stops appearing and the permission request returns false immediately; only Settings.ACTION_APP_NOTIFICATION_SETTINGS remains.
What still works: FCM data messages still reach onMessageReceived, only the visible post is dropped. A foreground service keeps running; its notification just does not show in the shade.
Before work whose only output is a notification, check areNotificationsEnabled(), and keep an in-app inbox or badge as the fallback.
On Android 8+ only the channel's importance; setPriority is read only below API 26, and FCM priority controls how fast the message gets through Doze, not how it alerts.
Importance levels: IMPORTANCE_HIGH gives a heads-up and sound, DEFAULT sound only, LOW a silent row in the shade, MIN a collapsed row without a status-bar icon, NONE blocked.
The user owns it: read getNotificationChannelCompat(id)?.importance at runtime before you debug the builder; a user-lowered channel explains most "silent" reports.
**FCM high**: wakes a dozing device and delivers now. A high-priority push into a LOW channel arrives instantly and silently.
Do Not Disturb: filters by setCategory and by channels the user allowed to bypass it, so set CATEGORY_MESSAGE, CATEGORY_CALL or CATEGORY_ALARM honestly.
Heads-up conditions: it needs the screen on, and the system may soften rapid repeated alerts from one app.
This is a notification trampoline: for apps targeting API 31+, a receiver or service started by a notification tap is not allowed to start an Activity, so the launch is silently dropped; point the content intent straight at the Activity.
What you see: no crash, only Indirect notification activity start (trampoline) from <pkg> blocked in logcat. QA reports it as "notifications do nothing".
Why it was banned: the hop cold-starts the process and runs your code before any pixel changes, at the moment the user is most sensitive to delay.
The fix: PendingIntent.getActivity with the route in the intent's data Uri. Log the open in onCreate and onNewIntent of that Activity, after the screen is already on its way.
Receivers are still fine for actions that open no UI: mark as read, snooze, setDeleteIntent.
If work must happen before the screen, start the Activity and let it enqueue the work.
Every chat gets the same PendingIntent: its identity is the request code plus the intent's component, action and data, never the extras, so FLAG_UPDATE_CURRENT overwrites chat_id and every notification opens the last chat.
Why: getActivity first looks for an existing record with the same request code and an Intent.filterEquals match (action, data, type, identifier, component, categories). Extras are ignored, so the second call returns the same PendingIntent (the first one); with FLAG_UPDATE_CURRENT its extras are replaced, without it the first chat's extras stick and every tap opens the first chat.
The fix: a request code unique per chat (chat.id, the same value as the notification id), so each conversation has its own record. A per-chat setData(Uri) such as app://chat/42 works too and doubles as a deep link.
**FLAG_IMMUTABLE is not the cause**: it stops the sender from filling in the intent; the app that created it can still replace the extras with FLAG_UPDATE_CURRENT.
Catching it: an instrumented test that posts notifications for two chats and asserts their contentIntents are not equal (PendingIntent.equals is true for the same record); adb shell dumpsys activity intents lists the live records.
Give every notification the same setGroup key, post one extra summary with setGroupSummary(true), and set setGroupAlertBehavior(GROUP_ALERT_SUMMARY) on all of them so only the summary alerts.
The summary: posted under its own id; it is the collapsed row the user actually reads, usually InboxStyle or a count.
Alert behaviour: the default GROUP_ALERT_ALL alerts once per child, which is your ten buzzes. GROUP_ALERT_CHILDREN is the mirror image.
Auto-bundling is cosmetic: Android 7+ bundles four or more notifications from one app even without a summary, but every child has already alerted by then.
Orphaned summary: when children are dismissed one by one, cancel the summary once the last child is gone.
Limit: an app can hold about 50 active notifications; past that new posts are dropped.
If the user cannot act on each item separately, post one row: "12 files synced".
Every notify() on a sounding channel alerts again, so the phone chimes on each chunk; the system rate-limits updates of an existing notification to about five per second per app and silently drops the rest, including the final "Done" update, because setProgress(0, 0, false) is not exempt.
Where to look: logcat shows Package enqueue rate is ... Shedding .... Only an update with completed progress (max != 0 and progress == max) is exempt from the limit.
What the user sees: the last chunk posts setProgress(100, 100, false), which passes, so the bar is full; the dropped update was the one that carried "Done".
Stop the chimes: setOnlyAlertOnce(true), and put progress on a LOW importance channel: it is status, not news.
Throttle: post only when the whole percent changes and no more than about once a second.
Finish reliably: cancel the progress notification and post the result under a new id (a new post is not counted as an update), or delay the final update. The 100/100 update is exempt but only fills the bar, it does not replace the text.
A long download belongs in WorkManager with setForeground, so it survives the user leaving the app.
The PendingIntent is immutable, so the system cannot write the typed text into it; and the receiver never re-posts the notification, which is the only thing that ends the "sending" state of the reply field.
**FLAG_MUTABLE**: required for a RemoteInput action because the system fills in the intent. Keep every other PendingIntent immutable.
Which chat? The intent carries no conversation id, so the receiver cannot tell where to send the text. Put it in the extras and use it as the request code.
Re-post under the same id: append the outgoing message to the MessagingStyle (or use setRemoteInputHistory) with setOnlyAlertOnce(true); that clears the spinner.
Send outside the receiver: onReceive gets about ten seconds, so the network call goes to WorkManager; on failure update the notification so the user knows.
A receiver is correct here: it opens no UI, so it is not a trampoline.
setSemanticAction(SEMANTIC_ACTION_REPLY) and setAllowGeneratedReplies(true) enable smart replies, Wear OS and Android Auto.
MessagingStyle with a Person for each message, plus setShortcutId pointing at a long-lived dynamic shortcut for that chat; without the shortcut the notification quietly falls back to the ordinary section.
Publish a long-lived shortcut per conversation: ShortcutInfoCompat with setLongLived(true) and a Person, via ShortcutManagerCompat.pushDynamicShortcut.
Build with MessagingStyle(me) and addMessage(text, time, sender), so the system draws avatars and the chat layout.
Set setShortcutId to the same id and setCategory(CATEGORY_MESSAGE).
What it unlocks: the Conversations section at the top of the shade, priority conversations that can break through Do Not Disturb, per-conversation settings, and bubbles via setBubbleMetadata.
One id per chat: add new messages to the style and re-post, instead of posting a row per message.
A message with a notification block is drawn by the FCM SDK itself while your app is in the background, so onMessageReceived is skipped; a data-only message always reaches onMessageReceived, and you build the notification yourself.
Notification only: foreground: onMessageReceived runs and nothing is drawn. Background: the system draws it and your code never runs.
Data only: onMessageReceived runs in both states. The only shape that lets you dedupe, decrypt, localise, route and log.
Both: background: the system draws the notification part and the data keys arrive as extras of the launcher Activity the tap opens. Foreground: one RemoteMessage with both.
What breaks: badge, analytics or "delivered" calls inside onMessageReceived silently never run for users who keep the app closed.
System-drawn channel: taken from android.notification.channel_id, else the manifest's default_notification_channel_id, else a Miscellaneous channel the SDK creates.
Time budget: treat onMessageReceived as having a few seconds (the Firebase sample says 10); hand anything longer to WorkManager. A force-stopped app receives nothing at all.
Use a notification block for marketing broadcasts; anything the product depends on goes data-only.
A token identifies one app install, not a user or a device, and it can change at any time, so the client re-uploads it whenever it differs from the last uploaded value and the server deletes tokens FCM reports as UNREGISTERED.
When it changes: fresh install, cleared app data, restore onto a new device, deleteToken(), and rotation by FCM itself.
**onNewToken is not enough**: it may fire before login and is missed when the token changed while the process was dead. Also compare FirebaseMessaging.getInstance().token on every launch.
Logout: tell the server to drop the row and call deleteToken(). Otherwise the next person signing in on that phone gets the previous user's pushes, which is a privacy incident.
Server storage: token, user id, platform, app version, updated_at. One user has several tokens, one device can host several accounts over time.
Send errors: UNREGISTERED means delete the row; UNAVAILABLE and INTERNAL mean retry with backoff.
Staleness: FCM treats a token as stale after a month without the app instance connecting (tune the threshold yourself, refresh and store updated_at monthly), and on Android expires it after 270 days of inactivity; sends to an expired token fail.
Without Google Play services there is no token at all; the token task fails.
You control the notification's visibility (VISIBILITY_PRIVATE with a redacted setPublicVersion, or VISIBILITY_SECRET), while whether the lock screen shows content at all belongs to the user and, on a managed device, to the admin.
The three values: PUBLIC shows everything; PRIVATE shows your public version, or a generic "Contents hidden" line if you gave none; SECRET shows nothing on the lock screen.
When it applies: only with a secure lock (PIN, pattern, password). The channel's lockscreenVisibility is set at creation and the user can change it later; the stricter of the two wins.
Not the heads-up: on an unlocked phone the full notification is shown, which is fine, but add setTimeoutAfter so it expires with the code.
Platform backstop: since Android 15 the system hides detected one-time codes from notification listeners without RECEIVE_SENSITIVE_NOTIFICATIONS. Heuristic, so set your own visibility anyway.
Admin: KEYGUARD_DISABLE_UNREDACTED_NOTIFICATIONS forces redaction, and a paused work profile hides its notifications entirely.
Make the public version a real notification with the same tap target, not a placeholder.
A full-screen intent asks the system to launch an Activity over the lock screen for urgent events such as calls and alarms; since Android 14 its permission is granted by default only to calling and alarm apps, and when denied the notification silently becomes a heads-up.
Never a guarantee: the Activity is launched only when the device is locked or the screen is off; on an unlocked phone the user gets a heads-up and stays where they are.
**Needs a IMPORTANCE_HIGH channel: on a lower one it never fires, permission or not.
Android 14+**: check NotificationManager.canUseFullScreenIntent() and, if false, send the user to Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT. Google Play grants it at install only to apps that declare calling or alarms as their core function, and reviews that declaration.
A call app today: CallStyle.forIncomingCall with CATEGORY_CALL, a foreground service of type phoneCall, and ideally a self-managed ConnectionService (or Jetpack core-telecom) so the system arbitrates audio and cellular calls.
Design the heads-up as the normal path and the takeover as an upgrade you might not get.
Nothing should buzz: the message goes into the repository, the open screen updates from it, and one notification presenter that knows what is on screen decides not to post.
The path
onMessageReceived writes the message through the repository and does nothing UI-related. That part runs in every app state.
The open screen collects the same Flow and shows the new message as it arrives.
The presenter asks two questions: is the app in the foreground (ProcessLifecycleOwner), and is this conversation the visible one (a small app-scoped holder the screen sets on resume and clears on pause).
Visible: suppress and mark as read. Foreground but another screen: an in-app banner. Background: post normally.
The traps
It only works with data-only messages: a notification the system draws for a backgrounded app cannot be suppressed.
Opening a conversation must cancel its notification, and the server should send a data-only "read" push so your other devices cancel theirs.
Use one stable notification id per conversation with setOnlyAlertOnce(true), so the fifth message updates the row instead of buzzing a fifth time.
Do not inspect the current Activity from the service. One holder, written by one lifecycle observer, read in one place.
A system notification is how you reach a user who is not looking; if they are looking, the UI is the notification.
collapse_key lets a newer message replace an undelivered one, ttl caps how long FCM stores it, and priority decides whether it wakes a dozing device now or waits for a maintenance window; get them wrong and chat messages vanish or a "driver arrived" push lands late.
Collapsing and queueing
collapse_key means "replace any undelivered message with this key on this device". Right for a state ping such as a badge count or sync cursor, wrong for anything the user must read, because only the last one survives.
FCM keeps at most four distinct collapse keys per device at a time.
Non-collapsible messages queue up to about 100 per device; past that FCM drops the queue and calls onDeletedMessages(), whose only correct handling is a full sync.
Priority and lifetime
high wakes a dozing device and briefly lets your process use the network; normal waits for a maintenance window, which can mean hours for an app in a restricted standby bucket.
High priority is earned: FCM may downgrade an app whose high-priority messages do not lead to a visible notification. Compare priority with originalPriority on the device.
ttl defaults to and caps at four weeks. ttl 0 means "deliver now or drop", right for a call or a one-time code.
Per message type
Driver arrived: high priority, ttl of minutes, no collapse key.
Chat message: high priority, not collapsible.
Badge refresh: normal priority, collapsible, long ttl.
Split the path into segments you can count (accepted by FCM, delivered to the device, received by your process, posted to the shade), because "notifications don't arrive" covers several unrelated failures and only one of them is the OEM's doing.
Instrument each segment
Server: log the message id every send returns, and the per-token error for failures.
Delivery: Firebase's delivery data and its BigQuery export show per message whether FCM delivered, dropped it for an inactive device or too many pending messages, or delayed it for the app's standby bucket.
Receipt: in onMessageReceived log the message id, the lag against sentTime, and priority against originalPriority to see FCM downgrading you.
Posting: just before notify() log areNotificationsEnabled() and the channel's importance. A user who switched the channel off looks exactly like a lost push from the server, and it explains many of these tickets.
Reproduce and segment
adb shell dumpsys notification --noredact shows posted records and channel state.
adb shell am set-standby-bucket <pkg> restricted plus adb shell dumpsys deviceidle force-idle, then a normal-priority send, reproduces the delayed case.
Report ActivityManager.isBackgroundRestricted and PowerManager.isIgnoringBatteryOptimizations() with the receipt event: an aggressive OEM battery manager shows up as a cohort.
A force-stopped app receives nothing until the user opens it again. That is platform behaviour, not an OEM quirk.
Only then act: ask the affected cohort to exempt the app, or raise priority for the messages that justify it.