Mobile Design Rounds
Build guideAndroid + iOS20 min read

Build it: a push notification SDK on Android and iOS

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: Design a push notification SDK (Android and iOS): tokens, payloads and delivery · The framework.

This is the implementation material behind the push notification SDK interview case. It covers:

  • the public API and the device record
  • the server calls and the push address lifecycle
  • logout, permission, payloads and taps
  • failures, testing, platform limits and the iOS version

None of it belongs in the 45 minutes. I draw it only if asked. The Kotlin was checked against firebase-messaging 25.1.3. The latest release I saw in the Firebase release notes on 14 October 2026 was 26.0.0. Check the current release notes before you copy a call name.


1. Public API and data model

The public surface fits on one screen. The calls login and logout are suspend functions, because each is a one-time write. The calls onRegistered and handleMessage are plain functions, because FCM callbacks must return fast.

// Sketch: the public surface fits on one screen. login and logout are suspend (one-shot writes);
// onRegistered and handleMessage stay plain because FCM callbacks must return quickly.
object PushSdk {
    fun configure(app: Application, config: PushConfig)
    fun onRegistered(installationId: String)            // host service forwards the FID (FCM's new path)
    @Deprecated("FCM deprecated onNewToken; kept as a bridge for hosts not yet on FIDs")
    fun onToken(token: String)
    fun handleMessage(message: RemoteMessage): Boolean  // true if the SDK consumed it
    suspend fun login(userId: String)
    suspend fun logout()                                // unbind first; platform-side deletion is covered in section 4
    fun setOptIn(topic: String, enabled: Boolean)
    suspend fun requestPermission(activity: Activity): PermissionState  // the host picks the moment
    val permissionState: StateFlow<PermissionState>     // GRANTED, DENIED, NOT_ASKED; refreshed on every foreground
    fun setListener(listener: PushListener)             // taps and foreground messages
}

// Sketch: one immutable row per install, kept in the local DB (source of truth).
data class DeviceRegistration(
    val installId: String,       // SDK-generated UUID, stable across address changes. Not the Firebase installation ID.
    val pushAddress: String?,        // FID or registration token on Android, APNs token on iOS
    val addressKind: AddressKind,    // FID, TOKEN or APNS; the backend routes by kind
    val uploadedAt: Long?,           // epoch ms of the last successful upload; null before the first one
    val environment: String?,        // iOS only: "sandbox" or "production"; null on Android
    val userId: String?,         // null when logged out
    val notificationsEnabled: Boolean,
    val topics: Set<String>,     // opt-ins, mirrored from setOptIn
    val localVersion: Long,      // bumped on every local change
    val uploadedVersion: Long,   // equals localVersion when the backend is current
)

In the code comment, "unbind" means unlink: remove the user from the device row. The row keeps only what the SDK must remember between launches. The SDK reads sdkVersion, locale and tz when it uploads, so it does not store them.

The push address is the ID that names the phone for the push service. It is a registration token or a Firebase installation ID (FID) on Android, and an APNs token on iOS.

The address can change, so installId is the stable key. The backend does an upsert by installId (insert the row, or update it if it exists) and replaces the address. Without it, every change creates a second device row, and the user gets each push twice.

The SDK makes installId itself. It is not the Firebase installation ID, which is only one possible value of pushAddress.

The SDK uses WorkManager (the Android tool for work that must survive restarts) for the upload. It must not start WorkManager itself or demand a custom worker factory. The host app may already set up WorkManager, and on-demand start-up removes the default starter. So workers take what they need from the SDK's own internal objects, not from @HiltWorker.

2. Server calls

PUT    /v1/devices/{installId}   { platform, pushAddress, addressKind, environment, userId, enabled, topics, sdkVersion, locale, tz }
DELETE /v1/devices/{installId}
POST   /v1/events                [ { messageId, type: delivered|opened, ts } ]
GET    /v1/inbox?since=<cursor>  (the sync path; the cursor is an opaque string the server returns)

The retry rule is the same one the chat app uses:

  • Network errors, 408, 429 and 5xx are temporary. The SDK retries them and follows Retry-After when it is present.
  • Any other 4xx code is permanent. The SDK stops and reports it.

3. The push address lifecycle

A push address is not an identity. It can change, and the system can cancel it without warning.

Android (FCM). FCM is moving from registration tokens to Firebase installation IDs (FIDs). Both work during the move.

  • In firebase-messaging 25.1.0, the release notes mark getToken, deleteToken and onNewToken as deprecated. The client page says the token flow will be removed in a later release.
  • The FID path is opt-in. Set the manifest flag firebase_messaging_installation_id_enabled to true. Override FirebaseMessagingService.onRegistered(). Send the FID to your server each time it fires.
  • If auto-init is off, also call FirebaseMessaging.getInstance().register() at start-up.
  • The SDK accepts both kinds and records which one it holds. That is the job of addressKind.
  • FCM treats a registration as stale if the app has not connected for a month. It expires Android registrations after 270 days without use.
  • With FIDs, the SDK refreshes the registration itself. With old tokens, you must send them again now and then. Firebase advises about once a month, and says a refresh more often than weekly gives no benefit.

I did not verify the lowest library version for the manifest flag. I also did not verify whether onNewToken still fires in FID mode. Test both on the version you ship.

iOS (APNs). Apple says to register at every launch and not to cache the token. The token changes after a restore from backup, an install on a new device, or an OS reinstall. It arrives in application(_:didRegisterForRemoteNotificationsWithDeviceToken:). A SwiftUI app needs a UIApplicationDelegateAdaptor to receive that call (I have not checked this against Apple's page). The backend must hold many tokens for each user.

The SDK flow has five steps:

  1. At start-up, ask the platform for the current push address. With FIDs, wait for onRegistered.
  2. If it differs from the stored one, or uploadedAt is older than the refresh time, add one to localVersion.
  3. A unique WorkManager job calls PUT /v1/devices/{installId} while localVersion is ahead of uploadedVersion. It then saves the version it sent, not the latest one, and sets uploadedAt. On iOS, the SDK sends the work in its own outbox (a saved list of work the server has not confirmed). The retry rule is in section 2.
  4. The push services answer with errors for dead addresses. The server reads them. The table below shows what to do.
  5. A scheduled server job removes rows that were not refreshed within your stale window. Firebase's sample uses 30 days.
ProviderResponseWhat the server does
FCMUNREGISTERED (404)Delete the registration
FCMINVALID_ARGUMENT (400)Delete only when the payload is known to be valid, since a bad payload gives the same error
APNs410 UnregisteredStop sending. Delete the address unless the app registered it again after the failure time (my reading of the timestamp)
APNs400 BadDeviceTokenCheck for a sandbox and production mismatch first. If there is none, delete
APNs429 TooManyRequestsRetry later; Apple says not to retry BadDeviceToken, Unregistered or PayloadTooLarge

Apple gives a timestamp with a 410 answer, and says to send again only if the app gets the same token again. My reading: an old failure must not remove a new registration, so compare the timestamp with your last registration time.

4. Logout in detail

Most real bugs are in logout. After user A logs out and user B logs in, A's content must never reach B. On logout, the SDK does three things:

  1. Set userId = null and add one to the version first. Queue the unlink call at once.
  2. Clear the local inbox and the saved payloads.
  3. At the next login, read the push address again and upload it with the new userId.

I would like to say "delete the platform token, so an old push cannot arrive". But I found no documented FID version of deleteToken. The current client page does not mention deleting. Check the current reference before you promise it.

The guarantee I give is different. The unlink call can fail offline. So the server also unlinks when a new user claims the same installId. And the client ignores any push whose userId does not match the current user.

5. Permission and presentation

Android 13 (API 33) added the runtime permission POST_NOTIFICATIONS.

  • If the app targets 13 or higher, the app chooses when the dialog appears. Android advises waiting until the user has context.
  • If the app targets 12L or lower, the dialog appears the first time an activity starts after a channel is created.
  • If the user swipes the dialog away, nothing changes.
  • If the app targets 12L or lower, one "Don't allow" means no more prompts until the app is reinstalled, or until it targets 13 or higher.
  • When a phone upgrades to 13, the system grants the permission to apps that already have a channel, unless the user turned notifications off on 12L or lower.
  • The FCM SDK (23.0.6 and later) declares the permission in the manifest. The app still asks at run time.
  • Check that notifications are enabled before you send.

iOS shows the prompt on the first requestAuthorization call and never again. Apple advises asking in context, not at first launch. The option .provisional skips the prompt. Notifications then arrive quietly in Notification Center, with Keep and Turn Off buttons. The status stays .provisional until the user chooses.

The SDK follows four rules:

  • It never prompts on its own. requestPermission(activity) is the only call that shows the dialog, so the host picks the moment. On iOS the same call wraps requestAuthorization.
  • It reads permissionState at every foreground. Hosts watch the StateFlow and do not need to check again and again. Users change the setting in Settings, so notificationsEnabled must follow. The backend should then stop high-priority sends to devices that cannot show them.
  • FCM may lower high-priority messages that do not produce a visible notification. It judges this for each app instance over about 7 days (priority page).
  • It creates Android channels at start-up, not inside the message handler. For apps that target 12L or lower, Android asks for permission when the app first creates a channel in the foreground. Firebase's client setup page warns that the FCM SDK creates the first channel in the background when a push arrives. Then the notification is not shown, and notifications that arrive before the user accepts are lost.

Channels and categories. Android 8 and later requires every notification to belong to a channel. After creation, code cannot change the importance, sound or vibration of a channel. The user has the final say. Code can change only the name and the description. Design channels around user intent, such as orders and promotions, so a user who dislikes one does not turn off all.

iOS has no importance for each channel. Categories (UNNotificationCategory) define action buttons. Each notification also carries an interruption level: passive, active, timeSensitive or critical. Map one SDK importance setting onto both. timeSensitive and critical need a capability that the host app must hold (I have not checked the current rules). So I map the top SDK importance to timeSensitive and never expose critical by default.

6. Payloads and app states

Message types. On Android, FCM shows a notification message itself when the app is in the background. A data message always goes to your code. A mixed message in the background shows in the tray (the system list of notifications), and gives its data as intent extras after a tap.

Both types are limited to 4096 bytes. The Firebase console allows 1000 characters. An SDK should prefer data messages, because the SDK controls the channel, the repeat check and the tap target. The cost is that your process must get a chance to run.

SettingFCM (Android)APNs (iOS)
Urgencynormal or high; high tries to deliver at once and can wake a sleeping deviceapns-priority: 10 is immediate, 5 saves power, 1 is low power
Type(not used)apns-push-type: alert or background; background always uses apns-priority: 5 (10 is an error)
LifetimeTTL 0 to 28 days; TTL 0 discards the message if it cannot be delivered nowapns-expiration as a Unix time; 0 tries once and does not store
Replacecollapse_key (a new message replaces an old one with the same key); four different keys held for each deviceapns-collapse-id, at most 64 bytes

Collapse only state where the latest value is all that matters. Never collapse items that each matter, such as chat. FCM stores at most 100 non-collapsible messages for each Android device. When the limit is reached, or the device was offline for over a month, the app may get onDeletedMessages(). It should then run a full sync. Use a short TTL for content that expires.

StateAndroidiOS
ForegroundonMessageReceived gets everything; the SDK decides on a bannerwillPresent chooses how to show it
BackgroundA data message reaches onMessageReceived; a notification message goes to the trayThe system shows alert pushes; content-available pushes are best effort
Killed by the systemDelivery is not guaranteed; a tap on a tray notification starts the launcher activityAlerts show; a held background push is discarded on force-quit
Force-stopped by the userFirebase lists a force-stopped app as a reason for dropped messages and says the sender can do little about itA background push is discarded

Apple documents background pushes as low priority, not guaranteed and possibly throttled. It advises no more than two or three an hour. So the push is a hint. Send {messageId, type, entityId} and let the client fetch the content from GET /inbox?since=cursor. The cursor sync repairs any gap, including the force-stopped case, because the app catches up the next time it starts.

On iOS, send a visible alert with mutable-content: 1. A Notification Service Extension (a small separate part of the app) can then change the content within about 30 seconds. For example, it can fetch or decrypt it. If the extension does not finish, the system shows the original content, so make that text acceptable.

The extension does not run for silent pushes, for pushes with only sound or badge, or when alerts are off.

Measuring delivery. Firebase warns that "delivered" does not mean "shown". On Android, Google Play services can display some high-priority notification messages for the app, without starting it. Those are not counted in the usual FCM delivery numbers (priority page). So treat delivery counts as a minimum.

7. Taps, tracking and coexistence

Routing a tap. A cold start (the app was not running) and a warm start (the app was in the background) must reach the same place.

  • On Android, put a PendingIntent for the destination activity in setContentIntent().
  • If the app targets Android 12 (API 31) or higher, a tap must open the activity directly. Sending the tap through a service or a receiver (a trampoline) is blocked, so a "tap handler service" does not work.
  • Pass the deep link and the messageId as extras. The activity reports the open.
  • On iOS, UNUserNotificationCenterDelegate.didReceive handles the tap. Set the delegate before launch ends, in willFinishLaunchingWithOptions or didFinishLaunchingWithOptions. Otherwise the app might miss incoming notifications.
  • The SDK hands the parsed link to the host through PushListener. It never opens a screen itself.

Tracking with repeat removal. Every push carries a messageId. The SDK records delivered when the handler runs, and opened on a tap. It keeps a table of seen IDs and ignores repeats, because the same message can arrive by push and by sync. It sends events in batches, with messageId plus type as the key that makes a repeat harmless.

The word delivered means "reached our handler", not "seen". On iOS the handler is willPresent, and it runs only when the app is in the foreground. So the proxy below counts too few alerts: it misses those shown while the app was in the background. Counting those needs the extension from section 6.

Several SDKs in one app. Libraries each want a FirebaseMessagingService. I found no documentation on which one Android calls when they share an intent filter. So the SDK ships no service. The host declares one and calls PushSdk.onRegistered (or the deprecated onToken) and PushSdk.handleMessage. When the SDK returns false, the host passes the message to the next library. The SDK decides by a payload key with its own name prefix.

Some vendors choose the opposite: their SDK declares a service, and the host forwards only if it has its own. That needs less setup, but it can collide. On iOS, the shared resource is the one notification delegate. So the SDK gives a proxy that forwards to the host's delegate. The analytics SDK page has the same single-owner problem and the kill switch side of sharing.

// Sketch: delegate proxy. `next` is the host's delegate. Call install() before launch finishes.
// The center holds its delegate weakly, so the SDK must retain the proxy: hence the singleton.
// @unchecked Sendable: `next` is written once at setup, on the main thread, before any callback.
final class PushDelegateProxy: NSObject, UNUserNotificationCenterDelegate, @unchecked Sendable {
    static let shared = PushDelegateProxy()
    var next: UNUserNotificationCenterDelegate?

    func install(on center: UNUserNotificationCenter = .current()) {
        if center.delegate !== self { next = center.delegate }  // keep a delegate the host set first
        center.delegate = self
    }

    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification,
                                withCompletionHandler completionHandler:
                                    @escaping @Sendable (UNNotificationPresentationOptions) -> Void) {
        PushSdk.shared.recordDelivered(notification.request.content.userInfo)
        if next?.userNotificationCenter?(center, willPresent: notification,
                                         withCompletionHandler: completionHandler) == nil {
            completionHandler([])  // what the system does when a delegate has no willPresent
        }
    }

    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse,
                                withCompletionHandler completionHandler: @escaping @Sendable () -> Void) {
        PushSdk.shared.recordOpened(response.notification.request.content.userInfo)
        if next?.userNotificationCenter?(center, didReceive: response,
                                         withCompletionHandler: completionHandler) == nil {
            completionHandler()
        }
    }
}

On iOS, the call configure runs install(). It puts the proxy in place and keeps any delegate the host set earlier in next. A host that assigns center.delegate after that replaces the proxy, and the SDK stops seeing taps. So the host sets PushDelegateProxy.shared.next instead.

When the host delegate has no willPresent, the proxy completes with []. Apple says the system then behaves as if no alert option was passed, so nothing shows in the foreground. A default of banner and sound would change host behavior, and a proxy must not do that.

One case differs. If the host sets no delegate at all, Apple says the system uses the original alert options. Then the proxy changes the host behavior, because the code also uses [] when next is empty.

Opt-out, topics, limits and quiet hours. Topic subscriptions are server state, keyed by installId, and copied on the device. A change uses the same upload job. Opt-out is a version change that the server checks at send time. Frequency caps and quiet hours belong on the server, because it sees everything sent to the user. The client can only hold banners during local quiet hours.

8. Failure modes, testing, versioning and privacy

ScenarioHandling
The push address changes while offlineThe version change is saved; the upload retries when the network returns
Logout with no networkThe local link is cleared at once; the server unlinks when a new user claims the installId
A push arrives for a logged-out userDrop it when userId does not match; never show it
UNREGISTERED or APNs 410The server deletes the registration; the client registers again at the next launch
Over 100 stored messages, or offline for a month (Android)onDeletedMessages() starts a full inbox sync
The user turns off notifications in SettingsThe foreground check updates notificationsEnabled; the server stops high-priority sends
The same message twicemessageId repeat removal; one event is recorded

Testing. Hide the platform behind thin interfaces (TokenProvider, DeviceApi). Test with fakes (simple stand-in classes), not mocks, so the state machine runs in plain unit tests: address change, login, logout, offline retry. Use TestListenableWorkerBuilder for the worker.

Versioning. Keep internals internal. Run a binary compatibility check in CI. Never rename payload keys; only add. The backend must accept old SDK versions for years, because app updates are slow. Send sdkVersion on every upload, so you can see how many old versions remain.

Privacy. The local row holds only these fields:

  • installId, the push address and its kind
  • the upload time and the iOS environment
  • the user ID, topics and the enabled flag
  • the two version counters

Each upload also sends sdkVersion, locale and time zone. The SDK does not store them. Never log payloads. Logout sets userId to null on the server row and keeps the row. The row is deleted only on an erasure request, through DELETE /v1/devices/{installId}.

9. Platform limits

LimitAndroidiOSSo the design changes by
Background deliveryHigh priority can wake a sleeping device; normal priority can be delayed in Doze (the deep sleep of an idle phone)Background pushes are low priority, not guaranteed and may be throttled; two or three an hour is the adviceA push carries a hint, never the only copy of the data
User force-stopFirebase lists it as a reason for dropped messagesA held background push is discardedA cursor sync at the next launch repairs the gap
Durable retryWorkManager work persists across restarts and rebootsNo built-in equivalent that I rely onOn iOS the SDK sends its own outbox at launch, foreground and push
PermissionPOST_NOTIFICATIONS from API 33; the app picks the momentrequestAuthorization shows the prompt once; .provisional skips itThe SDK never prompts; the host asks in context
Rich contentBuilt in the app processA Notification Service Extension, a separate process, about 30 secondsKeep the extension small and make the fallback text acceptable
TapsA PendingIntent to an activity; trampolines blocked from API 31didReceive on the delegate, set before launch endsThe SDK passes a link to the host and never navigates

10. How to build push on iOS (Swift)

The Swift delegate proxy is in section 7. This table shows what else differs.

ConcernAndroidiOS
Push address sourceonRegistered() with FIDs (onNewToken and getToken() are deprecated)registerForRemoteNotifications() at each launch
Invalid addressUNREGISTERED, 270-day expiry410 Unregistered, 400 BadDeviceToken
PermissionPOST_NOTIFICATIONS (API 33 and later)requestAuthorization, with the provisional option
User controlChannelsSettings for the app; categories define actions
UrgencyPriority plus channel importanceinterruption-level plus apns-priority
Collapsecollapse_keyapns-collapse-id; thread-id groups notifications
Rich contentBuilt in the app processNotification Service Extension (separate process)
TapPendingIntent to an activitydidReceive on the delegate
Durable retryWorkManagerNone that I rely on; send your own outbox on launch, foreground and push

Three things are really different:

  • The extension is a separate target. The host must add it. It has its own time limit (about 30 seconds). I did not verify its memory limit, so keep it small. I also did not verify the App Group details. An extension cannot share live objects with the app. An App Group is the usual way to share settings.
  • Silent pushes cannot carry correctness. Use content-available only on top of a sync at foreground. Send it with apns-push-type: background and priority 5. Expect Apple's advised two or three an hour.
  • Tokens belong to one environment. A sandbox token sent to production fails as BadDeviceToken. Record the environment on upload.

11. More practice prompts

  • Add reply actions. Categories on iOS, RemoteInput on Android. The action must reach the backend while the app is closed.
  • Add images and grouped notifications. What does the iOS extension need, and what does Android build in the app?
  • Read state across devices. The phone shows a message as read; the tablet still shows an alert. What clears it?
  • Build a "why did my push not arrive" tool for support. Which signals can the SDK send, and which can it not see?
  • Offer a base class that hosts can extend for the messaging service. What stays explicit?
  • How do you test against real FCM and APNs? Use fakes for unit tests. Then use a staging project for each platform, and a device farm run for the parts that exist only on a phone.

Further reading (official docs)

Verification notes (last reviewed 13 Oct 2026)

this guide is the 'Build it' part of the push SDK case, on its own page since 2026-10-14 so the interview page stays inside 45 minutes, in plain English. The three code blocks (the Kotlin surface with DeviceRegistration, the REST block and the Swift PushDelegateProxy) are unchanged from the verified page and were diffed byte for byte against the old file again on 2026-10-14. Prose, tables and section numbers (1 to 11) changed; cross-references were renumbered. Android architecture audit on 2026-10-13 against the official architecture, offline-first, WorkManager and FCM pages. Remaining deviations: the SDK uses its own internal graph instead of Hilt (it is a library); PushListener is a host callback, not a ViewModel event; onRegistered and handleMessage are not suspend functions. Read again on 2026-10-14 and matched to the text: the manage-tokens page (stale after a month by default; for Android, 270 days to expiry; monthly refresh and no benefit above weekly for old tokens; the SDK refreshes FIDs itself; 30-day sample; UNREGISTERED and INVALID_ARGUMENT; silent on logout), the FCM client page (onRegistered, the manifest flag, register(), deprecated token calls to be removed later, POST_NOTIFICATIONS declared from 23.0.6, and, for apps that target 12L or lower, the permission prompt on first channel creation in the foreground and the loss of notifications before the user accepts), the Firebase Android release notes (25.1.0 deprecates getToken, deleteToken and onNewToken; 26.0.0 was the latest listed, dated 7 October 2026), the receive-messages page (over 100 pending, over a month offline, full sync, intent extras for a mixed message), the message-type page (4096 bytes, 1000 characters in the console), the lifespan page (0 to 28 days, TTL 0), the collapsible page (four collapse keys, 100 stored non-collapsible messages, all discarded at the limit), the message priority page (7-day judgment, Doze delay for normal priority, wake for high priority, display by Play services, not counted in delivery metrics), the Firebase delivery post (force-stopped drops, delivered is not shown), the Android permission page (API 33, the app picks the moment when it targets 13 or higher, a swipe changes nothing, a denial blocks later prompts for apps that target 12L or lower, pre-grant on upgrade), the channels page (importance, sound and vibration fixed after creation; name and description can change), the Android 12 behavior page (trampolines are blocked for apps that target 12 or higher), Apple's background-updates page (low priority, not guaranteed, throttled, two or three an hour, discarded on force-quit, priority 5), the APNs registration page (register at every launch, never cache the token), the APNs request page (push types, priorities, expiration, apns-collapse-id up to 64 bytes), the APNs response page (410, 400, 429 and the no-retry list; it gives a timestamp for 410 but does not say to compare it with the registration time, so that rule is marked as my reading), the notification service extension page (about 30 seconds, mutable-content, original content shown if the handler is not called, no run for sound-only, badge-only or disabled alerts), the permission page for Apple (first request only, ask in context, provisional behavior) and the delegate pages (willPresent is for the foreground; no method means no alert; no delegate at all means the original options; set the delegate before launch ends or notifications might be missed). Fixes in this pass: the trampoline rule and the 12L prompt rules now say they apply to apps that target those versions; the channel advice now matches the client page; the proxy prose now says what the code does when the host has no delegate; the privacy list now matches the stored row; the Killed by the system row no longer claims that data messages arrive. Not verified: the lowest firebase-messaging version for the manifest flag, whether onNewToken still fires in FID mode, a documented FID replacement for deleteToken, how Android resolves several FirebaseMessagingService classes sharing one intent filter (the design avoids it), the Time Sensitive and Critical requirements, RemoteInput, App Group and extension memory details, the SwiftUI adaptor note, whether iOS offers a durable job queue the SDK could rely on, the on-demand WorkManager start-up note, Android 17 behavior changes, and what Apple says SDK authors must declare in a privacy manifest (those pages returned only a title). Compile check on 2026-10-13, offline, nothing downloaded: the Kotlin surface and DeviceRegistration (with requestPermission, uploadedAt, environment, topics) compiled with compileDebugKotlin on Gradle 9.8.1, AGP 9.1.0, Kotlin 2.3.21, compileSdk 36, firebase-messaging 25.1.3 and kotlinx.coroutines 1.10.2. The method bodies in the surface are stubbed ({} or TODO()), so only the declarations are checked, and PushConfig, PushListener and AddressKind are empty stand-ins. Hilt and Retrofit are not used on this page. The Swift proxy (with install(on:), the [] default and @unchecked Sendable) type-checked with Swift 6.4 against the iPhoneSimulator 27.0 SDK, -target arm64-apple-ios17.0-simulator, in language modes 5 and 6 and once more with -strict-concurrency=complete, with PushSdk stubbed. Neither snippet was run, so the iOS delegate behavior (install ordering, the [] default) is from Apple's docs, not a device test. The platform limits table in section 9 restates facts verified above, and its last column is design guidance and my opinion. A plain-English pass on 2026-10-14 used link and unlink for the user and device relation (the code comment says unbind), defined push address, upsert, WorkManager, Doze, tray, trampoline, extension, outbox and collapse key at first use, and removed idioms. No platform specialist has reviewed the Android and iOS sections yet. Code snippets here are original sketches, some adapted from patterns in the Android and Firebase documentation (Apache License 2.0).

Questions or corrections?

Add them to the discussion thread for this case on GitHub. To post, you need a free GitHub account. What you write there is public and follows GitHub's terms. Good answers are added to this page.

Keep reading

Get the next case study when it is published.

You get an email when a new case study is published, and nothing else.

You can unsubscribe at any time. Privacy.