# Android App — Plan

Status: **M0–M7 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. M9 (native SSO login) is now underway backend-first — the Mobile SSO Authorization Bridge is being built in `website/` + `docs/` ahead of the app-side client (§4.2, §9 M9); custom-scheme callback only for now, App Links deferred (see [`APP_LINKS.md`](./APP_LINKS.md)). **M11 (Protocol 3.0 shard parity)** is scoped and next: the app sees none of the four shard features v3 added (`ruleset`, `leaderboards`, `market`, `atlas`) and does not consult `GET /public/shard/features`, so it gates shard nav on session role alone while an admin can switch any of those surfaces off or raise its audience — the v3 `edge` → `main` cutover is held until it lands (§9 M11).** 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
via `npm run swagger`).

**Build progress (§9):** ✅ **M0 — repo scaffold** (2026-07-19, `RunicGateway/Android-app#2`):
Gradle 8.7 wrapper + AGP 8.6.1 / Kotlin 2.0.20, JDK 17, minSdk 29 / compile-target 35,
`applicationId com.runicgateway.app`; a version catalog pinning the full §2 stack; a Compose + Hilt
single-activity skeleton (externalized strings, adaptive icon); and CI (`pr-checks.yml` →
`./gradlew lint test assembleDebug`).

✅ **M1 — connect & browse** (2026-07-19, `RunicGateway/Android-app#6`, functional Kotlin pass): the
first-run base-URL connect flow (probe `GET /public/status`, verify the backend's version identity,
persist to DataStore; HTTPS-only in release, HTTP allowed in debug; Settings → Server hard reset);
a runtime-selected base URL via a sentinel-host Retrofit + `HostSelectionInterceptor` (the host is
**not** compiled in) plus a `UserAgentInterceptor` past the scanner guard (§8); the layered
`screen → ViewModel → repository → PublicApi → DTO` stack returning a typed `ApiResult`
(`Ok`/`HttpError`/`NetworkError`) for graceful degradation (§7); brand-seeded Material 3 theming from
`/public/settings`; and functional Compose screens for Home/Status, News (+ post detail), Wiki
(+ detail), CMS pages (block renderer: `heading/rich_text/image/quote/cta/divider/two_column`), and
the contact form, under one declarative navigation drawer (§5). JVM unit tests cover URL
normalization, host rewriting, `ApiResult`/`UiState` mapping, and brand-color parsing.

> **API-client deviation from §2 (recorded):** DTOs + the Retrofit interface are **hand-written and
> spec-aligned**, not `openapi-generator` output. The committed `swagger-output.json` is produced by
> **swagger-autogen**, whose component schemas are meta-descriptive (nested `{type, example}`
> wrappers) rather than codegen-clean OpenAPI models, so a generator would emit unusable DTOs. The
> hand-authored client is the "checked-in generated module" §2 already allows; shapes were matched
> against the website controllers/models and every DTO ignores unknown keys (additive fields are
> safe). True codegen would first require authoring the spec's component schemas as real OpenAPI
> models.

✅ **M2 — public shard** (2026-07-19, `RunicGateway/Android-app#7`, functional Kotlin pass): the public
shard widgets (§6.2) over `/public/shard/*` — a shard hub (connection status, online count, latest
economy, presence, staff online) plus live boards for champion spawns, guilds, governors (with
on-demand term history) and falling houses (IDOC) — and the live **SSE** feed. `ShardStreamClient`
consumes `/public/shard/stream` over OkHttp SSE and, unlike the browser `EventSource`, drives its own
reconnect/backoff (reset on open, no read timeout for the idle keepalive), so a dropped feed degrades
to "offline" rather than crashing (§7). Boards seed from a snapshot then merge `*.update` / `*.remove`
SSE deltas in place via a reusable `LiveBoard`, mirroring the website's merge semantics; DTOs are
hand-authored + spec-aligned (as recorded for M1) and the live frames decode into the same board
DTOs. Wired into the shared drawer (§5), all strings externalized (§2). JVM unit tests (28) cover DTO
/ live-frame decode, the board merge, event-text formatting (parity with `lib/shardEvents.js`), and
SSE frame parsing. No backend/API change — the app is a pure consumer of the existing public shard
surface.

✅ **M3 — auth** (2026-07-19, `RunicGateway/Android-app#8`, functional Kotlin pass): native
**username/password (+ single-request TOTP) login** over the existing `POST /auth/mobile/login` — a
`401 { totpRequired }` reveals the code field and a wrong code re-lands as a code error; `429` surfaces
a friendly backoff message (§4.1). The token pair lives in **EncryptedSharedPreferences** (a
`TokenStore` behind `SessionManager`, the single source of truth for the in-memory bearer + the
observable `Session`); the base URL stays in plain DataStore (§4.3). An OkHttp `AuthInterceptor`
attaches the bearer and a `TokenAuthenticator` does a **one-shot, mutex-serialized refresh** on a
bearer `401` and replays the request — refresh runs on its own **bare** client (no interceptor/
authenticator) so it can never recurse, rotated single-use tokens are stored atomically, and a dead
refresh (`401`) signs out while a transient network error keeps the session. Logout
(`POST /auth/mobile/logout`, this session or all devices) tears down locally even if the call fails.
`GET /auth/me` **re-validates the role on every resume** (`LifecycleResumeEffect`); a surviving `401`
signs out, so a server-side demotion drops menu access promptly (role stays advisory — the backend is
authority, §4.3). The **access-level menu** is one declarative list (`visibleEntries` filters by
session — public / signed-in / player) with a Sign in / Sign out toggle and a **My Account** screen
(identity + role + sign-out / sign-out-everywhere). Registration, forgot-password, and SSO are
**Custom-Tab hand-offs** (androidx.browser) to the website's own pages (`/account/register`,
`/account/forgot`, `/account/login`) — no native screens (§4.2). The Settings → Server switch now also
clears the stored session (§3). JVM unit tests (18) cover auth-DTO decode (incl. `totpRequired` vs a
plain credential `401`), the `SessionManager` lifecycle over a fake store, and the menu access filter
+ role mapping. **No backend/API change** — the app is a pure consumer of the existing mobile bearer +
`/auth/me` surface.

> **Biometric app-lock — descoped from v1 (decided at M6).** §4.3/§9 flagged an *optional* biometric
> app-lock, deferred from M3 to M6. At M6 it was **descoped from v1 entirely**: tokens are already
> encrypted at rest (Tink/AES-256-GCM), so an app-lock is a pure UX convenience, not a security
> requirement, and it changes no data flow. It is **not** in the first release; revisit only if it
> becomes a requested feature.

✅ **M4 — player self-service & game data** (2026-07-19, `RunicGateway/Android-app#9`, functional Kotlin
pass): the signed-in player surface, all as a pure consumer of the existing bearer-gated API.
**Account self-service** over the role-agnostic `/auth/me/account*` (§6.4) — change username (409
"taken" surfaced; a success re-validates the session so the shell reflects the new name at once),
change/set password (the SSO-account "no current password" path from `has_password`), TOTP
**setup → scan → enable** (the `data:` QR is base64-decoded to a bitmap in-app) / disable-by-code, and
list/unlink SSO identities — each mutation folding its `ApiResult` into a section-scoped, localized
banner (§7). **Game-account linking** (§6.3) — the in-game `[link` one-time code (`POST
/player/shard/link`) plus the hybrid signup (`POST /player/shard/account`, shown only when the public
`gameAccountSignup` flag is set — see the OWED note below), and the linked-accounts list. **Own game data**, text-only (§6.3):
per-account character roster → a character sheet (attributes, vitals, resistances, best-first skills,
equipment with AOS mods, and guild/governor standing chips — bare cliloc-number titles/item names are
skipped, as the app ships no cliloc table, matching the website's `CharacterSheet.jsx`); player
vendors (shops + listings) with recent sales; and the player's own houses (decay/IDOC). Each
per-account read carries its **own** load state, so a down shard degrades that one account to
offline/retry (`503`) — or not-found (`403`) — without blocking the rest. The menu gains three
**PLAYER-access** groups (My Characters / Vendors / Houses) revealed only when the session role is
`player`; a `PlayerGate` sends a signed-out or server-side-demoted user home. DTOs are hand-authored +
spec-aligned (as recorded for M1); 17 new JVM unit tests cover the account + player-shard DTO decode
(hex serials, permissive objects, equipment mods) and the character-sheet title/skill display helpers.
**No backend/API change** — the `/auth/me/*` and `/player/shard/*` surfaces the app consumes were the
§8 prerequisites, already landed.

> **OWED — `gameAccountSignup` moved and the app has not followed (2026-08-11).** Phase 3 slice 3 of
> the website's module extraction (`docs/website/MODULE_SYSTEM.md` §2.7.1) moved game-account signup
> policy out of core: the flag is no longer on `GET /public/settings` and is now on module-uo's
> `GET /public/shard/features`, beside the visibility flags. `PublicDto.kt:80` defaults it to
> `false`, so **nothing crashes** — `CharactersViewModel.kt:72` reads `false` and the app simply
> stops offering game-account creation. The fix is one DTO field, one ViewModel line and two tests,
> reading the flag from the features endpoint the app already calls.
>
> Deferred rather than coordinated into the slice (org lead, 2026-08-11): the website work lands on
> `edge` and reaches `main` as one cutover, so there is room. **It must land before that cutover** —
> after it, a shipped app quietly loses a feature.
>
> **The gate was released and the cutover went first (org lead, 2026-08-12.)** The module-system
> cutover merged as [website#150](https://gitea.whitlocktech.com/RunicGateway/website/pulls/150)
> with this unfixed, deliberately: the app needs a broader pass than this one field, so holding a
> finished workstream for it bought delay and nothing else.
>
> **So this is no longer owed-but-harmless — it is live.** Against `main`, `/public/settings` no
> longer carries `gameAccountSignup`, `PublicDto.kt:80` reads its `false` default, and
> `CharactersViewModel.kt:72` hides game-account creation from every user of the shipped app. Nothing
> crashes and nothing logs; the button is simply not there. The fix is unchanged — one DTO field, one
> ViewModel line and two tests, reading the flag from `/public/shard/features`, which the app already
> calls — and it is now a **bug fix against a live regression** rather than a scheduling item, to be
> picked up with whatever else that broader app pass covers.

✅ **M5 — design pass** (2026-07-20, `RunicGateway/Android-app#10`): the shard-website theme applied
across every screen, restyling the working M1–M4 UI with **no architecture, data-flow, endpoint, or DTO
change** (§2.1). The design was produced in Claude Design (`Runic Gateway Screens.dc.html`) and
implemented in Compose. Because the functional screens already draw their color/type/shape from
`MaterialTheme` tokens (§2), the restyle lives mostly in the **theme layer** and propagates: a deep
blue-black surface stack (page `#0b0f14` / screen `#0e1318` / elevated `#11161d`), a slate-blue accent
(`#7f99bd`) with a light CTA fill (`#cdd9e8`), parchment serif body copy, and the engraved **Cinzel**
serif display face (bundled weight-axis variable font, SIL OFL) for headings and the top bar. The app is
now **dark-only** — the shard-website look is a single dark theme, so the light scheme is dropped and the
system light/dark setting is ignored; **per-shard brand-accent seeding is retained** (a site's published
accent still tints the primary/secondary roles, §3). A small set of reusable components — semantic status
pills, section labels, a gradient "feature" card, and slim stat meters — carries the motifs the design
repeats (home status, shard-online banner, champ/character/house status, character vitals & skills). The
launch theme and system bars are darkened so the first frame matches (no white flash). Verified by
`:app:assembleDebug` + `:app:testDebugUnitTest` (green); an on-device visual pass against the mockup is
the one open QA item noted on the PR.

✅ **M6 — polish & release mechanics** (2026-07-20, `RunicGateway/Android-app#11`): the release
plumbing to ship v1 as a signed, sideloadable APK, with **no architecture, data-flow, or endpoint
change**. **Default brand app icons** — a gateway-medallion adaptive launcher icon (all densities +
round + Play Store icon) over the deep-indigo brand background (the Image Asset wizard's default
green grid was replaced, and the legacy square/round bitmaps + 512 Play icon recomposited to match);
plus an "RG" notification icon staged for M7. A **version-mismatch guard** (§3): the first-run connect
probe refuses a backend whose API version this build can't speak (a future `v2`) with a clear
"app out of date" message rather than mis-rendering (lenient on an older backend that omits `api`).
**Release build hardening** (§7, §12) — R8 full-mode minify + resource shrink (~31 MB debug → ~4 MB
signed release) with keep-rules for the kotlinx.serialization serializers, the wire DTOs, and the
Retrofit interfaces; a release `signingConfig` that reads keystore material from a **gitignored**
`keystore.properties` or env vars (absent → unsigned; the keystore is never committed); and
`versionName`/`versionCode` overridable via `-P` so a release tag + CI run number drive them (§10).
**CI `release.yml`** — on a `v*` tag, builds a **signed** APK (keystore decoded from a base64 Gitea
secret) and attaches it + `SHA256SUMS` to a Gitea release; `workflow_dispatch` is a signing dry run.
HTTPS-only in release (M1), no token logging (logging is debug-gated, M3), and the Settings → Server
hard reset (M3) were already in place. Biometric app-lock is **descoped from v1** (see the note below).

**The functional build (M0–M4), design pass (M5), and release mechanics (M6) are complete. Cutting
the first `v*` release tag (once the signing secrets are set + the on-device QA pass is done) and M7
push notifications are what remain.**

### M7 plan — push notifications (in progress)

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/` — ✅ 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
none (keeps §11's zero-interaction promise).
- **Two event sources, one publisher.** The fan-out is a small transport-agnostic
  `utils/pushDispatch.js` that both producers call: `utils/shardIngest.js` (`ingest()`, beside the
  existing `broadcast(event)`) for shard-derived streams, and the admin create-post path for the
  `news.post` stream (§11 lists news posts as a public stream, but they originate in the website, not
  the shard feed).
- **Stream catalog** (`config/notificationStreams.js`): public/opt-in — `news.post`,
  `server.status`, `idoc.warning`, `champ.start`, `governor.election`; personal/owner-keyed
  (require a linked game account) — `vendor.sale`, `house.idoc`, `account.login`. `mapShardEvent()`
  maps event kinds → streams, drawing public streams **only** from the SSE `PUBLIC_KINDS` allowlist;
  sensitive kinds are never fanned out publicly. Personal events are delivered only to the owning
  user's devices, resolved via `shardLinks.getByAccount` (same ownership source as `/player/shard/*`).
- **Tables:** `push_devices` (per-device endpoint) and `notification_subscriptions` (per-user opted-in
  streams), FK → `users` ON DELETE CASCADE, mirroring `mobile_refresh_tokens`.
- **Routes** under the role-agnostic self surface (never `/admin`): `POST|GET /auth/me/devices`,
  `DELETE /auth/me/devices/:id`, `GET /auth/me/notifications/streams` (catalog),
  `GET|PUT /auth/me/notifications/subscriptions`. All bearer/cookie auth; Swagger regenerated.
- **SSRF guard (important):** a device `endpoint` is a client-supplied URL the backend POSTs to, so
  registration and every publish validate it is HTTPS and its origin is in the shard's ntfy
  allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`), rejecting loopback/private hosts.
- **ntfy** added to `website/docker-compose.yml` as a pinned upstream image with a committed
  declarative `./ntfy/server.yml` and named volume, **published on a host port** (`NTFY_HOST_PORT`,
  default `2586` → container `:80`) so the public reverse proxy — which runs *outside* the compose
  network — can forward the notification subdomain to it, anonymous read-write to unguessable topics
  (no per-user accounts — safe because tickles are content-free). Both the app (SSE subscribe) and the
  backend (POSTing tickles to registered device endpoints) reach ntfy on that public origin, so all
  ntfy traffic transits the proxy — there is no separate internal publish port.

**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.
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": "<id>", "ref": "<opaque>" }`
  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://<ntfy-host>/<topic>/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://<ntfy-host>/<topic>`) — 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#15`; bumps `versionCode`/`versionName` for a post-v1 release
(§10). Like M1–M4 it records itself in the §9 build-progress block on landing.

### M9 plan — native SSO login (in progress)

M9 spans `website/` + `android-app/` + `docs/`, so — like M7 — it ships in **two parts**, backend
first (the app is a pure consumer of the bridge contract; §4.2, §9 item 10).

**Part 1 — `website/` backend + `docs/` — ✅ LANDED** (the Mobile SSO Authorization Bridge:
`mobile_auth_sessions`/`mobile_auth_codes` tables, `GET /auth/mobile/sso/start`, the `mode:'mobile'`
branch in the reused SSO callback + TOTP completion, `POST /auth/mobile/sso/exchange`, the exact-match
`MOBILE_AUTH_REDIRECT_URIS` allowlist, and bridge-table cleanup). Canonical ref:
`../website/BACKEND_DESIGN.md` → "Mobile SSO Authorization Bridge".

**Part 2 — the Android app (this milestone).** The native in-app "Sign in with Google / Discord"
client. **Additive** — a new auth slice (`core/auth/sso` + a `SsoApi`/`SsoAuthManager` + a login-screen
provider list) that feeds the *existing* M3 session machinery; it adds **no** new token-storage or
refresh code, and touches no other screen. **No backend work** — every endpoint it calls is merged.

The Part-1 contract the app codes against (verified against the merged `website` source):
- `GET /auth/providers` → `[{ id, name, icon, loginUrl, priority }]` (public discovery, no secrets).
  `icon` ∈ `google|discord|oidc|oauth2`. Render the provider buttons from this — don't hardcode.
- `GET /auth/mobile/sso/start?provider&code_challenge&state&redirect_uri` — **opened in a Custom Tab**
  (not an XHR): it 302s through the IdP and finally deep-links back to `redirect_uri`. `redirect_uri`
  must be an **exact** allowlist entry — the app always sends the one fixed callback
  `runicgateway://auth/callback`.
- The callback deep link carries **either** `?code=<one-time>&state=<echoed>` (success) **or**
  `?error=<reason>&state=<echoed>` (`invalid_provider`/`provider_unavailable`/`server_error`, or an
  IdP/link refusal) — **never a token**.
- `POST /auth/mobile/sso/exchange` `{ code, code_verifier }` → the **same** `{ accessToken,
  refreshToken, expiresIn, user }` pair as `/auth/mobile/login`; `401` on an unknown/expired/used code
  or a PKCE-verifier mismatch.

Work items:

1. **PKCE + state (Layer B, app↔website).** A pure-JVM `Pkce` helper (unit-testable, no Android
   framework types): `code_verifier` = 32 random bytes base64url (RFC 7636 S256), `code_challenge` =
   base64url(SHA-256(verifier)), plus a random `state`. `java.util.Base64` URL encoder without padding
   + `MessageDigest` — matches the backend's `crypto.createHash('sha256')…base64url` exactly.
2. **`SsoAuthManager` (Singleton) — the flow orchestrator.** Holds the **pending** `{state, verifier}`
   in memory (lost on process death → the exchange fails closed and the user retries; acceptable and
   safe, documented). `buildStartUrl(provider)` mints PKCE+state, stashes pending, and builds the
   absolute `/start` URL off `BaseUrlHolder` for the Custom Tab. `isCallback(uri)` matches our scheme;
   `complete(uri)` verifies `state` (CSRF), maps an `error`, exchanges the `code` with the stashed
   `verifier`, and on success drives `SessionManager.onSignedIn` — the *same* entry the password login
   uses, so push registration (`PushManager` observes the session) and the menu react identically. It
   exposes an `outcome: StateFlow` (Idle/Success/Failed(reason)) the login screen consumes, robust to a
   ViewModel/activity recreation while the Custom Tab is foreground.
3. **`SsoApi` + DTOs.** `GET api/v1/auth/providers` → `List<SsoProviderDto>`; `POST
   api/v1/auth/mobile/sso/exchange` tagged `Http.NO_SESSION_HEADER` (no bearer; a credential-style
   `401` must not be read as an expired session or trip the refresh `Authenticator`) → the reused
   `MobileTokenResponse`. Lenient Json (additive fields safe, §8).
4. **Deep link.** Register the `runicgateway://auth/callback` intent-filter on `MainActivity`
   (`VIEW` + `DEFAULT` + `BROWSABLE`, `scheme/host/path` from one shared constant) and set
   `launchMode="singleTop"` so the returning Custom Tab reuses the running task; `onCreate`/`onNewIntent`
   route a matching `ACTION_VIEW` intent to `SsoAuthManager.complete` on `lifecycleScope`. Custom scheme
   only for now — App Links deferred (`APP_LINKS.md`).
5. **Login screen.** Replace the single "SSO on the website" hand-off with a native provider list from
   `GET /auth/providers`: one button per provider (Google/Discord/OIDC glyph from `icon`), each opening
   its `/start` URL in a Custom Tab via the existing `WebHandoff`. The `LoginViewModel` collects
   `SsoAuthManager.outcome` → a success pops back like a password sign-in; a failure surfaces a friendly
   inline error (reusing the existing `LoginError` channel + a new SSO string). Falls back to the
   website login hand-off when discovery returns no providers or the base URL is unset.
6. **Tests (JVM, `testDebugUnitTest`).** `Pkce` (verifier charset/length, challenge = base64url-SHA-256
   of a known vector, no padding), start-URL building (encoded params, fixed `redirect_uri`), and
   `SsoAuthManager.complete` over a fake `SsoApi` + `SessionManager`: success signs in; a mismatched or
   missing `state` fails without exchanging; an `error=` callback maps to the right reason; a `401`
   exchange maps to expired-code; a missing pending (process death) fails closed.

Ships as a `RunicGateway/Android-app` PR; bumps `versionCode`/`versionName` for a post-v1 release
(§10) and records itself in the §9 build-progress block on landing. No `website`/`link`/`servuo-plugins`
code change is expected in Part 2.

**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),
✅ version/health surfacing (item 4) and ✅ branding for mobile (item 6). **Push notifications (item 3)
is the only remaining §8 work and is post-v1 (M7).** The app's functional Kotlin pass (M0–M4) is now
unblocked.

The workspace already holds `website/`, `link/`, `servuo-plugins/`, and `docs/`. `android-app/` is
the fifth repo. It is **purely an API client of the website backend** — it never talks to the
`link/` sidecar or the shard directly, and it ships none of the shard/sidecar wiring.

---

## 1. Purpose & scope

A native Android client for a Runic Gateway shard's public site + player self-service. It surfaces
the same content and player features as `website/client`, minus every administrative/management
console. It is a **read + self-service** app, not an operator tool.

### In scope
- **Public content** (no auth): news / Five-on-Friday / newsletter / screenshots, wiki, CMS pages,
  site status & maintenance page, contact form.
- **Public shard widgets** (no auth): shard status, online staff, live event feed, economy series,
  champion spawns, guilds, governors, houses/IDOC, presence — including the live **SSE** stream.
- **Account & auth** (bearer token): native **username/password login (with TOTP 2FA)**, logout,
  refresh; account self-service (change username/password, TOTP enroll/disable, list/unlink SSO
  identities). Registration, invite acceptance, password reset, and SSO are **website-handled** — the
  app hands off to the website's pages for those (§4.2), not native screens.
- **Player's own shard/game data** (bearer token): link a game account via a `[link` one-time code,
  hybrid game-account signup, list linked accounts, own character roster, character sheet, own
  player vendors, own vendor sales, own houses (home/decay status).
- **Access-level menu**: one shared navigation that reveals items based on the signed-in user's role.
- **Opt-in push notifications** (post-v1; architected for from the start): per-stream subscriptions the
  user chooses — nothing is pushed unless subscribed. See §11.
- **Staff operations subset** (post-v1, **M10** — decided 2026-07-21): a *defined* slice of the admin
  surface, native, for `admin`/`moderator` staff. In scope: **moderation actions** (kick / ban / unban /
  broadcast), the **support (help-page) queue** (reply / close / resolve), the **dashboard summary +
  site-mode** toggle, and **content management** (news posts — list / create / edit / publish / delete —
  plus wiki category & tag management). These consume the existing `/api/v1/admin/**` routes (which
  already accept bearer auth and re-check role every request); no backend routes are added. See §6.4 + §10.

### Explicitly OUT of scope (never in the app, for any role)
The exclusions are now a *narrow* list (M10 brought the operational admin subset in-scope, above). The
app still never ships:
- The **hero editor** and CMS **block/page authoring** (the visual page builder). *(News-post and
  wiki category/tag management ARE in scope; the excluded piece is the CMS block editor / hero builder.)*
- **Discord bot** configuration (and anything under the unpublished `/internal/**` port — it returns the
  decrypted bot token and must never be reachable from a client).
- **uo-link / sidecar administration** — the shard sidecar base-URL/token config (`uoLinkConfig`). (The
  app performs *shard operations* like kick/ban/broadcast against the live shard, but never configures
  the sidecar connection itself.)
- **OAuth / SSO provider setup** — creating/editing identity providers and their client secrets.

> The app calls `/api/v1/public/**`, `/api/v1/auth/**` (incl. the role-agnostic self surface
> `/auth/me/*`, §6.4), `/api/v1/player/**`, and — for staff, M10 — the **defined `/api/v1/admin/**`
> operations listed above. It never touches `/api/v1/internal/**`, nor the four excluded admin surfaces
> (hero/CMS block editor, Discord-bot config, uo-link config, OAuth-provider setup).

---

## 2. Architecture & stack

Native, Android-only:

| Concern | Choice |
|---|---|
| Language / UI | **Kotlin + Jetpack Compose** (Material 3) |
| Navigation | Navigation-Compose, single-activity |
| HTTP | **Retrofit + OkHttp**, `kotlinx.serialization` converter |
| Async | Coroutines + Flow; `viewModelScope` |
| DI | Hilt |
| Saved base URL / prefs | **Jetpack DataStore** (Preferences) |
| Tokens at rest | **EncryptedSharedPreferences** (Jetpack Security / Tink-backed) |
| Live feed | OkHttp SSE (`EventSource`) for `/public/shard/stream` |
| Images | Coil |
| Min SDK | **Android 10 (API 29)** — ~95% device reach with a modern baseline (biometric, storage, TLS) and no compat shims |
| Target/compile SDK | Latest stable (35) |
| Telemetry | **None in v1** — no crash/analytics SDK (privacy-first). Revisit self-hosted crash reporting later. |
| Localization | **Strings externalized from day one** (`res/values/strings.xml`); English is the only bundled locale, but the structure invites community translations. No hardcoded UI strings. |
| Web hand-off | Chrome Custom Tabs — opens the website for registration / invite / password reset / SSO (§4.2) |

**API model generation.** The DTOs and the Retrofit interface are generated from
`swagger-output.json` (OpenAPI 3.0) rather than hand-written, so the client stays in lockstep with
the backend contract. A build step (or a checked-in generated module regenerated on contract change)
runs `openapi-generator` against the committed spec. Endpoints that return
`additionalProperties: true` (several shard reads) are typed as permissive maps / JsonElement.

**Layering** mirrors the backend's discipline: `screen (Compose) → ViewModel → repository → API
service (Retrofit) → DTO`. Repositories expose `Result`-like sealed types so the UI degrades
gracefully (see §7).

### 2.1 Build workflow: Kotlin first, then design-led UI
The app is built in two passes. **First**, the functional Kotlin is written — the layering above with
placeholder/functional Compose screens: navigation, ViewModels, repositories, the generated API
client, auth/token handling, and every screen wired to its endpoints and working end-to-end. **Then**,
once that Kotlin code is done, **Claude Design produces the front-end design** for the app, and
**Claude Code implements the final UI (Compose screens, theming, components) according to that
design.** The design pass restyles and refines the already-working screens; it does not change the
architecture, data flow, or endpoint contracts established in the first pass. Keeping strings
externalized and branding data-driven (§2, §3) from the start is what lets the design pass reskin
freely without touching logic.

---

## 3. Base URL: first-run + settings

The app is **brandable to any shard's site** (one site per install), so the API host is not
compiled in.

- **First run (before init):** a mandatory **"Connect to your shard's website"** screen asks for the
  site base URL. The app validates it by calling `GET /api/v1/public/status` (and reads
  `/public/settings` for branding: name/colors/logo). Only on a successful, well-formed response is
  the URL persisted to DataStore and the app allowed to initialize its main UI.
  - Accept `https://host[/base]`; normalize/trim; require HTTPS in release builds (allow HTTP only in
    debug for local dev against `127.0.0.1:3000`). This app-layer rule (`ServerUrl`,
    `allowInsecureHttp = BuildConfig.DEBUG`) is backed at the platform socket layer by an explicit
    **network security config** (`res/xml/network_security_config.xml`, wired via
    `application android:networkSecurityConfig`): the main/release config permits **no** cleartext,
    and a debug-only override (`app/src/debug/res/xml/`) re-permits cleartext to loopback
    (`127.0.0.1`/`localhost`) only. Being explicit also stops a merged library manifest from
    re-enabling cleartext and clears the `usesCleartextTraffic`-implicitly-enabled scanner finding.
  - Failure states: unreachable, non-2xx, not-a-Runic-Gateway-site (missing expected `/public/status`
    shape), TLS error — each gets a clear retry message. Nothing else in the app runs until this
    succeeds.
- **Settings:** the base URL is editable later under **Settings → Server**. Changing it is a
  hard reset of session state: clear stored tokens, drop cached content, re-run the validation probe,
  and return to a signed-out state against the new host.
- **Version guard:** the backend is versioned; surface a clear "app/site version mismatch" state if a
  future protocol/version header disagrees, rather than mis-rendering.

---

## 4. Authentication & token handling

**Design rule (decided): credential/identity flows live on the website, not in the app.** The app
implements **only native username/password (+TOTP) login**. Registration, invite acceptance,
forgot/reset password, and SSO all **run through the website's API + web front end** — the app hands
off to the website in a browser (Chrome Custom Tab) and the user returns to sign in. This keeps every
account-provisioning, OAuth, and password path in one audited place rather than duplicated (and
security-reviewed twice), and it means **no new mobile-facing auth endpoints** are required for v1.
Password reset is being built on the backend + web front end **before** app work begins (§8), so it is
simply available in that hand-off, not app scope.

### 4.1 Username + password (+ TOTP) — the app's only native auth, ready today
Uses the existing **mobile bearer** surface, no backend changes:

- `POST /auth/mobile/login` `{ username, password, code? }` →
  `{ accessToken, refreshToken, expiresIn, user: { id, username, role } }`.
  - **Single-request 2FA:** a `401 { totpRequired: true }` means re-submit with `code`. The login
    screen reveals a code field on that response.
  - Respect `429` (backoff / rate-limit) with a friendly "try again shortly" state — login is guarded
    by per-IP backoff → slow-down → hard cap on the server.
- `POST /auth/mobile/refresh` `{ refreshToken }` → new pair. **Refresh tokens are single-use and
  rotated**: store the new pair atomically; a failed refresh (401) means the session is dead → sign
  out and return to login. An OkHttp `Authenticator`/interceptor performs a one-shot refresh on a
  `401` from a bearer call, with a mutex so concurrent 401s trigger only one refresh.
- `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — revoke this session or all
  sessions. Called on user logout and on "sign out everywhere."

#### 4.1.1 Trusted devices & recovery codes — **implemented** (app PR `feature/trusted-devices-mfa`)
The backend trusted-device + recovery-code feature (canonical ref: `../website/TRUSTED_DEVICES_MFA.md`)
is additive on the mobile surface. As built:
- **Login extras:** `POST /auth/mobile/login` accepts `recoveryCode` (a single-use alternative to
  `code`), `trustDevice: true`, `device_name`, and an `X-Trust-Token` header. On the `401 { totpRequired }`
  screen the app offers a "use a recovery code instead" toggle and a **"Trust this device"** checkbox.
  When `trustDevice` is accepted, the response carries `trustToken` → stored in a **dedicated
  EncryptedSharedPreferences file** (`runic_trust`, AES-256-GCM; never plain prefs/logs) **scoped to the
  username** it was minted for, and replayed as `X-Trust-Token` on that account's future logins to skip
  the TOTP prompt.
- **Login-time cap:** a `{ trustLimitReached, devices }` response means login **succeeded** but the
  device was not remembered (session is already issued on native, unlike the web cookie step). Rather
  than a blocking login-time modal, this is surfaced + resolved on the **Trusted Devices** account
  screen, whose `POST /auth/me/trusted-devices` gives the exact revoke-one-then-retry flow. (Deliberate
  deviation from the earlier "prompt at login" sketch — the mobile login is past the point a modal would
  gate.)
- **Self-service (account screens):** a **Security** section on the account screen links to two
  dedicated screens. **Trusted Devices** — `GET /auth/me/trusted-devices` (list), `DELETE …/:id`
  (revoke one), `DELETE …/trusted-devices` (untrust all), and `POST /auth/me/trusted-devices` to trust
  the current device (stores the returned `{ trustToken }`). **Recovery Codes** — enabling TOTP returns
  the one-time `recoveryCodes` (shown once on the account screen with copy/share); `GET
  …/recovery-codes/status` for the remaining count; `POST …/recovery-codes/generate` (password step-up)
  to regenerate, shown once.
- **Invalidation — trust token deliberately survives logout.** The trust token is the native analogue
  of the web `rg_trust` cookie, which per the canonical design *survives logout so the next login skips
  2FA*. On mobile the token is only ever consulted at a **fresh** login — i.e. exactly after a logout or
  a dead-refresh sign-out — so clearing it there would make the feature a no-op. It is therefore kept in
  its own store, **untouched by session teardown** (`SessionManager.onSignedOut`), and cleared only on a
  **Settings → Server switch** (bound to the old host), an **untrust-all**, or server-side revocation
  (password change/reset, TOTP disable — which makes any surviving token inert; the next login just
  prompts for the code). This supersedes the earlier handoff note that said to clear it on logout.

### 4.2 Website-handled flows: registration, invite, forgot-password, SSO
These are **not** rebuilt in the app. The app links out to the website's own pages/API and the user
completes them in a Custom Tab, then returns and signs in natively (§4.1):
- **Register / accept invite** — the app opens the website's register / `…/invite/:token` pages. Invite
  emails already link to the website. After the account exists, the user signs into the app with their
  new username + password. (No mobile register/invite endpoints needed.)
- **Forgot / reset password** — the app links to the website's reset page (the flow being built in §8
  before app work). The user resets there, then signs into the app. (No mobile reset endpoint needed.)
- **SSO (Google / Discord / OIDC)** — **v1** shipped this as a website browser hand-off: an SSO user
  links their identity and sets a password on the website, then uses password login in the app.
  `GET /auth/providers` is shown so the login screen can direct users to "sign in with … on the website."
  - **Native in-app SSO — now being built (M9), post-v1 additive.** The "possible later enhancement"
    noted here is now the **Mobile SSO Authorization Bridge**: a Custom-Tab flow that hands a one-time
    code back to the app's fixed callback (`runicgateway://auth/callback`), exchanged for the *existing*
    mobile bearer tokens. It **extends** the existing `/auth/sso/*` redirect flow rather than adding a
    parallel auth path — same PKCE-vs-IdP, same link-only + opt-in-provisioning policy, same TOTP gate,
    same token shape as `/auth/mobile/login`. The bridge adds a **second** PKCE layer (app ↔ website)
    and an app-generated `state` (CSRF, verified by the app before exchange). Backend + docs land first
    (this document's canonical API ref is `../website/BACKEND_DESIGN.md` → "Mobile SSO Authorization
    Bridge"); the native app client is M9. Custom-scheme callback only for now — App Links are deferred
    (see [`APP_LINKS.md`](./APP_LINKS.md)).

### 4.3 Session model (all paths)
- **Refresh:** `POST /auth/mobile/refresh` `{ refreshToken }` → new pair. **Single-use / rotated:** store
  the new pair atomically; a failed refresh (401) means the session is dead → sign out. An OkHttp
  `Authenticator` does a one-shot refresh on a bearer `401`, behind a mutex so concurrent 401s trigger
  only one refresh.
- **Logout:** `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — this session or
  all sessions ("sign out everywhere").
- **Storage:** access + refresh tokens live in EncryptedSharedPreferences, never in plain prefs/logs.
  The base URL may live in plain DataStore; tokens must not. An optional **biometric app-lock** was
  considered here but **descoped from v1** (tokens are already encrypted at rest; see the M3 note).
- **Role for the menu** comes from the login response `user.role` and is re-validated via
  `GET /auth/me` on app resume (roles can change server-side; admin access is re-checked every
  request on the backend, so the app treats role as *advisory for menu rendering* and lets the server
  be the authority — a 403 is handled gracefully, never assumed-away).

---

## 5. Navigation — one shared, access-level menu

A **single** navigation definition; each entry declares the minimum access it requires, and the menu
renders only the entries the current session satisfies. Roles: `anonymous` < `player` /
`moderator` / `editor` / `admin` (the three staff roles are not a strict ladder — gate by capability,
not rank).

| Menu group | Visible to | Backing endpoints |
|---|---|---|
| Home / Status | everyone | `/public/status`, `/public/settings` |
| News & content | everyone | `/public/posts/:category`, `/public/pages/:slug` |
| Wiki | everyone | `/public/wiki`, `/public/wiki/categories`, `/public/wiki/tags`, `/public/wiki/:slug` |
| Shard (live) | everyone | `/public/shard/*` + `/public/shard/stream` (SSE) |
| **Rules / Leaderboards / Market** | everyone, *if the shard publishes them* | `/public/shard/{ruleset,points,market}` (M11) |
| **Atlas** (bestiary) | everyone, *if the shard publishes it* | `/public/atlas/*` (M11) |
| Contact | everyone | `/public/contact` |
| **My Account** | signed-in | `/player/account/*` (or `/admin/account/*` for staff — see §6.4) |
| **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` |
| Sign in / Sign out | toggles on session | `/auth/mobile/*` |

Guidelines:
- The menu is **declarative + data-driven**, not a pile of `if role ==` checks — one list of entries
  with a `minAccess`/`requiredCapability` field, filtered by the session.
- **Session role is not the only gate on shard surfaces (M11).** Every shard-derived feature is
  *admin-configurable* — it can be switched off or raised to a higher audience rung — so a shard entry
  is filtered by the session role **and** by `GET /public/shard/features`, which reports the features
  the caller may actually reach. While that answer is unknown (in flight, or the lookup failed) the app
  shows everything: the server gates regardless, and a nav that flickers in on every load is worse than
  a link that briefly `403`s.
- Never hide the fact that more exists behind auth in a way that misleads; anonymous users see public
  groups and a "Sign in" affordance.
- The server is the source of truth: a hidden/greyed item is a UX convenience; every gated call still
  enforces on the backend and the app handles 401/403 cleanly.

---

## 6. Screen ↔ endpoint map

> **Path note:** endpoints below are `/api/v1`-relative and stay that way. The website's router
> refactor preserves every URL, and the `/api/mobile` facade that would have renamed them is deferred —
> see [`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Deferred: the `/api/mobile` facade.

### 6.1 Public content
- **Home/Status** — `GET /public/status`, `GET /public/settings` (branding + maintenance banner).
- **News hub** — `GET /public/posts/:category` (`news | five-on-friday | newsletter | screenshots`),
  detail via `GET /public/posts/:category/:idOrSlug`.
- **CMS pages** — `GET /public/pages/:slug` (block-based; render the block types the site uses).
- **Wiki** — list/categories/tags/detail as above.
- **Contact** — `POST /public/contact` (rate-limited; handle 429/502).

### 6.2 Public shard (live)
- Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the
  `/public/shard/*` GETs.
- **Live updates** — subscribe to `GET /public/shard/stream` (SSE) and patch the in-memory boards in
  place (champ/guild/city/house/presence update+remove frames). Reconnect with backoff; fall back to
  poll if SSE drops. What arrives on the stream is **resolved from the caller's audience rung at
  subscribe time**, not from a fixed allowlist (Protocol 3.0 §3.6) — the stream request carries the
  bearer like every other call, so a signed-in app session sees exactly what the same account sees on
  the web.
- **Visibility + the Protocol 3.0 surfaces (M11)** — `GET /public/shard/features` drives which of these
  the menu offers; `GET /public/shard/{ruleset,points,points/:system,market,market/meta,market/vendors/:serial}`
  and `GET /public/atlas/*` are the new reads. Full contract and traps in §9 M11.

### 6.3 Player self-service & game data (bearer)
- **Account** — `GET /player/account`; `PATCH /player/account/username`;
  `PATCH /player/account/password`; TOTP `setup`/`enable`/`disable`; identities `GET` / `DELETE`.
- **Game account linking** — `POST /player/shard/link` (one-time `[link` code),
  `POST /player/shard/account` (hybrid signup, when enabled), `GET /player/shard/accounts`.
- **My game data** — `GET /player/shard/roster/:account`, `/char/:serial`, `/vendors/:account`,
  `/sales`, `/houses`. All ownership-checked server-side; a `503` means shard/sidecar down → show an
  "offline, retry" state (see §7).
- **The character sheet carries two things the app does not yet read (M11):** the `points` block
  (per-character loyalty/points standings, Protocol 3.0 §7.3) and the server-resolved cliloc names on
  `equipment[].clilocName` / `titles.rewardResolved` (§8.6). Both are served **ungated** on this route —
  a character's own standings are self-service data and do not depend on the public `leaderboards`
  feature being visible, which is the behavior the app must mirror rather than re-gate.
- **Presentation is text-only for v1.** Character sheets and vendor listings render as data/text — no
  item icons or paperdoll art. A richer "pretty paperdoll" view is a **future** enhancement (pending the
  art/asset work on the platform side) and is explicitly out of the first release.

### 6.4 Self-service is role-agnostic under `/auth/**` (decided)
Player self-service is under `/player/account/*` (gated to `role='player'`) and staff use the *same*
handlers under `/admin/account/*`. Rather than have the app branch by role (and touch `/admin`), we
**add a role-agnostic self surface under `/auth/**`** — the canonical "me" endpoints for every role.
The app calls these regardless of role. This is an **additive v1**
change (see §8): the existing `/player/account/*` and `/admin/account/*` routes stay for web
back-compat; `/auth/me/*` reuses the same `account.controller` handlers behind `requireAuth` (any
authenticated role), so there's no logic duplication.

> **M10 update (2026-07-21):** *self-service* stays role-agnostic under `/auth/me/*` as above. Separately,
> the **operational** admin subset (§1, §10 — moderation, support queue, dashboard/site-mode, content)
> *does* call `/api/v1/admin/**` directly, gated to staff by a new `STAFF`/`ADMIN` menu access level.
> The earlier "the app never references `/admin`" rule is superseded for these defined staff operations
> only; `validateSession` accepts the mobile bearer on those routes and `requireRole` re-checks every
> request, so a demoted user loses the surface immediately (the app treats any `403` as authoritative).

---

## 7. Degradation & offline

Mirrors the website's "degrade gracefully" invariant:
- Every repository call returns a typed result (`Ok`/`HttpError(status)`/`NetworkError`); the UI never
  crashes on a down backend or shard.
- **Shard down** (`503` from shard reads, or `/public/shard/status` shows disconnected) → render the
  shard as **offline**, keep the rest of the app usable.
- **Site maintenance** (`/public/status` = maintenance) → show the maintenance page; public shard
  widgets may still render (they're not maintenance-gated server-side).
- **Offline caching is not a v1 requirement** (decided). The app assumes connectivity and shows clean
  loading/error/retry states; it does **not** ship a Room cache in v1. Cached read-only content can be
  added later without reworking the repository layer (its typed results already isolate the UI from the
  data source). No `Room` dependency in the initial build.

---

## 8. Cross-repo work to do *before* coding the app

The bridge repos are contracts; the app adds a new consumer. Land these first (in `website/` +
`docs/`), each with regenerated Swagger.

**Already verified — no change needed** (checked against the current backend):
- **CORS / native reachability.** CORS is only enabled when `CLIENT_ORIGIN` is set (local Vite dev);
  in prod the SPA is same-origin and CORS is off. A native HTTP client is not browser-origin-bound, so
  no CORS/preflight applies. *Caveat:* `app.js` mounts a bot/scanner guard before routing — the app
  must send a sane `User-Agent` so it isn't caught by scanner heuristics.
- **`GET /auth/me` bearer support.** `auth/token.js:extractToken` reads the cookie *then* falls back to
  `Authorization: Bearer`, and `/auth/me` advertises both auth schemes. It returns the current user for
  a bearer token today. The entire `/player/**` and self-service surface works with bearer as-is.
- **Token lifetimes.** Access `MOBILE_ACCESS_TTL` = 15m default; refresh `MOBILE_REFRESH_TTL_DAYS` =
  30 days. The login/refresh response's `expiresIn` reflects the access TTL — drive proactive refresh
  off it.

**API versioning: everything below stays in v1 (decided).** These are all *additive* routes — new
endpoints that change no existing response shape — so they do **not** warrant a v2. A v2 API is only
justified by a breaking change to a contract existing clients depend on, which none of this is. The
web client and the app both consume v1; a second parallel route tree + Swagger spec would be pure
maintenance cost. Reserve v2 for a real breaking re-shape if one ever arises.

**To build (all additive, v1):**
1. **Role-agnostic self-service under `/auth/**` (§6.4, decided).**
   ✅ **DONE (2026-07-19, RunicGateway/website#76 (+ this docs PR)).** A `me.routes.js`
   sub-router mounts the existing `account.controller` self handlers behind `requireAuth` (any role) at
   `/auth/me/*`, so the app has one self surface and never touches `/admin`. The old
   `/player/account/*` + `/admin/account/*` routes stay for web back-compat. Shipped routes:
   - `GET /auth/me` — current `{ id, username, role }` (already existed; the app's role source).
   - `GET /auth/me/account` — full self account.
   - `PATCH /auth/me/account/username`, `PATCH /auth/me/account/password`.
   - `POST /auth/me/account/totp/setup|enable|disable`.
   - `GET /auth/me/account/identities`, `DELETE /auth/me/account/identities/:provider`.
   - Swagger regenerated with `#swagger` annotations; `test/authMe.test.js` guards the auth gate; and
     an end-to-end smoketest confirmed both a player and an editor (staff) drive the same surface.
2. **Password reset — build on backend + web front end FIRST (a prerequisite, not app scope).**
   ✅ **DONE (2026-07-19, RunicGateway/website#75 + docs#8).** Full platform flow shipped in `website/`:
   request-reset (`POST /auth/password/forgot`, always a generic 200 — no account enumeration) emails a
   single-use, ~1h link → reset page + endpoints (`GET|POST /auth/password/reset/:token`) that verify,
   set the password, and revoke every session (web cutoff + mobile refresh tokens). The token is an
   opaque random value stored as a **sha256 hash** in a new `password_resets` table (mirroring
   `user_invites` — chosen over a signed JWT to match the house pattern; functionally equivalent). It
   also serves SSO-only accounts (null hash) as their set-initial-password path. Swagger regenerated;
   documented in `BACKEND_DESIGN.md`. The app just links users to the web page (§4.2) — **no mobile reset
   endpoint.**
   - No mobile SSO/invite/register endpoints are needed: SSO, registration, and invite acceptance all
     stay website-handled and the app hands off to them (§4.2). This is a deliberate scope reduction.
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 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 (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:<pkg> }`) is surfaced on
   `GET /public/status` (so the first-run probe recognizes the backend + reads its version in one call)
   and on a new **DB-free `GET /public/version`** (canonical target for the version-mismatch guard +
   a cheap liveness check). Swagger: `PublicVersion` schema. `test/publicVersion.test.js` covers both.
5. **Docs** — update `docs/website/BACKEND_DESIGN.md` for any new/changed endpoint; keep this file and
   the OpenAPI spec current. (The workspace `CLAUDE.md` is a **local, uncommitted** file — update it in
   place as repos come online, but it is never committed.)
6. **Branding for mobile.**
   ✅ **DONE (2026-07-19, RunicGateway/website#77 (+ this docs PR)).** Confirmed
   `GET /public/settings` returns the per-shard `brand` block (name, `accent` color, logo/hero/favicon,
   plus shortName/tagline/description/url/contactEmail) sourced from `BRAND_*` with admin
   `site_title`/`contact_email` overrides — the app themes itself from it. Made it first-class in the
   OpenAPI contract (`Brand` + `PublicSettings` schemas) so the app's codegen gets typed branding
   instead of an untyped map; `test/publicBrand.test.js` locks the contract. Asset fields may be
   site-relative paths — the app resolves them against its stored base URL.

No `link/` or `servuo-plugins/` changes are expected — the app is downstream of the website only.

---

## 9. Milestones

**Two passes (§2.1).** M0–M4 were the **functional Kotlin pass** — every screen wired to its endpoints
and working end-to-end with placeholder/functional Compose UI, no design investment yet. **M5 was the
design pass**: with the functional Kotlin done, Claude Design produced the front-end design and Claude
Code implemented the final UI to it. **Both passes are now complete** (M0–M5 landed); polish/release,
push, and Play (M6–M8) follow the designed app.

1. **M0 — Repo scaffold**: Gradle + Compose + Hilt skeleton, CI (build + lint + unit test), license
   headers (GPL-3.0-or-later), CONTRIBUTING/AI-disclosure parity with the other repos.
2. **M1 — Connect & browse** *(functional pass)*: first-run base-URL flow,
   `/public/status`+`/public/settings` theming, generated API client, public content
   (news/wiki/pages) + contact. No auth yet.
3. **M2 — Public shard** *(functional pass)*: shard widgets + SSE live stream with
   reconnect/degradation.
4. **M3 — Auth (§4)** *(functional pass)*: native password+TOTP login (429 handling), token storage,
   refresh interceptor, logout, `/auth/me` role re-validation, the access-level menu. Custom-Tab
   **hand-offs** to the website for register / invite / password-reset / SSO (no native screens for
   those). (An optional biometric app-lock was considered here, then descoped from v1 at M6.)
   *Prerequisite:* the website password-reset flow (§8) is already built.
5. **M4 — Player self-service & game data** *(functional pass)*: account management (via
   `/auth/me/*`), game-account linking, own roster/characters/vendors/houses/sales — **text-only**
   presentation (§6.3).
6. **M5 — Design pass & final UI (§2.1)**: with the functional Kotlin from M1–M4 working end-to-end,
   **Claude Design produces the front-end design** for the app, then **Claude Code implements the final
   UI to it** — Compose screens, Material 3 theming from the per-shard branding (§3), reusable
   components, loading/error/empty states, the designed access-level menu. Restyles the existing
   screens only; no changes to architecture, data flow, or endpoint contracts. Text-only game data
   (§6.3) still holds — this is visual design of the data screens, not paperdoll art. **Landed**
   2026-07-20 (`RunicGateway/Android-app#10`): dark-only shard-website theme, Cinzel display face,
   reusable pill/label/card/meter components; brand-accent seeding retained (see §9 build progress).
7. **M6 — Polish & release mechanics**: settings (server switch = hard reset, done M3),
   version-mismatch guard, release build hardening (HTTPS-only, no token logging, R8 minify + resource
   shrink, release signing). No offline cache in v1 (§7). **Ships v1 as a signed APK attached to a Gitea
   release** via `release.yml` on a `v*` tag (see §10). Biometric app-lock **descoped** (below).
   **Landed** 2026-07-20 (`RunicGateway/Android-app#11`).
8. **M7 — Push notifications** (post-v1): add the self-hosted `ntfy` service to
   `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.
   ✅ **Both parts landed** 2026-07-20 — Part 1 backend (`RunicGateway/website#78` merged + docs#20),
   Part 2 app (`RunicGateway/Android-app#15`) + a small `push.ntfyUrl` settings addition
   (`RunicGateway/website#79`). The app embeds its own ntfy distributor (a foreground-service SSE
   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
   — after the direct-APK release is stable.
10. **M9 — Native SSO login** (post-v1, additive; independent of M8): in-app "Sign in with Google /
    Discord" via the **Mobile SSO Authorization Bridge** (§4.2). **Backend-first**, mirroring M7's
    split:
    - **Part 1 — backend + docs (in progress):** `mobile_auth_sessions` + `mobile_auth_codes` bridge
      tables; `GET /auth/mobile/sso/start` (seeds a bridge session, reuses the existing SSO redirect
      tagged `mode:'mobile'`); a mobile branch in the SSO callback + TOTP-completion that mints a
      single-use, hashed, PKCE-bound authorization code and redirects to the fixed app callback instead
      of setting a cookie; `POST /auth/mobile/sso/exchange` (code + PKCE verifier → the existing mobile
      bearer token pair); an exact-match redirect-URI allowlist; boot-time + opportunistic cleanup of
      the bridge tables. Reuses `GET /auth/providers` for discovery and `POST /auth/mobile/{refresh,
      logout}` unchanged. See `../website/BACKEND_DESIGN.md`.
    - **Part 2 — app client:** register the `runicgateway://auth/callback` intent-filter; generate
      `code_verifier`/`code_challenge` + `state`; open the Custom Tab at `/auth/mobile/sso/start`;
      verify `state` on the callback; `POST …/exchange`; store the returned pair in the existing
      `TokenStore` (M3). No new token-storage or refresh code — it feeds the M3 session machinery.
    - **Follow-up — App Links (opt-in hardening on top of Part 2):** a website `GET
      /.well-known/assetlinks.json` route behind the `mobile_app_links_enabled` admin toggle, a
      self-origin HTTPS entry added to the redirect-URI allowlist when enabled, and an app-side
      `autoVerify` intent-filter for `https://<host>/mobile/callback` driven by a **build-time**
      `appLinkHost` (a single multi-tenant APK cannot autoVerify open-ended shard domains, so the
      generic build stays custom-scheme; white-label/first-party builds bake one host). The custom
      scheme remains the permanent fallback on every build. Full spec + rollout in
      [`APP_LINKS.md`](./APP_LINKS.md).
11. **M10 — Native SSO fixes + staff operations** (post-v1; decided 2026-07-21). Two threads found
    during on-device QA:
    - **SSO discovery + reachability fixes (app-only, done):** the native login screen only rendered
      provider buttons when `GET /auth/providers` was non-empty and otherwise fell back to the desktop
      website login (which can't deep-link a mobile session back → it hung). `AuthRepository.ssoProviders()`
      now returns `Available` / `None` / `Unavailable` (retry once); the login screen shows native
      buttons / a loading hint / a retry, and the website-login fallback is removed. The pending PKCE
      `{state,verifier}` is persisted (encrypted `PendingSsoStore`) so the exchange survives Custom-Tab
      process death. The nav drawer is now `verticalScroll`-wrapped so a signed-in session's longer menu
      (which includes **Notifications**) can't clip on short screens. Dev testing uses a **stub OAuth IdP**
      (`website/scripts/dev/`), since dev configures no real provider. Verified end-to-end on an emulator.
    - **Staff operations (§1, §6.4):** a new `STAFF`/`ADMIN` menu access level reveals a staff section
      for `admin`/`moderator`, with native screens over the existing `/api/v1/admin/**`: **moderation**
      (`POST /admin/shard/{kick,ban,unban,broadcast}`), **support queue** (`GET /admin/shard/pages`,
      `POST /admin/shard/pages/:id/{respond,close}`), **dashboard + site-mode** (`GET /admin/dashboard`,
      `PUT /admin/site-mode`), and **content** (news posts under `/admin/posts*`, wiki categories/tags
      under `/admin/wiki/*`). No backend routes added (they already accept bearer + re-check role);
      shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block
      editor, Discord-bot config, uo-link config, OAuth-provider setup.

12. **M11 — Protocol 3.0 shard parity** (post-v1; scoped 2026-07-30). The website's Protocol 3.0 work
    added four shard features and, with them, an **admin-configurable visibility framework** the app
    knows nothing about. `link/v3.md` §10 deferred the app side as a follow-up; it is now scoped
    deliberately, and **the v3 `edge` → `main` cutover is held until both parts land** so web and app
    surface the same shard on the same day (decided 2026-07-30).

    Neither part is coupled to the cutover *merge order*, which is what makes holding it a schedule
    decision rather than a technical one: against a pre-v3 website every new route and
    `/public/shard/features` simply `404`s, and each consumer below falls back to exactly today's
    behavior. The app declares no protocol version and never talks to the sidecar.

    - **Part 1 — the visibility rules + the read-model adds.** The security-shaped half, reviewed on
      its own:
      - `GET /public/shard/features` → `{ level, features[] }`: the features **this caller** may reach.
        A new singleton cache mirrors the web client's (`lib/useShardFeatures.js`): per-viewer but
        stable for a session, invalidated on sign-in/out and on a server switch.
      - `MenuEntry` gains `feature: String?` beside its existing `access`, so the one declarative menu
        (§5) filters on the session role **and** the shard's live feature config. While the lookup is
        in flight or has failed, **show everything** — the same deliberate fail-open the web client
        takes, because the server gates regardless and a nav that flickers in on every load is worse
        than a link that briefly `403`s. The gate is server-side; hiding is presentation.
      - **`404` and `403` mean different things here** and neither is a generic error:
        `requireFeature` `404`s a *disabled* feature (deliberately not disclosing that it exists) and
        `403`s a viewer *below its audience*. Both render "not available on this shard", alongside the
        existing `503` = shard offline (§7).
      - **The `level` from `/features` is authoritative — do not re-derive the rung from the role.**
        The server's ladder is `anonymous → logged_in → player → staff → admin`, where `player` means
        *a linked game account* and staff always satisfy `player` (the same superset rule `Menu.kt`
        already encodes as `isPlayer || isStaff`).
      - **`char.profile.points`** → the "Loyalty & Points" block the web character sheet gained:
        `CharProfileDto.points[{system, nameString, points, maxPoints, rank?}]`. Three traps, all of
        them things a real shard does and a fake one does not (`v3.md` §7.5): `maxPoints == 0` means
        **uncapped** and is the *common* case, so nothing may divide by it; `nameString` is usually
        `null` because most systems name themselves with a cliloc, making the humanise-the-`system`-key
        path the **primary** one rather than a fallback; and `rank` is absent unless the shard runs
        `PointsProfileRank=true` — absent and "unranked" are different answers.
      - **Cliloc-resolved names** (`v3.md` §8.6, already live on the website): `EquipmentDto` gains
        `name` + `clilocName` and `TitlesDto` gains `rewardResolved`, so equipment stops rendering as a
        layer or a bare id. Precedence is `name → clilocName → layer`: a player-given name outranks the
        resolved type name, and the server applies the same order. A shard with no cliloc table
        configured sends neither field and the sheet renders exactly as it does today.
      - `ActorDto` keeps its `acct` / `webId` fields (nullable, so nothing breaks) but its KDoc stops
        describing them as available: they are **locked to the admin rung**, always, and stripped from
        every response below it.
    - **Part 2 — the four new screens**, each hidden by its feature name in the menu:
      - **Rules** — `GET /public/shard/ruleset` (`ruleset`). A `null` body means "the shard has not
        published its ruleset yet", which is a different state from the feature being disabled. Every
        block is optional and omitted when its system is off. **`caps.skill` / `caps.totalSkill` are in
        tenths** (1000 = 100.0) and must be converted — the raw number is actively misleading, not
        merely unhelpful. Live via the `world.ruleset` frame, which is on the public stream by default.
      - **Leaderboards** — `GET /public/shard/points`, `/points/:system` (`leaderboards`). The same
        `maxPoints`/`nameString` traps as the profile block. Live via `points.board`.
      - **Market** — `GET /public/shard/market` (`q`, `minPrice`, `maxPrice`, `itemId`, `map`, `region`,
        `sort`, `limit`, `offset`), `/market/meta` for the filter options + staleness, and
        `/market/vendors/:serial` (`market`). Four things this screen must get right: it is the site's
        first **rate-limited** public endpoint, so handle `429` the way the contact form does; the
        *"prices last refreshed N minutes ago"* banner is **required, not decoration** — the shard
        sweeps vendors round-robin, so a listing can legitimately be a full cycle stale and a page
        implying live prices sends people to an item that sold twenty minutes ago; a `truncated` shop
        must say so; and `location` is a **nested object** that an admin may gate away entirely, which
        the vendor screen renders as "hidden by the shard" (a real answer) rather than as blank
        coordinates — same for `ownerName` / `ownerSerial`. **The `market` SSE fan-out is off by
        default** (a live firehose of vendor inventories would be the site's biggest bandwidth
        consumer), so the screen is a plain paginated read and must never depend on live frames.
      - **Atlas** — `GET /public/atlas/{creatures,creatures/:slug,regions,landmarks,champions,meta}`
        (`atlas`). Note the path: `/public/atlas`, **not** `/public/shard` — the atlas is static shard
        *content*, not live shard *state*, and unlike `/shard/*` it **is** `siteMode`-gated like
        `/posts` and `/wiki`, so a site in maintenance mode withholds it independently of the sidecar.
        Three traps. Two are units/naming, from `v3.md` §6.3: respawn delays are **seconds**
        throughout, and `points` is a *count* on the search route while `spawners` is the *list* on
        the detail route. The third is a **shape**: `places` is a list of
        `{facet, label, spawners, maxAlive}` **objects**, not of place-name strings — it is the
        aggregate the screen exists to show ("Shrines, Isamu-Jima, Yew"), it arrives only on the
        detail route, and typing it `List<String>` makes that whole route fail to decode while the
        request itself returns `200`.
    - **Verification** — the five-rung walk (`anonymous`, `logged_in`, `player`, `staff`, `admin`)
      against a local website on the cutover branch, per
      [`../link/v3.md`](../link/v3.md) §11 and the shard-visibility smoke harness; plus one pass with
      **every feature disabled** in Admin → Shard Visibility, confirming the app *hides* each surface
      instead of erroring on it. Unit tests cover the menu filter (role × feature set), the
      `404`/`403`/`503` mapping, and DTO decode for each new shape. **Decode tests must feed real
      captured JSON**, not DTOs built in Kotlin: the fakes under `data/api/fake/` construct objects
      directly, so they can never catch a wire/type mismatch — which is how the `places` shape above
      shipped past a green suite.
    - **Excluded**, in the same class as M10's exclusions: the admin *configuration* panels — Shard
      Visibility, Spawn Atlas and Cliloc import — alongside the hero/CMS block editor, Discord-bot
      config, uo-link config and OAuth-provider setup.

13. **M12 — Admin theming & navigation parity** (post-v1; scoped 2026-08-08). The website merged
    runtime admin theming, brand assets and nav overrides to `main` (website#126 / docs#109). The app
    reads exactly one field of it — `brand.accent` — and renders a hardcoded `APP_MENU`, so an admin
    who re-skins the site and restructures the header sees none of it on the phone. This milestone
    makes the app a full consumer of that contract.

    **Design of record: [`THEMING_AND_NAV.md`](./THEMING_AND_NAV.md)** — the token map, the phase
    list and the locked decisions live there rather than here, mirroring how the website side kept
    [`../website/THEMING_AND_NAV.md`](../website/THEMING_AND_NAV.md) separate from its own plan.

    **No backend work.** Everything consumed is already live on `website/main`:
    `GET /public/settings` gained `theme` (the full resolved token map) and `nav_public`, its `brand`
    block now returns *effective* values, and `GET /api/v1/settings/nav` serves the admin/player
    overrides to any authenticated account.

    The points that shaped the plan, and that a reader of this file should know without opening it:

    - **The app's palette is already the `runic-gateway` preset**, value for value — M5 was drawn
      from the same `theme.css` the preset was later extracted from. So the website's governing
      invariant (an untouched instance renders byte-for-byte as before) carries over as a *testable
      equality assertion* on the resolved `ColorScheme`, not an approximation.
    - **Radii apply as a ratio, not as literal dp.** The app's `Shapes` came from the M5 mockup and
      genuinely differ from the web tokens (`medium` 12dp vs `--radius-card` 10px); a literal mapping
      would restyle the untouched app the day this ships. A ratio against the `runic-gateway`
      baseline makes an untouched instance a provable no-op while still tracking the admin's intent.
    - **Fonts are bundled, not downloadable.** Seven families join the already-bundled Cinzel
      (~1.5–2.5 MB, against a 4.2 MB signed release). Downloadable fonts were rejected: they need the
      Play Store provider, so a de-Googled device silently falls back.
    - **Nav overrides are keyed by *website* paths**, so the app needs a path → route table — the one
      new cross-repo coupling here. Two asymmetries are decided rather than papered over: an override
      for a path the app does not surface in its menu (champs / guilds / governors / houses, which
      live behind the Shard hub) is **ignored**, because a nav override may never *introduce*
      navigation; and the app's own entries with no web counterpart keep their coded order.
    - **The gates are untouched.** `MenuAccess` and `MenuEntry.feature` still run *after* the merge,
      so `hidden: false` cannot un-hide what a role or the shard's visibility config withholds — the
      same boundary the website's §7 draws.
    - **The authenticated navs are thinner than they look.** `nav_player` reaches two app rows and
      `nav_admin` two (`/player`, `/account`, `/admin`, `/admin/moderation`); the sidebar's other
      ~18 rows are admin *configuration* the app excludes, and two of the app's four staff entries
      are aggregates with no single web row. That is why they honor `label` and `hidden` only — and
      why that phase was scheduled **last and marked optional**, so it could be dropped on its
      merits. **It was: phase 7 is cancelled (2026-08-08).** The app makes no authenticated settings
      call, `nav_admin` / `nav_player` are not consumed, and the player and staff drawer rows keep
      their coded — and therefore localized — labels. Nothing in phases 0–6 was scaffolding for it.
    - **Excluded**, in the same class as M10's and M11's exclusions: the admin *configuration* panels
      themselves. The app does not gain Appearance or Navigation editors; it is a consumer.

    Eight phases (0–6 and 8; **7 cancelled**) into a fresh `edge` in both repos, reaching `main` as
    one `edge` → `main` merge — the same shape the website side used. Phase 0 (the contract and the
    appearance store) carries a hard rule: it must change nothing on screen. **Phase 0 landed
    2026-08-08** (Android-app#33 / docs#112): `SiteAppearance` now holds the brand, the resolved
    theme tokens and the parsed `nav_public`, refreshed on resume, with nothing yet reading the last
    two — see THEMING_AND_NAV.md "Phase 0 as landed". **Phase 1 landed 2026-08-08**
    (Android-app#34 / docs#113): the fifteen color tokens now resolve into a `ShardPalette` that
    feeds both the Material scheme and a `LocalShardPalette`, and the no-op invariant is a test —
    the shipped palette reproduces the pre-M12 `ColorScheme` role for role. **Phase 2 landed
    2026-08-08** (Android-app#35 / docs#114): the four radii scale the app's own dp by their ratio
    to the `runic-gateway` baseline, and `--shadow-card` maps onto card elevation. The shadow is
    a **deliberate change to an untouched instance** — the app has been flat since M5 while the
    preset it was drawn from selects a shadow — and is why every `Card(` became a `ShardCard(`:
    Material takes elevation as a default argument, not from the theme. **Phase 3 landed
    2026-08-08** (Android-app#36 / docs#115): seven bundled families beside Cinzel, resolved from
    the stacks the theme publishes. It **nearly tripled the APK** — 4.80 → 12.94 MiB against a
    drafted 1.5–2.5 MB estimate, with Merriweather alone 6.08 MiB of the growth because upstream
    ships it as a barely-compressible three-axis variable font; verbatim bundling was chosen with
    the cheaper options costed and declined. **Phase 4 landed 2026-08-08** (Android-app#37 /
    docs#116): logo in the drawer header and top bar, hero on Home, no new dependency and no APK
    cost. **Phase 5 landed 2026-08-08** (Android-app#38 / docs#117): the public nav's label, order
    and hiding, keyed by the path→route table — whose sort keys must be indices into the
    *website's* sixteen-row nav, not the app's nine. **Phase 6 landed 2026-08-08**
    (Android-app#39 / docs#118): the drawer gains admin-authored sections and added links, a link
    the app can open natively doing so and one it cannot handing off to a Custom Tab.

    **Phase 8 landed 2026-08-08** (Android-app#40 / docs#119) and did more than its name. Scoped
    as docs, coverage and the cutover, its **AC-5 on-device walk** — two AVD passes against a local
    website, the themed one at every role rung — confirmed everything phases 1–6 had deferred to it
    (including that a Custom Tab really opens, checked in `dumpsys`, and that an anonymous caller
    sees the reordered, sectioned nav with no player or staff row leaking) **and found two theming
    defects that 476 passing tests could not see**:

    - every `ShardCard` — 26 sites in 20 files — drew in Material's grey rather than the shard's
      panel colour, because `CardDefaults.cardColors()` takes its container from
      `surfaceContainerHighest` and the mapping had `surfaceContainer`, `…High` and `…Low` but not
      `…Highest`; and
    - the drawer's selected row ignored `--radius-pill`, because `NavigationDrawerItem` takes
      `shape` as a default argument.

    Both are **phase 2's trap repeating**: Material takes these as default arguments, not from the
    theme. Phase 2 found it for depth and swept the call sites; nobody asked whether colour and
    shape had the same problem. The card fix also **changes the untouched app** — those grey cards
    predate M12 and go back to M5 — so the milestone ships two deliberate changes to a shard that
    has set nothing, the shadow and the card container, with AC-1 rewritten to record the second
    rather than absorb it. Phase 8 also narrowed a stale `ui/theme/**` Sonar coverage glob that was
    discarding three 98–100%-covered resolvers. **477 tests green.**

### Deferred (not a milestone)

- **`/api/mobile` facade migration + app-version floor** — briefly planned as its own milestone
  (2026-07-22), now **deferred with no app work scheduled**. The website's router refactor is being done in place with
  every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…`
  endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs
  to diverge from web, the migration comes back — starting from a one-line alias mount on the server,
  not a hand-written delegate layer. Reasoning and revival triggers:
  [`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Deferred: the `/api/mobile` facade.

---

## 10. Distribution

- **v1: direct APK.** Build a signed release APK in CI and **attach it to a Gitea release** (mirrors
  how `link/` cuts release binaries). Users sideload; the app already self-configures its server URL on
  first run (§3), so one APK works for any shard. Keep a stable **upload/signing keystore** out of the
  repo from day one — Play later requires a consistent signing identity.
- **Later: Google Play.** Add a Play Console listing and (if using FCM) a `google-services` config as a
  **build flavor**, so the direct-APK build stays Google-free. Versioning: semantic `versionName` +
  monotonic `versionCode`; tag releases in the repo.

## 11. Push notifications (built-for, shipped post-v1)

The app is architected from M0 to accommodate push, but push itself ships in M7 — it does not block the
first release. Users **opt in per stream**: nothing is pushed unless subscribed.

### Transport — UnifiedPush via self-hosted ntfy (decided)
- **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.

### ntfy deployment — fully automated, zero interactive setup (hard requirement)
- Runs as an **additional service in `website/docker-compose.yml`** (the compose *pulls* images and
  never builds — ntfy is a pinned upstream image, so this fits that model). Confirm the exact image
  path/tag at implementation.
- **All config is declarative** — a committed `ntfy` config file and/or `NTFY_*` env vars baked into
  compose. No `docker exec`, no interactive `ntfy user add`, no post-deploy manual steps. Bringing the
  stack up provisions a working push relay. Reachable via the existing reverse proxy on its own
  hostname/path — the container publishes `:80` on a host port (`NTFY_HOST_PORT`, default `2586`)
  because the proxy runs *outside* the compose network and can only reach a service through a
  published host port (the same reason `app` publishes `3000`). Both devices and the backend publisher
  reach ntfy on that public origin.
- **No per-user ntfy accounts to administer.** The security model (below) removes the need for ntfy ACL
  provisioning, which is exactly what keeps setup interaction-free. ntfy topics are the random,
  unguessable endpoints UnifiedPush hands out; the backend treats ntfy as an **untrusted relay**.

### Backend (additive, v1)
- `POST /auth/me/devices` — register a device: `{ transport, endpoint, platform }` where `endpoint` is
  the UnifiedPush/ntfy URL the distributor gave the app (or an FCM token for a Play/FCM build). `DELETE
  /auth/me/devices/:id` — unregister. Devices belong to the authenticated user.
- `GET /notifications/streams` — catalog of subscribable streams + which require a linked game account.
- `GET|PUT /auth/me/notifications/subscriptions` — the user's selected streams (per-user; applied to
  all their devices).
- **Fan-out worker** hangs off the existing event dispatcher (`website` `utils/shardIngest.js`) — the
  same event source that already feeds the SSE channels — matches events against subscriptions and
  **publishes a content-free tickle** (see below) to each matching device's endpoint. Store endpoints
  per device. Any secret (an ntfy publish token, or an FCM server key if that flavor is used) is
  encrypted at rest via `utils/secretBox.js`, like the other secrets.

### Stream catalog (initial)
- **Public / opt-in** (no account needed): news posts, server up/down, IDOC warnings, champion-spawn
  starts, governor elections.
- **Personal** (require a linked game account; delivered only to the owner): *your* vendor sold an
  item, *your* house entered IDOC, a login to *your* account.

### Security boundary (hard requirement)
The ntfy relay is treated as **untrusted infrastructure**, and the design makes that safe:

- **Content-free tickles.** A push payload carries **no sensitive data** — only a stream id and an
  opaque reference (e.g. `{ stream: "vendor.sale", ref: "…" }`). On receipt the app wakes and **pulls
  the actual content over the authenticated, ownership-checked API** (`/auth/me/*`, `/player/shard/*`).
  So even if an ntfy topic name leaked, nothing meaningful leaks with it, and no data reaches a device
  that its user isn't already entitled to fetch. This is what lets ntfy be automated with no per-user
  ACLs while still honoring the security rules.
- **Same allowlist split as the SSE streams.** Sensitive kinds (staff audit, cheat detection, login
  attempts, IPs) are never fanned out to push at all — the publisher applies the identical public/safe
  allowlist used by the SSE dispatcher.
- **Personal events are owner-keyed.** A personal tickle (your vendor sold, your house IDOC) is
  published **only** to the endpoints of the owning user, decided by the same ownership check as the
  `/player/shard/*` reads — a device never receives another user's events.
- **Transport hardening.** ntfy served over TLS via the reverse proxy; the backend→ntfy publish is
  internal. Endpoints are unguessable random topics; unregister on logout / token revocation.

### App
- A **Notifications** settings screen lists the catalog with per-stream toggles; personal streams are
  disabled/greyed until the user has a linked game account. Registration happens after login; toggles
  write to `/auth/me/notifications/subscriptions`. Tapping a notification deep-links to the relevant
  screen (§ open item below).

> **Gotcha — the PUT body must always carry `streams`, even when empty.** The backend validator
> requires the field (`body('streams').isArray()`), so an empty set has to be sent as
> `{"streams":[]}`, never `{}`. kotlinx.serialization omits a property equal to its default
> (`encodeDefaults=false`), so a DTO field like `streams: List<String> = emptyList()` gets *dropped*
> from the body when the set is empty — the app then sends `{}` and the server rejects it `400`.
> Symptom: clearing your **last** subscription fails with "could not save" and the toggle sticks
> (any non-empty set still includes the field, so only the final toggle-off breaks). Fix: give the
> request DTO field **no default** so kotlinx always encodes it (Android-app
> `fix/notifications-empty-subscriptions`). The same trap applies to any "replace the full set"
> `PUT`/`POST` whose empty value equals a DTO default — prefer no default on required request fields.

## 12. Build & CI (Gitea Actions)

Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other
repos use), on a bare `ubuntu:latest` container.

- **Toolchain:** JDK **17** (temurin) for Android Gradle Plugin 8.x; Android SDK installed in-CI via
  `android-actions/setup-android@v3` (cmdline-tools + license acceptance). Cache `~/.gradle` and the SDK.
- **Bare-image gotcha:** `ubuntu:latest` lacks `git`/`curl`/`unzip` that `actions/checkout` and
  `sdkmanager` need — first step `apt-get install -y git curl unzip`. (Faster option once builds are
  frequent: run the job under a prebuilt Android-SDK `container:` image so nothing installs per-run.)
- **PR gate** (`.gitea/workflows/pr-checks.yml`, on PR → `main`): `./gradlew lint test assembleDebug`.
  Debug builds are auto-signed, so the gate needs no secrets. Mirrors `website/`'s pre-merge gate.
- **Release** (`.gitea/workflows/release.yml`, M6+): build a **signed release APK** and attach it to a
  Gitea release (mirrors `link/`'s release job). The **keystore is a base64 Gitea Actions secret**
  decoded in CI; store/key passwords are secrets. The keystore never lives in the repo. Keep the
  signing identity stable from the first release (Play later requires consistency).
- Semantic `versionName` + monotonic `versionCode`; tag releases.

### 12.1 Code quality — SonarQube (`Runic-Gateway-Android-app`)

Analysis runs post-merge and non-blocking (`.gitea/workflows/sonarqube.yml`); the gate is
informational. The project is clean (0 bugs / 0 vulns / 0 hotspots, Maintainability **A**); the only
gate failure is **coverage = 0% on new code**, which is a *reporting* gap, not a testing gap — the
25-file JVM unit suite exists, but the source-only Sonar scan never received a JaCoCo report.

> Once the wiring below landed (`Android-app#26`), real coverage measured **16.4%** on new code —
> still under the 50% gate. The plan to raise it (exclude non-unit-testable framework/UI code + test
> ViewModels/DTOs/repositories) lives in [`COVERAGE_PLAN.md`](./COVERAGE_PLAN.md).

**Coverage wiring (the fix):**
- Apply the `jacoco` plugin in `app/build.gradle.kts` + a `jacocoTestReport` task fed by
  `testDebugUnitTest`, emitting XML. Exclude generated/DI/Compose scaffolding
  (`**/*_Hilt*`, `**/*_Factory*`, `**/di/**`, `**/*ComposableSingletons*`, `R`/`BuildConfig`).
- `sonar-project.properties`: set `sonar.coverage.jacoco.xmlReportPaths` to the report, and
  `sonar.coverage.exclusions` for pure-`@Composable` UI (JVM unit tests can't execute composable
  bodies without Robolectric, so counting those lines would unfairly sink new-code coverage).
- `sonarqube.yml` must now run a real Gradle build before the scan (JDK 17 + Android SDK, mirroring
  `pr-checks.yml`): `./gradlew testDebugUnitTest jacocoTestReport` → then the scan step.

**Issue triage (2026-07-22):** of 15 code smells, **3 fixed in code** — remove an unused import
(`AdminContentScreen.kt`), remove an unused `page` param (`AdminSupportScreen.RespondDialog`), and
decompose `LoginViewModel` (cognitive complexity 20). The remaining **12 marked *Won't Fix*** via the
Sonar API with rationale: 5 snake_case DTO fields (intentionally mirror the JSON wire contract) and 7
Compose/nav cognitive-complexity + hoisted-callback param-count smells (idiomatic for Jetpack Compose).

## 13. Open questions (revisit as we go)

**Decided (recorded here for context):** single shard per install (§3); native auth is
password+TOTP only, with registration/invite/reset/SSO **handled by the website** (§4); **password
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) 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:**
- **App identity / domain.** Target application ID **`com.runicgateway.app`** — pending securing the
  `runicgateway.app` domain (needed for a verified app-link host and a matching package namespace). Also
  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. **App Links resolved (M9 follow-up):** the SSO callback is the one place a
  verified deep-link-back helps; the server side (`assetlinks.json` + toggle) ships for any shard, but
  the app-side `autoVerify` needs a **literal build-time host**, so it is a white-label/first-party build
  opt-in (`-PappLinkHost=<host>`) — the generic multi-tenant build stays custom-scheme. A canonical
  `runicgateway.app` relay host, if secured, would let the generic build autoVerify one central domain.
  See [`APP_LINKS.md`](./APP_LINKS.md).
- 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). The
  reverse proxy forwards that hostname to the ntfy container's published host port (`NTFY_HOST_PORT`,
  default `2586`) — the container publishes `:80` because the proxy runs outside the compose network.
  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).
