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
Runic Gateway — Android app
A native Android client for a Runic Gateway shard's public site + player self-service. It is
purely an API client of the website backend — it never talks to the link/ sidecar or the game
shard directly, and it ships none of the shard/sidecar wiring. It surfaces the same content and
player features as the website's browser client, minus every administrative/management console.
The authoritative design contract is docs/android/PLAN.md
in the RunicGateway/docs repo. The authoritative API reference is the committed OpenAPI spec at
website/server/swagger/swagger-output.json.
Status
M0 — repo scaffold. Gradle + Compose + Hilt skeleton with CI (lint + unit test + debug build). The functional Kotlin pass (M1–M4) and the design pass (M5) follow — see the plan's milestones (§9).
Stack
| Concern | Choice |
|---|---|
| Language / UI | Kotlin + Jetpack Compose (Material 3) |
| Navigation | Navigation-Compose, single-activity |
| HTTP | Retrofit + OkHttp, kotlinx.serialization |
| Async | Coroutines + Flow |
| DI | Hilt |
| Prefs / base URL | Jetpack DataStore |
| Tokens at rest | EncryptedSharedPreferences |
| Images | Coil |
| Min SDK | Android 10 (API 29) |
| Target / compile SDK | 35 |
Dependency and plugin versions are pinned in gradle/libs.versions.toml.
Build
Requires JDK 17 and the Android SDK (ANDROID_HOME / local.properties).
./gradlew assembleDebug # build a debug APK -> app/build/outputs/apk/debug/
./gradlew test # JVM unit tests
./gradlew lint # Android lint
./gradlew installDebug # install on a connected device/emulator
The app self-configures its server URL on first run (PLAN.md §3), so a single build works against any shard's website — there is no compiled-in API host.
CI
.gitea/workflows/pr-checks.yml gates PRs into main and edge with
./gradlew lint test assembleDebug on the org's self-hosted runner (JDK 17 + Android SDK). Debug
builds are auto-signed, so the gate needs no secrets. This pipeline is verified green end-to-end on
the runner (M0). A signed release APK attached to a Gitea release comes at M6.
edge is in the trigger deliberately: a workstream that lands its phases on a working branch
before one cutover PR into main otherwise gets no CI at all until the cutover — which is what
happened to all nine M12 phase PRs (docs/website/ENGAGEMENT.md §7.1 Q8). sonarqube.yml is
unaffected: it is a push-on-main analysis, not a PR gate.
The workflow carries a few runner-specific accommodations (each explained in comments in the file), because this self-hosted runner differs from a stock GitHub runner:
- JDK 17 is installed via
apt(notactions/setup-java) — the runner can't resolveapi.adoptium.net, while the Ubuntu mirrors are reachable. - SDK packages are installed explicitly via
sdkmanager, withset +o pipefailsoyesdying ofSIGPIPEdoesn't fail the step. gradlewischmod +x'd in the run step — the runner's checkout does not preserve the git executable bit, so./gradlewalone fails with "Permission denied".
Contributing
See CONTRIBUTING.md. AI-assisted contributions must be disclosed (org
policy): tick the PR box naming the tool and add a Co-Authored-By trailer to AI-authored commits.
Licensed GPL-3.0-or-later.