diff --git a/android/PLAN.md b/android/PLAN.md index d745ca7..dd9ec1b 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -1,6 +1,6 @@ # Android App — Plan -Status: **M0–M2 landed; M3 (auth) next.** This document is the +Status: **M0–M3 landed; M4 (player self-service & game data) 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 @@ -47,7 +47,35 @@ DTOs. Wired into the shared drawer (§5), all strings externalized (§2). JVM un SSE frame parsing. No backend/API change — the app is a pure consumer of the existing public shard surface. -**M3–M4 (functional pass) and M5 (design pass) remain.** +✅ **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 — deferred (decided at M3).** §4.3/§9 flag an *optional* biometric app-lock; +> it is **deferred to M6 (release hardening)**. Tokens are already encrypted at rest (Tink/AES-256-GCM), +> so an app-lock is a UX layer, not a security requirement, and adding it in the functional pass would +> widen scope without changing the data flow. Revisit as an opt-in setting during M6. + +**M4 (functional pass) and M5 (design pass) 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), @@ -527,7 +555,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 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). +ntfy / UnifiedPush (§11); **biometric app-lock deferred to M6** (tokens already encrypted at rest, so +it is opt-in UX, not a v1 requirement — decided at M3). **Still open:** - **App identity / domain.** Target application ID **`com.runicgateway.app`** — pending securing the