Skip to content

feat(notifications): device-token lifecycle for push, with the FCM token behind a seam (#46) - #106

Merged
Adron merged 1 commit into
parity/queuefrom
issue/46-push-token-lifecycle
Sep 16, 2026
Merged

Adron merged 1 commit into
parity/queuefrom
issue/46-push-token-lifecycle

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

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-notifications documents the server contract fully, and I
confirmed live that POST /api/push/register is bearer-authenticated and that platform accepts
android
. So the entire lifecycle around the token is buildable and testable now, with the token
itself 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

PushTokenProvider exposes the device token as a StateFlow<String?>. That one shape covers all
three 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 (permanently null), with a TODO naming #45/#47 and exactly what
changes.

Repository

POST /api/push/register and DELETE /api/push/unregister. The DELETE carries a body, which
Retrofit's @DELETE forbids, so the API uses
@HTTP(method = "DELETE", path = …, hasBody = true).

environment is derived from the installed app's ApplicationInfo.FLAG_DEBUGGABLE, not
BuildConfig.DEBUG. A library module's BuildConfig reflects its own variant, not the variant of
the app embedding it, so it is the wrong signal — and generating one would mean enabling
buildConfig for a single boolean. The debuggable flag is also precisely the line push providers
draw 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 that
single method via a SessionTeardownTask multibinding, run before sessionStore.clear()
(the unregister is a bearer-authed call) and individually runCatching-guarded. There is no
sign-out path that bypasses it
, and no new lifecycle hook was added — as the issue asked.

The interface lives in :core:common/session beside the existing SessionTokenProvider, for the
identical reason that one is there: :feature:auth must run contributed steps without depending on
the 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_NOTIFICATIONS

Removed from cold start. Asked when the user switches a Push channel on in Notification
preferences — the same per-event push preference NotificationPushFilter already uses to gate
what 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. onNotificationPermissionGranted closes
the 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:notifications 115
tests / 0 failures; :feature:auth 32 / 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" and environment
exactly (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 by advanceUntilIdle() never
dispatched the collector — foreground work was already idle, so it returned immediately. The tests
use runCurrent().

Risks

  • The provider yields null today, so the lifecycle is proven by unit tests only, never against a
    live token. Push: handle FCM messages — channels from notification preferences, deep links on tap #47 should re-verify on-device once google-services.json exists.
  • Consequence of moving the prompt: a user who never visits Notification preferences is never asked,
    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.
  • A permission revoked after registration does not unregister — the server just pushes into a
    muted device. Deliberate YAGNI.

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
@Adron
Adron merged commit d6363fc into parity/queue Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant