Mirrors the website repo's setup: a source-based scan of app/src/main
(Kotlin) that reports to the self-hosted SonarQube server after merge,
never gating PRs.
Uses the existing SonarQube project key Runic-Gateway-Android-app (the
server rejects re-creating a case-variant key). Supersedes #18.
Co-Authored-By: Claude <noreply@anthropic.com>
Add the app side of Android App Links (M9 follow-up, docs/android/APP_LINKS.md),
layered on the M9 Part 2 native SSO callback:
- Build-time `appLinkHost` Gradle property -> BuildConfig.APP_LINK_HOST +
manifestPlaceholders["appLinkHost"]. autoVerify needs a literal host, so the
generic multi-tenant build leaves it empty (placeholder falls back to the
reserved runic-gateway.invalid sentinel, making the filter inert); a
white-label build bakes one host with -PappLinkHost=play.myshard.com.
- Manifest: an autoVerify https `/mobile/callback` intent-filter beside the
unchanged custom-scheme one (the permanent fallback).
- SsoAuthManager: request the https App Link redirect_uri iff the baked host
matches the paired shard host; matchesAppLinkCallback() enforces a paired-host
trust check (host must equal the currently-paired base URL host) as
defense-in-depth. Both matchers feed the same complete()/exchange path.
- MainActivity routes custom-scheme and App Link callbacks identically.
+5 JVM tests (SsoAuthManagerTest -> 14). Built green (JDK 21,
-Pksp.incremental=false); white-label host substitution verified in the merged
manifest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Add the app client for the Mobile SSO Authorization Bridge (PLAN.md §4.2):
native "Sign in with <provider>" without shipping any OAuth secret.
- Pkce: pure-JVM RFC 7636 S256 verifier/challenge + CSRF state, encoded to
match the backend's base64url(SHA-256) exactly.
- SsoAuthManager (Singleton): mints PKCE+state, builds the /auth/mobile/sso/start
URL for a Custom Tab, verifies the returned state, exchanges the one-time code
with the stashed verifier, and drives the existing SessionManager.onSignedIn —
no new token-storage or refresh code. Pending flow is in-memory (fails closed on
process death). Exposes an outcome StateFlow the login screen consumes.
- SsoApi + DTOs: GET /auth/providers discovery and POST /auth/mobile/sso/exchange
(tagged NO_SESSION so a credential 401 isn't read as an expired session).
- MainActivity: runicgateway://auth/callback intent-filter + singleTop; parses the
callback Uri (the Android edge) and hands raw params to SsoAuthManager.
- LoginScreen/ViewModel: render a button per discovered provider, opening the
bridge in a Custom Tab; fall back to the website login hand-off when none.
Additive — no other screen's data flow changes; no backend work. Custom scheme
only for now (App Links deferred, APP_LINKS.md).
Tests (JVM, +14): Pkce vector/charset, start-URL building, and the full
complete() flow over a fake SsoApi + real SessionManager (success signs in;
state mismatch / missing pending fail without exchanging; error callback →
declined; 401 → expired-code; replay finds no pending).
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
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>
The push-to-main release model kept failing: the job builds the signed APK
fine, but the final `git push origin HEAD:main` (version-bump commit) is
rejected by main's branch protection — "pre-receive hook declined / Internal
Server Error" — across runs #187, #194. main is deliberately protected
(allowlist push, required approvals, required status checks), which is
fundamentally incompatible with a CI job pushing a fresh commit to it.
Flip the trigger: the workflow now runs on pushing a `v*` tag (or via
workflow_dispatch with a tag input). The tag *is* the release input, so:
- version/versionCode are derived from the tag name (no version-planning engine);
- app/build.gradle.kts is set for the build only, never committed back;
- no `git push` to main, no tag creation, no REGISTRY_USER needed —
only REGISTRY_TOKEN, to create the Gitea release + upload the APK/SHA256SUMS.
To cut a release now: `git tag v0.1.0 && git push origin v0.1.0`.
Keeps the speed fixes from #13 (trimmed setup-android, no Gradle cache,
timeout-minutes). Changelog is still generated from conventional-commit
subjects since the previous tag.
Co-Authored-By: Claude <noreply@anthropic.com>
The release workflow was slow and sometimes appeared to hang mid-build:
- android-actions/setup-android@v3 pulled the entire Android emulator and the
legacy `tools` package (hundreds of MB, network-bound on the self-hosted
runner) that a headless APK build never uses. Pin `packages: ''` so it only
puts cmdline-tools on PATH; the next step installs exactly what we need.
- The `actions/cache@v4` Gradle step timed out every run against the Gitea
artifact-cache backend (`getCacheEntry failed: Request timeout`), adding
latency with no benefit. Dropped it.
- The job had no timeout, and with `concurrency.cancel-in-progress: false` a
genuinely wedged run would hang forever and block every later release behind
it. Added `timeout-minutes: 30` so a hang fails fast.
Co-Authored-By: Claude <noreply@anthropic.com>
Replace the tag-triggered release.yml with link/'s language-agnostic release
engine, adapted for Android. On every push to main it derives the next version
from conventional-commit subjects since the last v* tag (feat!/BREAKING -> major,
feat -> minor, fix|perf -> patch; nothing releasable -> no release), generates a
grouped changelog, bumps versionName in build.gradle.kts (versionCode derived
major*10000+minor*100+patch, monotonic), builds the SIGNED release APK, then
commits the bump [skip ci], tags vX.Y.Z, and creates the Gitea release with the
notes + APK + SHA256SUMS.
Uses REGISTRY_USER/REGISTRY_TOKEN (write:repository) to push the bump + create
the release, matching link/. main must allow that account to push (bump lands on
main; the [skip ci] + head-commit guard prevent a re-trigger loop). Signing
secrets (ANDROID_KEYSTORE_BASE64/_PASSWORD, ANDROID_KEY_ALIAS/_PASSWORD) unchanged.
Same self-hosted-runner handling as pr-checks.yml (apt JDK 17, sdkmanager, chmod).
Co-Authored-By: Claude <noreply@anthropic.com>
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>
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
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
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
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>
The M1 PR-checks run hung ~16 min in `lintReportDebug` and was killed by
the runner (33m35s, marked failure) — every compile/test/assemble task
completed first; no task FAILED. Android lint's report phase needs more
than the 2 GB heap and GC-thrashes to a hang below it on the full app
codebase (it passed at 2 GB only while the M0 scaffold was trivial).
- gradle.properties: -Xmx2048m → -Xmx3g, cap MaxMetaspaceSize=1g so the
larger heap doesn't crowd container RAM.
- pr-checks.yml: run `testDebugUnitTest lintDebug assembleDebug` instead
of the aggregate `test lint assembleDebug`, so the release variant
isn't compiled+linted in parallel — halving peak memory and build time
while keeping the same coverage (unit tests are variant-agnostic).
Verified locally with the exact command (`--no-daemon`); lintReportDebug
+ lintDebug run and pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
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
Record in the README that the M0 CI pipeline (lint + test + assembleDebug)
is verified green end-to-end on the self-hosted runner, and summarize the
runner-specific workflow accommodations (apt JDK, sdkmanager pipefail, gradlew
chmod) so contributors understand why they're there.
Co-Authored-By: Claude <noreply@anthropic.com>
The SDK install succeeds (reaches 100% unzip) but the step reported exit
141: with -o pipefail, 'yes' dying of SIGPIPE (128+13) once sdkmanager
closes the pipe poisons the pipeline status. Disable pipefail for that line
so only sdkmanager's exit gates success.
Co-Authored-By: Claude <noreply@anthropic.com>
PR #2 merged the M0 scaffold with the initial CI, which fails on this
self-hosted runner. Fix forward:
- JDK 17 via apt (openjdk-17-jdk-headless) + JAVA_HOME, replacing
actions/setup-java: the runner can't resolve api.adoptium.net (EAI_AGAIN).
- Explicit `sdkmanager` install of platform-tools + platforms;android-35 +
build-tools;35.0.0 so the build never relies on AGP auto-download.
- `chmod +x ./gradlew` in the run step (and set the 100755 git bit): this
runner's checkout drops file modes, so `./gradlew` alone hit
"Permission denied" (exit 126).
- ScaffoldSanityTest: drop the BuildConfig.DEBUG assertion, which fails under
testReleaseUnitTest (the `test` task runs both variants); assert VERSION_NAME.
- Remove the redundant android:label on MainActivity (RedundantLabel lint).
Verified locally against the Android Studio SDK (platform 35 + build-tools
35.0.0): `./gradlew lint test assembleDebug` passes and builds a debug APK.
Co-Authored-By: Claude <noreply@anthropic.com>
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>