docs(android): M7 Part 2 landed — embedded ntfy distributor + push.ntfyUrl

Flip M7 Part 2 to landed (Android-app#15) and record the two implementation
decisions: the direct-ntfy embedded distributor (no UnifiedPush library — the
plan's stated likely path; foreground-service SSE, no second app, no Google
Play Services), and the small additive push.ntfyUrl settings field the app
needs to discover the relay (website#79). Document push.ntfyUrl +
NTFY_PUBLIC_URL in BACKEND_DESIGN.md.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-20 15:27:18 -05:00
committed by Claude
parent 837b546f49
commit 71cb181152
2 changed files with 47 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
# Android App — Plan # Android App — Plan
Status: **M0M6 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 Status: **M0M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag.** This document is the
design contract for the `RunicGateway/Android-app` repo. It was written before implementation so 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 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 reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated
@@ -173,7 +173,37 @@ none (keeps §11's zero-interaction promise).
reverse proxy; internal-only for the publisher), anonymous read-write to unguessable topics (no reverse proxy; internal-only for the publisher), anonymous read-write to unguessable topics (no
per-user accounts — safe because tickles are content-free). per-user accounts — safe because tickles are content-free).
**Part 2 — the Android app (next pass; planned here).** UnifiedPush receiver + device registration **Part 2 — the Android app — ✅ LANDED** (2026-07-20, `RunicGateway/Android-app#15` + a small
`RunicGateway/website#79` settings addition + this docs PR). Built exactly to the plan below, with
two recorded implementation decisions:
- **Direct-ntfy embedded distributor, no UnifiedPush library (deviation from §2's "UnifiedPush
connector" wording — the plan's stated likely path, work item 1).** The app talks straight to ntfy
over its own topic rather than pulling in `org.unifiedpush.android:connector` + an external
distributor: a foreground `PushService` holds an OkHttp-SSE connection to `<ntfy>/<topic>/sse`
(reusing the M2 `ShardStreamClient` reconnect pattern) on a **bare** client, `PushManager`
orchestrates topic mint / device register / start-stop keyed to the session, and `PushNotifier`
posts a per-stream notification whose tap deep-links via `MainActivity` intent extras. No new Gradle
dependency; a `PushResult`/transport seam keeps the future FCM Play flavor cheap. Reasons: the
UnifiedPush distributor model assumes a *separate* app (exactly what the user vetoed), we already own
the SSE machinery, and this keeps the APK Google-free and dependency-light. New code lives in
`core/push/` + `ui/notifications/` + a `NotificationsApi`/`NotificationsRepository`; no existing
screen's data flow changed.
- **One small additive backend field was required after all (`push.ntfyUrl`).** The embedded
distributor must know the shard's client-facing ntfy URL to build its topic endpoint, and Part 1
never surfaced it (the `NTFY_*` vars are server-only). So `/public/settings` now carries
`push: { ntfyUrl }` (from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS`; never the internal
`NTFY_BASE_URL`), null when unconfigured → the app shows push as unavailable for that shard. This is
the "no backend work in Part 2" caveat corrected: it is additive, non-sensitive, and forward-compatible
(an older backend omitting it just decodes to null). **Deploy dependency stands:** push only delivers
once the shard sets `NTFY_PUBLIC_URL`/`NTFY_ALLOWED_ORIGINS` (§13).
Verified green: `:app:testDebugUnitTest` (18 new JVM tests — notifications DTO decode, ntfy tickle
parse incl. malformed, topic/URL building, stream→route map + personal gating) + `:app:lintDebug` +
`:app:assembleDebug`; backend 250 tests (+3 for `push.ntfyUrl`) and `npm run swagger` clean. The
foreground-service tradeoff (§11) and the POST_NOTIFICATIONS runtime permission are implemented as
planned; an on-device delivery pass against a live ntfy is the one open QA item.
**Part 2 (original plan) — the Android app.** UnifiedPush receiver + device registration
against the merged Part-1 contract, a Notifications settings screen, and notification-tap deep-links. 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 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 (`core/push` + `ui/notifications` + a `DevicesApi`/`NotificationsApi` pair) that touches no existing
@@ -283,7 +313,7 @@ hands the app an endpoint on *that* origin and the backend rejects a register wh
allow-listed. This is deployment config, not code, but Part 2 can't be end-to-end tested until it's 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. pinned. No `website`/`link`/`servuo-plugins` code change is expected in Part 2.
Ships as `RunicGateway/Android-app#<pr>`; bumps `versionCode`/`versionName` for a post-v1 release Ships as `RunicGateway/Android-app#15`; bumps `versionCode`/`versionName` for a post-v1 release
(§10). Like M1M4 it records itself in the §9 build-progress block on landing. (§10). Like M1M4 it records itself in the §9 build-progress block on landing.
**Prerequisite progress (§8):** all v1 prerequisites are **done** (2026-07-19) — ✅ password reset **Prerequisite progress (§8):** all v1 prerequisites are **done** (2026-07-19) — ✅ password reset
@@ -666,10 +696,12 @@ push, and Play (M6M8) follow the designed app.
`website/docker-compose.yml` (declarative, zero-interaction config), UnifiedPush integration in the `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 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. §11). The app is built with room for this from M0 but it does not gate the first release.
✅ **Part 1 (backend + docs) landed** 2026-07-20 (`RunicGateway/website#78` merged + docs#20) — see ✅ **Both parts landed** 2026-07-20 — Part 1 backend (`RunicGateway/website#78` merged + docs#20),
the "M7 plan" build-progress block above and §8 item 3. **Part 2 (the app: UnifiedPush receiver, Part 2 app (`RunicGateway/Android-app#15`) + a small `push.ntfyUrl` settings addition
device registration, subscriptions UI, notification-tap deep-links) is planned in the "M7 plan" (`RunicGateway/website#79`). The app embeds its own ntfy distributor (a foreground-service SSE
block and is next.** connection, no second app, no Google Play Services, no UnifiedPush library); see the "M7 plan"
Part 2 block for the recorded transport + backend-field decisions. Push delivers once the shard sets
its `NTFY_*` deploy config (§13).
9. **M8 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor 9. **M8 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor
— after the direct-APK release is stable. — after the direct-APK release is stable.

View File

@@ -241,7 +241,7 @@ create/publish-post path for `news.post`. The stream catalog + event→stream ma
### /public (public.routes.js → public.controller.js) — all GET, no auth ### /public (public.routes.js → public.controller.js) — all GET, no auth
| Method | Path | Notes | | Method | Path | Notes |
|---|---|---| |---|---|---|
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, and the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard. Asset fields may be site-relative paths (resolve against the base URL). | | GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. |
| GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard | | GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard |
| GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check | | GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check |
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots | | GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
@@ -381,6 +381,13 @@ CLIENT_ORIGIN=http://localhost:5173
# Push (M7): the ntfy relay URL — also the backend's SSRF allow-set for device # Push (M7): the ntfy relay URL — also the backend's SSRF allow-set for device
# endpoints. NTFY_ALLOWED_ORIGINS / NTFY_PUBLISH_TOKEN are optional. # endpoints. NTFY_ALLOWED_ORIGINS / NTFY_PUBLISH_TOKEN are optional.
NTFY_BASE_URL=https://ntfy.example.com NTFY_BASE_URL=https://ntfy.example.com
# The client-facing ntfy URL surfaced to the app via /public/settings.push.ntfyUrl
# (the app registers its topic endpoint here). Defaults to the first
# NTFY_ALLOWED_ORIGINS entry; set explicitly when the public URL differs from the
# internal NTFY_BASE_URL. Without it (and without NTFY_ALLOWED_ORIGINS) the app
# shows push as unavailable for the shard.
NTFY_PUBLIC_URL=https://ntfy.example.com
NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
``` ```
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`. `.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.