Commit Graph

101 Commits

Author SHA1 Message Date
4b22ab3756 chore(ci): re-run
All checks were successful
PR Checks / android-build (pull_request) Successful in 11m23s
Run 76 hung in `compileDebugKotlin` for thirteen minutes and was failed with no
error in its log, where the last good run finished that task in two. Nothing
about that reads as a compile error, and this repo's Gitea has no rerun
endpoint — so this empty commit is the re-run, to tell a transient runner
problem from a real one before bisecting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-17 04:10:44 -05:00
daf483f514 fix(ci): stop setup-android installing a package Google has removed
Some checks failed
PR Checks / android-build (pull_request) Failing after 18m49s
**Unrelated to this PR's feature work**, and fixed here because it blocks
verifying it (org lead, 2026-09-17). PR #46 passed on this workflow yesterday;
every Android PR fails now.

`android-actions/setup-android@v3` is a floating tag and the action's `packages`
input defaults to `tools` — an obsolete package Google has since removed from the
SDK repository. So the step runs `sdkmanager tools`, gets `Warning: Failed to
find package 'tools'`, exits 1, and CI fails in **Set up Android SDK**, before a
line of this repo is compiled.

`packages: ''` turns that install off. It was always redundant here: the very
next step installs exactly what the build targets — `platform-tools`,
`platforms;android-35`, `build-tools;35.0.0` — precisely so the build never
depends on what some action decided to fetch.

Not addressed here, and worth its own decision: `@v3` is a floating major tag, so
the next upstream change can break CI the same way without warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-17 03:50:04 -05:00
a6677d5bf9 fix(rust): what the emulator walk found
Some checks failed
PR Checks / android-build (pull_request) Failing after 2s
Three things, none of which a unit test could have seen.

**The drawer's live count resolved once per process.** It was keyed on the
capability answer alone, so it was read at connect and never again — which is
not what "live" means on a row somebody opens the drawer to look at. It now
refreshes on resume, beside the inbox's unread badge and for the same reason:
coming back to the app is exactly when a stale number would be noticed. Still
never on a timer, still nothing at all on a site without the module.

**Every card's text sat flush against its edge.** `ShardCard` is the themed
`Card` and carries no padding of its own — each caller pads its own content, and
these four did not. On a phone the first glyph of each line read as clipped.

**A name touched its own kill count.** Five numeric columns beside an
equal-weight name column left "Brannock" and "50" reading as one field. The name
now takes a wider share and ellipsizes, and the ACTIVE SORT is marked on the
header rather than by tinting a column of numbers — the header is the control,
and tinting the values says "these are special" instead of "this is what the
table is ordered by".

Walked against the phase-4 rig: a core with the module installed, one live
server and one that has never reported. Both halves of the phase criterion hold
on a phone — the Rust site renders every panel with its server unreachable, and
the same app against the UO core shows its five shard rows and no Rust row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-17 03:40:58 -05:00
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
2026-09-16 22:16:21 -05:00
ac2d75c3f9 Merge pull request 'fix(notifications): reload the inbox and its settings when the account changes' (#45) from fix/inbox-session-scope into edge
All checks were successful
PR Checks / android-build (pull_request) Successful in 7m41s
Reviewed-on: #45
2026-09-08 22:45:16 +00:00
aa055469a8 fix(notifications): reload the inbox and its settings when the account changes
All checks were successful
PR Checks / android-build (pull_request) Successful in 8m12s
The defect Phase 14b found in `MyEventsViewModel` and flagged next door: the
notifications surface has the identical shape, and it leaks the same way.

A drawer route's view model outlives a sign-out. `navigateTopLevel` uses
`popUpTo(HOME) { saveState = true }` with `restoreState = true`, so the
`NavBackStackEntry` keeps its `ViewModelStore` and a view model that loaded only
in `init` never runs again. Signing out and back in as somebody else showed the
second account the FIRST account's inbox — titles and body text written for
another person — with no request made at all, while the badge above the list
showed the new account's real unread count, because the shell refreshes that on
every session change.

`InboxCache` was never the hole: it is keyed by (base URL, user id) and a snapshot
has never crossed an account. The hole was the in-memory state, which nothing
invalidated.

Both view models now key on the signed-in account id, so a resume revalidation
that returns the same user does not refetch. The inbox resets its state *before*
loading rather than after, because `load()` paints the cache only when there is no
`Success` on screen — otherwise the previous account's rows stay up for the whole
round trip.

The settings screen behind the inbox's gear is fixed with it, and there the stale
render is worse than disclosure: those controls are written from, so a screen
still showing the previous account's preferences would send this account's PUT
built out of them.

Walked on the emulator against a local website, before and after: two accounts
with deliberately different inboxes, signed out and in within one process. Before,
the second account saw the first's rows and the server logged no inbox fetch;
after, it logs the fetch and shows its own.

572 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 17:13:53 -05:00
f3d90b189d Merge pull request 'feat(events): the app's events screens, and the module rows that were never gated (Phase 14b)' (#44) from feature/events-p14b-app into edge
Reviewed-on: #44
2026-09-08 21:59:23 +00:00
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
2026-09-08 13:08:43 -05:00
80441c3367 Merge pull request 'feat(notifications): the in-app inbox — cutover 6 of 7 (edgemain)' (#43) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in -46s
SonarQube / analysis (push) Successful in 4m58s
Reviewed-on: #43
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-01 14:00:58 +00:00
d3bf4853de Merge pull request 'feat(notifications): the in-app inbox, and per-channel preferences (engagement Phase 8)' (#42) from feature/engagement-inapp-android into edge
All checks were successful
PR Checks / android-build (pull_request) Successful in 13m50s
Reviewed-on: #42
2026-08-31 14:37:35 +00:00
21b6ddc29b fix(notifications): resolve an item's relative url, and document the CI trigger
Some checks failed
PR Checks / android-build (pull_request) Failing after 42m5s
Two things the live rig found, and the README half of the trigger change.

Phase 7 specifies an inbox item's `url` is RELATIVE-ONLY and validates it as
such — right for a browser already on the site, a dead link on a phone. The
first cut here only opened `http(s)`-prefixed strings, so on the rig every link
in the inbox did nothing at all. `InboxViewModel.linkFor` now resolves against
the configured base with OkHttp's `HttpUrl.resolve`, which absolutises the path
and returns null for anything that would not end up http(s) — so a `javascript:`
or `intent:` url in a notification body opens nothing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 09:27:39 -05:00
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>
2026-08-31 08:59:55 -05:00
21e235a07f ci(pr-checks): run the gate on pull requests into edge too
ENGAGEMENT.md §7.1 Q8. `pr-checks.yml` triggered only on PRs into `main`, so a
workstream that lands its phases on `edge` before one cutover PR got no CI at
all until the cutover — all nine M12 phase PRs merged without a single run, and
engagement Phase 8 was about to do the same. A phase should fail on its own PR.

Sonar is untouched: `sonarqube.yml` is a push-on-`main` analysis, not a PR gate,
so no phase PR was ever expected to run it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 02:40:39 -05:00
c55ee7f47e Merge pull request 'feat(theme): make the app a full consumer of the shard's admin theming and nav (M12 cutover)' (#41) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 21s
SonarQube / analysis (push) Successful in 9m39s
Release APK / release (push) Successful in 10m32s
Reviewed-on: #41
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
v0.5.0
2026-08-08 16:28:50 +00:00
6cbfdb1e65 Merge pull request 'fix(theme): reach Material's default arguments, and measure the theme resolvers (M12 phase 8)' (#40) from chore/m12-phase-8-coverage-and-cutover into edge
All checks were successful
PR Checks / android-build (pull_request) Successful in 11m31s
Reviewed-on: #40
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 16:07:01 +00:00
b84a973559 fix(theme): let the shard's panel color and pill radius reach Material's defaults (M12 phase 8)
Two defects found on device by phase 8's AC-5 walk, both the same trap phase 2
hit with card elevation: Material takes these values as DEFAULT ARGUMENTS, not
from the theme, so mapping the token is not enough on its own.

1. Every ShardCard drew in Material's grey, not the shard's panel color.
   CardDefaults.cardColors() takes its container from surfaceContainerHighest -
   FilledCardTokens.ContainerColor, checked in the material3 1.3.0 artifact's
   bytecode - and shardColorScheme mapped surfaceContainer, High and Low but not
   Highest. All 26 ShardCard sites across 20 files were affected. Themed
   instances showed it worst: on Fantasy the page went brown and the cards
   stayed grey.

   This is NOT an M12 regression. The untouched app draws the same grey cards
   and has since M5; M12 only made it obvious by theming everything around them.
   Fixing it therefore changes the untouched app too - cards move from Material's
   grey to --panel-flat - which is the milestone's second deliberate change to a
   shard that has set nothing, alongside phase 2's card shadow. AC-1 is updated
   to record that rather than absorb it: every other role is still asserted
   byte-for-byte against the verbatim pre-M12 scheme, and the two that moved are
   named, given their new values, and checked to have actually differed before.

   surfaceContainerLowest is mapped alongside it for consistency with
   surfaceContainerLow. It has no reader in this app - the phase 8 sweep checked
   every Material component the app draws against the roles the mapping leaves at
   Material defaults, and surfaceContainerHighest was the only live one. The
   drawer scrim reads the unmapped `scrim`, which stays Material's black
   deliberately.

2. The drawer's selected row ignored --radius-pill. NavigationDrawerItem takes
   `shape` as a default argument (CircleShape); the three call sites set `colors`
   but never `shape`, so on Fantasy every other radius went square while the
   selected row stayed fully round.

Verified on device against a Fantasy-themed local instance: the three ShardCards
on the shard screen now paint --panel-flat, and the selected drawer row is the
4px rectangle the preset asks for.

477 unit tests green (476 + 1), lintDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 11:00:06 -05:00
c14342aa51 chore(sonar): measure the theme resolvers instead of excluding them (M12 phase 8)
sonar.coverage.exclusions carried a ui/theme/** directory glob from the M11
coverage push (COVERAGE_PLAN.md §2 phase 0). At the time that directory held
only Color.kt, Type.kt and the composables, so excluding all of it cost nothing.

M12 put three pure resolvers in it. ShardPalette, ShardStructure and
ShardTypeface are the milestone's core logic, they are the reason phases 1-3
could prove the no-op invariant as a JVM assertion, and JaCoCo on edge measures
them at 98%, 100% and 100%. The directory glob was dropping all of that out of
the denominator, so a future change that deleted those tests would not move the
coverage number at all.

The glob is now the one file it was really about: Theme.kt, the composable
(52%). The rest of ui/theme/ is measured, all of it 93% or better.

This does not rescue the gate - M12's already-measured code (data/appearance/
and ui/navigation/) covers at 93-100% and clears new_coverage >= 50 on its own.
It makes the number honest about which code the tests actually hold.

ui/components/ stays excluded as a directory: BrandAssets.kt is 11%, and the
9 tests it does have are on brandAssetUrl, the one part of it that is not a
composable body.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 10:21:18 -05:00
aeda919376 Merge pull request 'feat(nav): group the drawer into the shard's sections and honor its added links (M12 phase 6)' (#39) from feat/m12-phase-6-nav-sections-links into edge
Reviewed-on: #39
2026-08-08 12:59:53 +00:00
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>
2026-08-08 07:49:12 -05:00
fbe8b0bab6 Merge pull request 'feat(nav): honor the shard's public nav order, labels and hiding (M12 phase 5)' (#38) from feat/m12-phase-5-public-nav into edge
Reviewed-on: #38
2026-08-08 12:12:44 +00:00
94a5c26d6c feat(nav): honor the shard's public nav order, labels and hiding (M12 phase 5)
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>
2026-08-08 07:07:10 -05:00
b95fc45548 Merge pull request 'feat(brand): draw the shard's logo and hero (M12 phase 4)' (#37) from feat/m12-phase-4-brand-assets into edge
Reviewed-on: #37
2026-08-08 11:52:03 +00:00
3edd45d5f4 feat(brand): draw the shard's logo and hero (M12 phase 4)
`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>
2026-08-08 06:13:16 -05:00
0051e97bc7 Merge pull request 'feat(theme): draw the app in the shard's chosen type families (M12 phase 3)' (#36) from feat/m12-phase-3-fonts into edge
Reviewed-on: #36
2026-08-08 10:51:39 +00:00
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>
2026-08-08 05:45:31 -05:00
7acbe54f46 Merge pull request 'feat(theme): scale the shard's radii and card depth onto the app's scale (M12 phase 2)' (#35) from feat/m12-phase-2-structure into edge
Reviewed-on: #35
2026-08-08 10:17:39 +00:00
c7c49a9d6b feat(theme): scale the shard's radii and card depth onto the app's scale (M12 phase 2)
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>
2026-08-08 05:15:08 -05:00
1530c83fbc Merge pull request 'feat(theme): resolve the shard's palette into the Material scheme (M12 phase 1)' (#34) from feat/m12-phase-1-colors into edge
Reviewed-on: #34
2026-08-08 09:57:21 +00:00
c65913c62a feat(theme): resolve the shard's palette into the Material scheme (M12 phase 1)
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>
2026-08-08 04:53:23 -05:00
17e9451494 Merge pull request 'feat(appearance): read the admin theme and nav contract into a SiteAppearance (M12 phase 0)' (#33) from feat/m12-phase-0-appearance-store into edge
Reviewed-on: #33
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 07:01:42 +00:00
b0117acac1 feat(appearance): read the admin theme and nav contract into a SiteAppearance (M12 phase 0)
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>
2026-08-08 01:57:45 -05:00
5eaf5d22c6 Merge pull request 'feat(shard): follow the visibility framework and read the Protocol 3.0 profile' (#30) from feat/protocol-3-visibility into main
All checks were successful
sync-project-tree / sync (push) Successful in 20s
SonarQube / analysis (push) Successful in 5m14s
Release APK / release (push) Successful in 10m13s
Reviewed-on: #30
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
v0.4.0
2026-08-01 07:22:05 +00:00
12b2172731 Merge pull request 'fix(shard): decode the atlas places objects and render them' (#32) from fix/atlas-places-decode into feat/protocol-3-visibility
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m22s
Reviewed-on: #32
2026-08-01 06:03:51 +00:00
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
2026-08-01 00:59:23 -05:00
06b6b015c2 Merge pull request 'feat(shard): the four Protocol 3.0 content screens' (#31) from feat/protocol-3-screens into feat/protocol-3-visibility
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m26s
Reviewed-on: #31
2026-07-30 07:54:27 +00:00
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>
2026-07-30 02:50:19 -05:00
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>
2026-07-30 02:34:41 -05:00
4fe7a7e2a3 Merge pull request 'feat(auth): persist the trust token returned by the SSO exchange' (#29) from feat/sso-trusted-device into main
All checks were successful
sync-project-tree / sync (push) Successful in 8s
SonarQube / analysis (push) Successful in 5m41s
Release APK / release (push) Successful in 9m29s
Reviewed-on: #29
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
v0.3.6
2026-07-28 06:11:30 +00:00
b10dd444b3 feat(auth): persist the trust token returned by the SSO exchange
All checks were successful
PR Checks / android-build (pull_request) Successful in 7m55s
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>
2026-07-28 01:01:33 -05:00
f3da6ea618 Merge pull request 'ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main' (#28) from chore/sync-project-tree-ci into main
All checks were successful
sync-project-tree / sync (push) Successful in 13s
SonarQube / analysis (push) Successful in 3m16s
Release APK / release (push) Successful in 9m9s
Reviewed-on: #28
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
v0.3.5
2026-07-22 21:24:49 +00:00
ae170670d9 ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main
All checks were successful
PR Checks / android-build (pull_request) Successful in 2m23s
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>
2026-07-22 16:22:06 -05:00
7fc497a1a4 Merge pull request 'test(coverage): raise unit coverage past the 50% gate (phases 0-2)' (#27) from test/coverage-phase-0-1-2 into main
Some checks failed
SonarQube / analysis (push) Has been cancelled
Reviewed-on: #27
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:11:54 +00:00
4e3bb914ff test(coverage): raise unit coverage past the 50% gate (phases 0-2)
All checks were successful
PR Checks / android-build (pull_request) Successful in 7m19s
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
2026-07-22 16:04:02 -05:00
efe14d3828 Merge pull request 'chore(sonar): wire JaCoCo coverage and clear actionable smells' (#26) from chore/sonar-coverage-and-cleanup into main
All checks were successful
SonarQube / analysis (push) Successful in 9m15s
Reviewed-on: #26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 19:11:01 +00:00
43215b49a0 chore(sonar): wire JaCoCo coverage and clear actionable smells
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m44s
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
2026-07-22 13:58:52 -05:00
a6446b04d8 Merge pull request 'fix(notifications): always serialize streams so clearing the last subscription saves' (#25) from fix/notifications-empty-subscriptions into main
All checks were successful
SonarQube / analysis (push) Successful in 1m24s
Release APK / release (push) Successful in 16m55s
Reviewed-on: #25
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
v0.3.4
2026-07-22 16:48:06 +00:00
9c52a3dafa fix(notifications): always serialize streams so clearing the last subscription saves
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m34s
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>
2026-07-22 11:36:32 -05:00
f0a3b6c03e Merge pull request 'fix(nav): show the player game-data groups to staff' (#24) from fix/staff-player-menu into main
All checks were successful
SonarQube / analysis (push) Successful in 56s
Release APK / release (push) Successful in 9m55s
Reviewed-on: #24
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
v0.3.3
2026-07-22 08:32:57 +00:00
3aeb295342 fix(nav): show the player game-data groups to staff
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m8s
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>
2026-07-22 02:18:33 -05:00
03d4ef6fad Merge pull request 'feat(auth): trusted devices & recovery codes on the mobile client' (#23) from feature/trusted-devices-mfa into main
All checks were successful
SonarQube / analysis (push) Successful in 1m14s
Release APK / release (push) Successful in 9m37s
Reviewed-on: #23
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
v0.3.2
2026-07-22 06:26:54 +00:00