Add a sync-project-tree workflow that regenerates this repo's tracked-file
tree and opens (or force-updates) a PR against RunicGateway/docs whenever the
layout on main changes. Never writes to the docs repo's main directly. Reuses
the existing REGISTRY_USER / REGISTRY_TOKEN secrets. Tree rendering lives in
.gitea/scripts/gen_tree.py (deterministic, dirs-first ordering).
Co-Authored-By: Claude <noreply@anthropic.com>
Executes COVERAGE_PLAN.md phases 0-2 to clear the SonarQube new-code coverage
gate (was 16.4%, threshold 50%). Estimated new-code coverage after this change
is ~57%. 109 new tests across 19 files; full suite is 264 tests, all green.
Phase 0 — coverage exclusions (sonar-project.properties): drop code a JVM unit
test can't execute from the *coverage* denominator (still analysed for
bugs/smells) — pure-@Composable UI the `*Screen.kt` glob missed
(ui/components/**, BlockRenderer, ShardComponents), Android-framework glue
(push services, Keystore-backed Encrypted* stores, Hilt di/**).
Phase 1 — DTO serialization tests: AdminDto, PublicDto, WikiDto, PostDto/PageDto/
ContactDto, SsoDto, the shard board DTOs and player game-data DTOs, and the
mobile-auth request bodies — decode + encode + computed helpers
(isPublished/isMaintenance/ActorDto.label/ShardStatusDto.isOnline).
Phase 2 — ViewModel tests: a MainDispatcherRule harness + hand-written API fakes
(FakePublicApi/FakeAdminApi/FakePlayerShardApi/FakeShardStream) drive real
repositories into the ViewModels. Covers the admin (dashboard/content/moderation/
support), content (news/post/page/wiki/home/contact), player (characters/
vendors/character/my-houses) and shard-board (champs/guilds/governors/houses/
hub) ViewModels — load success/error, form validation, role/status-aware
feedback, and live-frame merging.
To make the shard boards testable, extract a small `ShardStream` interface from
`ShardStreamClient` (bound in NetworkModule) so `ShardRepository` depends on the
capability, not the OkHttp client — lets a fake stream replace the perpetual SSE
reconnect loop in tests. No production behaviour change.
Phases 3 (repositories) and 4 (core net/auth top-up) are follow-ups; the
deep-dependency auth family (Login/Account/TrustedDevices ViewModels,
AuthRepository) lands with them. See docs/android/COVERAGE_PLAN.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Fix the SonarQube coverage gate (0% on new code) — a reporting gap, not a
testing gap: the JVM unit suite already exists but the source-only scan
never received a coverage report.
- app/build.gradle.kts: apply jacoco, enable debug unit-test coverage, add a
jacocoTestReport task (excludes generated/Hilt/Compose-singleton classes)
- sonar-project.properties: consume the JaCoCo XML; exclude pure-@Composable
UI from coverage (JVM unit tests can't execute composable bodies)
- .gitea/workflows/sonarqube.yml: run JDK 17 + Android SDK +
`testDebugUnitTest jacocoTestReport` before the scan
Also clear the three actionable code smells: remove an unused import
(AdminContentScreen), remove an unused parameter (AdminSupportScreen.
RespondDialog), and decompose LoginViewModel.submit() (cognitive complexity
20 -> under 15). The remaining 12 smells (snake_case DTO fields that mirror
the JSON wire contract; Compose/nav complexity) are marked Won't Fix in
SonarQube with rationale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Turning off the final notification subscription (going from one opted-in
stream to zero) failed with "could not save" and the toggle stuck on. The
backend's PUT /auth/me/notifications/subscriptions validator requires the
`streams` field (body('streams').isArray()), but kotlinx.serialization omits a
property equal to its default (encodeDefaults=false). NotificationSubscriptionsDto
defaulted `streams` to emptyList(), so an empty set serialized to `{}` and the
backend rejected it 400 "Validation failed". Any non-empty set included the
field, so only the last toggle-off broke — regardless of which stream it was.
Remove the default from NotificationSubscriptionsDto.streams so kotlinx always
emits the field; an empty set now sends `{"streams":[]}` (200). The one call
site already passes streams explicitly and the server always returns the field,
so response decoding is unaffected. Add a regression test asserting the empty
DTO serializes to `{"streams":[]}` under the production Json config.
Verified on-device (AVD) against the live site and via the live API
(`{}` -> 400, `{"streams":[]}` -> 200).
Co-Authored-By: Claude <noreply@anthropic.com>
Staff are a superset of players (all player abilities plus their staff
tools), and the backend's player self-service surface is role-agnostic,
but MenuAccess.PLAYER gated "My characters/vendors/houses" on
role == player — so a signed-in admin/editor/moderator saw neither the
menu items nor, via the greyed personal streams, their own notification
options, even with linked characters.
Gate MenuAccess.PLAYER on isPlayer OR isStaff. The notifications screen
needs no change: once the backend returns the caller's linked accounts
(paired with RunicGateway/website), hasLinkedAccount resolves and the
personal streams enable themselves.
Tests: MenuAccessTest now asserts every staff role sees the player
game-data groups and a PLAYER entry, and an unrecognized role / anon
still cannot. Full unit suite passes.
Co-Authored-By: Claude <noreply@anthropic.com>
feat(auth): trusted devices & recovery codes on the mobile client
Consumes the merged backend trusted-device + MFA feature
(RunicGateway/website#93, docs#32) per docs/android/PLAN.md §4.1.1.
Login (POST /auth/mobile/login):
- "Trust this device" checkbox and a "use a recovery code instead"
toggle on the 401 { totpRequired } step; sends trustDevice /
recoveryCode / device_name and replays a stored X-Trust-Token.
- A returned trustToken is stored in a dedicated, username-scoped
EncryptedSharedPreferences file (runic_trust, AES-256-GCM), separate
from the session store so it deliberately SURVIVES logout — the token
is only consulted at a fresh login, so clearing it there would make
the feature a no-op. Cleared only on a Settings→Server switch,
untrust-all, or server-side revocation. (Supersedes the handoff note
that said clear-on-logout; matches the canonical rg_trust design.)
Account → Security:
- Trusted Devices screen: list / revoke one / untrust all / trust this
device (persists the returned token).
- Recovery Codes screen: remaining count + password-stepped regenerate
with a show-once copy/share display; the one-time batch from enabling
2FA is also surfaced on the account screen.
Login-time trust cap (trustLimitReached) is surfaced + resolved on the
Trusted Devices screen rather than a blocking login modal, since the
native login has already issued the session.
Tests: DTO decode for all new wire shapes + AccountRepository logic
(the 409 cap-body parse, revoke, recovery). 154 unit tests pass;
assembleDebug clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
@
The public shard board DTOs typed in-game serials (and actor webId) as
Long, but the wire protocol (docs/link/INTEGRATION.md §1) sends them as
opaque hex strings ("0x1A2B"). The website returns board payloads
verbatim, so a guild leader / champ / governor carrying a hex serial
threw JsonDecodingException out of the Retrofit converter and crashed the
app on the Guilds/Champs/Governors boards. The API is the source of
truth, so the DTOs are corrected to match it.
- ActorDto.serial/webId, ChampDto.serial, HouseDto.serial,
OnlineStaffDto.serial: Long -> String
- champ.remove / house.decay live frames now read serial via stringField;
longField returned null on a hex serial, silently dropping every board
removal and live IDOC update
- safeApiCall now catches SerializationException -> ErrorKind.SERVER, so
any future contract drift degrades to a retry-able error instead of a
crash (defense in depth)
- DTO + result tests updated to the real hex-string wire shapes
AI-assisted: authored with Claude Code (Opus 4.8).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Collapse the per-provider login buttons into one "Sign in with SSO" entry. With a
single configured provider it launches straight through; with several it opens a
native ModalBottomSheet picker (driven by the discovery list the app already
fetches — no website chooser page, no Google SDK). Each row opens the Custom-Tab
bridge for that provider.
Also make the login screen dismiss reliably after any sign-in: the LOGIN
destination now pops as soon as the shared session becomes SignedIn, not only via
the login VM's local flag — the deep-link/recomposition timing of the Custom-Tab
return could otherwise leave the login screen up even though the session was
established.
Verified on emulator with two providers: the picker lists both, completing SSO via
one signs in and returns to Home (exchange 200, session persisted). lint + build green.
Co-Authored-By: Claude <noreply@anthropic.com>
The final two staff groups, both admin/moderator (MODERATOR menu access; StaffGate
now takes a role predicate). Over the shard write plane `/admin/shard/*`:
- Moderation: kick / ban / unban an account + broadcast a system message
(AdminModerationScreen form + AdminModerationViewModel guarded actions).
- Support queue: list open help pages, reply (optionally closing), close
(AdminSupportScreen + AdminSupportViewModel).
These need a live sidecar; offline they degrade cleanly (a clear error on writes,
an empty queue on the list) — never a crash (§7). AdminApi/AdminDto/AdminRepository
extended with the shard-op + help-page endpoints.
Verified on emulator: both entries appear for an admin (drawer now scrolls through
all four staff items); moderation broadcast returns a clean failure with the shard
offline; the support queue shows its empty state. assembleDebug + lint green.
Co-Authored-By: Claude <noreply@anthropic.com>
Second staff group over the existing /admin routes (any staff role; bearer-authed,
role re-checked every request). AdminApi/AdminDto/AdminRepository gain posts
(list/create/publish-toggle/delete) and wiki taxonomy (list categories + tags,
create/delete category). AdminContentScreen is a two-tab screen (Posts | Wiki) with
create dialogs; the CMS block/hero editor stays out of scope. Admin wiki DTOs are
prefixed (AdminWikiCategoryDto/AdminWikiTagDto) to avoid colliding with the public
wiki DTOs.
Verified on emulator against the dev backend: posts list with published/draft pills;
publish/unpublish flips the DB row with live reload; create a news post; create +
delete a wiki category (confirmed in MariaDB). assembleDebug + lint green.
Co-Authored-By: Claude <noreply@anthropic.com>
Add the staff-operations surface scaffolding and the first group. Session gains
isStaff/isModerator/isAdmin; the menu gains STAFF (admin/editor/moderator) and
MODERATOR (admin/moderator) access levels, plus a StaffGate mirroring PlayerGate.
Dashboard group (over the existing /api/v1/admin, bearer-authed, role re-checked
every request): AdminApi/AdminDto/AdminRepository for GET /admin/dashboard and
PUT /admin/site-mode; AdminDashboardScreen shows site mode, summary counts, and
recent admin activity, with an admin-only maintenance/live toggle.
Verified on emulator against the dev backend: an admin sees the Dashboard entry
(a player does not); counts + audit log render from real data; the site-mode
toggle flips /public/status to maintenance and back to live. MenuAccessTest +2
(8 total), assembleDebug + lint green.
Co-Authored-By: Claude <noreply@anthropic.com>
The ModalDrawerSheet stacked all items in a non-scrolling column. A signed-in
session adds My account, Notifications, and the three player groups (11 nav items
+ sign-out + change-server), which overflows the drawer height on shorter screens
or larger display-size / font-scale settings — clipping the lower entries
(Notifications among them) so they can't be reached. Wrap the drawer content in a
verticalScroll column so every entry is reachable regardless of screen height.
Verified on-device: a signed-in player sees Home…My houses + Sign out + Change
server, with Notifications present and its screen reachable.
Co-Authored-By: Claude <noreply@anthropic.com>
On-device, the native SSO buttons never appeared and the flow dumped users on
the desktop website login (which can't deep-link a mobile session back), so it
hung. Two app-side causes:
1. Discovery conflated "no providers" with "call failed" (ssoProviders() returned
emptyList() on any error) and the screen then showed a dead website-login
hand-off. Now ssoProviders() returns Available/None/Unavailable, retries once,
and the login screen renders native provider buttons, a loading hint, or a
retry — never the website login fallback (removed, along with WebsiteUrls.login).
2. The pending {state, verifier} lived only in memory, so a Custom-Tab-induced
process eviction lost it and the exchange failed STATE_MISMATCH. Persist it via
a new encrypted PendingSsoStore (EncryptedSharedPreferences, mirrors the token
store), cleared the moment the callback is consumed so replays still fail closed.
SsoAuthManager stays framework-free (store behind an interface). +1 test proving a
fresh manager on the persisted store completes (process-death sim); 15/15 SSO tests
pass, lint + assembleDebug green (JDK21, -Pksp.incremental=false).
Verified end-to-end against the local site via the dev stub IdP: player and admin
both sign in natively and receive the correct role.
Co-Authored-By: Claude <noreply@anthropic.com>
The app is purely an HTTPS API client, but the manifest left
usesCleartextTraffic implicit, which SonarQube S5332 flags (cleartext is
implicitly permitted on older Android and a merged library manifest could
re-enable it). Add an explicit network security config:
- main/release: base-config cleartextTrafficPermitted="false" (no cleartext).
- debug override (app/src/debug/res/xml): re-permits cleartext to loopback
(127.0.0.1/localhost) only, for local dev against http://127.0.0.1:3000.
This mirrors ServerUrl's rule (HTTPS required in release, HTTP allowed in
debug via allowInsecureHttp = BuildConfig.DEBUG) at the platform socket
layer. It also fixes a latent gap: at targetSdk 28+ the platform default
already blocks cleartext, so the debug loopback path only actually works
with the explicit domain-config now added.
Docs updated in RunicGateway/docs (android/PLAN.md M1).
Co-Authored-By: Claude <noreply@anthropic.com>
Mirrors the website repo's setup: a source-based scan of app/src/main
(Kotlin) that reports to the self-hosted SonarQube server after merge,
never gating PRs.
Uses the existing SonarQube project key Runic-Gateway-Android-app (the
server rejects re-creating a case-variant key). Supersedes #18.
Co-Authored-By: Claude <noreply@anthropic.com>
Add the app side of Android App Links (M9 follow-up, docs/android/APP_LINKS.md),
layered on the M9 Part 2 native SSO callback:
- Build-time `appLinkHost` Gradle property -> BuildConfig.APP_LINK_HOST +
manifestPlaceholders["appLinkHost"]. autoVerify needs a literal host, so the
generic multi-tenant build leaves it empty (placeholder falls back to the
reserved runic-gateway.invalid sentinel, making the filter inert); a
white-label build bakes one host with -PappLinkHost=play.myshard.com.
- Manifest: an autoVerify https `/mobile/callback` intent-filter beside the
unchanged custom-scheme one (the permanent fallback).
- SsoAuthManager: request the https App Link redirect_uri iff the baked host
matches the paired shard host; matchesAppLinkCallback() enforces a paired-host
trust check (host must equal the currently-paired base URL host) as
defense-in-depth. Both matchers feed the same complete()/exchange path.
- MainActivity routes custom-scheme and App Link callbacks identically.
+5 JVM tests (SsoAuthManagerTest -> 14). Built green (JDK 21,
-Pksp.incremental=false); white-label host substitution verified in the merged
manifest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Add the app client for the Mobile SSO Authorization Bridge (PLAN.md §4.2):
native "Sign in with <provider>" without shipping any OAuth secret.
- Pkce: pure-JVM RFC 7636 S256 verifier/challenge + CSRF state, encoded to
match the backend's base64url(SHA-256) exactly.
- SsoAuthManager (Singleton): mints PKCE+state, builds the /auth/mobile/sso/start
URL for a Custom Tab, verifies the returned state, exchanges the one-time code
with the stashed verifier, and drives the existing SessionManager.onSignedIn —
no new token-storage or refresh code. Pending flow is in-memory (fails closed on
process death). Exposes an outcome StateFlow the login screen consumes.
- SsoApi + DTOs: GET /auth/providers discovery and POST /auth/mobile/sso/exchange
(tagged NO_SESSION so a credential 401 isn't read as an expired session).
- MainActivity: runicgateway://auth/callback intent-filter + singleTop; parses the
callback Uri (the Android edge) and hands raw params to SsoAuthManager.
- LoginScreen/ViewModel: render a button per discovered provider, opening the
bridge in a Custom Tab; fall back to the website login hand-off when none.
Additive — no other screen's data flow changes; no backend work. Custom scheme
only for now (App Links deferred, APP_LINKS.md).
Tests (JVM, +14): Pkce vector/charset, start-URL building, and the full
complete() flow over a fake SsoApi + real SessionManager (success signs in;
state mismatch / missing pending fail without exchanging; error callback →
declined; 401 → expired-code; replay finds no pending).
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
Implements the app side of M7 push (docs/android/PLAN.md §11). The app EMBEDS
its own distributor — ntfy is only the relay server, no second app installed,
no Google Play Services. New feature slice; no existing screen's data flow
changes.
- core/push: NtfyTopic (random unguessable topic + endpoint/SSE URL builders),
PushTickle (content-free { stream, ref } parser over ntfy's SSE envelope),
NtfyStreamClient (bare-client OkHttp SSE to <ntfy>/<topic>/sse, reconnect/
backoff cloned from ShardStreamClient), PushNotifier (channels + per-stream
deep-link notification), PushService (foreground service holding the
connection), PushManager (mint topic / register-unregister device / start-stop,
keyed to the session), PushPreferences (DataStore state).
- data: NotificationsApi + DTOs + NotificationsRepository over the merged
/auth/me/devices + /auth/me/notifications/* contract; push block on SettingsDto.
- ui/notifications: settings screen + VM — per-stream toggles, personal streams
greyed until a game account is linked, POST_NOTIFICATIONS request on enable.
- Navigation: Routes.NOTIFICATIONS + stream→route deep-link map, menu entry,
RunicApp + MainActivity intent handling; teardown wired into logout + server
switch (deregister while bearer valid) and every sign-out (local, via session
observer).
- Manifest: POST_NOTIFICATIONS + FOREGROUND_SERVICE(_DATA_SYNC) + the service.
Deviation (recorded in PLAN.md): direct-ntfy transport, no UnifiedPush library
— the plan's stated likely path; keeps the APK Google-free and dependency-light,
with a PushResult/transport seam for a future FCM Play flavor. Requires the small
companion push.ntfyUrl settings field (website#<pr>).
18 new JVM tests; :app:testDebugUnitTest + lintDebug + assembleDebug green.
Co-Authored-By: Claude <noreply@anthropic.com>
The push-to-main release model kept failing: the job builds the signed APK
fine, but the final `git push origin HEAD:main` (version-bump commit) is
rejected by main's branch protection — "pre-receive hook declined / Internal
Server Error" — across runs #187, #194. main is deliberately protected
(allowlist push, required approvals, required status checks), which is
fundamentally incompatible with a CI job pushing a fresh commit to it.
Flip the trigger: the workflow now runs on pushing a `v*` tag (or via
workflow_dispatch with a tag input). The tag *is* the release input, so:
- version/versionCode are derived from the tag name (no version-planning engine);
- app/build.gradle.kts is set for the build only, never committed back;
- no `git push` to main, no tag creation, no REGISTRY_USER needed —
only REGISTRY_TOKEN, to create the Gitea release + upload the APK/SHA256SUMS.
To cut a release now: `git tag v0.1.0 && git push origin v0.1.0`.
Keeps the speed fixes from #13 (trimmed setup-android, no Gradle cache,
timeout-minutes). Changelog is still generated from conventional-commit
subjects since the previous tag.
Co-Authored-By: Claude <noreply@anthropic.com>
The release workflow was slow and sometimes appeared to hang mid-build:
- android-actions/setup-android@v3 pulled the entire Android emulator and the
legacy `tools` package (hundreds of MB, network-bound on the self-hosted
runner) that a headless APK build never uses. Pin `packages: ''` so it only
puts cmdline-tools on PATH; the next step installs exactly what we need.
- The `actions/cache@v4` Gradle step timed out every run against the Gitea
artifact-cache backend (`getCacheEntry failed: Request timeout`), adding
latency with no benefit. Dropped it.
- The job had no timeout, and with `concurrency.cancel-in-progress: false` a
genuinely wedged run would hang forever and block every later release behind
it. Added `timeout-minutes: 30` so a hang fails fast.
Co-Authored-By: Claude <noreply@anthropic.com>
Replace the tag-triggered release.yml with 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 versionName in build.gradle.kts (versionCode derived
major*10000+minor*100+patch, monotonic), builds the SIGNED release APK, then
commits the bump [skip ci], tags vX.Y.Z, and creates the Gitea release with the
notes + APK + SHA256SUMS.
Uses REGISTRY_USER/REGISTRY_TOKEN (write:repository) to push the bump + create
the release, matching link/. main must allow that account to push (bump lands on
main; the [skip ci] + head-commit guard prevent a re-trigger loop). Signing
secrets (ANDROID_KEYSTORE_BASE64/_PASSWORD, ANDROID_KEY_ALIAS/_PASSWORD) unchanged.
Same self-hosted-runner handling as pr-checks.yml (apt JDK 17, sdkmanager, chmod).
Co-Authored-By: Claude <noreply@anthropic.com>
Release-hardening pass (PLAN.md §9 M6, §10, §12). No architecture, data-flow,
or endpoint changes; the app remains a pure API client.
App icons (default brand assets):
- New gateway-medallion launcher icon set (all densities, adaptive fg/bg, round,
Play Store icon) + an RG notification icon staged for M7 push.
- Replace the Image Asset wizard's default green-grid adaptive background with the
deep-indigo brand fill (@color/ic_launcher_background #1B1033); recomposite the
legacy square/round webps and the 512 Play icon over indigo so the whole set is
coherent (the green never shipped). Restore the SPDX headers the wizard stripped;
drop the orphaned placeholder foreground vector. No <monochrome> layer — the
full-colour medallion has no clean silhouette, so themed mode falls back to the
standard icon rather than a tinted blob.
Version-mismatch guard (§3):
- The connect probe now refuses a Runic Gateway backend whose API version this
build can't speak (e.g. a future v2) with a clear "app out of date" message,
instead of mis-rendering; lenient on a blank api (older backend). Decision logic
extracted to a pure ConnectionRepository.evaluateVersion() with unit tests.
Release build hardening (§7, §12):
- Enable R8 full-mode minify + resource shrink for release (~31 MB debug -> 4.2 MB
signed release). ProGuard keep-rules for kotlinx.serialization serializers + our
wire DTOs, Retrofit service interfaces, and a -dontwarn for Tink's compile-only
Error Prone annotations (EncryptedSharedPreferences).
- Release signingConfig reads keystore material from a gitignored keystore.properties
or env vars; absent -> unsigned (debug + PR gate unaffected). Keystore never in repo.
- versionName/versionCode overridable via -P so the release tag + CI run number
drive them (§10).
CI:
- release.yml: on a `v*` tag, build a SIGNED release APK (keystore from a base64
Gitea secret) and attach it + SHA256SUMS to a Gitea release; workflow_dispatch is
a signing dry run. Mirrors pr-checks.yml's self-hosted-runner handling (apt JDK 17,
explicit sdkmanager, in-step chmod +x gradlew).
Co-Authored-By: Claude <noreply@anthropic.com>
Restyle every Android screen with the shard-website theme from the
"Runic Gateway Screens" design (docs/android/PLAN.md §M5): deep blue-black
surfaces, a slate-blue accent, parchment serif body copy, and an engraved
Cinzel serif display face. The app is now dark-only, matching the design.
Theme layer (propagates to all token-based screens):
- Color.kt: replace the placeholder purple palette with named shard tokens.
- Theme.kt: one dark color scheme mapped onto the palette + 8/12/16dp shapes;
drop the light branch; keep optional per-shard brand-accent seeding.
- Type.kt: full type scale — Cinzel display/headline/title, serif body,
letter-spaced sans labels/buttons.
- Font.kt + res/font/cinzel_variable.ttf (SIL OFL, app/licenses/Cinzel-OFL.txt):
the Cinzel display family, pinned to 500/600/700 via FontVariation.
Shared components (ui/components/ThemeComponents.kt): StatusPill (semantic
tones), OnlineDot, SectionLabel, FeatureCard (gradient), StatBar — adopted
across Home, Account, Shard hub, Champs, Characters, Houses, and the
character sheet (vitals/skills meters).
Shell: dark top-bar + drawer styling; dark launch theme and light system-bar
icons so the first frame matches (no white flash).
Build (assembleDebug) and unit tests green.
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
Account self-service over the role-agnostic /auth/me/account* surface
(change username/password, TOTP enroll/disable, linked SSO identities),
game-account linking ([link one-time code + hybrid signup gated on the
public gameAccountSignup flag), and text-only own game data: per-account
character roster -> character sheet (attributes/vitals/resistances/skills/
equipment + guild/governor standing), player vendors + recent sales, and
own houses (decay/IDOC).
Adds three PLAYER-access menu groups (My Characters/Vendors/Houses)
revealed only when the session role is player, with a PlayerGate that
sends a signed-out or server-side-demoted user home. Each per-account
read carries its own load state, so a down shard (503) degrades that
account to offline/retry without blocking the rest (7).
Pure consumer of the existing bearer API -- no backend/protocol change.
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.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Implements M3 (docs/android/PLAN.md §4): the functional Kotlin auth pass.
- Native username/password (+ single-request TOTP) login over the existing
POST /auth/mobile/login; a 401 { totpRequired } reveals the code field, 429
surfaces a backoff message (§4.1).
- Token pair in EncryptedSharedPreferences (TokenStore behind SessionManager,
the single source of truth for the in-memory bearer + observable Session);
base URL stays in plain DataStore (§4.3).
- OkHttp AuthInterceptor (bearer) + TokenAuthenticator: one-shot, mutex-
serialized refresh-on-401 that replays the request, on its own bare client so
it can never recurse; single-use rotation; dead refresh signs out, transient
network keeps the session.
- Logout (POST /auth/mobile/logout, this session or all devices) tears down
locally even on failure.
- GET /auth/me re-validates the role on every resume; a surviving 401 signs out
(role stays advisory — backend is authority).
- Declarative access-level menu (visibleEntries: public/signed-in/player) with a
Sign in / Sign out toggle + a My Account screen.
- Custom-Tab hand-offs (androidx.browser) to the website for register / forgot-
password / SSO — no native screens (§4.2).
- Settings → Server switch now also clears the stored session (§3).
Biometric app-lock is deferred to M6 (tokens already encrypted at rest; it is
opt-in UX, not a v1 requirement — decided at M3).
JVM unit tests (18): 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 — a pure consumer of the existing mobile
bearer + /auth/me surface.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Implements M2 of docs/android/PLAN.md §6.2 (functional pass): the public
shard surface over /api/v1/public/shard/*, plus the live SSE feed with
reconnect/backoff and graceful degradation (§7).
- Shard DTOs (status/economy/feed/online/presence/champs/guilds/governors/
houses) mirroring public/shard.controller.js; ignoreUnknownKeys keeps
additive backend fields safe, and the live *.update frames decode into the
same board DTOs.
- PublicApi: the /public/shard/* GETs (status, feed, economy, online,
presence, champs, guilds, governors + history, houses).
- ShardStreamClient: OkHttp SSE over /public/shard/stream. Unlike the browser
EventSource it reconnects itself — a cold Flow<ShardStreamEvent> with
growing backoff (reset on open), no read timeout for the idle keepalive,
and clean teardown on cancel so a dropped feed degrades to "offline".
- ShardRepository: typed ApiResult snapshot reads + the shared live feed and
frame decoders.
- Screens: a Shard hub (status/online count/economy/presence/staff + live
activity feed with a live indicator) linking to live boards for champion
spawns, guilds, governors (+ on-demand term history) and falling houses
(IDOC). Boards seed from a snapshot then merge SSE deltas in place via a
reusable LiveBoard, mirroring the website's merge semantics. Wired into the
shared navigation drawer (§5); all strings externalized (§2).
- Tests (28): DTO/frame decode, LiveBoard merge, event-text formatting, and
SSE frame parsing.
Co-Authored-By: Claude <noreply@anthropic.com>