19 Commits

Author SHA1 Message Date
cbaa18ea0b docs(android): release.yml is a conventional-commit auto-release engine
Update M6 docs to reflect release.yml mirroring link/'s engine (auto version +
changelog + tag + signed APK + Gitea release on merge to main), not a v* tag
trigger. Note REGISTRY_USER/REGISTRY_TOKEN + the main-push requirement, and the
derived monotonic versionCode. §9 status, §9 M6/M7 milestones, §12.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 04:25:05 -05:00
f79c2fa5a9 docs(android): record M6 (release mechanics) landed
Mark M6 landed (RunicGateway/Android-app#11): signed-APK release plumbing
(R8 minify + resource shrink, release signingConfig from a gitignored keystore,
release.yml on a v* tag), the §3 version-mismatch guard, and the default brand
app icons (deep-indigo medallion). Record the decision to descope the optional
biometric app-lock from v1 (tokens already encrypted at rest) across §4.3, the
M3 note, §9, and §13.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 03:37:37 -05:00
8798a0ff79 Merge pull request 'docs(android): record M5 (design pass) landed' (#18) from docs/android-m5-landed into main
Reviewed-on: #18
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 08:03:55 +00:00
4eae12448a docs(android): record M5 (design pass) landed
Mark the M5 shard-website design pass complete in docs/android/PLAN.md,
matching RunicGateway/Android-app#10: dark-only theme (deep blue-black
surfaces, slate accent, Cinzel display face), reusable pill/label/card/meter
components, and retained per-shard brand-accent seeding — restyling the
working M1–M4 screens with no architecture, data-flow, endpoint, or DTO
change. Updates the status header, the §9 build-progress record, and the
milestone list (M0–M5 landed; M6 release hardening next).

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 01:56:27 -05:00
17320ac578 Merge pull request 'docs(android): summarize website frontend theme for Android client' (#17) from docs/android-m4-landed into main
Reviewed-on: #17
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 03:44:03 +00:00
a3a5985268 docs(android): summarize website frontend theme for Android client
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 22:41:45 -05:00
3a1d091a71 Merge pull request 'docs(android): record M4 (player self-service & game data) landed' (#16) from docs/android-m4-landed into main
Reviewed-on: #16
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 03:36:32 +00:00
cdc2bf580e docs(android): record M4 (player self-service & game data) landed
Marks M4 done in android/PLAN.md §9 build-progress and flips the top
status to "M0–M4 landed; M5 (design pass) next." Documents the account
self-service (/auth/me/account*), game-account linking, and text-only
own game-data screens shipped in RunicGateway/Android-app#9 — a pure
consumer of the existing bearer API, no backend/protocol change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 22:35:35 -05:00
da3be61c7f Merge pull request 'docs(android): record M2 (public shard) + M3 (auth) landed' (#15) from docs/android-m3-landed into main
Reviewed-on: #15
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 00:16:39 +00:00
ed53990679 Merge branch 'main' into docs/android-m3-landed 2026-07-20 00:16:28 +00:00
0d5743ec74 docs(android): record M3 (auth) landed, M4 next
Records the M3 functional auth pass in docs/android/PLAN.md: native
username/password (+TOTP) login, EncryptedSharedPreferences token storage,
the refresh-on-401 authenticator, /auth/me resume re-validation, the
declarative access-level menu + My Account, and the Custom-Tab hand-offs for
register / forgot-password / SSO. Also records the decision to defer the
optional biometric app-lock to M6 (tokens are already encrypted at rest).

No backend/API change accompanied M3 — the app is a pure consumer of the
existing mobile bearer + /auth/me surface — so no Swagger regeneration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 19:09:15 -05:00
07603691bb Merge pull request 'docs(android): record M2 (public shard + SSE) landed' (#14) from docs/android-m2-landed into main
Reviewed-on: #14
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 23:39:02 +00:00
90bc9a0dad docs(android): record M2 (public shard + SSE) landed
Mark M2 done in the build-progress section: the public shard widgets over
/public/shard/* (hub + champ/guild/governor/house boards) and the live SSE
feed with self-driven reconnect/backoff (Android-app#7). Update the status
line to "M0–M2 landed; M3 next". No API change — the app consumes the
existing public shard surface.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 18:36:17 -05:00
292cdb3274 Merge pull request 'docs(android): record M1 (connect & browse) landed' (#13) from docs/android-m1-progress into main
Reviewed-on: #13
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 22:12:43 +00:00
eda817e6f3 docs(android): record M1 (connect & browse) landed
Mark M0+M1 done in the build-progress header and next up M2. Summarize
the M1 functional pass (first-run connect, runtime base URL + host
interceptor, layered ApiResult stack, brand-seeded theming, public
content/wiki/pages/contact screens) and record the deliberate
hand-written-vs-openapi-generated API-client deviation from §2 and its
rationale (swagger-autogen schemas are meta-descriptive, not
codegen-clean). Tracks RunicGateway/Android-app#6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 17:11:15 -05:00
061fbee8fc Merge pull request 'docs(android): mark M0 scaffold landed, M1 next' (#12) from docs/android-m0-status into main
Reviewed-on: #12
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 18:14:30 +00:00
78a455e3bb docs(android): mark M0 scaffold landed, M1 next
Update the PLAN.md status header now that the Android-app repo scaffold
(RunicGateway/Android-app#2) is in: Gradle+Compose+Hilt skeleton, version
catalog, and CI. Records M0 done and points at M1 (functional Kotlin pass).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 12:54:25 -05:00
e78c92850b Merge pull request 'docs: /public version+brand; mark PLAN §8 items 4 & 6 done' (#11) from docs/public-version-and-brand into main
Reviewed-on: #11
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 17:22:06 +00:00
874fcfd79d docs: document /public version+brand; mark PLAN §8 items 4 & 6 done
Counterpart to RunicGateway/website#77.

- BACKEND_DESIGN.md: /public table now documents the new GET /public/version
  (DB-free identity/version), the version block on /public/status, and the brand
  block on /public/settings (per-shard theming: name/accent/logo/hero/favicon).
- android/PLAN.md: mark §8 item 4 (version/health) and item 6 (branding) DONE and
  update the prerequisite-progress summary — all v1 prerequisites are now done;
  only push notifications (item 3) remains and is post-v1 (M7). App M0–M4 unblocked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 12:18:53 -05:00
3 changed files with 294 additions and 34 deletions

View File

@@ -1,15 +1,155 @@
# Android App — Plan # Android App — Plan
Status: **planning; no app code yet — website prerequisites in progress.** This document is the Status: **M0M6 landed; the functional build, design pass, and release mechanics are complete (the auto-release engine cuts a tagged, signed APK on merge to `main`; M7 push notifications next).** This document is the
design contract for the `RunicGateway/Android-app` repo. It is written before implementation so the design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the
API changes it depends on can be landed in `website/` and `docs/` first. When we start coding, the API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API
authoritative API reference is the committed OpenAPI spec at reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated
`website/server/swagger/swagger-output.json` (regenerated via `npm run swagger`). via `npm run swagger`).
**Prerequisite progress (§8):****Password reset** (§8 item 2 — the "build FIRST" prerequisite; **Build progress (§9):****M0 — repo scaffold** (2026-07-19, `RunicGateway/Android-app#2`):
website#75 + docs#8) and ✅ **role-agnostic `/auth/me/*` self surface** (§8 item 1) are **done** Gradle 8.7 wrapper + AGP 8.6.1 / Kotlin 2.0.20, JDK 17, minSdk 29 / compile-target 35,
(2026-07-19). Still open: version/health for first-run (item 4) and branding confirmation (item 6); `applicationId com.runicgateway.app`; a version catalog pinning the full §2 stack; a Compose + Hilt
push (item 3) is post-v1. 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), 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.
**M5 — design pass** (2026-07-20, `RunicGateway/Android-app#10`): the shard-website theme applied
across every screen, restyling the working M1M4 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` is the committed source of truth (bumped by the release engine); `versionCode` is derived
from it (`major*10000+minor*100+patch`, monotonic); both stay `-P`-overridable for local builds (§10).
**CI `release.yml`** — mirrors `link/`'s language-agnostic release engine, adapted for Android: on every
push to `main` it derives the next version from conventional-commit subjects since the last `v*` tag
(`feat!`/BREAKING → major, `feat` → minor, `fix`/`perf` → patch; nothing releasable → no release),
generates a grouped changelog, bumps `build.gradle.kts`, builds the **signed** APK (keystore decoded from
a base64 Gitea secret), then commits the bump `[skip ci]`, tags `vX.Y.Z`, and creates the Gitea release
with notes + APK + `SHA256SUMS`. Uses `REGISTRY_USER`/`REGISTRY_TOKEN` (as `link/` does) to push the bump
and create the release, so `main` must allow that account to push.
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 (M0M4), design pass (M5), and release mechanics (M6) are complete. The first
signed release now cuts automatically on the next release-worthy merge to `main` — once the signing +
`REGISTRY_*` secrets are set, `main` allows the CI account to push, and the on-device QA pass is done.
M7 push notifications are what remain.**
**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 (M0M4) is now
unblocked.
The workspace already holds `website/`, `link/`, `servuo-plugins/`, and `docs/`. `android-app/` is 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 the fifth repo. It is **purely an API client of the website backend** — it never talks to the
@@ -173,8 +313,8 @@ completes them in a Custom Tab, then returns and signs in natively (§4.1):
- **Logout:** `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — this session or - **Logout:** `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — this session or
all sessions ("sign out everywhere"). all sessions ("sign out everywhere").
- **Storage:** access + refresh tokens live in EncryptedSharedPreferences, never in plain prefs/logs. - **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 The base URL may live in plain DataStore; tokens must not. An optional **biometric app-lock** was
cleanly at API 29) gates access to the stored session — decide at M3. 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 - **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 `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 request on the backend, so the app treats role as *advisory for menu rendering* and lets the server
@@ -317,13 +457,23 @@ maintenance cost. Reserve v2 for a real breaking re-shape if one ever arises.
3. **Push notifications** — see §11. Additive v1 endpoints under `/auth/me/devices*` and 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`** `/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 M1M6). with fully declarative, zero-interaction config. Not required for the first release (M7, not M1M6).
4. **Version/health surfacing** — ensure `/public/status` (or a light `/public/version`) exposes 4. **Version/health surfacing.**
enough for the app's first-run probe and version-mismatch guard. ✅ **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 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 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.) place as repos come online, but it is never committed.)
6. **Branding for mobile** — confirm `/public/settings` returns the `BRAND_*` values (name, colors, 6. **Branding for mobile.**
logo/hero/favicon URLs) the app needs to theme itself per shard. ✅ **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. No `link/` or `servuo-plugins/` changes are expected — the app is downstream of the website only.
@@ -331,11 +481,11 @@ No `link/` or `servuo-plugins/` changes are expected — the app is downstream o
## 9. Milestones ## 9. Milestones
**Two passes (§2.1).** M0M4 are the **functional Kotlin pass** — every screen wired to its endpoints **Two passes (§2.1).** M0M4 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 is the and working end-to-end with placeholder/functional Compose UI, no design investment yet. **M5 was the
design pass**: once the functional Kotlin is done, Claude Design produces the front-end design and design pass**: with the functional Kotlin done, Claude Design produced the front-end design and Claude
Claude Code implements the final UI to it. Polish/release, push, and Play (M6M8) follow the designed Code implemented the final UI to it. **Both passes are now complete** (M0M5 landed); polish/release,
app. push, and Play (M6M8) follow the designed app.
1. **M0 — Repo scaffold**: Gradle + Compose + Hilt skeleton, CI (build + lint + unit test), license 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. headers (GPL-3.0-or-later), CONTRIBUTING/AI-disclosure parity with the other repos.
@@ -347,8 +497,8 @@ app.
4. **M3 — Auth (§4)** *(functional pass)*: native password+TOTP login (429 handling), token storage, 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 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 **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 those). (An optional biometric app-lock was considered here, then descoped from v1 at M6.)
already built. *Prerequisite:* the website password-reset flow (§8) is already built.
5. **M4 — Player self-service & game data** *(functional pass)*: account management (via 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** `/auth/me/*`), game-account linking, own roster/characters/vendors/houses/sales — **text-only**
presentation (§6.3). presentation (§6.3).
@@ -357,10 +507,15 @@ app.
UI to it** — Compose screens, Material 3 theming from the per-shard branding (§3), reusable 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 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 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. (§6.3) still holds — this is visual design of the data screens, not paperdoll art. **Landed**
7. **M6 — Polish & first release**: settings (server switch = hard reset), version-mismatch guard, 2026-07-20 (`RunicGateway/Android-app#10`): dark-only shard-website theme, Cinzel display face,
release build hardening (HTTPS-only, no token logging). No offline cache in v1 (§7). **Ship v1 as a reusable pill/label/card/meter components; brand-accent seeding retained (see §9 build progress).
signed APK attached to a Gitea release** (see §10). 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`'s conventional-commit engine on merge to `main` (see §10, §12).
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 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 `website/docker-compose.yml` (declarative, zero-interaction config), UnifiedPush integration in the
app, device registration, the subscriptions UI, and the content-free-tickle backend fan-out (see app, device registration, the subscriptions UI, and the content-free-tickle backend fan-out (see
@@ -460,11 +615,16 @@ repos use), on a bare `ubuntu:latest` container.
frequent: run the job under a prebuilt Android-SDK `container:` image so nothing installs per-run.) 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`. - **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. 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 - **Release** (`.gitea/workflows/release.yml`, M6): mirrors `link/`'s release engine — on every push to
Gitea release (mirrors `link/`'s release job). The **keystore is a base64 Gitea Actions secret** `main` it computes the next version from conventional commits since the last `v*` tag, generates a
decoded in CI; store/key passwords are secrets. The keystore never lives in the repo. Keep the changelog, bumps `build.gradle.kts`, builds a **signed release APK**, commits the bump `[skip ci]`,
signing identity stable from the first release (Play later requires consistency). tags `vX.Y.Z`, and creates the Gitea release with the notes + APK + `SHA256SUMS`. The **keystore is a
- Semantic `versionName` + monotonic `versionCode`; tag releases. base64 Gitea Actions secret** decoded in CI (`ANDROID_KEYSTORE_BASE64`); store/key passwords + alias
are secrets too. The keystore never lives in the repo. `REGISTRY_USER`/`REGISTRY_TOKEN`
(`write:repository`) push the bump + create the release, so `main` must allow that account to push.
Keep the signing identity stable from the first release (Play later requires consistency).
- Semantic `versionName` (bumped by the engine) + derived monotonic `versionCode`
(`major*10000+minor*100+patch`); the engine tags each release.
## 13. Open questions (revisit as we go) ## 13. Open questions (revisit as we go)
@@ -473,7 +633,8 @@ password+TOTP only, with registration/invite/reset/SSO **handled by the website*
reset built on backend + web first**, before app work (§8); minSdk 29, compile/target 35 (§2); no 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 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 data in v1, pretty paperdoll is future (§6.3); **no offline cache in v1** (§7); push via self-hosted
ntfy / UnifiedPush (§11). ntfy / UnifiedPush (§11); **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:** **Still open:**
- **App identity / domain.** Target application ID **`com.runicgateway.app`** — pending securing the - **App identity / domain.** Target application ID **`com.runicgateway.app`** — pending securing the

98
android/theme-plan.md Normal file
View File

@@ -0,0 +1,98 @@
# Android theme plan — mirroring the website frontend
This is a summary of the **website frontend theme** (source of truth:
`website/client/src/styles/theme.css`, applied at runtime by
`website/client/src/contexts/SiteContext.jsx`) so the native Android client can
present a visually consistent brand. Where the web uses CSS custom properties,
the Android equivalent is a Compose `MaterialTheme` `ColorScheme` + `Typography`.
## Overall character
A **dark, moody, "arcane fantasy" theme** — deep blue-black backgrounds, muted
slate-blue accent, parchment-white text, and an engraved serif display face. It
reads like a leather-and-moonlight fantasy ledger, not a bright consumer app.
There is **no light mode** on the web; the app should ship dark-only to match.
## Color tokens
The web theme is a flat set of CSS variables under `:root`. Map them to Compose
as follows (hex is authoritative):
| Web token | Hex | Role | Compose slot (suggested) |
|-------------------|------------|----------------------------------------|-------------------------------|
| `--bg` | `#0e1318` | App background | `background` |
| `--bg-deep` | `#0b0f14` | Deepest surface / on-accent text | `surfaceDim` / `onPrimary` |
| `--panel-a` | `#192231` | Card gradient top | `surface` |
| `--panel-b` | `#141a21` | Card gradient bottom | `surfaceContainer` |
| `--panel-flat` | `#11161d` | Flat panels, toolbars | `surfaceContainerLow` |
| `--line` | `#2a3544` | Borders / dividers | `outline` |
| `--line-soft` | `#1d2733` | Subtle row dividers | `outlineVariant` |
| `--accent` | `#7f99bd` | **Primary accent** (brand-overridable) | `primary` |
| `--accent-bright` | `#cdd9e8` | Primary button fill, active states | `primaryContainer` / bright |
| `--ink` | `#eef3f8` | Highest-contrast text | `onBackground` |
| `--head` | `#e6edf6` | Headings | heading color |
| `--text` | `#c4cdd8` | Body prose | `onSurface` |
| `--muted` | `#aeb8c4` | Secondary text | `onSurfaceVariant` |
| `--dim` | `#6f7d8e` | Meta / captions / placeholders | dim / disabled text |
| `--blue` | `#13243c` | Accent hover/active background | `secondaryContainer` |
| `--mode-live` | `#5fb98a` | "Shard live" status (green) | success |
| `--mode-maint` | `#e6c26a` | "Maintenance" status (amber) | warning |
### Semantic / status colors (used in badges, diffs, moderation)
- **Success / published / live:** green `#5fb98a` (fills at ~1622% alpha, text `#7fd0a4`).
- **Warning / maintenance / moderation (kick/mute/warn):** amber `#e0b070` / `#e6c26a`.
- **Danger / ban / red-link / errors:** desaturated red `#d98b84` (borders `#6e3b38`).
- **Admin badge:** near-white `#d8e2ef` on `#3a4a5e`.
## Branding is data, not code
The `--accent` value is **overridden at runtime** per shard instance. On the web,
`SiteContext` reads `brand.accent` from the site settings API and sets the CSS
variable, so one build reskins for any shard. **The Android app should do the
same:** fetch the brand payload (name, `accent`, colors, logo/hero/favicon) from
the website API and derive the `primary` color at runtime rather than hardcoding
`#7f99bd`. Default to `#7f99bd` when the brand payload is absent/offline.
## Typography
Three font families, by role:
- **Display** (`--display`): **Cinzel**, falling back to Georgia serif — an
engraved Roman capitals face used for the logo, `h1`/`.h1`, and prose
`h2`/`h3`. Bundle Cinzel as an app font; this face carries the brand.
- **Serif body** (`--serif`): **Georgia / Times New Roman** — default body and
prose text; `line-height ≈ 1.6`.
- **Sans** (`--sans`): **Helvetica Neue / Arial** — UI chrome: buttons, pills,
form labels, table headers, badges, meta. Labels/eyebrows/kickers are
UPPERCASE with wide letter-spacing (`0.10.18em`) and small (0.680.86rem).
Heading scale is fluid on web (`h1` clamps ~2.43.6rem); pick fixed Material type
scale equivalents (e.g. display for `h1`, headline for `h2`, title for `h3`).
## Shape, elevation & motion
- **Corners:** cards/panels `1012px` radius; inputs/small elements `8px`;
pills and buttons are **fully rounded** (`999px` / capsule).
- **Cards:** vertical gradient `--panel-a → --panel-b`, 1px `--line` border, soft
drop shadow (`0 14px 34px rgba(0,0,0,0.3)`). On hover the web lifts `-3px` and
brightens the border to `--accent` — translate to a pressed/focused accent
border on Android.
- **Buttons:** primary = bright fill (`--accent-bright`) with dark text;
ghost/secondary = translucent dark fill with accent-on-hover border.
- **Motion:** short, subtle transitions (0.120.18s). Keep animations understated.
## Signature accents (nice-to-have)
- The **"moon"** motif: a radial-gradient sphere (`#eef3f8 → #9fb0c6 → #5d6e88`) —
a small brand flourish worth reproducing.
- Accent-tinted focus rings and left-border "note" callouts
(`border-left: 3px solid --accent` over a translucent `--blue` background).
## Implementation note for Compose
Define one `darkColorScheme(...)` from the table above, a `Typography` binding the
three families, and a `Shapes` set (`small = 8.dp`, `medium = 10.dp`, capsule for
buttons). Load `accent` from the brand API into a state holder and rebuild the
`primary` (and derived `primaryContainer`) at runtime so a shard's custom accent
flows through the whole UI — exactly as `SiteContext` does on the web.

View File

@@ -195,8 +195,9 @@ by `seed.js` from env (see §6); further staff are created under `/admin/users`
### /public (public.routes.js → public.controller.js) — all GET, no auth ### /public (public.routes.js → public.controller.js) — all GET, no auth
| Method | Path | Notes | | Method | Path | Notes |
|---|---|---| |---|---|---|
| GET | `/settings` | whitelisted public keys only (mode, maintenance_message, status_message, homepage_teaser, contact_email, site_title) | | GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, and the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard. Asset fields may be site-relative paths (resolve against the base URL). |
| GET | `/status` | status message + current mode | | GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard |
| GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check |
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots | | GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
| GET | `/posts/:category/:idOrSlug` | single published post | | GET | `/posts/:category/:idOrSlug` | single published post |
| GET | `/wiki` | list of pages (slug + title) | | GET | `/wiki` | list of pages (slug + title) |