The drawer has been the app's coded `APP_MENU` in coded order since M1. Phase 5
lets an admin's `nav_public` row relabel, reorder and hide its public rows, which
is the first time anything in the app's navigation comes from the shard.
The public nav is keyed by **website** paths, so this needs a translation table,
and it is the one new piece of cross-repo coupling the milestone introduces. It
lives in a single file with the website's own `NAV` array quoted beside it —
`NavPaths.kt` — so the coupling is visible and reviewable in one place instead of
spread across the drawer's call sites. The `feature` values are deliberately not
mirrored: `APP_MENU` stays the app's own source of truth for gating, and a second
copy of a security-relevant value that drifts silently is worth more than it
costs.
Nine of the sixteen website rows have a drawer row. The other seven map to a
screen the app reaches another way — three news categories are tabs on one News
screen, and champs / guilds / governors / houses sit behind the Shard hub because
that is the better shape on a phone — and an override for one of them is
**ignored**, which is §6.1's rule that a nav override may never introduce
navigation. The hub is a design decision, not an accident to correct. The mapping
still exists for all sixteen because phase 6's added links resolve an
admin-authored path against the same table, and there a category tab or a hub
board is a perfectly good destination: the admin asked for it by path.
The merge is a port of the website's `applyNavOverrides`, narrowed to what a
drawer can express — `label`, `order`, `hidden`, and nothing else. It runs
**before** `visibleEntries`, so the two gates from M10/M11 still decide what this
caller may see and remain the actual boundary: an override that relabels the
Market row, moves it to the front and says `hidden: false` still shows nothing to
a caller whose shard does not publish the market. Hiding is subtractive, never
additive.
One thing the design did not settle and the sort turns on: an untouched row's
implicit key has to be its index in the **website's** nav, not the app's. A
stored `order` is a position in that list, so a key taken from the app's shorter
list would put explicit and implicit keys on two incomparable number lines and
scramble a partially-overridden nav. Both tie-breaks are the web's — an explicit
order beats a coincidental index, and two explicit orders keep code order.
`Routes.news(category)` and an optional NavHost argument ship here as the table's
route builder; phase 6 is their first caller. Navigating to plain `Routes.NEWS`
matches the new pattern with no argument and opens the default tab, so the drawer
and the push deep-link are unaffected — but `destination.route` is now a pattern
with a query, so the top-level and selected-row checks compare on the part before
it.
Two questions went to the org lead before any code. The three news-category paths
get a mapped route but no drawer row of their own, on the same rule as the hub
four. And an admin **may** hide Home, mirroring the website, where `/` is
hideable too: Home stays the NavHost's start destination and stays reachable by
back-press, and the app does not invent a policy the site doesn't have.
442 unit tests green (410 + 32), `lintDebug` and `assembleDebug` clean. The
strongest of them is AC-1's: with no stored row the merge returns `APP_MENU`
itself — identity, not equality — so an instance whose admin never touched the
nav provably gets the drawer the app shipped with.
Co-Authored-By: Claude <noreply@anthropic.com>
`brand.logo` and `brand.hero` have ridden in `BrandDto` since M1 and neither
has ever been drawn — the app spells the instance out in text everywhere the
website shows a mark. Phase 4 renders them on the three surfaces §5.6 names:
the logo above the name in the drawer header, the logo in place of the
uppercased title in the top bar, and the hero as a band above Home's title
block.
Nothing new is fetched. `LocalAssetResolver` already turns a site-relative
`/uploads/…` path into an absolute URL and Coil is already a dependency, so
this phase is entirely presentation.
The rule that governs the file is §5.6's: an empty slot renders nothing — not
a placeholder, not a reserved gap. Every size modifier hangs off the image
itself, so when the image is not composed neither is its padding, and a caller
that wants space below a hero passes `Modifier.padding` instead of a sibling
`Spacer`. A failed load is an empty slot: no broken-image icon, no retry.
The top bar is the one place where "empty" is not "nothing". The logo replaces
the title there, so a 404 would strand the app in an unnamed shell until the
next resume refresh; it falls back to the text, which is what empty already
showed. There is no fallback while the load is in flight — drawing the text
first would flash text to logo on every navigation for one frame.
The hero is a fixed 180dp band, cropped, rather than the intrinsic aspect the
app's other images draw at. The website's hero is a CSS background driven by
`hero_layout`, which the app does not port, and the website's default hero is a
square emblem — at the intrinsic aspect an uploaded square would be a ~360dp
block that pushes the status card off the first screenful. It clips to
`shapes.medium`, so it follows `--radius-card` like every other surface.
The logo carries a content description only in the top bar, where it stands
alone; beside the name in text it is decorative, the same call the website's
`alt=''` makes.
410 unit tests green (401 + 9), `lintDebug` and `assembleDebug` clean. The
drawing itself is out of reach for JVM tests — the app carries no Robolectric,
so a composable body cannot run — but the decision of *whether* to draw is
pure, and `brandAssetUrl` is pulled out so it can be pinned.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 3 of M12 (docs/android/THEMING_AND_NAV.md §5.3) — the fonts third of the
admin's Appearance page, after phase 1's colors and phase 2's structure.
ShardTypeface.resolve(theme) maps the three font stacks onto three FontFamily
values and shardTypography(faces) draws the M5 type scale in them. Only the
family moves: every size, weight, line height and tracking is the M5 value, so
an unthemed instance reproduces the pre-M12 scale exactly. Resolution is pure,
so every assertion is a plain JVM test with no Compose rule.
Seven families are bundled beside the existing Cinzel (EB Garamond,
Merriweather, Playfair Display, IM Fell English, Inter, Work Sans,
Source Sans 3), taken verbatim from google/fonts the way M5 took Cinzel, each
with its SIL OFL licence under app/licenses/. Italics for the four families
client/index.html requests one for; the rest are skewed, as they were before.
Three things worth knowing:
1. The per-role font list is not the set of values a role can hold. The server
validates admin-entered fonts against FONT_OPTIONS[role], but a preset's
tokens are copied verbatim by resolveThemeTokens and never pass through it —
`modern` publishes --display: 'Work Sans' and `fantasy` publishes
--sans: 'EB Garamond', neither of which its own dropdown offers. The lookup
is therefore one global map keyed by the lowercased first family name, and
both preset cases are asserted by name so a per-role "tidy-up" fails loudly.
Same trap phase 2 hit with --shadow-card, in a different token group.
2. The APK nearly tripled, and that was a decision, not a discovery. Measured
unsigned release, R8 + resource shrink: 5,031,411 B (4.80 MiB) before,
13,574,703 B (12.94 MiB) after — +8.15 MiB against a drafted estimate of
1.5-2.5 MB. Merriweather alone is 6.08 MiB of that, because upstream ships
it as a three-axis [opsz,wdth,wght] variable font that deflates only 31%.
The org lead chose to bundle it verbatim with the cheaper options costed:
Google's own static 400/700 builds would have held the app near 6.8 MiB,
and dropping it near 6.2 MiB at the price of a serif option that silently
does nothing on Android.
3. Typography implements equals — like phase 2's Shapes, unlike phase 1's
ColorScheme, checked the same way in the material3 1.3.0 bytecode. AC-1's
type half is one comparison against a verbatim copy of the pre-M12 scale
held in the test.
No LocalShardTypeface: unlike the palette and the structure, MaterialTheme
carries the families completely, and the two composables that override
anything override the style rather than the family. Type.kt's `val Typography`
becoming a function is the whole migration — the three families were
referenced from that one file and nowhere else.
Tests: ShardTypefaceTest (15). 401 unit tests green (386 + 15), lintDebug and
assembleDebug clean. Not exercised on device — that is AC-5, in phase 8, where
IM Fell English's synthesised bold is the thing to look at.
Docs: RunicGateway/docs#TBD
Co-Authored-By: Claude <noreply@anthropic.com>
The structure half of the admin's Appearance page. ShardStructure.resolve() turns
the four --radius-* tokens and --shadow-card into a Material shape scale, a pill
shape and a card elevation; RunicGatewayTheme feeds the scale to MaterialTheme and
the other two to a LocalShardStructure, mirroring phase 1's palette split.
Radii are applied as a ratio, never as a literal. The app's Shapes came from the
M5 mockup and the website's from theme.css, and the two scales differ - copying
the web value in would have restyled an untouched app on day one. Each field is
scaled by resolved / runic-gateway baseline instead, so the shipped theme and an
explicit runic-gateway both give ratio 1.0 and are provable no-ops.
Three things the spec did not survive contact with:
Card depth is not a no-op, and that is the org lead's decision. Material3's
filled Card is Level0 and FeatureCard drew none of the shadow its own docs
claimed, so the app has been flat since M5 - while the preset it was drawn from
selects the "Default" shadow. Section 5.4 is applied as written rather than
rebased on the flat baseline, which would have collapsed three of the admin's
four choices onto 0dp. Every card gains 4dp; sections 2, 5.4 and AC-1 record it.
The shadow is matched by nearest blur, not by exact string. The fantasy preset
publishes a --shadow-card that SHADOW_OPTIONS does not contain, because a
preset's tokens are copied verbatim and never pass through the admin dropdown -
an exact match would have missed the one preset whose point is a heavier shadow.
--radius-pill is resolved as a literal px, because CircleShape is a percentage
and has no shipped dp for a ratio to scale. It reaches exactly one composable:
the app's other two CircleShape uses are 8dp status dots, and a dot stays a dot.
ShardCard exists because Material's theme cannot carry elevation - Card takes it
as a default argument. All 24 Card( call sites across 20 files moved to the
wrapper, which is mechanical because every one of them passed only a modifier. A
Card( outside ThemeComponents.kt is now, by construction, an unthemable card.
Shapes does implement equals (unlike ColorScheme), so the structural no-op proof
is one assertion against a verbatim copy of the pre-M12 scale. 13 new tests, 386
green, lintDebug and assembleDebug clean.
Co-Authored-By: Claude <noreply@anthropic.com>
The fifteen themable tokens of GET /public/settings' theme map are parsed into
a ShardPalette and applied field by field over the shipped M5 palette, which is
the runic-gateway preset value for value — so an instance with no theme_visual
row resolves back to a color scheme identical to the one the app shipped, not
an approximation of it (THEMING_AND_NAV.md §2, §5.1).
Ten tokens have a Material role and go through darkColorScheme; the other five
reach screens through LocalShardPalette. ShardOnCta and ShardPillFg are derived
rather than themed — they track --bg-deep and --accent-bright, following the
server's rule that a value expressed in terms of another token is never frozen
as a literal.
RunicGatewayTheme(accent) becomes RunicGatewayTheme(appearance). The old
signature put --accent on primary, which the contract assigns to
--accent-bright; brand.accent now seeds --accent alone, and the server already
resolves it as theme['--accent'] || env so the two can never disagree.
ThemeComponents.kt was the only file reaching past MaterialTheme.colorScheme
for a themable color; its seven now come from the palette and its seven
semantic constants stay imported.
Co-Authored-By: Claude <noreply@anthropic.com>
The app has been reading exactly one field of the website's admin theming
contract -- brand.accent. This lands the store the rest of M12 builds on:
GET /public/settings' `theme` (the resolved token map) and `nav_public` (the
raw nav override row) are now decoded, coerced and held beside the brand as
one SiteAppearance, refreshed on resume alongside the session re-validation.
Nothing reads the two new fields yet. Phase 0's hard rule is that it must
change nothing on screen, so RunicApp still takes `brand: BrandDto?` and the
theme is still seeded from the accent alone; AppState.Ready is the only place
a type changed.
Two judgement calls, both in service of THEMING_AND_NAV.md section 2's
forgiving-on-read rule:
- `theme` is modeled as a raw JsonElement rather than Map<String,String>?.
kotlinx fails the decode of the whole object on a value of an unexpected
kind, and `theme` shares its payload with `brand` and `push` -- one odd
token would have blanked the branding and dropped the push relay URL. It is
coerced field-by-field instead, so a bad token costs exactly itself.
- A failed *refresh* keeps the last good appearance rather than falling back
to NONE. Only the initial load can produce NONE, so a moment of no
connectivity on resume cannot repaint a themed shard back to the defaults.
The second-stage parse stops at "is this a plain object", mirroring the web
client's lib/settingsJson.js exactly; reading items/sections/links out of it
is phases 5 and 6's job, so no half-built nav model ships here.
Tests: SettingsJsonTest (6) and SiteAppearanceTest (8) cover the two pure
modules, plus three decode cases in PublicDtoTest for the wire shapes.
360 unit tests green; lintDebug and assembleDebug clean.
Co-Authored-By: Claude <noreply@anthropic.com>
`AtlasCreatureDto.places` was typed `List<String>` while the server sends
`{facet, label, spawners, maxAlive}` objects. The detail route answers 200 with
~49 KB, kotlinx throws on decode, and the screen renders "Something went wrong
on the server" — so the whole Atlas creature page was dead, and the error
blamed a server that was fine. Nullable-with-defaults protects against a
missing field, never a wrong element type.
Adds AtlasPlaceDto, plus the `art` field the server also sends, so a decode
cannot depend on that staying absent (neither client renders art yet).
`places` was never rendered either, so the aggregate the atlas exists to give —
"Shrines, Isamu-Jima, Yew", resolved server-side by point-in-rect — was missing
from the app while the web page led with it. Adds a "Where it spawns" section
above the individual spawners, matching web's ordering, and a plural for the
spawner count now that single-spawner places are on screen in bulk.
Adds ShardContentDtoTest — the first decode test any of the four Protocol 3.0
DTOs has had, fed payloads captured from a live server. That absence is the
root cause: the fakes in data/api/fake/ construct DTOs in Kotlin, so no test in
the suite could see a wire mismatch, even though PLAN.md §9 already required
"DTO decode for each new shape".
Also renders a placeholder row on an unscored leaderboard (the instance name,
em dash where a score goes) rather than a blank card — deliberately not shaped
like a real entry, since a placeholder that looked like a standing would be a
fabricated one.
Found by the on-device five-rung walk against a live shard; all four screens
re-verified on the emulator afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
M11 Part 2 (docs/android/PLAN.md §9), on the visibility plumbing Part 1 added.
Each screen hides from the menu when the shard doesn't publish its feature, and
self-reports "not available here" from its own 404/403 so a deep link still
lands on an honest answer.
- Rules (/public/shard/ruleset). A null body means the shard has never
published a ruleset, which is a SUCCESS state, not the feature being off —
the screen tells the two apart. Blocks render only when published, since an
omitted block means the system is off rather than unknown. Skill caps are
converted out of tenths; the raw 1000 reads as ten times the real limit.
Live via world.ruleset, which the shard re-emits on every reconnect.
- Leaderboards (/public/shard/points). Boards order most-contested first, live
via points.board. maxPoints 0 is uncapped so no cap line is drawn, and a
cliloc-named board (nameString null, the usual case) falls back to the
humanised PointsType key. A nameless rank is a valid row: the character name
is the feature's one admin-configurable field.
- Market (/public/shard/market + /meta + /vendors/:serial). NOT live: the
market feature ships with its SSE fan-out disabled, so this is a plain
paginated read, searched on submit rather than per keystroke because it is
the site's first rate-limited public endpoint. The staleness line is
required, not decoration — the round-robin sweep means a price can be a full
cycle old. The vendor screen is the only surface that can render a truncated
shop and a gated location, the latter as a real answer rather than a blank
coordinate.
- Atlas (/public/atlas/creatures[/:slug]). Static shard content, so it stays
readable while the shard is down — but site-mode gated, unlike /shard/*.
Rows lead with the server's placement label ("Despise, Felucca"), which is
the transform the whole feature exists for. Respawn delays are read as
SECONDS, the unit the parser normalises XmlSpawner's mixed minutes/seconds
into. Facet filter options are discovered from the shard's own data — nothing
here names a facet, since a shard may add, replace or rename them.
336 unit tests pass (32 new); lint clean. The five-rung on-device walk runs
against a local website on the cutover branch before the cutover merges.
Co-Authored-By: Claude <noreply@anthropic.com>
M11 Part 1 (docs/android/PLAN.md §9). The website's Protocol 3.0 work made every
shard-derived surface admin-configurable — a feature can be switched off, or its
audience raised above the caller's rung — and the app knew nothing about it: it
gated shard navigation on the session role alone, so an admin change left the
drawer and the hub offering entries that 404/403 into a generic error where the
web client hides them.
The visibility rules:
- GET /public/shard/features behind a singleton ShardFeaturesRepository,
re-resolved on every session change (the answer is per-viewer) and dropped on
a Settings → Server switch, which is the one case no session change covers.
- MenuEntry gains `feature` beside `access`; the two gates are independent and
both must pass. ShardBoard tags each hub tile the same way.
- An unknown answer FAILS OPEN, matching lib/useShardFeatures.js: the server
gates every call regardless, so a link that briefly 403s beats a drawer that
flickers its entries in on every cold start. A pre-3.0 website 404s this
route, which reads as "unknown" and behaves exactly as before.
- toShardUiState() maps 404 AND 403 to a new ErrorKind.FEATURE_UNAVAILABLE:
requireFeature answers 404 for a disabled feature (deliberately not
disclosing it exists) and 403 for a viewer below its rung. Kept separate from
toUiState() because both statuses mean something else off the shard surface —
a deleted post, an ownership refusal. That state renders without a retry
button; an admin controls it, so retrying cannot change the answer.
The read-model adds, from the same v3 series:
- char.profile `points` — the Loyalty & Points block. maxPoints 0 means
UNCAPPED and is the common case, so nothing divides by it and only a capped
system gets a meter; nameString is usually null (systems name themselves with
a cliloc) so humanising the PointsType key is the primary display path; rank
is absent unless the shard opts in, and absent is not "unranked".
- Cliloc-resolved names — equipment `clilocName` and titles `rewardResolved`,
so items stop rendering as a layer. rewardResolved is positional: an entry
the table could not resolve is null and is skipped WITHOUT shifting the
`selected` index onto its neighbour.
ActorDto keeps acct/webId but documents them as admin-locked rather than
available. Points ride ungated on /player/shard/char/:serial — a character's own
standings are self-service and do not depend on the public leaderboards feature,
so the app mirrors that rather than re-gating it.
304 unit tests pass; lint clean.
Co-Authored-By: Claude <noreply@anthropic.com>
Pairs with website feat/sso-trusted-device, which makes "trust this device" work
for SSO sign-ins. Two things reach this device when the user ticks the box:
1. The rg_trust COOKIE in the Custom Tab. Custom Tabs share the system
browser's cookie jar, so that alone makes the next SSO sign-in skip the
TOTP step — no app change needed for that half.
2. A trustToken in the /auth/mobile/sso/exchange response, which is what this
commit stores. That covers the app's NATIVE password login on the same
device, which reads the token back out of TrustTokenStore and replays it as
X-Trust-Token.
MobileTokenResponse already carried trustToken (the native login path has always
persisted it) — SsoAuthManager simply dropped it on the floor. Save it scoped to
the signed-in username, exactly like AuthRepository.login does, so it is never
replayed for a different account on a shared device; and save it before
onSignedIn so a process death mid-callback can't lose it.
Tests: 2 new cases in SsoAuthManagerTest (token persisted + scoped to its owner;
absent token leaves the store untouched), with an in-memory FakeTrustTokenStore
matching the file's existing fake style. Full unit suite green: 266 tests.
Co-Authored-By: Claude <noreply@anthropic.com>
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