feat(push): M7 Part 2 — opt-in push notifications (embedded ntfy distributor) #15

Merged
whitlocktech merged 1 commits from feat/m7-push-notifications into main 2026-07-20 21:18:29 +00:00
Member

What & why

The app side of M7 push notifications (docs/android/PLAN.md §11). The app embeds its own distributor — the self-hosted ntfy is only the relay server, no second app installed, no Google Play Services. Opt-in per stream; nothing is pushed unless the user turns it on. A new, additive feature slice — no existing screen's data flow changes.

How it works

The app mints its own random, unguessable ntfy topic, registers that topic's URL as its device endpoint (POST /auth/me/devices), and a foreground service holds an OkHttp-SSE connection to <ntfy>/<topic>/sse open in the background — reusing the M2 ShardStreamClient reconnect/backoff pattern on a bare client. The backend POSTs a content-free { stream, ref } tickle to that endpoint; the service receives it over SSE and posts a per-stream notification that deep-links to the relevant screen.

Changes

  • core/push/NtfyTopic (topic + endpoint/SSE URL builders), PushTickle (tickle parse over ntfy's SSE envelope), NtfyStreamClient (bare-client SSE), PushNotifier (channels + deep-link notification), PushService (foreground service), PushManager (register/unregister + start/stop, keyed to the session), PushPreferences (DataStore state), PushStreams (ids + localized titles).
  • data/NotificationsApi + DTOs + NotificationsRepository over the merged /auth/me/devices + /auth/me/notifications/* contract; a push block on SettingsDto.
  • ui/notifications/ — settings screen + VM: per-stream toggles bound to GET/PUT …/subscriptions, personal streams greyed until a game account is linked, POST_NOTIFICATIONS requested on enable.
  • Navigation / lifecycleRoutes.NOTIFICATIONS + stream→route deep-link map, menu entry, RunicApp + MainActivity intent handling; device teardown wired into logout + server switch (clean DELETE while the bearer is valid) and every sign-out (local, via a session-state observer), mirroring the M3 token teardown.
  • ManifestPOST_NOTIFICATIONS, FOREGROUND_SERVICE(_DATA_SYNC), and the service.

Recorded deviation

Direct-ntfy transport, no UnifiedPush library — the plan's stated likely path (work item 1). The UnifiedPush distributor model assumes a separate app (which the project owner vetoed) and we already own the SSE machinery, so pulling in org.unifiedpush.android:connector would add a dependency for no gain. A PushManager.PushResult / transport seam keeps a future FCM Play flavor cheap.

Dependencies

  • Needs the small companion RunicGateway/website#79 (push.ntfyUrl in /public/settings) so the app can discover the relay.
  • Deploy: push only delivers once the shard sets NTFY_PUBLIC_URL / NTFY_ALLOWED_ORIGINS (§13). Until then the Notifications screen shows push as unavailable.

Testing

18 new JVM unit tests (notifications DTO decode, ntfy tickle parse incl. malformed/keepalive, topic + URL building, stream→route map + personal gating). :app:testDebugUnitTest + :app:lintDebug + :app:assembleDebug green (JDK 21, -Pksp.incremental=false). On-device delivery against a live ntfy is the one open QA item.

AI disclosure

Authored with Claude Code (Claude Opus). Commits carry a Co-Authored-By: Claude trailer per org policy.

🤖 Generated with Claude Code

## What & why The app side of **M7 push notifications** (docs/android/PLAN.md §11). The app **embeds its own distributor** — the self-hosted **ntfy is only the relay server, no second app installed, no Google Play Services**. Opt-in per stream; nothing is pushed unless the user turns it on. A new, additive feature slice — **no existing screen's data flow changes**. ## How it works The app mints its own random, unguessable ntfy **topic**, registers that topic's URL as its device endpoint (`POST /auth/me/devices`), and a **foreground service** holds an OkHttp-SSE connection to `<ntfy>/<topic>/sse` open in the background — reusing the M2 `ShardStreamClient` reconnect/backoff pattern on a bare client. The backend POSTs a content-free `{ stream, ref }` tickle to that endpoint; the service receives it over SSE and posts a per-stream notification that deep-links to the relevant screen. ## Changes - **`core/push/`** — `NtfyTopic` (topic + endpoint/SSE URL builders), `PushTickle` (tickle parse over ntfy's SSE envelope), `NtfyStreamClient` (bare-client SSE), `PushNotifier` (channels + deep-link notification), `PushService` (foreground service), `PushManager` (register/unregister + start/stop, keyed to the session), `PushPreferences` (DataStore state), `PushStreams` (ids + localized titles). - **`data/`** — `NotificationsApi` + DTOs + `NotificationsRepository` over the merged `/auth/me/devices` + `/auth/me/notifications/*` contract; a `push` block on `SettingsDto`. - **`ui/notifications/`** — settings screen + VM: per-stream toggles bound to `GET/PUT …/subscriptions`, **personal streams greyed until a game account is linked**, `POST_NOTIFICATIONS` requested on enable. - **Navigation / lifecycle** — `Routes.NOTIFICATIONS` + stream→route deep-link map, menu entry, `RunicApp` + `MainActivity` intent handling; device teardown wired into logout + server switch (clean DELETE while the bearer is valid) and every sign-out (local, via a session-state observer), mirroring the M3 token teardown. - **Manifest** — `POST_NOTIFICATIONS`, `FOREGROUND_SERVICE(_DATA_SYNC)`, and the service. ## Recorded deviation **Direct-ntfy transport, no UnifiedPush library** — the plan's stated likely path (work item 1). The UnifiedPush distributor model assumes a *separate* app (which the project owner vetoed) and we already own the SSE machinery, so pulling in `org.unifiedpush.android:connector` would add a dependency for no gain. A `PushManager.PushResult` / transport seam keeps a future FCM Play flavor cheap. ## Dependencies - Needs the small companion **`RunicGateway/website#79`** (`push.ntfyUrl` in `/public/settings`) so the app can discover the relay. - **Deploy:** push only delivers once the shard sets `NTFY_PUBLIC_URL` / `NTFY_ALLOWED_ORIGINS` (§13). Until then the Notifications screen shows push as unavailable. ## Testing 18 new JVM unit tests (notifications DTO decode, ntfy tickle parse incl. malformed/keepalive, topic + URL building, stream→route map + personal gating). `:app:testDebugUnitTest` + `:app:lintDebug` + `:app:assembleDebug` green (JDK 21, `-Pksp.incremental=false`). **On-device delivery against a live ntfy is the one open QA item.** ## AI disclosure Authored with **Claude Code** (Claude Opus). Commits carry a `Co-Authored-By: Claude` trailer per org policy. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-07-20 20:26:46 +00:00
feat(push): M7 Part 2 — opt-in push notifications (embedded ntfy distributor)
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m16s
e2ced06a83
Implements the app side of M7 push (docs/android/PLAN.md §11). The app EMBEDS
its own distributor — ntfy is only the relay server, no second app installed,
no Google Play Services. New feature slice; no existing screen's data flow
changes.

- core/push: NtfyTopic (random unguessable topic + endpoint/SSE URL builders),
  PushTickle (content-free { stream, ref } parser over ntfy's SSE envelope),
  NtfyStreamClient (bare-client OkHttp SSE to <ntfy>/<topic>/sse, reconnect/
  backoff cloned from ShardStreamClient), PushNotifier (channels + per-stream
  deep-link notification), PushService (foreground service holding the
  connection), PushManager (mint topic / register-unregister device / start-stop,
  keyed to the session), PushPreferences (DataStore state).
- data: NotificationsApi + DTOs + NotificationsRepository over the merged
  /auth/me/devices + /auth/me/notifications/* contract; push block on SettingsDto.
- ui/notifications: settings screen + VM — per-stream toggles, personal streams
  greyed until a game account is linked, POST_NOTIFICATIONS request on enable.
- Navigation: Routes.NOTIFICATIONS + stream→route deep-link map, menu entry,
  RunicApp + MainActivity intent handling; teardown wired into logout + server
  switch (deregister while bearer valid) and every sign-out (local, via session
  observer).
- Manifest: POST_NOTIFICATIONS + FOREGROUND_SERVICE(_DATA_SYNC) + the service.

Deviation (recorded in PLAN.md): direct-ntfy transport, no UnifiedPush library
— the plan's stated likely path; keeps the APK Google-free and dependency-light,
with a PushResult/transport seam for a future FCM Play flavor. Requires the small
companion push.ntfyUrl settings field (website#<pr>).

18 new JVM tests; :app:testDebugUnitTest + lintDebug + assembleDebug green.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-07-20 20:32:04 +00:00
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-07-20 20:32:09 +00:00
whitlocktech merged commit d97c06d6e1 into main 2026-07-20 21:18:29 +00:00
whitlocktech deleted branch feat/m7-push-notifications 2026-07-20 21:18:30 +00:00
Sign in to join this conversation.
No description provided.