ADR-0030: Self-contained Android push via the Subscribe stream
Metadata
- Status: Accepted
- Date: 2026-08-15
- Deciders: Eagraí Clainne Team
- Related: ADR-0019 (offline queue and the session model the stream reuses);
the web
startNotificationsconsumer this mirrors; issue #58; follow-up #59 (foreground service and Doze survival)
Context
Android push depended on two external things, both against system rule 3 (keep it self-contained):
- A separate ntfy push server (
deploy/k8s/ntfy/ntfy.yaml) acting as the UnifiedPush relay. - A third-party UnifiedPush distributor app each member had to install on
their phone (
org.unifiedpush.android:connector).
A relay exists to reach a device that holds no live connection. But the main
server already exposes a live notification stream —
NotificationService.Subscribe (proto/api/core/v1/notification.proto), a
Connect server-streaming RPC — and the web client already consumes it
(web/src/notifications.ts). Android did not: it opened the same stream to
keep the inbox fresh (InboxRepository.live()) but only wrote rows to its
cache; it never raised a system notification from the stream, and it leaned on
a distributor for closed-app delivery.
We do not want to run a second service, and we do not want to force members to install another app.
Decision
Hold our own Subscribe stream open on the device and render each streamed
Notification through the existing Notifier. Drop UnifiedPush entirely.
- Transport: the existing Connect server-streaming RPC (
Subscribe), not a new SSE endpoint. Connect stays proto-first (system rule 4); the typed Kotlin client is already generated ingen/android, so there is no hand-rolled parser and no schema drift. Android has no built-inEventSource, so SSE would be hand-rolled anyway and buys nothing. - No Web Push encryption on this path. Web Push payload encryption exists to
protect an untrusted relay. Here the app holds its own connection straight to
our
Subscribestream over our own TLS and JWT channel, so there is no relay to protect. - Render on the stream.
InboxRepository.live()gains anonNotificationcallback. The signed-in shell passes one that callsNotifier.show(...), keyed on the notification's kind (its own channel) and uid (its dedupe tag). The cache upsert it already did stays, so the bell's unread dot still moves without a poll. - Keep the 15-minute
InboxPollWorkeras the background fallback, and remove its old guard that skipped polling when a distributor endpoint was registered — there is no endpoint any more, so the poll always runs when the app is closed.
Removed
- The
org.unifiedpush.android:connectordependency and its version pin. PushRegistrar.ktandAppPushService.kt(the distributor registration and receiver), and theAppPushServiceentry in the manifest.- The push-endpoint storage in
SessionStore(push_endpoint), and its clearing on sign-out and profile switch. - The Android "send a test notification" button and the Android
ApiClient.pushclient it used.PushService.SendTestdelivers Web Push to subscribed devices; with no Android subscription it can only fail. The button was a UnifiedPush-era affordance and goes with it.
Kept unchanged (out of scope)
internal/push, the VAPID keys, thepush_subscriptiontable and thePushServiceRPCs. These still serve web Web Push, which uses the browser's own vendor push service and does not change. Web is untouched.- The per-kind notification preferences (
UserSettings.push_disabled_kinds) and their Settings toggles. They still gate which kinds render.
Consequences
- Two external dependencies gone: no distributor app to install, no relay to reason about on the Android path. The server is the only thing Android talks to.
- Interim regression (accepted). A self-held connection lives only while the app process does. Until the follow-up foreground service (#59), closed-app push falls back to the 15-minute poll — a member who had installed an external distributor loses instant closed-app delivery. For a family install with the connector being dropped anyway, this is acceptable, and it is the tradeoff a shared wakeup channel (FCM, UnifiedPush) exists to avoid. We take the per-app cost in exchange for zero external services and no extra app.
- Deliberate surface divergence (rule 1). Android's delivery model (a self-held stream) now differs from web's (Web Push), so Android loses the test-send button while web keeps it. Recorded here.
- The ntfy deployment (
deploy/k8s/ntfy) loses its only consumer once the connector is gone. Tearing it down (its manifest, thedeploy-ntfyMake target and the CoreDNS rewrite) is left as a follow-up cleanup — it is not in this issue's scope and it touches the live cluster's deploy pipeline, so it moves on its own.
Alternatives considered
- Embed the ntfy server as a Go package. It imports and is modular, but it
solves the wrong half: the hard part is on the device (Kotlin), and a relay is
unnecessary when the app holds its own connection. It also pulls firebase,
stripe and twilio into
go.modeven when gated off, which reads badly against the OSS, pinned-dependency and FCM-free invariants. - A raw SSE endpoint. Would bypass the generated Connect surface (system rule 4) and need a hand-rolled native reader anyway. No gain over the typed streaming RPC we already have.
- Keep UnifiedPush. Rejected: it is the external dependency this change exists to remove.
Update 2026-08-15 — the foreground service (#59)
The deferred tradeoff above is now paid. Android will not let a background app
hold a socket open through Doze, so the Subscribe consumer moves into a
foreground service with a persistent notification. This is the per-app cost
a shared wakeup channel (FCM, UnifiedPush) exists to avoid, and we accept it.
Decisions taken during implementation of #59:
- Always on while signed in. The service runs whenever a session exists —
no user toggle. A toggle would be a way to silently break push; a family app
that opted into self-contained push wants it reliable. The persistent
notification uses a dedicated
IMPORTANCE_MINchannel, so it sits silent and collapsed at the bottom of the shade. - Foreground service type
specialUse. The connection is long-lived and indefinite;dataSynccarries a per-day time cap on Android 15 that would break the overnight-idle case this work exists to fix. We self-distribute (no Play review), sospecialUsewith a declared subtype is the honest fit. - Started from the foreground. The signed-in Activity starts the service
(a background start would hit
ForegroundServiceStartNotAllowedExceptionon Android 12+).START_STICKYasks the OS to restart it after a kill. - The 15-minute
InboxPollWorkerstays as a last-resort backstop. Some OEMs (Xiaomi, Huawei, Samsung) kill foreground services regardless; on those devices the poll still lands notifications, just delayed. Cheap insurance, and the allowed background path for a process the OS restarts cold. - Battery-optimization exemption, prompted once. On first signed-in run we
ask the member to exempt the app (
ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS), which materially improves survival on stock Android. The ask is shown once; declining leaves the backstop poll in place. - Reconnect and token freshness. The stream's redial loop already recovers from drops (airplane toggles, server restarts, Wi-Fi↔mobile handovers) after a short backoff. It now refreshes the session (an authed catch-up read) before each dial, so an idle stream whose access token expired overnight reconnects on its own rather than waiting for the backstop poll to roll the token.
Consequence
Closed-app push now arrives within seconds on stock Android, at the cost of one silent persistent notification. Devices with aggressive OEM battery management may still fall back to the 15-minute poll. This behaviour can only be confirmed on a physical device over a multi-hour idle window; it is not reproducible in CI or an emulator.