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>
47 KiB
Android App — Plan
Status: M0–M6 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 was written before implementation so the
API changes it depends on could be landed in website/ and docs/ first. The authoritative API
reference is the committed OpenAPI spec at website/server/swagger/swagger-output.json (regenerated
via npm run swagger).
Build progress (§9): ✅ M0 — repo scaffold (2026-07-19, RunicGateway/Android-app#2):
Gradle 8.7 wrapper + AGP 8.6.1 / Kotlin 2.0.20, JDK 17, minSdk 29 / compile-target 35,
applicationId com.runicgateway.app; a version catalog pinning the full §2 stack; a Compose + Hilt
single-activity skeleton (externalized strings, adaptive icon); and CI (pr-checks.yml →
./gradlew lint test assembleDebug).
✅ M1 — connect & browse (2026-07-19, RunicGateway/Android-app#6, functional Kotlin pass): the
first-run base-URL connect flow (probe GET /public/status, verify the backend's version identity,
persist to DataStore; HTTPS-only in release, HTTP allowed in debug; Settings → Server hard reset);
a runtime-selected base URL via a sentinel-host Retrofit + HostSelectionInterceptor (the host is
not compiled in) plus a UserAgentInterceptor past the scanner guard (§8); the layered
screen → ViewModel → repository → PublicApi → DTO stack returning a typed ApiResult
(Ok/HttpError/NetworkError) for graceful degradation (§7); brand-seeded Material 3 theming from
/public/settings; and functional Compose screens for Home/Status, News (+ post detail), Wiki
(+ detail), CMS pages (block renderer: heading/rich_text/image/quote/cta/divider/two_column), and
the contact form, under one declarative navigation drawer (§5). JVM unit tests cover URL
normalization, host rewriting, ApiResult/UiState mapping, and brand-color parsing.
API-client deviation from §2 (recorded): DTOs + the Retrofit interface are hand-written and spec-aligned, not
openapi-generatoroutput. The committedswagger-output.jsonis 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/mesurface.
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 M1–M4 UI with no architecture, data-flow, endpoint, or DTO
change (§2.1). The design was produced in Claude Design (Runic Gateway Screens.dc.html) and
implemented in Compose. Because the functional screens already draw their color/type/shape from
MaterialTheme tokens (§2), the restyle lives mostly in the theme layer and propagates: a deep
blue-black surface stack (page #0b0f14 / screen #0e1318 / elevated #11161d), a slate-blue accent
(#7f99bd) with a light CTA fill (#cdd9e8), parchment serif body copy, and the engraved Cinzel
serif display face (bundled weight-axis variable font, SIL OFL) for headings and the top bar. The app is
now dark-only — the shard-website look is a single dark theme, so the light scheme is dropped and the
system light/dark setting is ignored; per-shard brand-accent seeding is retained (a site's published
accent still tints the primary/secondary roles, §3). A small set of reusable components — semantic status
pills, section labels, a gradient "feature" card, and slim stat meters — carries the motifs the design
repeats (home status, shard-online banner, champ/character/house status, character vitals & skills). The
launch theme and system bars are darkened so the first frame matches (no white flash). Verified by
:app:assembleDebug + :app:testDebugUnitTest (green); an on-device visual pass against the mockup is
the one open QA item noted on the PR.
✅ M6 — polish & release mechanics (2026-07-20, RunicGateway/Android-app#11): the release
plumbing to ship v1 as a signed, sideloadable APK, with no architecture, data-flow, or endpoint
change. Default brand app icons — a gateway-medallion adaptive launcher icon (all densities +
round + Play Store icon) over the deep-indigo brand background (the Image Asset wizard's default
green grid was replaced, and the legacy square/round bitmaps + 512 Play icon recomposited to match);
plus an "RG" notification icon staged for M7. A version-mismatch guard (§3): the first-run connect
probe refuses a backend whose API version this build can't speak (a future v2) with a clear
"app out of date" message rather than mis-rendering (lenient on an older backend that omits api).
Release build hardening (§7, §12) — R8 full-mode minify + resource shrink (~31 MB debug → ~4 MB
signed release) with keep-rules for the kotlinx.serialization serializers, the wire DTOs, and the
Retrofit interfaces; a release signingConfig that reads keystore material from a gitignored
keystore.properties or env vars (absent → unsigned; the keystore is never committed); and
versionName 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 (M0–M4), 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 (M0–M4) is now
unblocked.
The workspace already holds website/, link/, servuo-plugins/, and docs/. android-app/ is
the fifth repo. It is purely an API client of the website backend — it never talks to the
link/ sidecar or the shard directly, and it ships none of the shard/sidecar wiring.
1. Purpose & scope
A native Android client for a Runic Gateway shard's public site + player self-service. It surfaces
the same content and player features as website/client, minus every administrative/management
console. It is a read + self-service app, not an operator tool.
In scope
- Public content (no auth): news / Five-on-Friday / newsletter / screenshots, wiki, CMS pages, site status & maintenance page, contact form.
- Public shard widgets (no auth): shard status, online staff, live event feed, economy series, champion spawns, guilds, governors, houses/IDOC, presence — including the live SSE stream.
- Account & auth (bearer token): native username/password login (with TOTP 2FA), logout, refresh; account self-service (change username/password, TOTP enroll/disable, list/unlink SSO identities). Registration, invite acceptance, password reset, and SSO are website-handled — the app hands off to the website's pages for those (§4.2), not native screens.
- Player's own shard/game data (bearer token): link a game account via a
[linkone-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).
2.1 Build workflow: Kotlin first, then design-led UI
The app is built in two passes. First, the functional Kotlin is written — the layering above with placeholder/functional Compose screens: navigation, ViewModels, repositories, the generated API client, auth/token handling, and every screen wired to its endpoints and working end-to-end. Then, once that Kotlin code is done, Claude Design produces the front-end design for the app, and Claude Code implements the final UI (Compose screens, theming, components) according to that design. The design pass restyles and refines the already-working screens; it does not change the architecture, data flow, or endpoint contracts established in the first pass. Keeping strings externalized and branding data-driven (§2, §3) from the start is what lets the design pass reskin freely without touching logic.
3. Base URL: first-run + settings
The app is brandable to any shard's site (one site per install), so the API host is not compiled in.
- First run (before init): a mandatory "Connect to your shard's website" screen asks for the
site base URL. The app validates it by calling
GET /api/v1/public/status(and reads/public/settingsfor 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 against127.0.0.1:3000). - Failure states: unreachable, non-2xx, not-a-Runic-Gateway-site (missing expected
/public/statusshape), TLS error — each gets a clear retry message. Nothing else in the app runs until this succeeds.
- Accept
- 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 withcode. 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.
- Single-request 2FA: a
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 OkHttpAuthenticator/interceptor performs a one-shot refresh on a401from 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/:tokenpages. 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/providerscan 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 OkHttpAuthenticatordoes a one-shot refresh on a bearer401, behind a mutex so concurrent 401s trigger only one refresh. - Logout:
POST /auth/mobile/logout{ refreshToken?, all? }(requires bearer) — this session or all sessions ("sign out everywhere"). - Storage: access + refresh tokens live in EncryptedSharedPreferences, never in plain prefs/logs. The base URL may live in plain DataStore; tokens must not. An optional biometric app-lock was considered here but descoped from v1 (tokens are already encrypted at rest; see the M3 note).
- Role for the menu comes from the login response
user.roleand is re-validated viaGET /auth/meon 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 aminAccess/requiredCapabilityfield, 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 viaGET /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; TOTPsetup/enable/disable; identitiesGET/DELETE. - Game account linking —
POST /player/shard/link(one-time[linkcode),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; a503means 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 (
503from shard reads, or/public/shard/statusshows 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
Roomdependency 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_ORIGINis 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.jsmounts a bot/scanner guard before routing — the app must send a saneUser-Agentso it isn't caught by scanner heuristics. GET /auth/mebearer support.auth/token.js:extractTokenreads the cookie then falls back toAuthorization: Bearer, and/auth/meadvertises 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; refreshMOBILE_REFRESH_TTL_DAYS= 30 days. The login/refresh response'sexpiresInreflects 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):
- Role-agnostic self-service under
/auth/**(§6.4, decided). ✅ DONE (2026-07-19, RunicGateway/website#76 (+ this docs PR)). Ame.routes.jssub-router mounts the existingaccount.controllerself handlers behindrequireAuth(any role) at/auth/me/*, so the app has one self surface and never touches/admin. The old/player/account/*+/admin/account/*routes stay for web back-compat. Shipped routes:GET /auth/me— current{ id, username, role }(already existed; the app's role source).GET /auth/me/account— full self account.PATCH /auth/me/account/username,PATCH /auth/me/account/password.POST /auth/me/account/totp/setup|enable|disable.GET /auth/me/account/identities,DELETE /auth/me/account/identities/:provider.- Swagger regenerated with
#swaggerannotations;test/authMe.test.jsguards the auth gate; and an end-to-end smoketest confirmed both a player and an editor (staff) drive the same surface.
- Password reset — build on backend + web front end FIRST (a prerequisite, not app scope).
✅ DONE (2026-07-19, RunicGateway/website#75 + docs#8). Full platform flow shipped in
website/: request-reset (POST /auth/password/forgot, always a generic 200 — no account enumeration) emails a single-use, ~1h link → reset page + endpoints (GET|POST /auth/password/reset/:token) that verify, set the password, and revoke every session (web cutoff + mobile refresh tokens). The token is an opaque random value stored as a sha256 hash in a newpassword_resetstable (mirroringuser_invites— chosen over a signed JWT to match the house pattern; functionally equivalent). It also serves SSO-only accounts (null hash) as their set-initial-password path. Swagger regenerated; documented inBACKEND_DESIGN.md. The app just links users to the web page (§4.2) — no mobile reset endpoint.- No mobile SSO/invite/register endpoints are needed: SSO, registration, and invite acceptance all stay website-handled and the app hands off to them (§4.2). This is a deliberate scope reduction.
- Push notifications — see §11. Additive v1 endpoints under
/auth/me/devices*and/auth/me/notifications*, plus a self-hostedntfyservice added towebsite/docker-compose.ymlwith fully declarative, zero-interaction config. Not required for the first release (M7, not M1–M6). - Version/health surfacing.
✅ DONE (2026-07-19, RunicGateway/website#77 (+ this docs PR)). A dependency-free
config/version.js({ service:'runic-gateway', api:'v1', server:<pkg> }) is surfaced onGET /public/status(so the first-run probe recognizes the backend + reads its version in one call) and on a new DB-freeGET /public/version(canonical target for the version-mismatch guard + a cheap liveness check). Swagger:PublicVersionschema.test/publicVersion.test.jscovers both. - Docs — update
docs/website/BACKEND_DESIGN.mdfor any new/changed endpoint; keep this file and the OpenAPI spec current. (The workspaceCLAUDE.mdis a local, uncommitted file — update it in place as repos come online, but it is never committed.) - Branding for mobile.
✅ DONE (2026-07-19, RunicGateway/website#77 (+ this docs PR)). Confirmed
GET /public/settingsreturns the per-shardbrandblock (name,accentcolor, logo/hero/favicon, plus shortName/tagline/description/url/contactEmail) sourced fromBRAND_*with adminsite_title/contact_emailoverrides — the app themes itself from it. Made it first-class in the OpenAPI contract (Brand+PublicSettingsschemas) so the app's codegen gets typed branding instead of an untyped map;test/publicBrand.test.jslocks the contract. Asset fields may be site-relative paths — the app resolves them against its stored base URL.
No link/ or servuo-plugins/ changes are expected — the app is downstream of the website only.
9. Milestones
Two passes (§2.1). M0–M4 were the functional Kotlin pass — every screen wired to its endpoints and working end-to-end with placeholder/functional Compose UI, no design investment yet. M5 was the design pass: with the functional Kotlin done, Claude Design produced the front-end design and Claude Code implemented the final UI to it. Both passes are now complete (M0–M5 landed); polish/release, push, and Play (M6–M8) follow the designed app.
- 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.
- M1 — Connect & browse (functional pass): first-run base-URL flow,
/public/status+/public/settingstheming, generated API client, public content (news/wiki/pages) + contact. No auth yet. - M2 — Public shard (functional pass): shard widgets + SSE live stream with reconnect/degradation.
- M3 — Auth (§4) (functional pass): native password+TOTP login (429 handling), token storage,
refresh interceptor, logout,
/auth/merole re-validation, the access-level menu. Custom-Tab hand-offs to the website for register / invite / password-reset / SSO (no native screens for those). (An optional biometric app-lock was considered here, then descoped from v1 at M6.) Prerequisite: the website password-reset flow (§8) is already built. - M4 — Player self-service & game data (functional pass): account management (via
/auth/me/*), game-account linking, own roster/characters/vendors/houses/sales — text-only presentation (§6.3). - M5 — Design pass & final UI (§2.1): with the functional Kotlin from M1–M4 working end-to-end,
Claude Design produces the front-end design for the app, then Claude Code implements the final
UI to it — Compose screens, Material 3 theming from the per-shard branding (§3), reusable
components, loading/error/empty states, the designed access-level menu. Restyles the existing
screens only; no changes to architecture, data flow, or endpoint contracts. Text-only game data
(§6.3) still holds — this is visual design of the data screens, not paperdoll art. Landed
2026-07-20 (
RunicGateway/Android-app#10): dark-only shard-website theme, Cinzel display face, reusable pill/label/card/meter components; brand-accent seeding retained (see §9 build progress). - 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 tomain(see §10, §12). Biometric app-lock descoped (below). Landed 2026-07-20 (RunicGateway/Android-app#11). - M7 — Push notifications (post-v1): add the self-hosted
ntfyservice towebsite/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. - M8 — 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-servicesconfig as a build flavor, so the direct-APK build stays Google-free. Versioning: semanticversionName+ monotonicversionCode; tag releases in the repo.
11. Push notifications (built-for, shipped post-v1)
The app is architected from M0 to accommodate push, but push itself ships in M7 — it does not block the first release. Users opt in per stream: nothing is pushed unless subscribed.
Transport — UnifiedPush via self-hosted ntfy (decided)
- Primary: UnifiedPush, delivered by a self-hosted
ntfyservice added to the website'sdocker-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
ntfyconfig file and/orNTFY_*env vars baked into compose. Nodocker exec, no interactiventfy 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 }whereendpointis 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 (
websiteutils/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 viautils/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~/.gradleand the SDK. - Bare-image gotcha:
ubuntu:latestlacksgit/curl/unzipthatactions/checkoutandsdkmanagerneed — first stepapt-get install -y git curl unzip. (Faster option once builds are frequent: run the job under a prebuilt Android-SDKcontainer: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. Mirrorswebsite/'s pre-merge gate. - Release (
.gitea/workflows/release.yml, M6): mirrorslink/'s release engine — on every push tomainit computes the next version from conventional commits since the lastv*tag, generates a changelog, bumpsbuild.gradle.kts, builds a signed release APK, commits the bump[skip ci], tagsvX.Y.Z, and creates the Gitea release with the notes + APK +SHA256SUMS. The keystore is a 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, somainmust 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 monotonicversionCode(major*10000+minor*100+patch); the engine tags each release.
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); biometric app-lock descoped from v1 (tokens already encrypted at rest, so it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6; revisit only if requested).
Still open:
- App identity / domain. Target application ID
com.runicgateway.app— pending securing therunicgateway.appdomain (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 M8.
- 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).