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-Afterwhen 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,deleteTokenandonNewTokenas 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_enabledtotrue. OverrideFirebaseMessagingService.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:
- At start-up, ask the platform for the current push address. With FIDs, wait for
onRegistered. - If it differs from the stored one, or
uploadedAtis older than the refresh time, add one tolocalVersion. - A unique WorkManager job calls
PUT /v1/devices/{installId}whilelocalVersionis ahead ofuploadedVersion. It then saves the version it sent, not the latest one, and setsuploadedAt. 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. - The push services answer with errors for dead addresses. The server reads them. The table below shows what to do.
- A scheduled server job removes rows that were not refreshed within your stale window. Firebase's sample uses 30 days.
| Provider | Response | What the server does |
|---|---|---|
| FCM | UNREGISTERED (404) | Delete the registration |
| FCM | INVALID_ARGUMENT (400) | Delete only when the payload is known to be valid, since a bad payload gives the same error |
| APNs | 410 Unregistered | Stop sending. Delete the address unless the app registered it again after the failure time (my reading of the timestamp) |
| APNs | 400 BadDeviceToken | Check for a sandbox and production mismatch first. If there is none, delete |
| APNs | 429 TooManyRequests | Retry 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:
- Set
userId = nulland add one to the version first. Queue the unlink call at once. - Clear the local inbox and the saved payloads.
- 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 wrapsrequestAuthorization. - It reads
permissionStateat every foreground. Hosts watch theStateFlowand do not need to check again and again. Users change the setting in Settings, sonotificationsEnabledmust 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.
| Setting | FCM (Android) | APNs (iOS) |
|---|---|---|
| Urgency | normal or high; high tries to deliver at once and can wake a sleeping device | apns-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) |
| Lifetime | TTL 0 to 28 days; TTL 0 discards the message if it cannot be delivered now | apns-expiration as a Unix time; 0 tries once and does not store |
| Replace | collapse_key (a new message replaces an old one with the same key); four different keys held for each device | apns-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.
| State | Android | iOS |
|---|---|---|
| Foreground | onMessageReceived gets everything; the SDK decides on a banner | willPresent chooses how to show it |
| Background | A data message reaches onMessageReceived; a notification message goes to the tray | The system shows alert pushes; content-available pushes are best effort |
| Killed by the system | Delivery is not guaranteed; a tap on a tray notification starts the launcher activity | Alerts show; a held background push is discarded on force-quit |
| Force-stopped by the user | Firebase lists a force-stopped app as a reason for dropped messages and says the sender can do little about it | A 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
PendingIntentfor the destination activity insetContentIntent(). - 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
messageIdas extras. The activity reports the open. - On iOS,
UNUserNotificationCenterDelegate.didReceivehandles the tap. Set the delegate before launch ends, inwillFinishLaunchingWithOptionsordidFinishLaunchingWithOptions. 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
| Scenario | Handling |
|---|---|
| The push address changes while offline | The version change is saved; the upload retries when the network returns |
| Logout with no network | The local link is cleared at once; the server unlinks when a new user claims the installId |
| A push arrives for a logged-out user | Drop it when userId does not match; never show it |
UNREGISTERED or APNs 410 | The 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 Settings | The foreground check updates notificationsEnabled; the server stops high-priority sends |
| The same message twice | messageId 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
| Limit | Android | iOS | So the design changes by |
|---|---|---|---|
| Background delivery | High 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 advice | A push carries a hint, never the only copy of the data |
| User force-stop | Firebase lists it as a reason for dropped messages | A held background push is discarded | A cursor sync at the next launch repairs the gap |
| Durable retry | WorkManager work persists across restarts and reboots | No built-in equivalent that I rely on | On iOS the SDK sends its own outbox at launch, foreground and push |
| Permission | POST_NOTIFICATIONS from API 33; the app picks the moment | requestAuthorization shows the prompt once; .provisional skips it | The SDK never prompts; the host asks in context |
| Rich content | Built in the app process | A Notification Service Extension, a separate process, about 30 seconds | Keep the extension small and make the fallback text acceptable |
| Taps | A PendingIntent to an activity; trampolines blocked from API 31 | didReceive on the delegate, set before launch ends | The 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.
| Concern | Android | iOS |
|---|---|---|
| Push address source | onRegistered() with FIDs (onNewToken and getToken() are deprecated) | registerForRemoteNotifications() at each launch |
| Invalid address | UNREGISTERED, 270-day expiry | 410 Unregistered, 400 BadDeviceToken |
| Permission | POST_NOTIFICATIONS (API 33 and later) | requestAuthorization, with the provisional option |
| User control | Channels | Settings for the app; categories define actions |
| Urgency | Priority plus channel importance | interruption-level plus apns-priority |
| Collapse | collapse_key | apns-collapse-id; thread-id groups notifications |
| Rich content | Built in the app process | Notification Service Extension (separate process) |
| Tap | PendingIntent to an activity | didReceive on the delegate |
| Durable retry | WorkManager | None 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-availableonly on top of a sync at foreground. Send it withapns-push-type: backgroundand 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,
RemoteInputon 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)
- Push notification SDK interview case (this site)
- Best practices for FCM registration management (Firebase)
- Set up an FCM client on Android (Firebase)
- Receive messages in an Android app (Firebase)
- Set and manage Android message priority (Firebase)
- Understanding FCM message delivery on Android (Firebase blog)
- Notification and data message types (Firebase)
- Setting the lifespan of a message (Firebase)
- Collapsible and non-collapsible messages (Firebase)
- Android data layer
- Build an offline-first app (Android)
- WorkManager (Android)
- Notification runtime permission (Android)
- Create and manage notification channels (Android)
- Behavior changes in Android 12, notification trampolines
- Registering your app with APNs (Apple)
- Sending notification requests to APNs (Apple)
- Handling notification responses from APNs (Apple)
- Asking permission to use notifications (Apple)
- UNUserNotificationCenterDelegate (Apple)
- Modifying content in newly delivered notifications (Apple)
- Pushing background updates to your app (Apple)
- Staged rollouts (Google Play Console help)
- Release a version update in phases (App Store Connect help)
- Related on this site: staff mobile loop guide, analytics SDK, chat app
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.