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>
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 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.
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.