37a828736e027045245d97bc1969c4d1a4596c95
24 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| a6b6c92c33 |
feat(rust): the Rust server list and one server's page (phase 5, Android leg A)
The app's half of module-rust's read path — the leg R10 says trails the website surface it consumes by one phase, so it is built against routes that exist. Two screens, mirroring what phase 4 shipped: `/rust` is the server list (D12), and one server is a single screen with four tabs (D13) rather than four destinations. Both render entirely from the website's own tables, so the phase criterion — a fleet that is entirely off still shows its maps, seeds, wipe dates, killfeeds, leaderboards and last known presence — holds here for the same reason it holds on the web. What is new to the app rather than copied: - **A poll that is not a load.** `PollWhileResumed` + `refreshInto` (D17): a refresh is invisible when it succeeds and KEEPS the rows when it fails. The app had one shape for a read — blank, ask, replace — which is right for opening a screen and would clear the killfeed three times a minute here. Gated on RESUMED, so a backgrounded app makes no requests at all and returning to it refreshes at once. - **A second game module in the drawer.** `Capability.RUST`, gating one row. It deliberately does not gate on `servers`/`killfeed`/`leaderboard`/`presence`/ `wipes`: those name surfaces, core flattens every module's capabilities into one list, and another module declaring `servers` would reveal these screens on a site with no Rust. Module-Rust#5 adds the identity string. - **`/rust` in NavPaths**, so an admin's nav override or an added link opens natively instead of handing off to a browser (D19). - **A live player count on the drawer row** (D19) — the phone's answer to D15's footer slot, in the same badge slot the inbox count uses, with the same screen-reader treatment. Zero renders nothing; a failed read keeps the last number; it never polls. Two things carried across from the website's own page walk rather than rediscovered: "last reported" reads `lastSeenAt` and never `updatedAt` (a failed poll moves the second), and a feed row from another calendar day carries its date, or a row from a past wipe reads as this afternoon. The four navigation tests that moved did so because APP_MENU gained a row and the website's nav number line gained an index; each now says which. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| e10e1f1617 |
feat(events): the app's events screens, and the module rows that were never gated (Phase 14b)
All checks were successful
PR Checks / android-build (pull_request) Successful in 12m7s
Events Phase 14b, the app half — recorded as M13 in docs/android/PLAN.md. Four screens on the four routes Phase 14a shipped: the public calendar, an event page carrying `?run=`, an arc, and participation history. One drawer row for the history, at SIGNED_IN rather than PLAYER: the route is `requireAuth` alone and self-scoped, and the website needed two mounts for it only because `RequirePlayer` guards `/account` there. The prerequisite fix is the larger half. The app read `/public/modules` nowhere and mapped every `/public/shard/features` failure to "unknown", which `canSee` treats as visible — so on a site with no `uo` module every shard row rendered and every one of them 404'd. Absence of an answer is not an answer of absence: a successful module list that omits `shard` hides the rows, a failed read keeps the last answer the host gave, and a host that has never answered leaves the gate open. Capability and feature compose as two gates and answer different questions: whether the module is installed (per host) and whether this shard publishes the surface to this viewer (per viewer). Also corrects the website path → route table, wrong since the module-system cutover on 2026-08-12: core's NAV is eight rows, not sixteen, and the nine shard rows moved to `/uo/*`. A nav override on any shard row was ignored, an added link to one handed off to a browser, and the sort-key line was wrong. Two existing tests had been passing vacuously since that day. An inbox link to an event now opens the app rather than a Custom Tab, through `resolveWebPath` rather than a second mechanism — so its "a query hands off" rule gains exactly one exception, `run` on an event page. The emulator walk found three defects that 563 green tests did not: - the three player game-data rows read `/player/shard/*` and were not gated, so they rendered and 404'd; the test meant to catch that asked whether every row *with a feature* declared the capability, and those three have none. It now asks by route. - `score` is DECIMAL(18,4) and was declared an integer, so one `318.5` made kotlinx refuse the entire body and a 200 rendered as a server error — latent on the public results table for every visitor. - a drawer route's view model outlives a sign-out, so signing in as a second account showed it the first account's participation history with no request made at all. 570 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| d393cf022e |
feat(notifications): the in-app inbox, and per-channel preferences (engagement Phase 8)
The app's half of the in-app channel. Phase 7 shipped four inbox routes with no consumer on either platform; this is the Android one, plus the per-channel preferences Phase 3 added and the shipped screen could not express. The drawer's "Notifications" is the INBOX now, with the preferences one tap away behind its gear — the arrangement Phase 7 shipped on the web, and what a person means when they tap the word. The settings screen moved off /notifications/subscriptions onto /notifications/channels: it renders a control per channel that applies to each id (from the item's own `channels`, never a hardcoded three) and per mode that channel accepts, which is how email's `digest` reaches the app. The old endpoint is the push projection of the new table server-side, so the shipped APK went on working the whole time. A tapped tickle whose `ref` starts with `notification:` lands on the inbox whatever its stream is — an engagement rule's stream id is a TRIGGER id in the one namespace, and `forStream`'s fixed map would have sent most of them Home. Every other tickle keeps the route it has always had. The ref is not decoded beyond that prefix and never rendered: it is a hint that a row exists, and the contract stays wake-and-pull. PLAN.md §7's "no Room cache in v1" stands; the offline snapshot is its one named exception, settled with the org lead. The inbox is a short, read-only, newest-first list with a server-side cursor, so what "works offline" needs is the newest page and the badge, not a database — one JSON blob in the DataStore the push code already uses. Every snapshot is scoped to (base URL, user id) and only handed back to that pair: that, not the clear-on-logout, is what stops a cache surviving into another account on the paths that never reach a logout at all. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 15a4d44c3f |
feat(nav): group the drawer into the shard's sections and honor its added links (M12 phase 6)
Phase 6 of M12 (docs/android/THEMING_AND_NAV.md §6.3): the drawer gains the sections an admin grouped rows into and the links they added of their own, the last of the public nav the website publishes. buildNavTree ports the web's buildPublicNav and pruneNav; a link's path is validated by the website's own read rule and resolved through resolveWebPath, which the app has to answer for any page on the site rather than the nav's sixteen. A link the app can open natively does; one it cannot hands off to a Custom Tab, absolute against the configured base URL. Phase 6 does not re-implement phase 5: with no sections and no links stored, buildNavTree hands straight to applyNavOverrides, so an untouched instance still gets APP_MENU back by identity and AC-1's proof is unchanged. visibleEntries is split into isEntryVisible so pruneNav can apply the same predicate inside a section, and drop one the gates leave empty. 476 unit tests green (442 + 34); lintDebug and assembleDebug clean. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| a19fdd3582 |
feat(theme): draw the app in the shard's chosen type families (M12 phase 3)
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> |
|||
| 4f85021be2 |
fix(shard): decode the atlas places objects and render them
`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
|
|||
| aacef35def |
feat(shard): the four Protocol 3.0 content screens
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>
|
|||
| 833e51de69 |
feat(shard): follow the visibility framework and read the Protocol 3.0 profile
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m20s
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>
|
|||
| a1fa4901ef |
@
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m22s
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 @ |
|||
| 422892f1ed |
feat(sso): single "Sign in with SSO" button with a native provider picker
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> |
|||
| 7f876377f0 |
feat(admin): moderation + support queue (M10 Phase 3, part 3)
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> |
|||
| c5596845c1 |
feat(admin): staff content — news posts + wiki taxonomy (M10 Phase 3, part 2)
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> |
|||
| ac99a012b0 |
feat(admin): staff nav + dashboard/site-mode (M10 Phase 3, part 1)
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> |
|||
| 0f93f3dcd3 |
fix(sso): make native SSO discovery legible and survive process death
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>
|
|||
| 26b8eecde6 |
fix(security): declare explicit network security config to forbid cleartext
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m30s
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> |
|||
| 7665975d59 |
feat(auth): M9 Part 2 — native in-app SSO via the mobile bridge
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m34s
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 |
|||
| e2ced06a83 |
feat(push): M7 Part 2 — opt-in push notifications (embedded ntfy distributor)
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m16s
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>
|
|||
| 0df862a6af |
feat(release): m6 release mechanics — signed APK, R8, version guard, icons
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m52s
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> |
|||
| 52e06da8fc |
feat(ui): m5 shard-website design pass
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m6s
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 |
|||
| d4f7fcb241 |
feat(m4): player self-service & game data
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m18s
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 |
|||
| 1c56eda64b |
feat(m3): native auth — login+TOTP, token storage, refresh, access-level menu
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m41s
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
|
|||
| c8f4e76370 |
feat(m2): public shard widgets + live SSE stream
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m4s
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> |
|||
| 9019ded556 |
feat(m1): connect & browse — first-run flow, public content, contact
Some checks failed
PR Checks / android-build (pull_request) Failing after 33m35s
Implements M1 (functional Kotlin pass, docs/android/PLAN.md §9): the
first-run base-URL connect flow, brand-seeded Material 3 theming from
/public/settings, a Retrofit/OkHttp/kotlinx-serialization client with a
runtime host-selection interceptor (the base URL is not compiled in),
the layered repository stack returning a typed ApiResult for graceful
degradation, and functional Compose screens for Home/Status, News
(+ post detail), Wiki (+ detail), CMS pages (block renderer), and the
contact form. One shared, declarative navigation drawer. No auth yet (M3).
DTOs + the Retrofit interface are hand-written and spec-aligned rather
than openapi-generated: the committed swagger-output.json is produced by
swagger-autogen and its component schemas are meta-descriptive (nested
{type, example} wrappers), not codegen-clean, so a hand-authored client
module is the pragmatic "checked-in generated module" the plan allows
(§2). Shapes were matched against the website controllers/models.
JVM unit tests cover URL normalization, host rewriting, ApiResult/UiState
mapping, and brand-color parsing. `lint test assembleDebug` green locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
|
|||
| c920e8805b |
chore(scaffold): M0 Gradle + Compose + Hilt skeleton with CI
Some checks failed
PR Checks / android-build (pull_request) Failing after 5m13s
Stand up the Android-app repo per docs/android/PLAN.md M0: a buildable Kotlin + Jetpack Compose (Material 3) single-activity skeleton wired for Hilt, ready for the M1-M4 functional pass. - Gradle 8.7 wrapper; AGP 8.6.1 / Kotlin 2.0.20, JDK 17, minSdk 29, target 35. - Version catalog (gradle/libs.versions.toml) pins the full planned stack (Compose, Hilt, Retrofit/OkHttp + kotlinx.serialization, DataStore, security-crypto, Coil, Navigation) so later milestones reference by alias. - RunicGatewayApp (@HiltAndroidApp) + MainActivity (Compose) + ui/theme/*. - Strings externalized from day one; adaptive launcher icon; backup rules exclude the token store / DataStore (no session material off-device). - CI: .gitea/workflows/pr-checks.yml gates PRs with lint + test + assembleDebug (JDK 17 + Android SDK on the self-hosted runner; debug builds auto-signed, no secrets). Placeholder JVM unit test so the test gate runs. - .gitattributes forces LF on gradlew so the wrapper runs on the Linux runner. Verified locally: `gradle help`/`projects` configure the :app module and resolve all six plugins cleanly (full assemble needs the Android SDK, done in CI). app id: com.runicgateway.app (PLAN.md §13, pending runicgateway.app domain). Co-Authored-By: Claude <noreply@anthropic.com> |