App reference

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 startNotifications consumer 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):

  1. A separate ntfy push server (deploy/k8s/ntfy/ntfy.yaml) acting as the UnifiedPush relay.
  2. 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 in gen/android, so there is no hand-rolled parser and no schema drift. Android has no built-in EventSource, 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 Subscribe stream over our own TLS and JWT channel, so there is no relay to protect.
  • Render on the stream. InboxRepository.live() gains an onNotification callback. The signed-in shell passes one that calls Notifier.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 InboxPollWorker as 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:connector dependency and its version pin.
  • PushRegistrar.kt and AppPushService.kt (the distributor registration and receiver), and the AppPushService entry 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.push client it used. PushService.SendTest delivers 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, the push_subscription table and the PushService RPCs. 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, the deploy-ntfy Make 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.mod even 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_MIN channel, so it sits silent and collapsed at the bottom of the shade.
  • Foreground service type specialUse. The connection is long-lived and indefinite; dataSync carries 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), so specialUse with a declared subtype is the honest fit.
  • Started from the foreground. The signed-in Activity starts the service (a background start would hit ForegroundServiceStartNotAllowedException on Android 12+). START_STICKY asks the OS to restart it after a kill.
  • The 15-minute InboxPollWorker stays 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.