Engineering

One notification API, four delivery systems that behave differently

Channels, categories, a service worker and a tray have different rules.

MobileByteSensei 10 min read
kmpplatformarchitecture

TL;DR

  • A shared notify(title, body, groupKey) that grouped correctly on Android and produced nine separate banners on iOS for nine messages in one conversation — because iOS groups by threadIdentifier, which our shared call had no field for.
  • Four delivery systems with four grouping models, four permission lifecycles and four scheduling guarantees. The payload is portable; none of the behaviour is.
  • The fix is a shared payload plus a per-platform delivery block, and a capability report so the app knows which guarantees it did not get.
  • What reading on buys: the four grouping models, the permission lifecycle differences that broke our re-prompt, and the measurement that showed 31% of iOS users had silently revoked.

The case this post is about

A messaging notification, shared:

// commonMain/kotlin/notify/Notifier.kt
expect class Notifier {
    suspend fun notify(
        id: String,
        title: String,
        body: String,
        groupKey: String,          // "conversation:7f2a"
    )
    suspend fun requestPermission(): Boolean
}
// androidMain
actual suspend fun notify(id: String, title: String, body: String, groupKey: String) {
    NotificationManagerCompat.from(ctx).notify(id.hashCode(),
        NotificationCompat.Builder(ctx, CHANNEL_MESSAGES)
            .setContentTitle(title).setContentText(body)
            .setGroup(groupKey)                        // groups into a bundle
            .build())
}
// iosMain
actual suspend fun notify(id: String, title: String, body: String, groupKey: String) {
    val content = UNMutableNotificationContent().apply {
        setTitle(title); setBody(body)
        // groupKey has nowhere to go that affects grouping
    }
    UNUserNotificationCenter.currentNotificationCenter()
        .addNotificationRequest(UNNotificationRequest.requestWithIdentifier(id, content, null), null)
}

The symptom

Nine messages arriving in one conversation while the app was backgrounded:

  android     1 bundled notification, "9 new messages"
  ios         9 separate banners, stacked, each one buzzing
  desktop     9 toasts, 3 of which were dropped by the OS as a flood
  web         1 notification, replacing the previous one each time

Four behaviours from one call. The iOS one generated a support complaint about "notification spam" from a user who had received nine messages — which is not spam, it is nine notifications for nine messages, and every other platform had made a decision about that which iOS had not been told.

And a second symptom found while instrumenting the first:

# iOS users with notifications enabled, per our own records vs the system
  our stored flag "permission granted"        100%   (of users who had tapped allow, ever)
  system authorization status, queried         69%

Thirty-one percent had revoked in Settings and we were still calling notify, which succeeds and delivers nothing.

Nine messages in one conversation producing four different outcomes, with one bundled notification on Android, nine separate banners on iOS, three of nine toasts dropped by the desktop system, and a single replaced notification on the web

Why the obvious fix failed

The first fix was to pass the group key as a thread identifier, which is the right field:

content.setThreadIdentifier(groupKey)

That groups on iOS. It also does nothing about the summary: Android's bundle shows "9 new messages" because we build a summary notification; iOS shows nine stacked notifications in a thread, collapsed, which is the correct iOS behaviour and looks different. There is no shared "grouped" result, and expecting one was the mistake.

The second fix was to suppress duplicates ourselves — keep a count and notify once:

if (pendingByGroup[groupKey]?.size ?: 0 > 1) notifySummaryOnly(groupKey)

Which reimplements Android's bundling badly, breaks iOS's own thread collapsing, and on the web replaced the single notification with a different single notification. A shared implementation of a feature every platform already has.

The third attempt was to query permission before each notify, which fixed the 31% and was far too slow:

if (!requestPermission()) return        // ← on iOS this can SHOW A PROMPT

requestPermission on iOS shows the system prompt if the status is notDetermined, so calling it defensively before every notification prompted users who had never been asked — at a random moment, with no context, which is the worst possible time to ask. Querying and requesting are different operations and our API had one method for both.

The actual mechanism

Four systems, and the differences are in behaviour rather than availability.

Android groups by a group key plus a summary notification you build. Channels — created once, user-configurable per channel — carry importance, sound and the ability to be disabled individually. A notification to a disabled channel succeeds and shows nothing.

iOS groups by threadIdentifier and collapses automatically. There is no summary to build. Categories carry actions rather than importance. Authorization is one switch for the app, with sub-options (banners, sounds, badges) the user controls separately — so "authorized" is not one boolean.

Desktop toasts have no grouping and an OS-level flood limit. Three of nine were dropped by the system, silently, which is a rate limit rather than a bug.

The web replaces by tag. A notification with the same tag replaces the previous one — which is a third grouping model, neither bundling nor threading, and it is why nine messages showed one notification with the ninth message's text.

And the permission lifecycles differ in a way that broke our re-prompt:

  android    may be asked repeatedly; a denial is revocable by asking again (pre-13: granted by default)
  ios        asked ONCE. A denial is permanent from the app's side — only Settings can reverse it
  web        tied to a user gesture; a denial is "denied" and the prompt cannot be shown again
  desktop    no prompt at all on most configurations

So "ask for permission" is one operation on one platform and a one-shot irreversible decision on two others — and our single requestPermission() was being called defensively, which on iOS burns the one prompt the app will ever get.

The grouping mechanism and permission lifecycle of each platform, with three distinct grouping models and a permission that is one-shot and irreversible on two of the four

The fix

A shared payload, a per-platform delivery block, and separate query and request operations.

// commonMain/kotlin/notify/Notification.kt
//
// The portable part: what the notification SAYS. Everything about how it behaves is in the
// per-platform block below, because none of it is portable and a shared field that only one
// platform honours is worse than no field.
data class NotificationPayload(
    val id: String,
    val title: String,
    val body: String,
    val deepLink: String? = null,
)

/**
 * Per-platform delivery, supplied by the CALLER because only the caller knows the intent —
 * "these are messages in one conversation" — and each platform expresses that differently.
 */
data class Delivery(
    /** Android: groups into a bundle. iOS: threadIdentifier. Web: tag (REPLACES). Desktop: ignored. */
    val groupKey: String? = null,
    /** Android only: which channel, and therefore which user-controlled importance. */
    val androidChannel: String = Channels.DEFAULT,
    /** Android only: the summary's text when a bundle has more than one member. */
    val androidSummary: ((count: Int) -> String)? = null,
    /** Desktop only: coalesce locally, because the OS drops a flood without telling you. */
    val desktopCoalesceWindow: Duration = 5.seconds,
)

sealed interface PermissionStatus {
    data object Granted : PermissionStatus
    /** Never asked. A request here will SHOW A PROMPT — ask at a moment that makes sense. */
    data object NotDetermined : PermissionStatus
    /** Asked and refused. On iOS and the web this is final; only Settings can change it. */
    data class Denied(val canAskAgain: Boolean) : PermissionStatus
    /** Granted at the app level and silenced for this channel. Android only. */
    data class ChannelDisabled(val channel: String) : PermissionStatus
}

expect class Notifier {
    /** Never prompts. Safe to call before every notify, and the 31% is why you should. */
    suspend fun status(channel: String = Channels.DEFAULT): PermissionStatus

    /** MAY prompt, exactly once on two platforms. Call from a deliberate, explained moment. */
    suspend fun requestPermission(): PermissionStatus

    suspend fun notify(payload: NotificationPayload, delivery: Delivery): Boolean
}
// iosMain — the grouping field the shared call had nowhere to put
actual suspend fun notify(payload: NotificationPayload, delivery: Delivery): Boolean {
    // Checked, not requested. The original called requestPermission defensively, which on a
    // notDetermined status shows the prompt — burning the single prompt iOS allows, at a
    // moment the user has no context for.
    if (status() != PermissionStatus.Granted) return false

    val content = UNMutableNotificationContent().apply {
        setTitle(payload.title)
        setBody(payload.body)
        // iOS's grouping mechanism. Collapsing is automatic; there is no summary to build,
        // and androidSummary is correctly ignored here rather than approximated.
        delivery.groupKey?.let { setThreadIdentifier(it) }
        payload.deepLink?.let { setUserInfo(mapOf("deepLink" to it)) }
    }
    return suspendCoroutine { cont ->
        UNUserNotificationCenter.currentNotificationCenter()
            .addNotificationRequest(
                UNNotificationRequest.requestWithIdentifier(payload.id, content, null),
            ) { error -> cont.resume(error == null) }
    }
}

And the call site, which now expresses intent once and accepts four behaviours:

// commonMain
suspend fun onMessageReceived(msg: Message) {
    val sent = notifier.notify(
        NotificationPayload(
            id = msg.id,
            title = msg.senderName,
            body = msg.preview,
            deepLink = "notes://conversation/${msg.conversationId}",
        ),
        Delivery(
            groupKey = "conversation:${msg.conversationId}",
            androidChannel = Channels.MESSAGES,
            androidSummary = { n -> "$n new messages" },
        ),
    )

    // A false return is not an error — it is "the user has this switched off", which the
    // original API could not report because notify returned Unit and succeeds when muted.
    if (!sent) pendingBadgeCount.increment()
}

And the output it produced, nine messages in one conversation:

$ ./tools/notification-probe.sh --messages 9 --conversation 7f2a

  android   1 bundled notification · "9 new messages" · channel MESSAGES
  ios       1 collapsed thread · 9 notifications inside it · threadIdentifier conversation:7f2a
  desktop   2 toasts · 7 coalesced locally within the 5s window · 0 dropped by the OS
  web       1 notification · tag conversation:7f2a · showing the latest message

  delivery suppressed (permission): 0 of 4 platforms

Four platform-appropriate behaviours, and nothing dropped silently.

How we proved it

That groupKey had nowhere to go on iOS — read from the API surface rather than inferred from the symptom:

  UNMutableNotificationContent fields affecting grouping
    threadIdentifier     ← the mechanism
    categoryIdentifier     actions, not grouping
    subtitle, body, title  content
  our shared call passed groupKey to: (nothing)

That 31% of iOS users had revoked. The measurement, from querying the system rather than reading our own flag:

  our stored "granted" flag                   100% of users who ever tapped allow
  UNNotificationSettings.authorizationStatus   69% authorized
                                               24% denied
                                                7% notDetermined (never asked)
  notify() return value, before                always Unit — no way to observe it

Our flag recorded a historical event; the system records the current state, and nothing had been comparing them.

That calling requestPermission defensively burns the prompt. This is the one worth being explicit about, because the defensive call looks careful:

  status before      requestPermission() does        after
  notDetermined      SHOWS THE SYSTEM PROMPT         granted or denied — permanently
  denied             returns denied, no prompt       denied
  granted            returns granted, no prompt      granted

A defensive call on a notDetermined user prompts them at whatever moment the code happened to run. We measured the consequence on a cohort before separating the methods:

  prompt shown from the onboarding screen, with an explanation   allow rate 71%
  prompt shown incidentally, before a notification               allow rate 34%

Half the allow rate, from the same prompt at a worse moment.

That the desktop drops were an OS flood limit. Separated from our own behaviour by counting both ends:

  notifications we posted      9
  toasts the OS displayed      6
  errors returned to us        0

Three dropped with no error, which is why the local coalescing window exists rather than relying on the system.

That the web's tag replaces rather than groups. Verified because it reads like grouping in the result and is a different mechanism:

  9 notifications, same tag     1 shown, containing message 9    (messages 1–8 never seen)
  9 notifications, unique tags  9 shown, stacked

Replacement is correct for a "latest state" notification and wrong for messages, so the messages channel uses the conversation id as a tag deliberately and the body says "9 new messages" rather than quoting the ninth.

What I cannot tell you is the Android picture across OEMs. Several vendors apply their own notification throttling and grouping on top of the platform's, and our probe ran on two devices. The bundling worked on both; I would not claim it works everywhere.

And an honest asymmetry. Delivery has three Android-only fields and one desktop-only field, so it reads as Android-centric. That is because Android exposes the most control, and the alternative — a lowest-common-denominator struct — is what produced nine banners. A caller setting androidSummary on a platform that has no summaries is writing a line that does nothing, which is clearer than a shared field that silently means something different per platform.

Three measurements: a stored permission flag recording a historical event against the system reporting a current state, the allow rate halving when the prompt appears incidentally, and three desktop toasts dropped with no error returned

What this generalizes to

When four platforms all have a feature, the temptation is a shared API over the intersection — and the intersection is usually the payload, with every behavioural difference falling outside it. A field that only one platform honours is worse than no field, because it reads as configuration and is a no-op; the honest shape is a shared payload plus a per-platform block where the asymmetry is visible. And where a platform's operation is irreversible — one permission prompt, ever — the API must separate querying from requesting, or careful code will spend it.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS