Notifications & Push
Irbisa · cheatsheetSeptember 13, 2026

Notifications & Push

NotificationCompat, channels, POST_NOTIFICATIONS, FCM, data vs notification messages, deep links

Middle Developer16 itemscompressed for a skim
  1. 01

    Your notifications never appear in the shade — no crash, no error — and raising the channel's importance in code changes nothing. What is going on?

    Easy

    Both symptoms are the notification channel: on Android 8+ a notification posted to a channel that was never created is dropped silently, and a channel's behaviour is frozen the moment it is first created.

    class App : Application() {
      override fun onCreate() {
        super.onCreate()
        val nm = NotificationManagerCompat.from(this)
    
        nm.createNotificationChannelGroup(
    …
  2. 02

    Your notification shows a plain white square where the icon should be, and tapping it does nothing — what is wrong with the builder?

    Easy

    The white square is your full-colour launcher icon reduced to its alpha channel, and the dead tap is a missing setContentIntent — neither is an error the build will catch.

    // res/drawable/ic_stat_order.xml — white paths on transparent, 24x24dp, no background.
    // The system throws the colours away and keeps the silhouette, so a launcher icon = white blob.
    
    val open = Intent(Intent.ACTION_VIEW, "myapp://orders/42".toUri(), ctx, MainActivity::class.java)
    val tap = PendingIntent.getActivity(
      ctx, 0, open, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
    …
  3. 03

    Pushes arrive on Android 13 but nothing shows up for fresh installs, while your older users are fine — what changed, and what do you do when the user says no?

    Easy

    Android 13 made notifications opt-in: POST_NOTIFICATIONS is a runtime permission, denied by default on a new install, while devices that upgraded with your app already installed and notifications enabled were pre-granted it — which is exactly why only new users are missing.

    // AndroidManifest.xml
    // <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    
    @Composable
    fun NotifyMeButton(onEnabled: () -> Unit) {
      val ctx = LocalContext.current
    …
  4. 04

    Ten order notifications sit in the shade and every single one of them opens order #10 — where does that come from?

    Medium

    PendingIntent matching ignores extras, so all ten calls handed you the same token, and FLAG_UPDATE_CURRENT kept rewriting its extras with the newest order id.

    // ❌ ten notifications, one PendingIntent: extras are not compared, request code is always 0
    fun badTap(ctx: Context, orderId: Long): PendingIntent {
      val intent = Intent(ctx, DetailActivity::class.java).putExtra("orderId", orderId)
      return PendingIntent.getActivity(
        ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
      ) // UPDATE_CURRENT overwrites the shared token's extras -> every row opens the newest order
    …
  5. 05

    Tapping a notification used to hit a BroadcastReceiver that logged the open and then started the Activity, and on Android 12 the screen simply never opens — why?

    Medium

    That is a notification trampoline, and since Android 12 the system blocks a service or broadcast receiver started by a notification tap from calling startActivity — nothing throws, the Activity just never launches.

    // ❌ the trampoline: tap -> receiver -> startActivity. Silently dropped on Android 12+.
    class OpenReceiver : BroadcastReceiver() {
      override fun onReceive(ctx: Context, intent: Intent) {
        analytics.log("notification_open", intent.getStringExtra("route"))
        ctx.startActivity(Intent(ctx, MainActivity::class.java))   // blocked, no exception
      }
    …
  6. 06

    The backend sends the push with priority high and the builder sets PRIORITY_MAX, yet it still slides in silently — what actually decides how loud a notification is?

    Medium

    On Android 8+ only the channel's importance decides how a notification behaves; the other two are a pre-26 fallback and a delivery setting.

    // The channel is what makes noise — importance is fixed at creation
    NotificationManagerCompat.from(ctx).createNotificationChannel(
      NotificationChannelCompat.Builder(CH_CHAT, NotificationManagerCompat.IMPORTANCE_HIGH)
        .setName("Messages")
        .build()
    )
    …
  7. 07

    Your chat notification is truncated to one line in the shade and never lands in the Conversations section at the top — what is missing?

    Medium

    A NotificationCompat.Style is what fills the expanded view, and the Conversations section additionally requires MessagingStyle plus a long-lived shortcut that the notification points at by id.

    val me = Person.Builder().setName("You").setKey("me").build()
    val sender = Person.Builder()
      .setName("Dana").setKey("user:88").setIcon(IconCompat.createWithBitmap(avatar)).build()
    
    // 1. A long-lived dynamic shortcut per conversation — without it there is no Conversations section
    val shortcutId = "chat:88"
    …
  8. 08

    A background sync finishes and the phone buzzes ten times in a row, once per synced item — how do you fix that without hiding the results?

    Medium

    Post them as a group with one summary and set setGroupAlertBehavior(GROUP_ALERT_SUMMARY) on every notification in it, so the children go into the shade silently and only the summary makes a sound.

    private const val GROUP_SYNC = "sync_results"
    private const val SUMMARY_ID = 0
    
    fun postSyncResults(ctx: Context, files: List<SyncedFile>) {
      val nm = NotificationManagerCompat.from(ctx)
    …
  9. 09

    Your direct-reply notification does send the message, but the reply box keeps spinning forever — what did the handler forget to do?

    Medium

    The system leaves the reply field in its sending state until you post an update under that same notification id or cancel it — the reply is not finished when your receiver returns.

    private const val KEY_REPLY = "key_reply"
    
    // WRONG: immutable, so the system has nowhere to put the typed text
    val broken = PendingIntent.getBroadcast(
      ctx, convId, Intent(ctx, ReplyReceiver::class.java), PendingIntent.FLAG_IMMUTABLE
    )
    …
  10. 10

    Pushes show up fine on a backgrounded app, yet onMessageReceived only ever fires while it is open — what is in that payload?

    Medium

    A message carrying a notification block is drawn by the FCM SDK itself whenever your app is not in the foreground, and your FirebaseMessagingService is never called; only a data-only message always reaches onMessageReceived.

    class AppMessagingService : FirebaseMessagingService() {
    
      override fun onMessageReceived(message: RemoteMessage) {
        // Always runs for a data-only message. For a message that carries a
        // "notification" block it runs ONLY while the app is in the foreground.
        val data = message.data
    …
  11. 11

    Your send job reports a growing pile of UNREGISTERED tokens and open rates keep sliding — what is happening to the registrations?

    Medium

    An FCM registration token identifies one app instance, not a user and not a device, and it is replaced without warning — if your only upload happens at login, the server keeps paying to notify installs that no longer exist.

    class AppMessagingService : FirebaseMessagingService() {
      // Fires on install, data clear, restore to a new device, and rotation —
      // often long before anyone has signed in.
      override fun onNewToken(token: String) {
        TokenStore.savePending(this, token)
        TokenUploadWorker.enqueue(this)     // retryable: the network may be gone
    …
  12. 12

    A chat push arrives while the user is already reading that exact thread. What should happen, and which layer gets to make that call?

    Medium

    Nothing should buzz: the message lands in the repository, the open screen updates from it, and one notification presenter — the only thing that knows what is on screen — decides not to post.

    // One app-scoped holder, written only by the screen that is showing.
    object VisibleConversation {
      @Volatile var id: String? = null
    }
    
    @Composable
    …
  13. 13

    Four chat messages produced one notification, and the "your driver is here" push landed forty minutes late. Which send-side options did that?

    Hard

    A shared collapse_key made each of those four messages replace the previous one still sitting in the queue, and normal priority left the driver push waiting for the device's next Doze maintenance window.

    // Server, Firebase Admin SDK. One shape per message type, never one for all.
    
    fun driverArrived(token: String, rideId: String): Message = Message.builder()
      .setToken(token)
      .putData("ride_id", rideId)
      .setAndroidConfig(
    …
  14. 14

    After the Android 14 update, your incoming-call screen no longer takes over the lock screen — it is just a heads-up now. What broke?

    Hard

    USE_FULL_SCREEN_INTENT became a restricted permission in Android 14: only apps whose core function is calling or alarms are granted it at install, everyone else is denied by default, and a denied full-screen intent silently degrades into an ordinary heads-up notification.

    // Android 14+: declaring the permission is no longer enough to hold it.
    fun ensureFullScreenIntent(activity: Activity) {
      if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return
      val nm = activity.getSystemService(NotificationManager::class.java)
      if (nm.canUseFullScreenIntent()) return
      activity.startActivity(
    …
  15. 15

    Users on a handful of OEM phones swear your notifications never arrive, and you cannot reproduce it. How do you find out where they actually die?

    Hard

    Split the path into segments you can count — accepted by FCM, delivered to the device, posted by your process, opened by the user — because "notifications don't arrive" covers four unrelated failures and only one of them is the OEM's doing.

    // Segments 3 and 4, reported from the client with the ids the server logged.
    class AppMessagingService : FirebaseMessagingService() {
    
      override fun onMessageReceived(message: RemoteMessage) {
        val nm = NotificationManagerCompat.from(this)
        val am = getSystemService(ActivityManager::class.java)
    …
  16. 16

    Security wants the one-time code off the lock screen. Which of those knobs are actually yours, and which belong to the user or the IT admin?

    Hard

    You control exactly one thing — the notification's visibility and the redacted setPublicVersion shown in its place; whether a locked screen shows notifications at all belongs to the user's lock settings and, on a managed device, to the admin.

    // The public version is a real notification, not an apology for one.
    private fun publicVersion(ctx: Context, tap: PendingIntent) =
      NotificationCompat.Builder(ctx, "security")
        .setSmallIcon(R.drawable.ic_lock)
        .setContentTitle("Acme")
        .setContentText("New sign-in code")     // says what it is, never the code
    …