feat(notifications): device-token lifecycle for push, with the FCM token behind a seam (#46) - #106
Merged
Merged
Conversation
Build the whole lifecycle around a push device token while Firebase is still blocked (#45): the token itself sits behind a `PushTokenProvider` seam whose only implementation today reports "unavailable", so nothing in the lifecycle knows FCM exists and #47 lands as a single binding swap. - `PushRegistrationRepository` over `POST /api/push/register` and `DELETE /api/push/unregister` (body-carrying DELETE via `@HTTP`), always sending `platform = "android"` and an `environment` derived from the build type: the installed app's `FLAG_DEBUGGABLE`, since a library module's `BuildConfig.DEBUG` tracks its own variant rather than the app's. - `PushRegistrationManager` owns when: register on first token availability and on rotation, re-register on every app launch (the docs ask for it — a StateFlow replay plus a fresh per-process record gives it for free), retire a superseded token on rotation, and unregister on session end. - Sign-out and account deletion both funnel through `AuthRepository.logout()`, so the unregister hangs off a new `SessionTeardownTask` multibinding run there while the bearer token is still valid. No new lifecycle hook, and no exit from a session that can skip it — a stale registration would deliver one account's notifications to whoever signs in next. - `POST_NOTIFICATIONS` moves off cold start to the moment the user switches a "Push" channel on in Notification preferences, which is the same signal the WorkManager poll already filters tray notifications by. Denial is inert: the preference still saves, the poll still runs, the in-app tray is unaffected, and no registration is issued for a device that cannot display a push. The WorkManager poll is untouched and remains the delivery mechanism (#48). Closes #46
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #46. Part of epic #44.
Why this could be built while #45 is still blocked
The parity review filed this epic as blocked on Firebase. It is — but only the token source is.
https://interlinedlist.com/help/api/push-notificationsdocuments the server contract fully, and Iconfirmed live that
POST /api/push/registeris bearer-authenticated and thatplatformacceptsandroid. So the entire lifecycle around the token is buildable and testable now, with the tokenitself behind a provider seam. Landing Firebase later is a single binding swap.
No Firebase plugin or dependency was added. Nothing in the lifecycle code references FCM.
The seam
PushTokenProviderexposes the device token as aStateFlow<String?>. That one shape covers allthree cases the server contract cares about: the current value replays on collection
(re-register on launch, which the docs require), the first non-null value is "token became
available", and later values are rotations. The only implementation today is
UnavailablePushTokenProvider(permanentlynull), with a TODO naming #45/#47 and exactly whatchanges.
Repository
POST /api/push/registerandDELETE /api/push/unregister. The DELETE carries a body, whichRetrofit's
@DELETEforbids, so the API uses@HTTP(method = "DELETE", path = …, hasBody = true).environmentis derived from the installed app'sApplicationInfo.FLAG_DEBUGGABLE, notBuildConfig.DEBUG. A library module'sBuildConfigreflects its own variant, not the variant ofthe app embedding it, so it is the wrong signal — and generating one would mean enabling
buildConfigfor a single boolean. The debuggable flag is also precisely the line push providersdraw between their gateways. The field is optional server-side, but sending it always prevents a
developer build's token being filed as a production device.
Lifecycle — the security-relevant half
Both user-facing exits already converge on
AuthRepository.logout(): the Account hub's "Sign out"and
AccountSettingsRoute(onSignedOut = …)after account deletion. So the unregister hangs off thatsingle method via a
SessionTeardownTaskmultibinding, run beforesessionStore.clear()(the unregister is a bearer-authed call) and individually
runCatching-guarded. There is nosign-out path that bypasses it, and no new lifecycle hook was added — as the issue asked.
The interface lives in
:core:common/sessionbeside the existingSessionTokenProvider, for theidentical reason that one is there:
:feature:authmust run contributed steps without depending onthe modules that contribute them. This is the one file outside the stated scope — 20 lines, no
build-file change anywhere.
Registration runs from the signed-in shell's existing bootstrap
LaunchedEffect, entered exactly on"launch while signed in" and "just signed in". A rotation also retires the token it supersedes,
so the server never accumulates dead registrations.
POST_NOTIFICATIONSRemoved from cold start. Asked when the user switches a Push channel on in Notification
preferences — the same per-event
pushpreferenceNotificationPushFilteralready uses to gatewhat the poll raises, so the dialog answers a question the user just posed. Denial is inert: the
preference still saves, the poll still runs, the in-app tray is unaffected, and the manager issues
no registration for a device that cannot display a push.
onNotificationPermissionGrantedclosesthe loop if it is granted later.
WorkManager polling is untouched — that is #48, and it stays until FCM actually delivers.
Verification
./gradlew :app:assembleDebug testDebugUnitTest→ BUILD SUCCESSFUL.:feature:notifications115tests / 0 failures;
:feature:auth32 / 0.Tests: register on new token; register on rotation; re-register on launch with an unchanged token;
no duplicate register within a session; retry after a failed register; unregister on sign-out;
unregister on account deletion; rotation retires the superseded token; teardown with no token issues
no call; re-register after signing back in; denied permission issues no registration and breaks
nothing; grant-later registers. Repository tests assert
platform == "android"andenvironmentexactly (both values), the exact body key set, the DELETE method plus body, and that
unregistering an unknown token still succeeds.
Debugging note worth keeping:
backgroundScope.launch { … }followed byadvanceUntilIdle()neverdispatched the collector — foreground work was already idle, so it returned immediately. The tests
use
runCurrent().Risks
nulltoday, so the lifecycle is proven by unit tests only, never against alive token. Push: handle FCM messages — channels from notification preferences, deep links on tap #47 should re-verify on-device once
google-services.jsonexists.so on Android 13+ the poll's tray notifications stay silent for them. That is the trade the issue
asked for; kept to one moment so as not to burn Android 13's two-prompt budget.
muted device. Deliberate YAGNI.