docs(android): add Android app design plan
Add docs/android/PLAN.md — the design contract for the RunicGateway/Android-app repo (planning only, no app code yet). Scope: native Kotlin + Jetpack Compose client of the website v1 API. Public content + public shard widgets (incl. SSE), native username/password + TOTP login, player self-service via a new role-agnostic /auth/me/* surface, and a player's own shard/game data. Excludes every admin/management console (hero editor, auth/provider admin, Discord bot, shard/uo-link ops). Key decisions captured: stay on v1 (all additions are additive, no v2); registration/invite/reset/SSO are website-handled hand-offs, not native screens; password reset is built on the backend + web front end first; single shard per install; minSdk 29; no telemetry and no offline cache in v1; text-only game data (paperdoll is future); strings externalized from day one; push via a self-hosted ntfy/UnifiedPush service with content-free tickles that keep the relay untrusted; Gitea Actions build on ubuntu:latest with a signed-APK release. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
451
android/PLAN.md
Normal file
451
android/PLAN.md
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
# Android App — Plan
|
||||||
|
|
||||||
|
Status: **planning only, no code yet.** This document is the design contract for the
|
||||||
|
`RunicGateway/Android-app` repo. It is written before implementation so the API changes it
|
||||||
|
depends on can be landed in `website/` and `docs/` first. When we start coding, the authoritative
|
||||||
|
API reference is the committed OpenAPI spec at
|
||||||
|
`website/server/swagger/swagger-output.json` (regenerated via `npm run swagger`).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Explicitly OUT of scope (never in the app, for any role)
|
||||||
|
- The **hero editor** and any CMS authoring/block editing.
|
||||||
|
- The **admin / auth-management console** — user management, invites issuance, SSO provider config,
|
||||||
|
moderation console, email config, bot-activity/ban console. (Players still *log in*; what's
|
||||||
|
excluded is the management surface, not authentication itself.)
|
||||||
|
- The **Discord bot** management (and anything under the unpublished `/internal/**` port — it
|
||||||
|
returns the decrypted bot token and must never be reachable from a client).
|
||||||
|
- **Shard / uo-link administration** — sidecar base-URL/token config (`uoLinkConfig`), shard ops,
|
||||||
|
the staff shard-user console. (The app shows *public* shard widgets and a player's *own* game
|
||||||
|
data; it does not manage the sidecar.)
|
||||||
|
|
||||||
|
> The excluded surfaces all live under `/api/v1/admin/**` and `/api/v1/internal/**`. The app only
|
||||||
|
> ever calls `/api/v1/public/**`, `/api/v1/auth/**` (incl. the new role-agnostic self surface
|
||||||
|
> `/auth/me/*`, §6.4), and `/api/v1/player/**` — it never references `/admin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`).
|
||||||
|
- 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.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)** — SSO stays the website's browser redirect flow (`/auth/sso/*`),
|
||||||
|
**link-only** (no auto-provisioning). For v1 the app does **not** do one-tap in-app SSO; instead an
|
||||||
|
SSO user links their identity and sets a password on the website (the existing "set initial password"
|
||||||
|
path for SSO-provisioned accounts), then uses password login in the app. `GET /auth/sso/providers`
|
||||||
|
can still be shown so the login screen can direct users to "sign in with … on the website."
|
||||||
|
- *Possible later enhancement (out of v1):* true in-app SSO via a Custom-Tab flow that hands a
|
||||||
|
one-time code back to an app link, exchanged for mobile tokens — a small new backend endpoint. Only
|
||||||
|
build it if password-for-SSO-users proves too clunky.
|
||||||
|
|
||||||
|
### 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. Optional **biometric app-lock** (available
|
||||||
|
cleanly at API 29) gates access to the stored session — decide at M3.
|
||||||
|
- **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) |
|
||||||
|
| 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.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### 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, safe kinds only) 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.
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
- **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, and never references `/admin`. 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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).** Mount the existing
|
||||||
|
`account.controller` self handlers behind `requireAuth` (any role), so the app has one self surface
|
||||||
|
and never touches `/admin`. Keep the old `/player/account/*` + `/admin/account/*` routes for web
|
||||||
|
back-compat. New canonical routes:
|
||||||
|
- `GET /auth/me` — current `{ id, username, role }` (already exists; 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`.
|
||||||
|
- Regenerate Swagger; add `#swagger` annotations for each.
|
||||||
|
2. **Password reset — build on backend + web front end FIRST (a prerequisite, not app scope).**
|
||||||
|
No reset route exists anywhere today. Build the full platform flow in `website/` **before** app work
|
||||||
|
starts: request-reset (email a signed, single-use, expiring token) → reset page + endpoint (verify →
|
||||||
|
set password → revoke sessions), reusing the mail sender + `secretBox`/hashing. The app then just
|
||||||
|
links users to that website page (§4.2) — **no mobile reset endpoint.** Regenerate Swagger; document
|
||||||
|
in `BACKEND_DESIGN.md`.
|
||||||
|
- 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 (M6, not M1–M5).
|
||||||
|
4. **Version/health surfacing** — ensure `/public/status` (or a light `/public/version`) exposes
|
||||||
|
enough for the app's first-run probe and version-mismatch guard.
|
||||||
|
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** — confirm `/public/settings` returns the `BRAND_*` values (name, colors,
|
||||||
|
logo/hero/favicon URLs) the app needs to theme itself per shard.
|
||||||
|
|
||||||
|
No `link/` or `servuo-plugins/` changes are expected — the app is downstream of the website only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Milestones
|
||||||
|
|
||||||
|
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**: 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**: shard widgets + SSE live stream with reconnect/degradation.
|
||||||
|
4. **M3 — Auth (§4)**: 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). Optional biometric
|
||||||
|
app-lock. *Prerequisite:* the website password-reset flow (§8) is already built.
|
||||||
|
5. **M4 — Player self-service & game data**: account management (via `/auth/me/*`), game-account
|
||||||
|
linking, own roster/characters/vendors/houses/sales — **text-only** presentation (§6.3).
|
||||||
|
6. **M5 — Polish & first release**: settings (server switch = hard reset), version-mismatch guard,
|
||||||
|
release build hardening (HTTPS-only, no token logging). No offline cache in v1 (§7). **Ship v1 as a
|
||||||
|
signed APK attached to a Gitea release** (see §10).
|
||||||
|
7. **M6 — 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.
|
||||||
|
8. **M7 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor
|
||||||
|
— after the direct-APK release is stable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 M6 — 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.
|
||||||
|
- **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 to devices via the existing reverse proxy on its
|
||||||
|
own hostname/path; internal-only for the backend publisher.
|
||||||
|
- **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).
|
||||||
|
|
||||||
|
## 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`, M5+): 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.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
**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 — confirm whether any deep-link-back is wanted at all for v1.
|
||||||
|
- ntfy: exact upstream image + pinned tag, its reverse-proxy hostname/path, and whether to add a
|
||||||
|
backend publish token (optional hardening — the content-free-tickle design does not require one).
|
||||||
|
- FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M7.
|
||||||
|
- Deep-link / share targets for wiki pages, posts, and notification taps.
|
||||||
|
- 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).
|
||||||
Reference in New Issue
Block a user