Merge pull request 'docs(android): add Android app design plan' (#6) from docs/android-app-plan into main

Reviewed-on: #6
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-19 08:17:42 +00:00

451
android/PLAN.md Normal file
View 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 M1M5).
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).