Self-contained Android push via NotificationService.Subscribe #58

Closed
opened 2026-08-14 10:06:32 +00:00 by nalum · 0 comments
Owner

Summary

Make Android push notifications self-contained. Remove the dependency on an external UnifiedPush push server (ntfy) and on a separate distributor app. The main server already exposes a live notification stream. Android will hold that stream open and render notifications directly.

Motivation

Today Android push depends on two external things:

  1. A separate ntfy push server deployment (deploy/k8s/ntfy/ntfy.yaml).
  2. A third-party distributor app that each family member installs on their phone (UnifiedPush connector:3.3.3).

Both break system rule 3 (keep it self-contained). We do not want to run a second service for notifications, and we do not want to force members to install another app.

The backend for a self-contained path already exists. proto/api/core/v1/notification.proto:63 defines:

rpc Subscribe(SubscribeRequest) returns (stream Notification)

The web client already consumes this stream (web/src/notifications.ts:98). Android does not — it only runs a 15-minute InboxPollWorker. The plan closes that gap.

Why not embed ntfy

The ntfy server (heckel.io/ntfy/v2) does import as a Go package, and it is modular. We still reject it:

  • It solves the wrong half. The ntfy server is a relay backend. The hard part of this work is on the device, which is Kotlin. A Go import does not help there.
  • We do not need a relay. A relay exists to reach a device that holds no connection. In this design the app holds its own connection straight to our Subscribe stream over our own TLS and JWT channel. No Web Push encryption is needed, because that protects untrusted relays.
  • Importing ntfy pulls firebase, stripe, and twilio into go.mod even when gated off. That reads badly against the OSS, pinned-dependency, and FCM-free invariants.

Transport decision

Use the existing Connect server-streaming RPC (Subscribe). Do not add a Server-Sent Events endpoint.

  • Connect stays proto-first and obeys system rule 4. A raw SSE endpoint would bypass the generated surface and need its own justification.
  • The Kotlin client for Subscribe is already generated in gen/android. Android iterates a typed Notification flow. No hand-rolled parser, no schema drift.
  • A native SSE reader would be hand-rolled anyway, because Android has no built-in EventSource. So SSE offers no real gain.

Scope of this issue

Server:

  • Confirm NotificationService.Subscribe fits a long-lived mobile consumer.
  • Add keepalive or heartbeat tuning if the stream needs it.

Android:

  • Consume the Subscribe stream while the app is alive. Render each Notification through the existing Notifier.
  • Keep InboxPollWorker (15-minute) as the background fallback for now.
  • Write a reconnect loop with backoff, as the web client does.

Removal:

  • Remove the org.unifiedpush.android:connector dependency.
  • Remove PushRegistrar.kt and AppPushService.kt.
  • Remove the push-endpoint storage in SessionStore.

Out of scope (keep as-is):

  • internal/push, VAPID keys, the push_subscription table, and the PushService RPCs. These still serve web Web Push, which uses the browser vendor push service and does not change.
  • The follow-up foreground service and Doze reliability work. See #59.

ADR

Add docs/adr/0028-self-contained-android-push.md. Record:

  • The decision to drop UnifiedPush and hold our own Subscribe stream.
  • The transport choice (Connect streaming over SSE).
  • The deferred tradeoff: a self-held connection needs a foreground service and owns its own wakeup reliability. FCM and UnifiedPush exist to share one wakeup channel across apps. We accept the per-app cost in exchange for zero external services and no extra app.

Known interim regression

Until the follow-up foreground service lands, closed-app Android push falls back to the 15-minute poll. A member who had installed an external distributor loses instant closed-app push. For a family install with the connector being dropped anyway, this is acceptable.

Surface parity note

Web is unchanged. MCP and CLI do not carry push. The change updates the Android surface and its removed dependency, per system rule 1.

Definition of done

  • ADR 0028 merged.
  • Android renders notifications live from Subscribe while the app is open.
  • The UnifiedPush connector and its two classes are gone.
  • make check passes. Behavior verified on device against the live deploy.
### Summary Make Android push notifications self-contained. Remove the dependency on an external UnifiedPush push server (ntfy) and on a separate distributor app. The main server already exposes a live notification stream. Android will hold that stream open and render notifications directly. ### Motivation Today Android push depends on two external things: 1. A separate ntfy push server deployment (`deploy/k8s/ntfy/ntfy.yaml`). 2. A third-party distributor app that each family member installs on their phone (UnifiedPush `connector:3.3.3`). Both break system rule 3 (keep it self-contained). We do not want to run a second service for notifications, and we do not want to force members to install another app. The backend for a self-contained path already exists. `proto/api/core/v1/notification.proto:63` defines: ``` rpc Subscribe(SubscribeRequest) returns (stream Notification) ``` The web client already consumes this stream (`web/src/notifications.ts:98`). Android does not — it only runs a 15-minute `InboxPollWorker`. The plan closes that gap. ### Why not embed ntfy The ntfy server (`heckel.io/ntfy/v2`) does import as a Go package, and it is modular. We still reject it: - It solves the wrong half. The ntfy server is a relay backend. The hard part of this work is on the device, which is Kotlin. A Go import does not help there. - We do not need a relay. A relay exists to reach a device that holds no connection. In this design the app holds its own connection straight to our `Subscribe` stream over our own TLS and JWT channel. No Web Push encryption is needed, because that protects untrusted relays. - Importing ntfy pulls firebase, stripe, and twilio into `go.mod` even when gated off. That reads badly against the OSS, pinned-dependency, and FCM-free invariants. ### Transport decision Use the existing Connect server-streaming RPC (`Subscribe`). Do not add a Server-Sent Events endpoint. - Connect stays proto-first and obeys system rule 4. A raw SSE endpoint would bypass the generated surface and need its own justification. - The Kotlin client for `Subscribe` is already generated in `gen/android`. Android iterates a typed `Notification` flow. No hand-rolled parser, no schema drift. - A native SSE reader would be hand-rolled anyway, because Android has no built-in `EventSource`. So SSE offers no real gain. ### Scope of this issue Server: - Confirm `NotificationService.Subscribe` fits a long-lived mobile consumer. - Add keepalive or heartbeat tuning if the stream needs it. Android: - Consume the `Subscribe` stream while the app is alive. Render each `Notification` through the existing `Notifier`. - Keep `InboxPollWorker` (15-minute) as the background fallback for now. - Write a reconnect loop with backoff, as the web client does. Removal: - Remove the `org.unifiedpush.android:connector` dependency. - Remove `PushRegistrar.kt` and `AppPushService.kt`. - Remove the push-endpoint storage in `SessionStore`. Out of scope (keep as-is): - `internal/push`, VAPID keys, the `push_subscription` table, and the `PushService` RPCs. These still serve **web** Web Push, which uses the browser vendor push service and does not change. - The follow-up foreground service and Doze reliability work. See #59. ### ADR Add `docs/adr/0028-self-contained-android-push.md`. Record: - The decision to drop UnifiedPush and hold our own `Subscribe` stream. - The transport choice (Connect streaming over SSE). - The deferred tradeoff: a self-held connection needs a foreground service and owns its own wakeup reliability. FCM and UnifiedPush exist to share one wakeup channel across apps. We accept the per-app cost in exchange for zero external services and no extra app. ### Known interim regression Until the follow-up foreground service lands, closed-app Android push falls back to the 15-minute poll. A member who had installed an external distributor loses instant closed-app push. For a family install with the connector being dropped anyway, this is acceptable. ### Surface parity note Web is unchanged. MCP and CLI do not carry push. The change updates the Android surface and its removed dependency, per system rule 1. ### Definition of done - ADR 0028 merged. - Android renders notifications live from `Subscribe` while the app is open. - The UnifiedPush connector and its two classes are gone. - `make check` passes. Behavior verified on device against the live deploy.
nalum added reference refs/tags/v1.3.0 2026-08-14 10:08:41 +00:00
nalum added this to the (deleted) project 2026-08-14 12:20:29 +00:00
nalum closed this issue 2026-08-16 12:40:58 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
eagraiclainne/app#58
No description provided.