From 1dbdeb789e40e8fedc34411fc916d00f6dbe4692 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 20 Jul 2026 14:57:32 -0500 Subject: [PATCH] docs(android): plan M7 Part 2 (app UnifiedPush push notifications) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 1 backend landed (website#78 merged); flip its status to landed and expand the M7 plan block into a detailed Part 2 (app) plan, grounded in the merged /auth/me/devices + notifications contract. Transport decision (per user): the app EMBEDS its own UnifiedPush distributor — ntfy is only the relay server, no second app installed, no Google Play Services. A foreground-service persistent ntfy connection (reusing the ShardStreamClient pattern) subscribes to the app's own topic; the registered endpoint is that topic URL. A PushTransport seam keeps the future FCM Play flavor cheap. Also records the ten Part-2 work items, resolves the §13 notification-tap deep-link open item, and drops the now-decided distributor-strategy question. Co-Authored-By: Claude --- android/PLAN.md | 154 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 137 insertions(+), 17 deletions(-) diff --git a/android/PLAN.md b/android/PLAN.md index cadf43f..05ed8ea 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -1,6 +1,6 @@ # Android App — Plan -Status: **M0–M6 landed; M7 (push notifications) Part 1 — backend + docs — in review (website#78). Remaining: cut the v1 tag, then M7 Part 2 (the app's UnifiedPush integration).** This document is the +Status: **M0–M6 landed; M7 (push notifications) Part 1 — backend + docs — landed (website#78 merged 2026-07-20). Remaining: cut the v1 tag, then M7 Part 2 (the app's UnifiedPush integration — planned below).** This document is the design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated @@ -143,7 +143,8 @@ push notifications are what remain.** M7 spans three repos, so it ships in **two parts**; the backend contract lands first because the app is a pure consumer of it (§8/§11). -**Part 1 — `website/` backend + `docs/` (this pass).** Additive, v1-only (new tables/routes/compose +**Part 1 — `website/` backend + `docs/` — ✅ LANDED** (2026-07-20, `RunicGateway/website#78` merged ++ docs#20). Additive, v1-only (new tables/routes/compose service; no existing response shape changes). Decision: **no ntfy publish token** — publishes go over the internal compose network to unguessable per-device topics carrying **content-free tickles** (`{ stream, ref }`); the publisher honors an optional `NTFY_PUBLISH_TOKEN` if ever set but requires @@ -172,11 +173,118 @@ none (keeps §11's zero-interaction promise). reverse proxy; internal-only for the publisher), anonymous read-write to unguessable topics (no per-user accounts — safe because tickles are content-free). -**Part 2 — the Android app (next pass).** UnifiedPush distributor integration, device registration -against `POST /auth/me/devices`, the Notifications settings screen (per-stream toggles; personal -streams greyed until a game account is linked), notification-tap deep-links, and the notification -channel/icon. Built against the Part-1 contract; the app is architected for push from M0 (§11) so it -adds no data-flow change to the existing screens. +**Part 2 — the Android app (next pass; planned here).** UnifiedPush receiver + device registration +against the merged Part-1 contract, a Notifications settings screen, and notification-tap deep-links. +The app is architected for push from M0 (§11), so this is **additive** — a new feature slice +(`core/push` + `ui/notifications` + a `DevicesApi`/`NotificationsApi` pair) that touches no existing +screen's data flow. Everything the app calls already exists and is merged; there is **no backend +work** in Part 2. + +The Part-1 contract the app codes against (verified against the merged `website` source): +- `POST /auth/me/devices` `{ transport?: 'unifiedpush'|'fcm', endpoint, platform? }` → `201 PushDevice` + `{ id, transport, endpoint, platform, createdAt, lastSeenAt }`. Idempotent per `(user, endpoint)` + (upsert). `endpoint` **must** be HTTPS on the shard's ntfy allow-set — a private/loopback or + off-allowlist origin is rejected `400` (the SSRF guard). Bearer-auth, so registration only happens + while signed in. +- `GET /auth/me/devices` → `PushDevice[]`; `DELETE /auth/me/devices/:id` → `{ ok: true }` (`404` if not + the caller's). +- `GET /auth/me/notifications/streams` → `{ streams: [{ id, label, description, personal, + requiresLinkedAccount }] }` — the eight-stream catalog (`news.post`, `server.status`, + `idoc.warning`, `champ.start`, `governor.election`; personal `vendor.sale`, `house.idoc`, + `account.login`). Render from this, don't hardcode. +- `GET /auth/me/notifications/subscriptions` → `{ streams: [id…] }`; `PUT` the same shape (full + replace; unknown ids dropped server-side; the stored set is echoed back). +- **The wire tickle** the device receives is the content-free `{ "stream": "", "ref": "" }` + JSON body (`utils/pushDispatch.js`). `ref` is a serial / city / timestamp hint — **never** content. + +Work items: + +1. **Transport — the app is its own distributor; no second app (DECIDED).** The Runic Gateway app + **embeds its own UnifiedPush distributor**. The self-hosted **ntfy is only the relay server**, never + a user-installed app — the user installs *one* APK and it receives its own notifications, with no + external distributor (no ntfy app, no NextPush) and no Google Play Services. Concretely, the embedded + distributor holds a **persistent connection to the shard's ntfy** in a **foreground service**, + reusing the OkHttp reconnect/backoff pattern already built for `core/net/ShardStreamClient` (M2): it + subscribes to the app's own random, unguessable ntfy **topic** (over `wss:////ws` + or the `/json` stream) and forwards each received `{stream,ref}` tickle to the app's receiver. The + **endpoint the app registers** with the backend (work item 5) is that topic's public URL + (`https:///`) — exactly the client-supplied `endpoint` the merged `POST + /auth/me/devices` contract expects and the URL the backend POSTs tickles to. Keep the transport + behind a small `PushTransport` seam so the **future Play/FCM build flavor** (§11, §M8) can swap the + embedded-ntfy distributor for FCM without touching registration, subscriptions, or notification code. + (Implementation detail to confirm: whether a maintained Google-free embedded UnifiedPush-distributor + library fits, or — more likely — a thin in-app distributor written directly over ntfy's subscribe API + reusing `ShardStreamClient`. Either way the distributor lives **inside this app**; the UnifiedPush + *receiver* abstraction is retained only to keep the FCM-flavor seam clean.) + - **Tradeoff, accepted:** instant background delivery requires a persistent foreground service with + an ongoing (low-importance) notification and its battery cost — this is exactly how ntfy's own app + does instant delivery, and it is the price of Google-free self-delivery. A future "battery saver" + option could fall back to periodic polling, but v1 ships the always-connected foreground service. +2. **Deps + manifest.** Add the UnifiedPush connector + the embedded-distributor transport (per #1) to + the version catalog; declare `POST_NOTIFICATIONS` (API 33+ runtime permission) **and + `FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_DATA_SYNC`** (API 34+, for the persistent ntfy + connection); register the receiver and the foreground service in `AndroidManifest.xml`; define the + notification channels (id/name externalized, §2) — one for real notifications plus a low-importance + channel for the ongoing foreground-service notification — and reuse the "RG" notification icon + **already staged in M6**. +3. **`core/push` — embedded distributor + receiver.** The **distributor** component is a foreground + service that owns the ntfy connection (per #1): it (re)creates the app's topic, subscribes over + OkHttp with reconnect/backoff cloned from `ShardStreamClient`, and forwards each frame to the + receiver. The **receiver** parses the `{ stream, ref }` tickle (`kotlinx.serialization`; an + unknown/garbled body is dropped, not crashed — §7 discipline) and posts a notification (work item 7). + Endpoint (re)registration against the backend fires on first subscribe / topic (re)creation + (work item 5); a transient ntfy drop is just a reconnect, not a re-register. +4. **`DevicesApi` + `NotificationsApi` (Retrofit) + DTOs.** Hand-authored, spec-aligned (as recorded + for M1): `RegisterDeviceRequestDto`, `PushDeviceDto`, `NotificationStreamDto`, + `NotificationStreamsDto`, `NotificationSubscriptionsDto`. Both go through the existing bearer/refresh + stack (`AuthInterceptor` + `TokenAuthenticator`) and return the typed `ApiResult` (§7). A + `NotificationsRepository` owns register/list/delete-device and get/put streams+subscriptions. +5. **Endpoint ↔ backend lifecycle (mirror the M3 token teardown).** Persist the app's ntfy topic, its + endpoint URL, and the returned device `id` in prefs (DataStore; the topic/endpoint isn't a secret — + its security rests on being unguessable + the content-free tickle, §11). Start the embedded + distributor and `POST /auth/me/devices` **only when the user has ≥1 subscription and is signed in**. + On **logout / dead-refresh sign-out / Settings→Server switch**, `DELETE /auth/me/devices/:id`, **stop + the foreground service**, and drop the topic — wire this into `SessionManager` beside the existing + token-clear so a signed-out device stops receiving (§4.3, §11 "unregister on logout / token + revocation"). On a **server (base-URL) switch**, mint a fresh topic against the new shard's ntfy (the + old endpoint's origin won't be on the new host's allow-set). Re-assert the endpoint + restart the + service on app start when signed-in + subscribed. A `400` on register (endpoint origin off the + shard's `NTFY_ALLOWED_ORIGINS`) surfaces a clear "your shard's push relay isn't reachable" state, not + a crash. +6. **Notifications settings screen (`ui/notifications`).** Lists the catalog from + `GET …/streams` with a per-stream toggle bound to `GET/PUT …/subscriptions`; a **personal** stream + (`requiresLinkedAccount`) is greyed with a "link a game account" hint until the user has a linked + account — reuse the linked-accounts signal already fetched for M4's player surface + (`PlayerShardRepository`), not a fresh source of truth. Toggling to a non-empty set triggers the + register flow (#5) and requests `POST_NOTIFICATIONS`; emptying the set unregisters. Each mutation + folds its `ApiResult` into a section-scoped, localized banner (§7 parity with M4). +7. **Deep-links (resolves the §13 open item).** Tapping a notification opens the app to the stream's + home: `news.post`→News, `server.status`/`champ.start`/`idoc.warning`/`governor.election`→Shard, + `vendor.sale`→Vendors, `house.idoc`→My Houses, `account.login`→My Account. Routed through the + existing `ui/navigation/Routes.kt`; a signed-out/deep-link-to-player tap lands on the `PlayerGate` + (M4) rather than erroring. **v1 shows a generic per-stream notification** (localized catalog + `label`) and deep-links — it does **not** pull `ref` content first; the content-free design means + nothing needs decrypting to render the tap, and the target screen fetches fresh over the + authenticated API on open. (Pulling `ref` for a richer inline notification is a possible later + enhancement, not v1.) +8. **Menu.** Add a **Notifications** entry to the signed-in group in `ui/navigation/Menu.kt` (near My + Account), visible once signed in. +9. **Permission UX.** Request `POST_NOTIFICATIONS` at the moment the user first enables a stream (API + 33+); on denial, keep the toggle off and show how to enable it in system settings — never nag on + launch. +10. **Tests (JVM, `testDebugUnitTest`).** DTO decode (device/stream/subscription), `{ stream, ref }` + tickle parse (incl. a malformed body → dropped), the stream→deep-link map, the "personal greyed + until linked" gate, and the register/unregister lifecycle over a fake `SessionManager` + repository + (parity with M3's session tests). + +**Cross-repo dependency to confirm before/at implementation** (a Part-1 §13 open item): the shard's +finalized **ntfy reverse-proxy hostname** must be in `NTFY_ALLOWED_ORIGINS`, because the distributor +hands the app an endpoint on *that* origin and the backend rejects a register whose origin isn't +allow-listed. This is deployment config, not code, but Part 2 can't be end-to-end tested until it's +pinned. No `website`/`link`/`servuo-plugins` code change is expected in Part 2. + +Ships as `RunicGateway/Android-app#`; bumps `versionCode`/`versionName` for a post-v1 release +(§10). Like M1–M4 it records itself in the §9 build-progress block on landing. **Prerequisite progress (§8):** all v1 prerequisites are **done** (2026-07-19) — ✅ password reset (item 2; website#75 + docs#8), ✅ role-agnostic `/auth/me/*` self surface (item 1; website#76 + docs#10), @@ -490,11 +598,12 @@ maintenance cost. Reserve v2 for a real breaking re-shape if one ever arises. 3. **Push notifications** — see §11. Additive v1 endpoints under `/auth/me/devices*` and `/auth/me/notifications*`, plus a **self-hosted `ntfy` service added to `website/docker-compose.yml`** with fully declarative, zero-interaction config. Not required for the first release (M7, not M1–M6). - 🚧 **Backend + docs IN REVIEW (2026-07-20, RunicGateway/website#78 (+ this docs PR)).** The + ✅ **Backend + docs LANDED (2026-07-20, RunicGateway/website#78 merged (+ docs#20)).** The contract Part 1 is built: the two tables, the stream catalog + `PUBLIC_KINDS`-gated event mapping, the content-free-tickle fan-out (`utils/pushDispatch`, SSRF-guarded endpoints, owner-keyed personal streams), the six `/auth/me/*` routes (Swagger regenerated), and the declarative `ntfy` compose - service. Full server suite green. The app (Part 2, §9 M7) consumes this next. + service (247 server tests green). The app (Part 2, §9 M7) consumes this next — see the "M7 plan" + block for the detailed Part 2 plan. 4. **Version/health surfacing.** ✅ **DONE (2026-07-19, RunicGateway/website#77 (+ this docs PR)).** A dependency-free `config/version.js` (`{ service:'runic-gateway', api:'v1', server: }`) is surfaced on @@ -557,9 +666,10 @@ push, and Play (M6–M8) follow the designed app. `website/docker-compose.yml` (declarative, zero-interaction config), UnifiedPush integration in the app, device registration, the subscriptions UI, and the content-free-tickle backend fan-out (see §11). The app is built with room for this from M0 but it does not gate the first release. - 🚧 **Part 1 (backend + docs) in review** 2026-07-20 (`RunicGateway/website#78` + this docs PR) — see - the "M7 plan" build-progress block above and §8 item 3. **Part 2 (the app: UnifiedPush, device - registration, subscriptions UI, notification handling) is next.** + ✅ **Part 1 (backend + docs) landed** 2026-07-20 (`RunicGateway/website#78` merged + docs#20) — see + the "M7 plan" build-progress block above and §8 item 3. **Part 2 (the app: UnifiedPush receiver, + device registration, subscriptions UI, notification-tap deep-links) is planned in the "M7 plan" + block and is next.** 9. **M8 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor — after the direct-APK release is stable. @@ -584,6 +694,10 @@ first release. Users **opt in per stream**: nothing is pushed unless subscribed. - **Primary: UnifiedPush, delivered by a self-hosted `ntfy` service added to the website's `docker-compose.yml`.** FOSS, no Google Play Services dependency, works for the sideloaded APK on any device, and keeps delivery under the org's own infrastructure — consistent with the self-hosted ethos. +- **The app embeds its own distributor — no second app (decided; see M7 Part 2 work item 1).** ntfy is + purely the relay *server*; the Runic Gateway app receives notifications itself via an in-app embedded + UnifiedPush distributor (a foreground-service persistent connection to the shard's ntfy). The user + installs one APK — never a separate distributor app — and no Google Play Services is involved. - **FCM stays optional and Play-only.** If/when a Play build wants it, add FCM as a **build flavor**; the direct-APK flavor stays Google-free. The backend fan-out is **transport-agnostic** and dispatches to whatever endpoint a device registered, so adding FCM later touches no core logic. @@ -668,7 +782,8 @@ password+TOTP only, with registration/invite/reset/SSO **handled by the website* reset built on backend + web first**, before app work (§8); minSdk 29, compile/target 35 (§2); no telemetry in v1 (§2); strings externalized from day one, English-only bundled (§2); **text-only** game data in v1, pretty paperdoll is future (§6.3); **no offline cache in v1** (§7); push via self-hosted -ntfy / UnifiedPush (§11); **biometric app-lock descoped from v1** (tokens already encrypted at rest, so +ntfy / UnifiedPush (§11) with the **distributor embedded in the app — no second app to install** +(M7 Part 2 work item 1); **biometric app-lock descoped from v1** (tokens already encrypted at rest, so it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6; revisit only if requested). **Still open:** @@ -677,9 +792,14 @@ it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6; the fixed launcher name (baked at build even though in-app branding is per-shard — one APK, any shard). Since SSO/invite/reset are website-handled, the app mostly *opens* website URLs rather than needing its own verified app links — confirm whether any deep-link-back is wanted at all for v1. -- ntfy: exact upstream image + pinned tag, its reverse-proxy hostname/path, and whether to add a - backend publish token (optional hardening — the content-free-tickle design does not require one). -- FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M8. -- Deep-link / share targets for wiki pages, posts, and notification taps. +- ntfy: exact upstream image + pinned tag (Part-1 landed the compose service — confirm the tag), and + its reverse-proxy hostname/path. The hostname must land in `NTFY_ALLOWED_ORIGINS` before M7 Part 2 is + end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). No + backend publish token — **decided** (the content-free-tickle design does not require one; optional + `NTFY_PUBLISH_TOKEN` is honored if ever set). +- FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M8. (The M7 + Part 2 `PushTransport` seam keeps this swap cheap.) +- Deep-link / share targets for wiki pages and posts (share/open-in-app). *Notification-tap* deep-links + are **resolved** for M7 Part 2 (stream→screen map, work item 7). - iOS: none planned (this is the Android-only choice); revisit only if cross-platform is later required (would change §2 — and push, which would then favor a cross-platform transport). -- 2.49.1