55 Commits

Author SHA1 Message Date
02fb7d47d5 Merge pull request 'chore(release): cut edge over to main — Google Play, account deletion, report/block/hide (v0.7.0)' (#65) from edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 30s
SonarQube / analysis (push) Failing after 2m16s
Release APK / release (push) Failing after 27m20s
Reviewed-on: #65
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-30 22:26:16 +00:00
6630d05e76 Merge branch 'main' into edge
All checks were successful
PR Checks / android-build (pull_request) Successful in 3m3s
2026-10-30 22:23:03 +00:00
b20abdf4ba Merge pull request 'feat(safety): report, block and hide chat lines in the app' (#64) from feat/user-safety into edge
All checks were successful
PR Checks / android-build (pull_request) Successful in 4m41s
Reviewed-on: #64
2026-10-30 15:30:14 +00:00
5386889ef4 feat(safety): report, block and hide chat lines in the app
All checks were successful
PR Checks / android-build (pull_request) Successful in 24m48s
USER_SAFETY.md step 4 (§4.3): the app half of report, block and hide.

- Long-press a Rust chat line for its menu: Report (the six reasons and an
  optional detail), Hide for me, Block player where the line says canBlock,
  and for admins and moderators Hide for everyone, or Show to everyone again
  on a hidden line. Signed out, it says Sign in to report or block and opens
  the login screen (S6).
- The feed is re-read after a hide, a block or a staff hide, never edited in
  the app: the server filters it per reader (S11). A report changes nothing
  and does not re-read.
- Staff see a line hidden for everyone dimmed and marked Hidden by staff (S10).
- Account -> Blocked lists account and Steam-ID blocks with Unblock, says when
  a block no longer applies because its target is now staff (S8), and offers
  Show them again for hidden items.
- Core's routes on a new SafetyApi; the module's two staff routes on RustApi.
  RustEventDto gains canBlock and hidden.

Two fixes from the emulator walk: Back on the report form or the block
confirmation now steps back to the menu instead of closing the sheet (Back
to put the keyboard away was throwing a half-written report away), and the
sheet opens fully so Send report is on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 09:56:25 -05:00
d0c5bd6ddf Merge pull request 'feat(account): delete my account in the app' (#63) from feat/account-deletion into edge
Reviewed-on: #63
2026-10-30 05:27:57 +00:00
6be83fe70a feat(account): delete my account in the app
All checks were successful
PR Checks / android-build (pull_request) Successful in 14m55s
ACCOUNT_DELETION.md §4.3 (step 4). My account gains a Delete my account
row, last and set apart, which opens its own screen: what goes, what stays,
the staff note, then the typed username, the password and, when two-factor is
on, an authenticator or recovery code. Delete stays off until the name matches.

MeApi.deleteAccount sends DELETE /auth/me/account and reads the raw response,
so each refusal's code becomes its own sentence (the website's words). A
wrong password is a 400 (AD12), so the authenticator never replays it.

On success AuthRepository.forgetDeletedAccount does what a sign-out does
without its server calls: the inbox snapshot, the trust token (which a
sign-out keeps, but the account is gone) and the session, whose teardown
also drops the push registration. It is not cancellable. The route holds the
view model, so it tells a deletion from any other sign-out, and opens sign-in
with "Your account has been deleted." on the shell's snackbar.

Tests: the refusal mapping per code, 429, unknown codes, no answer, the
request body; the confirm rule, the staff rule, each refusal's sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 00:13:34 -05:00
19c2f77a98 Merge pull request 'fix(push): hold the ntfy connection as specialUse and restart it on boot' (#62) from fix/push-special-use into edge
Reviewed-on: #62
2026-10-29 13:10:17 +00:00
690441a8c3 fix(push): hold the ntfy connection as specialUse and restart it on boot
All checks were successful
PR Checks / android-build (pull_request) Successful in 24m52s
PushService was a dataSync foreground service. Android 15 stops dataSync
after six hours a day and expects the service to stop itself on
onTimeout; it never did, so with target 36 the app would crash. A held-open
push connection is not a transfer either, which is what Play reserves
dataSync for.

- The service is now specialUse, with the PROPERTY_SPECIAL_USE_FGS_SUBTYPE
  reason Play reviews (ntfy does the same on Play). No daily limit.
- PushBootReceiver restarts it after BOOT_COMPLETED and MY_PACKAGE_REPLACED
  when push was on and a registration is saved (a sign-out clears it). It
  starts the service from the saved topic instead of re-registering, since
  the network is often not up at boot.

PLAN.md M8, decided by the org lead 2026-10-29.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-29 07:47:43 -05:00
ac60427a3e Merge pull request 'feat(demo): explain the public demo's refusals and show its strip on Home' (#61) from feat/demo-mode into edge
Reviewed-on: #61
2026-10-26 14:18:12 +00:00
1579a1982c Merge pull request 'feat(release): target API 36 and upload each tag's bundle to Google Play (M8 step 1)' (#60) from feat/m8-google-play into edge
Reviewed-on: #60
2026-10-26 14:17:56 +00:00
aca358d72b feat(demo): explain the public demo's refusals and show its strip on Home
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m2s
Step 4 of docs website/DEMO_MODE.md §10 (website#222).

- DemoLockInterceptor on the main client peeks every 403 body. On
  code DEMO_LOCKED it reports the server's sentence to DemoNotices, and the
  shell shows it as one snackbar, like the web's toast (DM19). A newer refusal
  replaces the one showing. Screens added later get it without doing anything.
- ApiResult.HttpError carries the body's code (read only on a 403, peeked so
  the body is not consumed); isDemoLocked(). ErrorKind.DEMO_LOCKED is mapped
  ahead of the status in toUiState and toShardUiState, and is not retryable.
- Screens whose 403 copy blames the role now say the demo sentence instead:
  the four admin view models, Characters' account creation, and the contact
  form's line (the contact form is locked on the demo).
- Home draws the strip from GET /public/settings' demo block: the wipe time in
  UTC, the published usernames, and the site's sign-in page for the passwords.
  The app does not list the passwords (DM20). A real shard sends no block, so
  it draws nothing.

Checked on an emulator against website main (69998ec) with DEMO_MODE=true and
seed:demo: the strip on Home; demo-player's username change refused with the
snackbar under the screen's own line; demo-admin's site-mode switch refused
with the demo sentence in place of "You don't have permission".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 06:29:16 -05:00
7a8231ba9c feat(release): target API 36 and upload each tag's bundle to Google Play
All checks were successful
PR Checks / android-build (pull_request) Successful in 21m22s
Step 1 of M8 (docs/android/PLAN.md item 9).

- compileSdk/targetSdk 36 (Play's floor since 2026-08-31). AGP 8.6.1
  cannot build against 36, so AGP goes to 8.13.2 (the last 8.x) with the
  Gradle 8.14.3 wrapper. Kotlin, KSP and Hilt are unchanged. All three
  workflows install platforms;android-36; AGP 8.13 still defaults to
  build-tools 35.0.0.
- release.yml also runs bundleRelease, signed with the same keystore,
  and attaches the .aab to the Gitea release beside the APK (the first
  bundle has to be uploaded by hand, and this is where it comes from).
- A last step uploads the bundle to Play's internal track through the
  Play Developer API v3 edit flow. The OAuth token comes from a
  service-account JWT signed with openssl; the key is the new
  PLAY_SERVICE_ACCOUNT_JSON secret. While that secret is unset, the
  step warns and skips. The step runs after the Gitea release, so a
  Play failure never holds back the APK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-23 07:03:54 -05:00
3d01871c72 Merge pull request 'chore(release): cut edge over to main — the Rust screens and the app's words (v0.6.0)' (#59) from edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 25s
Release APK / release (push) Successful in 18m36s
SonarQube / analysis (push) Failing after 16s
Reviewed-on: #59
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-23 07:09:11 +00:00
478206e0bd Merge pull request 'feat(strings): the app's words for every game (M20)' (#58) from feat/app-vocabulary into edge
All checks were successful
PR Checks / android-build (pull_request) Successful in 3m9s
Reviewed-on: #58
2026-10-23 06:50:49 +00:00
c41aff9b2e feat(rust): print the weapon name the module sends in the feed
All checks were successful
PR Checks / android-build (pull_request) Successful in 14m33s
PLAN.md M20 step 4. Module-Rust#60 gives every event frame a weaponName: the
game's own name for a ranged weapon, or the prefab name as words. The feed
printed the prefab name as words itself, which read "with ak47u entity".

weaponName() in RustFormat.kt is the Kotlin half of module-rust's format.js
weapon(). It prints frame.weaponName. Against a module too old to send it,
it drops the .entity / .weapon / .deployed ending before prefabName(), as
the website does, so an older module's kill reads "with ak47u".

Tests: a kill with weaponName, the same kill from an older module, and the
fallback's edges (blank name, each ending, an ending only at the end, no
weapon).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-23 01:38:01 -05:00
bb9cd9008d fix(nav): gate Moderation and Support queue on the shard module
Found by the M20 trace, and fixed in this PR by the org lead's decision.
Both rows call /admin/shard/*, module-uo's admin mount, but they carried no
capability. So a moderator on a Rust-only site saw "Moderation" in the drawer
and opened a UO form ("Serial (0x…)", kick or ban by UO account) where every
action failed.

Both rows now declare Capability.SHARD, as the player rows do, so they are UO
screens and their strings belong in strings_uo.xml, where step 1 put them. A
new MenuCapabilityGatingTest case covers it: a moderator on a Rust-only
backend sees neither row but keeps the Dashboard, and on a UO backend sees
both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-23 01:36:53 -05:00
3048582ce0 test(strings): guard each strings file against another game's words
PLAN.md M20 step 3. StringVocabularyTest reads strings.xml, strings_uo.xml
and strings_rust.xml in every values* directory, so translations are checked
too. It fails, naming the key and the word, when the core file holds either
game's word, the UO file holds a Rust word, or the Rust file holds a UO word.
The word list is runicgateway.com's checkTerms.mjs list, word for word. The
app never shows Discord's "guild ID", so that allowlist entry isn't carried
over.

Three more tests keep the guard honest:
- a fourth strings file with no owner is refused, because Android would read
  it and the guard would skip it;
- the three default files must exist and hold strings, so a wrong path can't
  pass on nothing;
- the matcher is checked against known hits and misses.

Run against the core file as it was before the rewording, it names exactly
the 14 strings that commit changed.

build.gradle.kts declares src/main/res as an input of the Test tasks. A
reworded string changes no class, so without that input Gradle left the test
task UP-TO-DATE and the guard did not run again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-23 01:35:59 -05:00
5e28989adc feat(strings): use core's words on the screens every deployment shows
PLAN.md M20 step 2. The first screen a Rust community's player sees said
"Connect to your shard", and the next said "Sign in with your shard account".
The core strings now use core's words, as the website and runicgateway.com do:

- connect_*: "your community", "a Runic Gateway site"
- login_subtitle: "Sign in with your account on this site."
- error_shard_offline: "The game server is offline right now." It is the
  shared 503 text that both games' screens show.
- error_feature_unavailable and notifications_*: "This site …"
- push_channel_* and push_service_text: "Notifications from your community"

Two comments in the file said "shard" too, and now say "site". Keys are
unchanged. The UO screens keep their words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-23 01:33:10 -05:00
ff8b66ace1 refactor(strings): split strings by owner into core, UO and Rust files
PLAN.md M20 step 1. strings.xml keeps the core strings; strings_uo.xml and
strings_rust.xml take the strings only that game's screens use. Android merges
the three into one R.string, so no key changes and no Kotlin changes.

Each string was classified by the screens that reference it, traced in code,
not by its key: a string any deployment's screen can show stays core.
error_shard_offline and error_feature_unavailable are the shared error-view
texts, so they stay core. All seven legacy push streams, server.status
included, are declared only by module-uo, so their titles are UO.

No text changes: all 578 elements are moved byte for byte (checked by
comparing name → element before and after).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-23 01:32:37 -05:00
2c8ab390e2 Merge pull request 'feat(account): several Steam accounts on the account screen' (#57) from feat/steam-signin into edge
Reviewed-on: #57
2026-10-20 15:25:28 +00:00
c905aee94f feat(account): several Steam accounts on the account screen
All checks were successful
PR Checks / android-build (pull_request) Successful in 13m57s
Steam sign-in (docs/website/STEAM_SIGNIN.md) lets a person link more than one
Steam account. Unlink went by provider, so it would have removed all of them;
it now sends the identity's id (DELETE …/identities/:provider?id=). A Steam row
reads "Steam: <persona name>" with its SteamID64 beneath, since Steam sends no
email. The sign-in sheet already lists providers from /auth/providers, so Steam
appears there with no change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-19 20:32:54 -05:00
8e7a41f053 Merge pull request 'feat(rust): your backpack on the Rust account screen (PLAN_REDESIGNS §11.3)' (#56) from feat/rust-backpacks into edge
Reviewed-on: #56
2026-10-12 19:41:08 +00:00
6a6fa5d76e feat(rust): your backpack on the Rust account screen (PLAN_REDESIGNS §11.3)
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m32s
A Backpack section under Coins: one card per server (GET /player/rust/servers),
an Open button per linked account that reads GET /player/rust/backpacks once
per press and never polls, and the slots as pages of four-wide rows named by
the game (D340), blueprints by what they make (D341), overflow marked with the
take-back sentence. A 409 says Backpacks is not installed; the section is left
out with Coins on that read's 404, since both ship in protocol 14.

backpackPages/stackLabel are pure (BackpackLayout.kt) and tested, with the DTO
decode and the view model's open/read-again/close. Local gradle lint test
assembleDebug passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 14:35:55 -05:00
8c6c6f918d Merge pull request 'feat(rust): coin balances on the Rust account screen (PLAN_REDESIGNS §11.2)' (#55) from feat/rust-economics into edge
Reviewed-on: #55
2026-10-12 12:41:10 +00:00
531f3b2c4b feat(rust): coin balances on the Rust account screen (PLAN_REDESIGNS §11.2)
All checks were successful
PR Checks / android-build (pull_request) Successful in 11m28s
The app half of the module's Economics (protocol 14): a Coins section on the
Rust account screen, from GET /player/rust/economics — one card per server,
each linked account's balance, "last known" when the server did not answer,
and "Economics is not installed on this server" where it is absent (D199).

A third independent read beside the links and the entitlements; a link
re-reads it, and a 404 (a module older than protocol 14) leaves the section
out rather than showing an error the player can do nothing about. A balance
never reported shows a dash, not zero.

testDebugUnitTest, lintDebug and assembleDebug pass locally; four new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 07:12:55 -05:00
3dddcb6525 Merge pull request 'feat(rust): named NPC markers, bigger bosses, and an event's progress lines (runicnpc stage 8)' (#54) from feat/runicnpc-stage8 into edge
Reviewed-on: #54
2026-10-06 13:32:55 +00:00
e6aa9526c8 feat(rust): named NPC markers, bigger bosses, and an event's progress lines (runicnpc stage 8)
All checks were successful
PR Checks / android-build (pull_request) Successful in 13m36s
- The map (D302, D303), as the site draws it:
  - an NPC's card is its name, or "(boss)" or "(a boss's add)";
  - the card names its event, with the title from the run resolver's
    existing calendar read;
  - a boss is bigger and magenta;
  - a boss outside any event is a world-layer marker with its name.
- The event screen renders the live occurrence's progress lines
  (MODULE_API 1.12.0, D304): "Bandit: 2 of 3 left", "Walk Juggernaut:
  59%". It re-reads quietly every 15 s while the event is live.
- DTOs gain the new fields with defaults, so an older site decodes as
  before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 08:23:17 -05:00
63bdf824be Merge pull request 'feat(rust): chat titles on the leaderboard (module-rust phase 17, M19)' (#53) from feat/rust-phase-17-titles into edge
Reviewed-on: #53
2026-09-25 23:31:55 +00:00
96aae7239b feat(rust): chat titles on the leaderboard (module-rust phase 17, M19)
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m38s
Each leaderboard row carries `titles: [{ text, color }]` from a phase-17
module: the chat titles the player holds now. Drawn as chips under the
name, in the operator's colour with black or white ink chosen by WCAG
contrast (the website's rule). Absent from an older module, so the field
defaults to empty and nothing is drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 17:48:22 -05:00
341c13a98c Merge pull request 'feat(rust): a Rust server's next wipe (module-rust phase 16, M18)' (#52) from feat/rust-phase-16-next-wipe into edge
Reviewed-on: #52
2026-09-25 19:18:14 +00:00
89c317f694 feat(rust): a Rust server's next wipe (module-rust phase 16, M18)
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m28s
RustServerDto gains nextWipe { at, source }, null both when the operator
set no schedule and against a module older than phase 16. The server
list row and the server screen's header read "Next wipe Thu 1 Oct,
19:00 · in 6 days" in the phone's own zone, and "(rescheduled)" for a
one-off date the operator set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 13:30:12 -05:00
edcbc0727b Merge pull request 'feat(rust): the map on a phone (module-rust phase 15, M17)' (#51) from feat/rust-phase-15-map into edge
Reviewed-on: #51
2026-09-25 17:43:20 +00:00
09eb101c52 feat(rust): the map on a phone (module-rust phase 15, M17)
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m53s
A fifth tab on the Rust server screen, between Online and Wipes, shown
when the site's module declares `map` under the app's one capability
rule (D122). `?tab=map` opens it; on a site without a map it opens the
feed.

The phone draws the map itself (D121): Coil loads the picture once per
map as RGB_565, and a Compose Canvas draws the grid and the markers,
with pinch, pan and double tap. The frame is §30.3's transform, ported
from the web's mapGeometry.js with its test cases. Markers keep their
size on screen; a zone is drawn on the ground. Nothing here gates a
layer: map/live is projected per viewer on the server, and the app
draws what it is sent.

- The legend: a switch per layer the viewer was sent, and who can see
  each one they were not (with the D113 line). The mates row offers
  the Steam link to a signed-in, unlinked viewer.
- The tap card: what a marker is, its grid square, and what its layer
  carried. A site-event marker whose run core's public calendar lists
  (D125) has Open event, which opens the app's own event page on that
  run (D123). A rehearsal's or unlisted event's marker says only
  "Site event".
- The poll: map/live every pollMs (10 s) while RESUMED on the Map tab
  (D124), and not at all for a viewer sent nothing that moves. A new
  mapKey re-reads the map, so a new seed's picture follows in place.
- Per account: the map's state is dropped and re-asked on a change of
  signed-in account, and an answer asked for the previous account is
  discarded when it lands.

Found on the emulator walk and fixed here: the reader's zoom was lost
on the way back from the event page, and again whenever the status line
under the map changed length (a resize was treated as a rotation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 08:28:26 -05:00
adf9547e01 Merge pull request 'feat(notifications): a Rust notification on a phone (module-rust phase 11)' (#50) from feat/rust-phase-11-notifications into edge
Reviewed-on: #50
2026-09-23 20:49:28 +00:00
640b423dfa feat(notifications): a Rust notification on a phone (module-rust phase 11)
All checks were successful
PR Checks / android-build (pull_request) Successful in 8m54s
Three places the app still assumed its site was a UO shard (D69-D71):

- The linked-account check behind personal streams asked module-uo's
  /player/shard/accounts. On a Rust site that failed, and the whole raid
  alert row was disabled in every channel. It now asks the site's own
  module by capability, and the link only holds back switching push ON;
  switching anything off is never refused.
- A tickle whose ref names an inbox row is titled from that row, pulled
  over the authenticated inbox API (wake-and-pull). The lock screen shows
  only the per-stream title. Each tickle runs in its own job, because
  collectLatest would cancel a pull on the next tickle.
- /player/rust and /rust/servers/<id>?tab=<tab> open natively; a tab the
  app does not have (clans) still hands off to the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 15:26:32 -05:00
8e3c62d966 Merge pull request 'fix(rust): say who is online is withheld, never that nobody is' (#49) from fix/rust-presence-visibility into edge
Reviewed-on: #49
2026-09-23 05:36:33 +00:00
f695eb6c45 fix(rust): say who is online is withheld, never that nobody is
Some checks failed
PR Checks / android-build (pull_request) Failing after 17m40s
Module-Rust now withholds who is online below an operator-chosen
audience - staff by default (org lead, 2026-09-22: nothing names who is
online by default). The Online route answers an empty list with hidden,
count and audience; the feed answers presenceHidden and
presenceAudience.

Before this the app would have rendered the withheld list as "the
island is empty" on a full server. Now:

* RustOnlineDto and RustEventListDto carry the flags; the repository
  and view model keep the whole answer instead of its rows.
* the Online tab says "2 players online" and who can see the names;
  the feed says, once above the rows, that joins, deaths and chat are
  not shown.
* an older module without the flags decodes as visible, as before.

Walked on an emulator against a core with the module installed: the
withheld panel and the feed note as a signed-in player at the staff
default, and the names appearing on the next poll after the fleet was
widened to signed-in - which also proves the app's bearer session
reaches the module's viewer check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 00:30:31 -05:00
59d955a11d Merge pull request 'feat(rust): a player's own Rust account on the phone — M15 (module-rust phase 8, Android leg B)' (#48) from feat/rust-p8-android-b into edge
Reviewed-on: #48
2026-09-23 01:52:10 +00:00
a079bd481e fix(rust): say when and where an account was linked, as the website's row does
Some checks failed
PR Checks / android-build (pull_request) Failing after 22m6s
Found by the emulator walk, not by the suite: the app's row carried the
name and the Steam id and stopped there, while the website's says "linked
just now on rust-oxide". Neither fact is part of the identity — a link is
fleet-wide — but which server minted the code is where a support
conversation starts, and the module's own schema comment says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 20:32:26 -05:00
4878b74e09 feat(rust): a player's own Rust account on the phone — M15 (module-rust phase 8, Android leg B)
R10's leg B: identity, and the half of R2 a player is allowed to see. Every
route this calls existed and answered before a line of Kotlin was written,
except the entitlement read, which is this phase's one website change.

**One drawer row under the player group, shaped like `CharactersScreen` one
game along**: the code card first, then what the code got them. A link is
fleet-wide, so it is not a tab under one server — a Steam account is one
person wherever they play, while stats are per server and per wipe.

**Gated on `rust`, not on `identity`.** The module declares a surface word
per feature, and D16's rule is that a capability answers one question — *is
the module there*. `MenuAccess.PLAYER` is `isPlayer || isStaff`, which is
right here: `/player/rust/*` is `requireAuth` with no role above it, and
staff play the game too.

**Two reads, neither blocking the other.** An entitlement is authored
against the website account, so it exists before a Steam id does — the
person who has just been given something and has not linked yet is exactly
the one who needs both halves at once, and a failure on either leaves the
other standing.

**The four refusals stay four pieces of advice.** 400 is a spent code, 409
is a Steam account another account holds (`/unlink` in game releases it),
429 is the limiter, and 503 is a server that could not be reached — where
the code is still good, so it may not say "get a new one". A player told
otherwise goes back to the same unreachable server for another code.

The app does no scope arithmetic: `*` never reaches a screen. Each entry
arrives with its servers already resolved and each marked, because a second
implementation of `inScope` is a second thing to keep true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 20:19:13 -05:00
37a828736e Merge pull request 'feat(rust): the Rust server list and one server's page — M14 (module-rust phase 5, Android leg A)' (#47) from feature/rust-p5-android-a into edge
Reviewed-on: #47
2026-09-17 09:22:00 +00:00
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
5e61ee2bbb Merge pull request 'feat(events): the app's event screens — M13 (Phase 16b cutover, 4 of 6)' (#46) from edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 24s
SonarQube / analysis (push) Failing after 1m23s
Reviewed-on: #46
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-09 20:09:36 +00: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 (edge → main)' (#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
171 changed files with 18363 additions and 826 deletions

View File

@@ -1,5 +1,5 @@
# Gate every pull request into `main` on lint + unit tests + a debug build, so a
# broken build can't reach the deployable branch. Debug builds are auto-signed,
# Gate every pull request into `main` or `edge` on lint + unit tests + a debug
# build, so a broken build can't reach the deployable branch. Debug builds are auto-signed,
# so this gate needs no secrets. The signed *release* APK + Gitea release come
# later (release.yml, M6). See docs/android/PLAN.md §12.
#
@@ -18,9 +18,14 @@
name: PR Checks
# `edge` is here because a workstream that lands ten phase PRs onto it before one
# cutover PR into `main` otherwise gets NO CI at all until the cutover — which is
# exactly what happened to all nine M12 phase PRs, and would have happened again
# to engagement Phase 8 (ENGAGEMENT.md §7.1 Q8). A phase should fail on its own
# PR, not inside the cutover window with a whole workstream's diff to bisect.
on:
pull_request:
branches: [main]
branches: [main, edge]
concurrency:
group: pr-checks-${{ github.ref }}
@@ -40,8 +45,15 @@ jobs:
- uses: actions/checkout@v4
# `packages: ''` is load-bearing, not tidying. The action's own default is
# `tools` -- a package Google has REMOVED from the SDK repository -- so the
# default makes `sdkmanager tools` exit 1 and the step fails before a line
# of this repo is compiled. It is redundant here regardless: the next step
# installs exactly what the build targets.
- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
packages: ''
# Install exactly what the build targets so it never depends on AGP's
# build-time auto-download. `yes |` accepts any license prompts; `set
@@ -51,7 +63,7 @@ jobs:
- name: Install Android SDK packages
run: |
set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
- name: Cache Gradle
uses: actions/cache@v4

View File

@@ -18,6 +18,17 @@
# ANDROID_KEYSTORE_PASSWORD — keystore password
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
# ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore)
# PLAY_SERVICE_ACCOUNT_JSON — Google Cloud service-account key (the whole JSON file)
# with release rights on com.runicgateway.app only. Unset,
# the Play step is skipped with a warning (PLAN.md M8).
#
# Google Play (PLAN.md M8): the same keystore also signs an app bundle (.aab). After
# the Gitea release exists, the bundle is uploaded to Play's *internal* track through
# the Play Developer API. Promotion to closed testing / production stays a click in
# the console, so a tag never reaches testers or review on its own. Google requires
# the app's FIRST bundle to be uploaded by hand: take it from the Gitea release
# assets. The Play step runs last, so a Play failure fails the run but never holds
# back the APK release.
#
# Runner handling matches pr-checks.yml (self-hosted `ubuntu-latest`): the container
# lacks git/curl/unzip and can't reach api.adoptium.net, so we apt-install the base
@@ -56,7 +67,7 @@ jobs:
- name: Install base tools + JDK 17
run: |
apt-get update
apt-get install -y git curl unzip jq openjdk-17-jdk-headless
apt-get install -y git curl unzip jq openssl openjdk-17-jdk-headless
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
- name: Check out the release tag (full history for the changelog)
@@ -103,6 +114,7 @@ jobs:
echo
echo "---"
echo "Signed APK — sideload on Android 10+ (§10). The app self-configures its shard site on first run."
echo "The .aab is the same build as a Google Play bundle; it does not install on a phone."
} > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
@@ -123,7 +135,7 @@ jobs:
- name: Install Android SDK packages
run: |
set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
- name: Decode signing keystore
env:
@@ -148,7 +160,7 @@ jobs:
sed -i -E "s/(toIntOrNull\(\) \?: )[0-9]+/\1${VERSION_CODE}/" "${GRADLE_MODULE}/build.gradle.kts"
grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts"
- name: Unit tests + signed release APK
- name: Unit tests + signed release APK + app bundle
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
@@ -156,15 +168,20 @@ jobs:
run: |
set -euo pipefail
chmod +x ./gradlew
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest \
:${GRADLE_MODULE}:assembleRelease :${GRADLE_MODULE}:bundleRelease
- name: Package APK + SHA256SUMS
- name: Package APK + AAB + SHA256SUMS
run: |
set -euo pipefail
SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
test -f "$SRC" || { echo "::error::release APK not found at $SRC"; exit 1; }
cp "$SRC" "dist/runic-gateway-${{ steps.plan.outputs.version }}.apk"
( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS )
VERSION="${{ steps.plan.outputs.version }}"
APK="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
AAB="${GRADLE_MODULE}/build/outputs/bundle/release/app-release.aab"
test -f "$APK" || { echo "::error::release APK not found at $APK"; exit 1; }
test -f "$AAB" || { echo "::error::release bundle not found at $AAB"; exit 1; }
cp "$APK" "dist/runic-gateway-${VERSION}.apk"
cp "$AAB" "dist/runic-gateway-${VERSION}.aab"
( cd dist && sha256sum "runic-gateway-${VERSION}.apk" "runic-gateway-${VERSION}.aab" > SHA256SUMS )
ls -l dist && cat dist/SHA256SUMS
# ── Create the Gitea release + upload assets (no push to main) ───────
@@ -186,9 +203,94 @@ jobs:
| jq -r '.id')"
echo "Created release ${TAG} (id=${REL_ID})"
for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" SHA256SUMS; do
for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" \
"runic-gateway-${{ steps.plan.outputs.version }}.aab" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
echo " uploaded ${f}"
done
# ── Google Play: bundle → internal track (PLAN.md M8) ────────────────
# Play Developer API v3 edit flow: open an edit, upload the bundle, put its
# versionCode on the internal track, commit. The OAuth token comes from a
# service-account JWT signed with openssl, so no Gradle plugin and no Google
# SDK. The release is "completed" on the internal track, which Play only
# accepts once the app has had a release rolled out; before that, its error
# is "Only releases with status draft may be created on draft app", and the
# fix is the hand upload of the first bundle, not a change here.
- name: Upload the bundle to Google Play (internal track)
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
set -euo pipefail
if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
echo "::warning::PLAY_SERVICE_ACCOUNT_JSON is not set; the bundle was not uploaded to Google Play."
exit 0
fi
PACKAGE="com.runicgateway.app"
TAG="${{ steps.plan.outputs.tag }}"
VERSION_CODE="${{ steps.plan.outputs.versionCode }}"
AAB="dist/runic-gateway-${{ steps.plan.outputs.version }}.aab"
API="https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${PACKAGE}"
UPLOAD_API="https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${PACKAGE}"
# The service-account key only ever touches the runner's temp dir.
umask 077
SA_KEY="${RUNNER_TEMP}/play-sa.pem"
trap 'rm -f "$SA_KEY"' EXIT
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" | jq -r '.private_key' > "$SA_KEY"
CLIENT_EMAIL="$(printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" | jq -r '.client_email')"
# play <curl args...>: prints the response body; on a non-2xx it prints
# Google's error to stderr and fails, so `set -e` stops the step.
play() {
local out code
out="$(mktemp)"
code="$(curl -sS -o "$out" -w '%{http_code}' "$@")"
if [ "${code:0:1}" != "2" ]; then
echo "::error::Google Play API returned HTTP ${code}" >&2
cat "$out" >&2; echo >&2
rm -f "$out"
return 1
fi
cat "$out"
rm -f "$out"
}
# Service-account JWT → OAuth access token (RFC 7523).
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
NOW="$(date +%s)"
JWT_HEAD="$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | b64url)"
JWT_CLAIMS="$(jq -cn --arg iss "$CLIENT_EMAIL" --argjson iat "$NOW" \
'{iss:$iss, scope:"https://www.googleapis.com/auth/androidpublisher",
aud:"https://oauth2.googleapis.com/token", iat:$iat, exp:($iat + 3600)}' | b64url)"
JWT_SIG="$(printf '%s' "${JWT_HEAD}.${JWT_CLAIMS}" | openssl dgst -sha256 -sign "$SA_KEY" -binary | b64url)"
ACCESS_TOKEN="$(play -X POST https://oauth2.googleapis.com/token \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
--data-urlencode "assertion=${JWT_HEAD}.${JWT_CLAIMS}.${JWT_SIG}" \
| jq -r '.access_token')"
echo "::add-mask::${ACCESS_TOKEN}"
AUTH="Authorization: Bearer ${ACCESS_TOKEN}"
EDIT_ID="$(play -X POST "${API}/edits" -H "$AUTH" \
-H 'Content-Type: application/json' -d '{}' | jq -r '.id')"
echo "Opened edit ${EDIT_ID}"
UPLOADED_CODE="$(play -X POST "${UPLOAD_API}/edits/${EDIT_ID}/bundles?uploadType=media" \
-H "$AUTH" -H 'Content-Type: application/octet-stream' \
--data-binary "@${AAB}" | jq -r '.versionCode')"
if [ "$UPLOADED_CODE" != "$VERSION_CODE" ]; then
echo "::error::Play read versionCode ${UPLOADED_CODE} from the bundle, expected ${VERSION_CODE}"
exit 1
fi
echo "Uploaded bundle (versionCode ${UPLOADED_CODE})"
play -X PUT "${API}/edits/${EDIT_ID}/tracks/internal" -H "$AUTH" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg name "$TAG" --arg code "$VERSION_CODE" \
'{track:"internal", releases:[{name:$name, versionCodes:[$code], status:"completed"}]}')" \
>/dev/null
play -X POST "${API}/edits/${EDIT_ID}:commit" -H "$AUTH" -d '' >/dev/null
echo "Released ${TAG} (versionCode ${VERSION_CODE}) to the internal track"

View File

@@ -64,7 +64,7 @@ jobs:
- name: Install Android SDK packages
run: |
set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
- name: Cache Gradle
uses: actions/cache@v4

View File

@@ -48,10 +48,15 @@ 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.
`.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:

View File

@@ -38,13 +38,13 @@ val hasReleaseSigning = ksStoreFilePath != null && ksStorePassword != null &&
android {
namespace = "com.runicgateway.app"
compileSdk = 35
compileSdk = 36
defaultConfig {
// Target application id per docs/android/PLAN.md §13 (pending runicgateway.app domain).
applicationId = "com.runicgateway.app"
minSdk = 29
targetSdk = 35
targetSdk = 36
// These committed defaults are the version source of truth (PLAN.md §10).
// release.yml's conventional-commit engine bumps versionName here and commits
// it on release; versionCode is derived from it (major*10000+minor*100+patch)
@@ -181,6 +181,13 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}
// StringVocabularyTest (PLAN.md M20) reads the string resources from disk. A reworded
// string changes no class, so without this input the test task would stay UP-TO-DATE
// and the guard would not run again.
tasks.withType<Test>().configureEach {
inputs.dir("src/main/res").withPathSensitivity(PathSensitivity.RELATIVE)
}
// JaCoCo XML coverage from the JVM unit tests, consumed by SonarQube (§12.1). Generated,
// DI (Hilt), and Compose-scaffold classes are excluded so they don't dilute the number;
// pure-@Composable UI is excluded on the Sonar side (sonar.coverage.exclusions) because

View File

@@ -8,10 +8,14 @@
<!-- Opt-in push notifications (M7): the runtime notification permission (API 33+)
and a foreground service that holds the persistent ntfy connection open — the
embedded UnifiedPush distributor, so no separate app is needed (PLAN.md §11). -->
embedded UnifiedPush distributor, so no separate app is needed (PLAN.md §11).
specialUse, not dataSync (PLAN.md M8, 2026-10-29): a held-open connection is not a
transfer, and Android 15 stops dataSync after six hours a day. RECEIVE_BOOT_COMPLETED
restarts it after a reboot or an update. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".RunicGatewayApp"
@@ -70,11 +74,28 @@
</activity>
<!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11).
dataSync foreground type; not exported — started only by PushManager. -->
specialUse foreground type, with the reason Play reviews in the property below;
not exported — started only by PushManager and PushBootReceiver. -->
<service
android:name=".core.push.PushService"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Instant push notifications without Google services. When the user turns notifications on, this service holds one connection open to the push relay their community's server names, and shows a notification for each message from it. The ongoing notification shows the service is running; turning notifications off or signing out stops it." />
</service>
<!-- Restarts push after a reboot or an app update, if the member had it on and was
signed in when the phone went down (PLAN.md M8). Not exported: only the system
delivers these broadcasts. -->
<receiver
android:name=".core.push.PushBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -58,9 +58,16 @@ class MainActivity : ComponentActivity() {
// consumed once by RunicApp which navigates to the stream's screen.
private var pendingStream by mutableStateOf<String?>(null)
// The tickle's other half: an opaque ref, carried since M7 and read since
// ENGAGEMENT.md phase 8, where a `notification:<id>` ref means the engine wrote
// an inbox row and the tap should land there. Never rendered — it is a hint that
// something exists, and the app pulls the real item over the authenticated API.
private var pendingRef by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
pendingRef = intent?.getStringExtra(PushNotifier.EXTRA_REF)
handleSsoCallback(intent)
// Dark-only app (M5): force light system-bar icons over the transparent bars so
// they stay legible on the deep blue-black surfaces regardless of system theme.
@@ -99,7 +106,11 @@ class MainActivity : ComponentActivity() {
appearance = s.appearance,
onChangeServer = appViewModel::changeServer,
deepLinkStream = pendingStream,
onDeepLinkConsumed = { pendingStream = null },
deepLinkRef = pendingRef,
onDeepLinkConsumed = {
pendingStream = null
pendingRef = null
},
)
}
}
@@ -116,7 +127,13 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let { pendingStream = it }
intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let {
pendingStream = it
// Cleared alongside, not conditionally: a tickle with no ref arriving
// after one with a ref must not inherit the earlier ref and land on the
// inbox instead of its own screen.
pendingRef = intent.getStringExtra(PushNotifier.EXTRA_REF)
}
handleSsoCallback(intent)
}

View File

@@ -0,0 +1,74 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.inbox
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.runicgateway.app.data.api.dto.NotificationItemDto
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
private val Context.inboxDataStore: DataStore<Preferences> by preferencesDataStore(name = "inbox")
/**
* [InboxCache] over the same plain DataStore the push state uses. Not secret —
* tokens stay in the encrypted store — but an inbox body is a person's own
* notifications, which is why the snapshot is owner-scoped and cleared on
* sign-out rather than left lying about.
*/
@Singleton
class DataStoreInboxCache @Inject constructor(
@param:ApplicationContext private val context: Context,
private val json: Json,
) : InboxCache {
private val store = context.inboxDataStore
override suspend fun read(owner: String): InboxCache.Snapshot? {
val raw = store.data.first()[KEY_SNAPSHOT] ?: return null
val stored = try {
json.decodeFromString(Stored.serializer(), raw)
} catch (_: Exception) {
// A snapshot this build can't parse is a snapshot from an older one;
// dropping it silently is right — it will be rewritten on the next pull.
return null
}
if (stored.owner != owner) return null
return InboxCache.Snapshot(items = stored.items, unread = stored.unread, savedAt = stored.savedAt)
}
override suspend fun write(owner: String, items: List<NotificationItemDto>, unread: Int) {
val payload = Stored(
owner = owner,
items = items.take(InboxCache.MAX_ITEMS),
unread = unread,
savedAt = System.currentTimeMillis(),
)
store.edit { it[KEY_SNAPSHOT] = json.encodeToString(Stored.serializer(), payload) }
}
override suspend fun clear() {
store.edit { it.remove(KEY_SNAPSHOT) }
}
@Serializable
private data class Stored(
val owner: String,
val items: List<NotificationItemDto>,
val unread: Int,
val savedAt: Long,
)
private companion object {
val KEY_SNAPSHOT = stringPreferencesKey("snapshot")
}
}

View File

@@ -0,0 +1,65 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.inbox
import com.runicgateway.app.data.api.dto.NotificationItemDto
/**
* The inbox's offline snapshot (ENGAGEMENT.md phase 8).
*
* **PLAN.md §7 decided the app ships no Room cache, and that decision stands** —
* this is its one named exception, settled with the org lead 2026-08-31. The
* inbox is a short, read-only, newest-first list with a server-side cursor and no
* joins, 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. Nothing
* here is a source of truth — a successful pull always replaces it, and the
* screen says out loud when it is showing this instead.
*
* **The [owner] key is the security property, not a convenience.** A snapshot is
* written under the base URL *and* the account id that produced it and is only
* ever handed back to that exact pair, so a cache cannot survive into another
* account or another shard — including the sign-out paths that never reach
* [clear] at all (a dead refresh token, a server switch). Clearing on logout is
* the tidy-up; this is what makes it safe.
*
* An interface for the same reason [com.runicgateway.app.core.auth.TokenStore] is
* one: the storage needs a `Context` and the view models that use it should be
* testable without one.
*/
interface InboxCache {
/**
* What was cached for [owner], or null when nothing was — including when the
* stored snapshot belongs to a different account or shard, which is the same
* answer on purpose.
*/
suspend fun read(owner: String): Snapshot?
/**
* Replace the snapshot with the newest page.
*
* Only the FIRST page is ever cached, capped at [MAX_ITEMS]: an offline inbox
* is there so the last things you were told are still readable on a train, not
* so the whole history is. Later pages come from the server or not at all.
*/
suspend fun write(owner: String, items: List<NotificationItemDto>, unread: Int)
/** Forget everything. Called on sign-out, alongside the push deregistration. */
suspend fun clear()
/** What the screen renders from while offline, with the time it was captured. */
data class Snapshot(
val items: List<NotificationItemDto>,
val unread: Int,
val savedAt: Long,
)
companion object {
/** The server's own default page size — caching more than it sends is pointless. */
const val MAX_ITEMS = 30
/** The (shard, account) a snapshot belongs to. */
fun ownerKey(baseUrl: String?, userId: Long): String = "${baseUrl.orEmpty()}|$userId"
}
}

View File

@@ -0,0 +1,39 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import com.runicgateway.app.core.result.DEMO_LOCKED_STATUS
import com.runicgateway.app.core.result.demoRefusalOf
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* Spots the public demo's refusal on any response of the main client and reports it
* to [DemoNotices] (`docs/website/DEMO_MODE.md` §3.1, DM19). Every API rides this
* client, so a screen added later is covered without doing anything.
*
* It only reads: the body is peeked, not consumed, and the response goes on to its
* caller unchanged. Only a `403` is looked at, so an ordinary answer is never
* buffered.
*/
@Singleton
class DemoLockInterceptor @Inject constructor(
private val notices: DemoNotices,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.code != DEMO_LOCKED_STATUS) return response
val body = runCatching { response.peekBody(MAX_PEEK_BYTES).string() }.getOrNull()
demoRefusalOf(body)?.let(notices::report)
return response
}
private companion object {
/** The refusal is one short JSON object; anything longer is not one. */
const val MAX_PEEK_BYTES = 8L * 1024
}
}

View File

@@ -0,0 +1,35 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import com.runicgateway.app.core.result.DemoRefusal
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* The app's one channel for the public demo's refusals (`docs/website/DEMO_MODE.md`
* §3.1, DM19) — the counterpart of the web client's `demoNotice.js`.
* [DemoLockInterceptor] reports every `DEMO_LOCKED` answer here and the shell shows
* it as one snackbar, wherever the action was, so no screen has to learn the code.
* The screen still gets its error as usual.
*
* A refusal nobody is collecting (the app in the background) is dropped rather than
* queued: a snackbar about a tap from minutes ago would explain nothing.
*/
@Singleton
class DemoNotices @Inject constructor() {
private val _refusals = MutableSharedFlow<DemoRefusal>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val refusals: SharedFlow<DemoRefusal> = _refusals.asSharedFlow()
fun report(refusal: DemoRefusal) {
_refusals.tryEmit(refusal)
}
}

View File

@@ -0,0 +1,52 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.push
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Restarts [PushService] after a reboot or an app update (PLAN.md M8, 2026-10-29). Before
* this, push stayed off after either until the member next opened the app.
*
* It starts the service straight from the saved registration rather than through
* [PushManager], which re-registers with the backend first: at boot the network is often
* not up yet, and a failed registration would leave push off. The registration survives a
* reboot, the topic on the relay does not change, and the service's stream client already
* retries with backoff until the network arrives. A sign-out clears the registration, so a
* saved one also means the member was signed in.
*/
@AndroidEntryPoint
class PushBootReceiver : BroadcastReceiver() {
@Inject lateinit var prefs: PushPreferences
override fun onReceive(context: Context, intent: Intent) {
if (intent.action !in RESTART_ACTIONS) return
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
if (shouldRestart(prefs.snapshot())) {
runCatching { PushService.start(context.applicationContext) }
}
} finally {
pending.finish()
}
}
}
internal companion object {
val RESTART_ACTIONS = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)
/** Push was on, and a registration (so a signed-in member) is still saved. */
fun shouldRestart(snapshot: PushPreferences.Snapshot): Boolean =
snapshot.enabled && !snapshot.topic.isNullOrBlank() && !snapshot.ntfyUrl.isNullOrBlank()
}
}

View File

@@ -0,0 +1,77 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.push
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.NotificationItemDto
import com.runicgateway.app.data.repository.NotificationsRepository
import com.runicgateway.app.ui.navigation.Routes
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
/**
* The inbox id a tickle's `notification:<id>` ref names, or null.
*
* Prefix-exact, the same rule [Routes.forTickle] reads the ref by, and digits
* only. The relay is untrusted, so a ref is a hint, and anything that is not
* exactly a positive id is treated as no hint at all.
*/
internal fun inboxIdFromRef(ref: String?): Long? {
if (ref == null || !ref.startsWith(Routes.INBOX_REF_PREFIX)) return null
val digits = ref.substring(Routes.INBOX_REF_PREFIX.length)
if (digits.isEmpty() || digits.length > MAX_ID_DIGITS || !digits.all { it in '0'..'9' }) return null
return digits.toLongOrNull()?.takeIf { it > 0 }
}
/**
* The item from [page] that [tickle] is about, or null.
*
* The item must be the one the ref names **and** come from the trigger the tickle
* names. Only the user's own inbox can be read, so a forged pair could only put
* one of their own items on their own phone. A tickle that disagrees with the row
* it points at is still not one to title from.
*/
internal fun itemForTickle(tickle: PushTickle, page: List<NotificationItemDto>): NotificationItemDto? {
val id = inboxIdFromRef(tickle.ref) ?: return null
return page.firstOrNull { it.id == id && it.triggerId == tickle.stream }
}
/**
* Pulls the inbox row a tickle points at, so the notification can say what
* happened (`docs/modules/rust/PLAN.md` D70).
*
* This is the wake-and-pull contract core's `pushChannel.js` describes: the relay
* carries `{ stream, ref }` and nothing else, and the content comes over the
* authenticated, ownership-checked inbox API. Core has no single-item read, so
* this reads the first page. The row was written moments ago, so it is on that
* page, and if it is not the caller falls back to the per-stream title.
*
* **Every failure is null**: no ref, a signed-out app, a dead network, a timeout,
* an item not on the page. A GET of the inbox marks nothing read, so the badge
* is left alone.
*/
@Singleton
class PushContentResolver @Inject constructor(
private val notifications: NotificationsRepository,
) {
suspend fun itemFor(tickle: PushTickle): NotificationItemDto? {
if (inboxIdFromRef(tickle.ref) == null) return null
val page = withTimeoutOrNull(PULL_TIMEOUT_MS) {
(notifications.inbox() as? ApiResult.Ok)?.data?.items
} ?: return null
return itemForTickle(tickle, page)
}
private companion object {
/**
* Long enough for a phone waking on a slow network, and short enough that a
* notification is never held back noticeably for a title.
*/
const val PULL_TIMEOUT_MS = 5_000L
}
}
/** A `BIGINT UNSIGNED` never runs past 20 digits. */
private const val MAX_ID_DIGITS = 20

View File

@@ -13,6 +13,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.runicgateway.app.MainActivity
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.NotificationItemDto
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
@@ -20,10 +21,18 @@ import javax.inject.Singleton
/**
* Builds the notification channels and posts a notification for a received tickle
* (PLAN.md §11, M7 Part 2 work items 2/3/7). v1 shows a **generic per-stream**
* notification titled from the fixed [PushStreams] catalog — the content-free tickle
* carries nothing to render, so nothing is fetched to display the notification; tapping
* deep-links into [MainActivity] (which fetches fresh over the authenticated API).
* (PLAN.md §11, M7 Part 2 work items 2/3/7). Tapping deep-links into [MainActivity],
* which fetches fresh over the authenticated API.
*
* **The title comes from the inbox row when there is one** (`docs/modules/rust/PLAN.md`
* D70). The tickle carries nothing to render. When its ref names an inbox row,
* [PushContentResolver] has already pulled that row over the authenticated API, and
* the notification says what the row says. Every other tickle keeps the M7 behaviour:
* a **generic per-stream** title from the fixed [PushStreams] catalog. Until D70 that
* was every tickle, and "New notification" is all a Rust raid alert ever said.
*
* **The lock screen shows only the generic title.** The row's text is content, and
* a locked phone on a table is not the place for "a door was destroyed in S16".
*/
@Singleton
class PushNotifier @Inject constructor(
@@ -65,17 +74,32 @@ class PushNotifier @Inject constructor(
.setContentIntent(deepLinkIntent(stream = null, ref = null))
.build()
/** Post a notification for a tickle, deep-linking to the stream's screen on tap. */
fun notify(tickle: PushTickle) {
/**
* Post a notification for a tickle, deep-linking to the stream's screen on tap.
*
* [item] is the inbox row the tickle points at, when it could be pulled; null
* falls back to the per-stream title.
*/
fun notify(tickle: PushTickle, item: NotificationItemDto? = null) {
if (!manager.areNotificationsEnabled()) return // POST_NOTIFICATIONS not granted
val title = context.getString(PushStreams.titleRes(tickle.stream))
val notification = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
.setContentTitle(title)
val generic = context.getString(PushStreams.titleRes(tickle.stream))
val content = notificationText(item, generic)
val redacted = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
.setContentTitle(generic)
.setSmallIcon(R.drawable.ic_stat_name)
.build()
val builder = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
.setContentTitle(content.title)
.setSmallIcon(R.drawable.ic_stat_name)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(redacted)
.setContentIntent(deepLinkIntent(tickle.stream, tickle.ref))
.build()
content.body?.let { body ->
builder.setContentText(body).setStyle(NotificationCompat.BigTextStyle().bigText(body))
}
val notification = builder.build()
try {
manager.notify(nextId.getAndIncrement(), notification)
} catch (_: SecurityException) {
@@ -108,3 +132,18 @@ class PushNotifier @Inject constructor(
const val EXTRA_REF = "com.runicgateway.app.push.REF"
}
}
/** What a posted notification says: a title always, and a body when the row had one. */
internal data class NotificationText(val title: String, val body: String?)
/**
* The text for a notification about [item], falling back to [generic] (D70).
*
* A row with a blank title is treated as no row: a notification with an empty
* headline is worse than one that says only that something happened.
*/
internal fun notificationText(item: NotificationItemDto?, generic: String): NotificationText {
val title = item?.title?.trim().orEmpty()
if (title.isEmpty()) return NotificationText(generic, null)
return NotificationText(title, item?.body?.trim()?.takeIf { it.isNotEmpty() })
}

View File

@@ -16,7 +16,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -26,13 +25,20 @@ import javax.inject.Inject
* connection to the shard's ntfy open in the background — the price of Google-free,
* self-contained instant delivery — and posts a notification for each tickle. It runs
* under a low-importance ongoing notification and restarts sticky; [PushManager] starts
* and stops it as the user opts in/out or signs out.
* and stops it as the user opts in/out or signs out, and [PushBootReceiver] starts it
* again after a reboot or an update.
*
* Its foreground type is `specialUse` (PLAN.md M8, 2026-10-29). It was `dataSync`, which
* Android 15 stops after six hours a day — and this service never stopped itself on that
* timeout, so the app would have crashed. `specialUse` has no daily limit; the manifest
* carries the reason Google Play reviews.
*/
@AndroidEntryPoint
class PushService : Service() {
@Inject lateinit var streamClient: NtfyStreamClient
@Inject lateinit var notifier: PushNotifier
@Inject lateinit var content: PushContentResolver
@Inject lateinit var prefs: PushPreferences
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -51,7 +57,7 @@ class PushService : Service() {
private fun startAsForeground() {
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
0
}
@@ -67,8 +73,15 @@ class PushService : Service() {
stopSelf()
return
}
streamClient.events(snapshot.ntfyUrl, topic).collectLatest { event ->
if (event is NtfyStreamClient.Event.Message) notifier.notify(event.tickle)
// Each tickle in its own job (D70). Titling one from its inbox row
// suspends on a network read, and inside `collectLatest` the next tickle
// would cancel it. A burst (two owners' raid alerts, then a restart) would
// then post only the last.
streamClient.events(snapshot.ntfyUrl, topic).collect { event ->
if (event is NtfyStreamClient.Event.Message) {
val tickle = event.tickle
scope.launch { notifier.notify(tickle, content.itemFor(tickle)) }
}
}
}

View File

@@ -14,12 +14,18 @@ import java.io.IOException
* an expected failure, it returns a typed variant the screen can render.
*
* - [Ok] — a 2xx response with a decoded body.
* - [HttpError] — the server answered with a non-2xx status.
* - [HttpError] — the server answered with a non-2xx status. [HttpError.code] is
* the error body's `code` field when it carried one (today only
* the demo's `DEMO_LOCKED` is read, see [isDemoLocked]).
* - [NetworkError] — the request never got an answer (offline, DNS, TLS, timeout).
*/
sealed interface ApiResult<out T> {
data class Ok<T>(val data: T) : ApiResult<T>
data class HttpError(val status: Int, val message: String? = null) : ApiResult<Nothing>
data class HttpError(
val status: Int,
val message: String? = null,
val code: String? = null,
) : ApiResult<Nothing>
data class NetworkError(val cause: Throwable) : ApiResult<Nothing>
}
@@ -54,12 +60,31 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
} catch (e: CancellationException) {
throw e
} catch (e: HttpException) {
ApiResult.HttpError(e.code(), e.message())
ApiResult.HttpError(e.code(), e.message(), errorCodeOf(e))
} catch (e: IOException) {
ApiResult.NetworkError(e)
} catch (e: SerializationException) {
ApiResult.HttpError(MALFORMED_RESPONSE_STATUS, e.message)
}
/**
* The error body's `code`, read only for a status that can carry one the app acts on,
* so an ordinary 404 or 500 body is never looked at. The body is peeked, not
* consumed, so whoever else holds the exception can still read it.
*/
private fun errorCodeOf(e: HttpException): String? {
if (e.code() != DEMO_LOCKED_STATUS) return null
val body = runCatching {
e.response()?.errorBody()?.source()?.let { source ->
source.request(MAX_ERROR_PEEK_BYTES)
source.peek().readUtf8(minOf(source.buffer.size, MAX_ERROR_PEEK_BYTES))
}
}.getOrNull()
return errorCodeOf(body)
}
/** The refusal is one short JSON object; anything longer is not one. */
private const val MAX_ERROR_PEEK_BYTES = 8L * 1024
/** Synthetic status for a 2xx body the app couldn't decode — an invalid upstream response. */
private const val MALFORMED_RESPONSE_STATUS = 502

View File

@@ -0,0 +1,54 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.result
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* The public demo's refusal (`docs/website/DEMO_MODE.md` §3.1). With `DEMO_MODE` on,
* the server answers a locked write
* `403 { "message": "This is turned off on the demo site.", "code": "DEMO_LOCKED" }`.
* A module may word its own sentence (a refused console command names the command),
* so the message is carried as sent.
*
* The lock is the server's (DM10). Nothing in the app decides what is allowed; it
* only explains what was refused.
*/
const val DEMO_LOCKED_CODE = "DEMO_LOCKED"
/** Status the server answers a demo refusal with. */
const val DEMO_LOCKED_STATUS = 403
/** A refusal read off a response body. [message] is null when the body carried none. */
data class DemoRefusal(val message: String?)
private val lenient = Json { ignoreUnknownKeys = true }
/**
* The `code` field of a JSON error body, or null for any body that is not a JSON
* object carrying a string `code`. Never throws: an error body is whatever the
* server or a proxy in front of it sent.
*/
fun errorCodeOf(body: String?): String? = errorObject(body)?.stringField("code")
/** The refusal in this error body, or null when it is not one. */
fun demoRefusalOf(body: String?): DemoRefusal? {
val obj = errorObject(body) ?: return null
if (obj.stringField("code") != DEMO_LOCKED_CODE) return null
return DemoRefusal(obj.stringField("message")?.takeIf { it.isNotBlank() })
}
/** True when this result is the demo's refusal. */
fun ApiResult<*>.isDemoLocked(): Boolean =
this is ApiResult.HttpError && code == DEMO_LOCKED_CODE
private fun errorObject(body: String?): JsonObject? {
if (body.isNullOrBlank()) return null
return runCatching { lenient.parseToJsonElement(body) as? JsonObject }.getOrNull()
}
private fun JsonObject.stringField(name: String): String? =
(get(name) as? JsonPrimitive)?.takeIf { it.isString }?.content

View File

@@ -0,0 +1,41 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.time
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
/**
* Parse a timestamp off the wire, in either shape the backend sends.
*
* **Which one arrives is not the app's to decide.** Express serializes a `Date`
* to ISO-8601 with a `Z`, but these values start life as MariaDB `DATETIME`
* columns, and one read back as a string reaches the wire as
* `2026-08-31 07:13:50` with no zone at all. A zoneless stamp is read as **UTC**,
* because that is what the server stores — reading it as local time would
* silently shift every timestamp by the device's offset, which is a bug that
* looks right on the machine it was written on.
*
* Anything unparseable answers null, and every caller is expected to render
* *something* without it: a notification with an odd date is still worth reading,
* and an event with one is still worth listing.
*
* Lives here rather than beside either caller because the trap is the wire's, not
* one screen's — the inbox found it (ENGAGEMENT.md phase 8) and the event screens
* inherit it (EVENTS.md §I).
*/
fun parseWireInstant(raw: String?): Instant? {
val text = raw?.trim().orEmpty()
if (text.isEmpty()) return null
return try {
Instant.parse(text)
} catch (_: Exception) {
try {
LocalDateTime.parse(text.replace(' ', 'T')).atZone(ZoneId.of("UTC")).toInstant()
} catch (_: Exception) {
null
}
}
}

View File

@@ -0,0 +1,84 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.EventCalendarDto
import com.runicgateway.app.data.api.dto.EventHistoryDto
import com.runicgateway.app.data.api.dto.EventSeriesResponse
import com.runicgateway.app.data.api.dto.PublicEventResponse
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
/**
* The event surface (PLAN.md §9 M13, `docs/website/EVENTS.md` § API surface).
*
* **These are CORE routes, not a module's**, which is why they live here rather
* than beside the shard reads in [PublicApi]: they exist on a backend
* with no game module installed at all, and they are gated by core's own `events`
* capability rather than by a module's. Nothing here is under `/shard`.
*
* The three public reads and the one player read share an interface for the same
* reason the website mounts them in one feature: the history row's whole purpose
* is to link back to the public page. The player call carries a bearer through
* [com.runicgateway.app.core.net.AuthInterceptor] like every other authenticated
* call; there is one Retrofit.
*/
interface EventsApi {
/**
* The public calendar. Defaults to now through 31 days out when neither end
* is named; the window may span at most 92 days and the server 400s past it.
*
* Rehearsals and unlisted events are absent — that filtering is in SQL, not
* in the answer, so there is nothing here to re-check.
*/
@GET("api/v1/public/events")
suspend fun getCalendar(
@Query("from") from: String? = null,
@Query("to") to: String? = null,
@Query("seriesId") seriesId: Long? = null,
): EventCalendarDto
/**
* One event.
*
* **[run] selects which occurrence the results table is about**, and is what
* an announcement's link carries: the page lives at the definition's slug, so
* a weekly event has one address that survives a retitle, while every
* `event.` trigger is about one occurrence. A run belonging to some other
* event is ignored rather than refused, so a stale link in a months-old mail
* still opens the page it was about.
*
* A draft, an archived definition and an unlisted one all answer 404,
* indistinguishable from a slug that never existed.
*/
@GET("api/v1/public/events/{slug}")
suspend fun getEvent(
@Path("slug") slug: String,
@Query("run") run: String? = null,
): PublicEventResponse
/**
* One arc. A series with no listed events answers 404 rather than an empty
* page — an arc is a label on its definitions, so a page for an empty one
* would publish that an operator has named something they have not announced.
*/
@GET("api/v1/public/events/series/{slug}")
suspend fun getSeries(@Path("slug") slug: String): EventSeriesResponse
/**
* The caller's own participation history. Self-scoped on the session's user
* id server-side; there is deliberately no id parameter here, because there
* is none on the route.
*
* [before] is a participation row id, not an offset — the list gains rows at
* the top as the reader attends things.
*/
@GET("api/v1/player/events/history")
suspend fun getHistory(
@Query("limit") limit: Int? = null,
@Query("before") before: Long? = null,
): EventHistoryDto
}

View File

@@ -5,6 +5,8 @@ package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.DeleteAccountRequest
import com.runicgateway.app.data.api.dto.DeleteAccountResultDto
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
@@ -27,6 +29,7 @@ import retrofit2.http.HTTP
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/**
* The role-agnostic self-service surface (PLAN.md §6.3, §6.4): account, credential
@@ -60,8 +63,10 @@ interface MeApi {
// DELETE with no body — a plain @DELETE would suffice, but @HTTP keeps the
// path template explicit alongside the provider argument.
// `id` (optional) removes that one identity rather than every one of the
// provider — a person may link several Steam accounts (STEAM_SIGNIN.md).
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit
suspend fun unlinkIdentity(@Path("provider") provider: String, @Query("id") id: Int? = null): Unit
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) — devices allowed to skip TOTP ──
@@ -86,4 +91,11 @@ interface MeApi {
@POST("api/v1/auth/me/account/recovery-codes/generate")
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
// ── Deleting the account (ACCOUNT_DELETION.md §4.1) ──────────────────────
// A DELETE with a body needs @HTTP. Raw [Response] so the caller can read the
// refusal's `code` off a 400 or 409, which a thrown HttpException would not carry.
@HTTP(method = "DELETE", path = "api/v1/auth/me/account", hasBody = true)
suspend fun deleteAccount(@Body body: DeleteAccountRequest): Response<DeleteAccountResultDto>
}

View File

@@ -3,8 +3,13 @@
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto
import com.runicgateway.app.data.api.dto.NotificationInboxDto
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
import com.runicgateway.app.data.api.dto.PushDeviceDto
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
import retrofit2.http.Body
@@ -13,10 +18,13 @@ import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
/**
* The opt-in push surface under `/auth/me` (PLAN.md §11, M7 Part 2): device
* (endpoint) registration and per-user stream subscriptions. Every call rides the
* The notification surface under `/auth/me` (PLAN.md §11): device (endpoint)
* registration, per-user stream subscriptions, the per-channel preferences that
* supersede them (ENGAGEMENT.md phase 3), and the in-app **inbox** — the first
* of these that carries content rather than a preference (phase 7/8). Every call rides the
* main client, so [com.runicgateway.app.core.net.AuthInterceptor] attaches the
* bearer and [com.runicgateway.app.core.net.TokenAuthenticator] refreshes on 401 —
* registration only ever succeeds while signed in.
@@ -40,4 +48,41 @@ interface NotificationsApi {
@PUT("api/v1/auth/me/notifications/subscriptions")
suspend fun putSubscriptions(@Body body: NotificationSubscriptionsDto): NotificationSubscriptionsDto
// ── Per-channel preferences (ENGAGEMENT.md phase 3) ────────────────────
//
// The superset of the two calls above: `notification_subscriptions` is now
// the push projection of this table and the server fans every write to
// either one into the other, so the two cannot disagree.
@GET("api/v1/auth/me/notifications/channels")
suspend fun channelPrefs(): NotificationChannelPrefsDto
/** SPARSE — send only the pairs that changed; everything unnamed is untouched. */
@PUT("api/v1/auth/me/notifications/channels")
suspend fun putChannelPrefs(
@Body body: NotificationChannelPrefsUpdateDto,
): NotificationChannelPrefsDto
// ── The inbox (ENGAGEMENT.md phase 7/8) ────────────────────────────────
//
// Keyset-paged on `before`, never an offset. There is no way to name another
// user on any of these: the caller is the only account they can read or write.
@GET("api/v1/auth/me/notifications")
suspend fun inbox(
@Query("limit") limit: Int? = null,
@Query("before") before: Long? = null,
@Query("unread") unread: Boolean? = null,
): NotificationInboxDto
@GET("api/v1/auth/me/notifications/unread-count")
suspend fun unreadCount(): NotificationUnreadDto
/** Idempotent; 404 both for a missing item and for another account's. */
@POST("api/v1/auth/me/notifications/{id}/read")
suspend fun markRead(@Path("id") id: Long): NotificationReadResultDto
@POST("api/v1/auth/me/notifications/read-all")
suspend fun markAllRead(): NotificationReadResultDto
}

View File

@@ -0,0 +1,104 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.RustBackpackDto
import com.runicgateway.app.data.api.dto.RustEconomicsDto
import com.runicgateway.app.data.api.dto.RustLinkListDto
import com.runicgateway.app.data.api.dto.RustLinkRequest
import com.runicgateway.app.data.api.dto.RustLinkResultDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
import com.runicgateway.app.data.api.dto.RustServerListDto
import com.runicgateway.app.data.api.dto.RustUnlinkResultDto
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/**
* A player's own Rust identity and what it earns them
* (`docs/modules/rust/PLAN.md` §19, §20, §22; M15), over the bearer-gated
* `/player/rust/…` surface.
*
* Its own interface beside [RustApi] rather than four more methods on it, for
* the same reason [PlayerShardApi] is separate from [PublicApi]: these need a
* session and those do not, and one interface holding both makes the tier a
* property of the method name instead of the type.
*
* **The paths are hardcoded, which is the contract and not a shortcut.**
* `MODULE_API.md` §2.9 forbids a client inferring a route from a capability, so
* the app cannot build `/<module id>/links` from what `GET /public/modules`
* reports. A capability answers *is the module there*; these four addresses are
* knowledge the app has because someone read the module's router.
*/
interface PlayerRustApi {
/** The Steam accounts the caller holds. Fleet-wide: a link is not per server. */
@GET("api/v1/player/rust/links")
suspend fun links(): RustLinkListDto
/**
* Redeem the code `/link` handed the player in game.
*
* **The refusals are not interchangeable and the screen must not flatten
* them.** A 400 is a code that is unknown or expired — go and get another;
* a 409 is a Steam account another website account holds — run `/unlink` in
* game; a 503 is a server that could not be reached, where the code is still
* good and the only right advice is to wait a minute. A player told to run
* `/link` again when the server their code came from was merely down will
* get another code from the same down server. The server sends a sentence
* for each; this leg renders it rather than writing one of its own.
*
* Rate-limited server-side (ten per quarter-hour per IP), so a 429 is an
* ordinary answer here rather than a bug.
*/
@POST("api/v1/player/rust/link")
suspend fun link(@Body body: RustLinkRequest): RustLinkResultDto
/**
* Release a link the caller holds.
*
* Scoped to the caller inside the server's statement, so a Steam id that is
* somebody else's answers the same 404 as one that is nobody's.
*/
@DELETE("api/v1/player/rust/links/{steamId}")
suspend fun unlink(@Path("steamId") steamId: String): RustUnlinkResultDto
/**
* What the site has given this player in game — ranks and direct grants,
* each already resolved to the servers its scope reaches.
*
* Read-only by construction: everything that authors one of these rows is an
* admin route.
*/
@GET("api/v1/player/rust/permissions")
suspend fun permissions(): RustPlayerPermissionsDto
/**
* The caller's coin balances on every server, per linked account
* (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2.2). Live where a server
* answers, the last balance it reported where it does not. A module older
* than protocol 14 answers 404, and the screen then shows no coins at all.
*/
@GET("api/v1/player/rust/economics")
suspend fun economics(): RustEconomicsDto
/**
* The servers this site follows, on the authenticated tier — the choices the
* backpack section offers. The same list as the public one.
*/
@GET("api/v1/player/rust/servers")
suspend fun servers(): RustServerListDto
/**
* One of the caller's own backpacks on one server
* (`docs/modules/rust/PLAN_REDESIGNS.md` §11.3.2), read live from the game.
* 409 is a server without Backpacks; a module older than protocol 14 answers
* 404, and the screen then says the site cannot show backpacks yet.
*/
@GET("api/v1/player/rust/backpacks")
suspend fun backpack(@Query("serverId") serverId: String, @Query("steamId") steamId: String): RustBackpackDto
}

View File

@@ -18,6 +18,7 @@ import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.api.dto.MarketMetaDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.api.dto.ModulesDto
import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
@@ -67,6 +68,18 @@ interface PublicApi {
@GET("api/v1/public/settings")
suspend fun getSettings(): SettingsDto
/**
* Which modules this backend is serving, and the capabilities each declares
* (§5, M13). Read together with the `version` block's own `capabilities` —
* core's list and a module's are separate lists on purpose.
*
* This is what lets the app tell a module that is **not installed** from a
* lookup that failed: `/public/shard/features` 404s in both cases, and only
* this call distinguishes them.
*/
@GET("api/v1/public/modules")
suspend fun getModules(): ModulesDto
// ── News & content ───────────────────────────────────────────────────
@GET("api/v1/public/posts/{category}")
suspend fun getPosts(@Path("category") category: String): List<PostDto>

View File

@@ -0,0 +1,126 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.RustEventListDto
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
import com.runicgateway.app.data.api.dto.RustMapDto
import com.runicgateway.app.data.api.dto.RustMapLiveDto
import com.runicgateway.app.data.api.dto.RustOnlineDto
import com.runicgateway.app.data.api.dto.RustServerListDto
import com.runicgateway.app.data.api.dto.RustServerResponse
import com.runicgateway.app.data.api.dto.RustWipeListDto
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/**
* `module-rust`'s public read path (PLAN.md §9 M14; `docs/modules/rust/PLAN.md`
* §17).
*
* **These paths are hardcoded, and that is the contract rather than a shortcut.**
* `MODULE_API.md` §2.9 forbids a client inferring a route from a capability, so
* the app cannot build `/<module id>/servers` from what `GET /public/modules`
* reports. A capability answers one question — *is the module there* — and these
* five addresses are knowledge the app has because someone read the module's
* router, exactly as the nine `/uo/` paths in [NavPaths] are.
*
* Its own interface, not a section of [PublicApi], for the reason [EventsApi] is
* its own: these exist only where the Rust module is installed, and a backend
* running a different game answers none of them.
*/
interface RustApi {
/**
* Every Rust server this site follows.
*
* Answers from the module's own tables and never from a live call to a game
* host, so it succeeds while every server in the fleet is off — a server
* nobody can reach comes back `online: false, stale: true` with everything it
* last said still attached. There is no failure case here for the game being
* down, only for the website being down.
*/
@GET("api/v1/public/rust/servers")
suspend fun getServers(): RustServerListDto
/**
* One server, or a **404**.
*
* The only route under `/servers/{id}` that can say a server is not there:
* the four below answer an empty list for an id nobody configured, because an
* unknown server genuinely has no events and nobody online. A server an
* operator **disabled** answers the same 404 — switching one off is not
* switching it into a refusal.
*/
@GET("api/v1/public/rust/servers/{id}")
suspend fun getServer(@Path("id") id: String): RustServerResponse
/**
* The feed, newest first.
*
* [kind] is comma-separated and [wipe] a wipe id; both are optional, and an
* **absent one must be absent rather than empty** — `?wipe=` asks for a wipe
* whose id is the empty string and answers nothing, with no error to notice.
* Retrofit drops a null `@Query` entirely, which is why these are nullable
* and never defaulted to `""`.
*
* The server serves a default-deny allowlist: moderation events, login
* attempts and anything carrying an IP address are stored and never returned
* here, whatever is asked for.
*/
@GET("api/v1/public/rust/servers/{id}/events")
suspend fun getEvents(
@Path("id") id: String,
@Query("kind") kind: String? = null,
@Query("wipe") wipe: String? = null,
@Query("limit") limit: Int? = null,
): RustEventListDto
/** Per wipe when [wipe] is given, all-time otherwise — the same rows summed. */
@GET("api/v1/public/rust/servers/{id}/leaderboard")
suspend fun getLeaderboard(
@Path("id") id: String,
@Query("wipe") wipe: String? = null,
@Query("sort") sort: String? = null,
@Query("limit") limit: Int? = null,
): RustLeaderboardDto
/** Every wipe this server has had, newest first. */
@GET("api/v1/public/rust/servers/{id}/wipes")
suspend fun getWipes(@Path("id") id: String): RustWipeListDto
/** The presence board, which an unreachable server does not clear. */
@GET("api/v1/public/rust/servers/{id}/online")
suspend fun getOnline(@Path("id") id: String): RustOnlineDto
/**
* The map: where its picture is, the frame to draw it in, and which layers
* this viewer gets (Rust phase 14). A module older than phase 14 answers 404.
*/
@GET("api/v1/public/rust/servers/{id}/map")
suspend fun getMap(@Path("id") id: String): RustMapDto
/**
* What moves, already cut down to this viewer on the server: a layer they
* may not see is absent. The module asks the game at most once per five
* seconds per server, however many viewers there are (D111).
*/
@GET("api/v1/public/rust/servers/{id}/map/live")
suspend fun getMapLive(@Path("id") id: String): RustMapLiveDto
// ── Staff's hide for everyone (USER_SAFETY.md §4.2, S10) ─────────────────
/**
* Hide one chat line for everybody but staff, who still get it with `hidden:
* true`. Admins and moderators; anybody else is a 403. [lineId] is the event
* row's id, and an id that is not a chat line is a 404.
*/
@POST("api/v1/admin/rust/chat/{id}/hide")
suspend fun hideChat(@Path("id") lineId: Long): Unit
/** Undo [hideChat] — the only place a hide for everyone is undone. */
@POST("api/v1/admin/rust/chat/{id}/unhide")
suspend fun unhideChat(@Path("id") lineId: Long): Unit
}

View File

@@ -0,0 +1,65 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.BlockListDto
import com.runicgateway.app.data.api.dto.BlockResultDto
import com.runicgateway.app.data.api.dto.HiddenListDto
import com.runicgateway.app.data.api.dto.ReportRequest
import com.runicgateway.app.data.api.dto.ReportResultDto
import com.runicgateway.app.data.api.dto.SafetyOkDto
import com.runicgateway.app.data.api.dto.SafetyTargetRequest
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Report, block and hide (`docs/website/USER_SAFETY.md` §4.1, MODULE_API 1.21.0).
*
* **Core's routes, not a module's**: the Reports queue, a member's blocks and their
* *Hide for me* list are the site's, and hold for the forum and every game module
* alike. A block made here hides the same author on the website, and one made there
* hides them here — they are saved on the account (S2).
*
* All of them need a signed-in account (S6), and all of them are role-agnostic: a
* staff member reports and hides like anybody else. They ride the main client, so
* the bearer is attached and a 401 is refreshed.
*/
interface SafetyApi {
/**
* File a report. A report changes nothing about the item; it opens a row in the
* staff Reports queue. An item this account could not have seen is a 404, the
* same as one that does not exist; an open report of its own on the same item is
* a 409.
*/
@POST("api/v1/player/reports")
suspend fun report(@Body body: ReportRequest): ReportResultDto
@GET("api/v1/auth/me/blocks")
suspend fun blocks(): BlockListDto
/**
* Block the author of an item. `403` is S8's refusal (the author is staff), `409`
* an existing block, `404` an item this account cannot see.
*/
@POST("api/v1/auth/me/blocks")
suspend fun block(@Body body: SafetyTargetRequest): BlockResultDto
@DELETE("api/v1/auth/me/blocks/{id}")
suspend fun unblock(@Path("id") id: Long): SafetyOkDto
@GET("api/v1/auth/me/hidden")
suspend fun hidden(): HiddenListDto
/** *Hide for me*: this one item, for this account, on every device. */
@POST("api/v1/auth/me/hidden")
suspend fun hide(@Body body: SafetyTargetRequest): SafetyOkDto
/** Account → Blocked's *Show them again*: every item this account hid. */
@DELETE("api/v1/auth/me/hidden")
suspend fun clearHidden(): SafetyOkDto
}

View File

@@ -116,6 +116,22 @@ data class RevokedFlagDto(val revoked: Boolean = false)
@Serializable
data class RevokedCountDto(val revoked: Int = 0)
/**
* `DELETE /auth/me/account` body (ACCOUNT_DELETION.md §4.1). The password is always
* sent (AD7); a two-factor [code] or a [recoveryCode] only when two-factor is on.
*/
@Serializable
data class DeleteAccountRequest(
val username: String,
val password: String,
val code: String? = null,
val recoveryCode: String? = null,
)
/** The `{ deleted: true }` a successful deletion answers. */
@Serializable
data class DeleteAccountResultDto(val deleted: Boolean = false)
/** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */
@Serializable
data class RecoveryStatusDto(val remaining: Int = 0)
@@ -128,10 +144,18 @@ data class RecoveryGenerateRequest(val currentPassword: String? = null)
@Serializable
data class RecoveryCodesDto(val recoveryCodes: List<String> = emptyList())
/** A linked external identity (`GET /auth/me/account/identities`). */
/**
* A linked external identity (`GET /auth/me/account/identities`). [id] unlinks this
* one identity when a provider has several (a person may link more than one Steam
* account); [subject] is the person's own external id — for Steam the SteamID64 —
* and [display_name] the Steam persona name (null for other providers).
*/
@Serializable
data class LinkedIdentityDto(
val id: Int? = null,
val provider: String = "",
val subject: String? = null,
val email: String? = null,
val display_name: String? = null,
val linked_at: String? = null,
)

View File

@@ -0,0 +1,235 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.Serializable
/**
* Wire shapes for the public event surface (`docs/website/EVENTS.md` §I, events
* Phase 14a; the app's half is M13). Field names match
* `server/src/model/events/eventPublic.model.js` exactly.
*
* **That model is a PROJECTION, and these DTOs must not out-grow it.** Nothing on
* the server side is spread into a public entry — a field reaches one because a
* line put it there — so three things are absent from every shape below and each
* absence is a decision core made: the **spec** (phases, steps, actions and their
* params are the operator's plan for changing a live world; a visitor gets the
* phase LABEL while a run is live and nothing else), **health, cleanup, claims
* and errors** (facts about the deployment's plumbing, not about the event), and
* **`member_key`** (module-opaque, so core cannot say what publishing one would
* disclose). Adding a field here that the server does not send would decode to a
* default and render as a fact.
*
* Every DTO ignores unknown keys (NetworkModule's lenient Json), so an additive
* backend field is safe.
*/
/**
* One calendar entry. [kind] is `run` or `projected` and the two are drawn
* differently on purpose.
*
* A **run** is a materialised occurrence: a row exists, it can be cancelled, and
* what it says is committed to. A **projected** entry is arithmetic past the
* materialisation horizon — a forecast with nothing behind it — so the screen
* labels it rather than drawing it as a booking. [adjusted] and [shiftMinutes]
* only ever arrive on a projection, and say a DST shift moved it.
*
* [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never
* the reader's. See [com.runicgateway.app.ui.events.eventTime].
*
* [runId] is on a run and never on a projection, which has nothing committed to
* it. Core added it for Rust D125, so a map marker naming a run can find its
* event; a core older than that omits it, and the marker stays unlinked.
*/
@Serializable
data class EventCalendarEntryDto(
val kind: String = "run",
val runId: Long? = null,
val title: String = "",
val slug: String = "",
val seriesName: String? = null,
val seriesSlug: String? = null,
val scheduledFor: String = "",
val timezone: String? = null,
val status: String = "scheduled",
val live: Boolean = false,
val adjusted: Boolean = false,
val shiftMinutes: Int = 0,
) {
/** True for a forecast the server has committed nothing to. */
val isProjected: Boolean get() = kind == "projected"
}
/** `GET /public/events` — the calendar for a window, ascending by instant. */
@Serializable
data class EventCalendarDto(
val entries: List<EventCalendarEntryDto> = emptyList(),
/** True when the server capped the answer; the screen says so rather than lying by omission. */
val truncated: Boolean = false,
)
/**
* One occurrence on an event's page.
*
* [phase] is the label of the phase a live run is in, resolved from the version
* that run PINNED — so an edit since does not relabel a run in flight. It is null
* on anything that is not live, which is why the screen only ever shows it there.
*/
@Serializable
data class EventOccurrenceDto(
val runId: Long = 0,
val scheduledFor: String = "",
val timezone: String? = null,
val startedAt: String? = null,
val endedAt: String? = null,
val status: String = "scheduled",
val live: Boolean = false,
val scope: String? = null,
val phase: String? = null,
val resultsPublishedAt: String? = null,
/**
* On the live occurrence only: how its steps stand, in the words their modules
* chose (MODULE_API 1.12.0; runicnpc D304). Core interprets none of it, and nor
* does the app: each line is a label with a count or a percent.
*/
val progress: List<EventProgressDto> = emptyList(),
)
/**
* One progress line: a [label] with [left] of [of] ("Bandits: 3 of 8 left"), or a
* [percent] ("The Juggernaut: 62%"). Never both.
*/
@Serializable
data class EventProgressDto(
val label: String = "",
val left: Int? = null,
val of: Int? = null,
val percent: Int? = null,
)
/**
* One row of a published results table.
*
* [name] is whatever the module put in its participation `meta`, and there is
* genuinely nothing else to render when it is absent: core has no name for a
* character and the member key is not published, so the screen says "Unnamed"
* rather than inventing one.
*
* **[score] is fractional, and it has to be.** `event_run_participants.score` is
* `DECIMAL(18,4)`, and a module scoring by distance, time or a weighted tally
* writes a fraction — the live walk found `318.5` in the first row it read.
* Declaring it `Long` does not merely round: kotlinx REFUSES the body, the whole
* response fails to decode, and the screen reports a server error for a `200`.
* See [com.runicgateway.app.ui.events.scoreText] for how it is rendered.
*/
@Serializable
data class EventParticipantDto(
val name: String? = null,
val score: Double = 0.0,
val rank: Int? = null,
)
/** The results table for ONE occurrence, present only once it has been published. */
@Serializable
data class EventResultsDto(
val runId: Long = 0,
val scheduledFor: String = "",
val publishedAt: String? = null,
val participants: List<EventParticipantDto> = emptyList(),
)
/** The arc an event belongs to, as its own page names it. */
@Serializable
data class EventSeriesRefDto(
val name: String = "",
val slug: String = "",
)
/** `GET /public/events/:slug` — the event. */
@Serializable
data class PublicEventDto(
val title: String = "",
val slug: String = "",
val summary: String? = null,
/** Sanitized HTML, written the way a wiki page and a forum post are. */
val body: String? = null,
val imageUrl: String? = null,
val timezone: String? = null,
val series: EventSeriesRefDto? = null,
val live: Boolean = false,
val current: EventOccurrenceDto? = null,
/**
* The next occurrence — **narrower than the first of [upcoming]**, and the
* server decides which. A cancelled occurrence still appears under what is
* coming, because "next Friday is off" is what somebody checking a calendar
* came to find out; it is not what "next" means.
*/
val next: EventOccurrenceDto? = null,
val upcoming: List<EventOccurrenceDto> = emptyList(),
val past: List<EventOccurrenceDto> = emptyList(),
val results: EventResultsDto? = null,
)
/** The envelope `GET /public/events/:slug` answers with. */
@Serializable
data class PublicEventResponse(val event: PublicEventDto = PublicEventDto())
/** One event as an arc lists it — the editor's order, so no dates. */
@Serializable
data class EventSeriesEntryDto(
val title: String = "",
val slug: String = "",
val summary: String? = null,
val imageUrl: String? = null,
)
/** `GET /public/events/series/:slug` — one arc and the listed events in it. */
@Serializable
data class EventSeriesDto(
val name: String = "",
val slug: String = "",
val description: String? = null,
val events: List<EventSeriesEntryDto> = emptyList(),
)
/** The envelope `GET /public/events/series/:slug` answers with. */
@Serializable
data class EventSeriesResponse(val series: EventSeriesDto = EventSeriesDto())
/**
* One row of the caller's own participation history.
*
* [rank] is null until `core.results.publish` ran for that occurrence, and that
* is a real state rather than an error — the screen says "not published" rather
* than rendering a dash that reads as a bug.
*
* [id] is the participation row's own id and is what the keyset page walks back
* on: the list gains a row every time the reader attends something, so an offset
* would skip and repeat.
*/
@Serializable
data class EventHistoryEntryDto(
val id: Long = 0,
val runId: Long = 0,
val title: String = "",
val slug: String = "",
val seriesName: String? = null,
val seriesSlug: String? = null,
val scheduledFor: String = "",
val startedAt: String? = null,
val endedAt: String? = null,
val timezone: String? = null,
val status: String = "scheduled",
val joinedAt: String? = null,
// Fractional, for the reason [EventParticipantDto.score] gives.
val score: Double = 0.0,
val rank: Int? = null,
val resultsPublishedAt: String? = null,
)
/** `GET /player/events/history` — self-scoped, one page. */
@Serializable
data class EventHistoryDto(
val entries: List<EventHistoryEntryDto> = emptyList(),
)

View File

@@ -72,3 +72,139 @@ data class NotificationStreamsDto(
data class NotificationSubscriptionsDto(
val streams: List<String>,
)
// ── The in-app channel (ENGAGEMENT.md phase 7/8) ───────────────────────────
//
// The inbox is the first notification surface that carries CONTENT. Everything
// above is a preference or a content-free tickle; these four shapes are the
// items themselves, pulled over the authenticated API after a tickle wakes the
// app. The wire names come from `userNotifications.db.js`'s `toItem`.
/**
* One inbox item. [read] is the flag and [readAt] the stamp, sent side by side so
* a client renders one without parsing the other.
*
* [url] is where the item points on the site (rendered from the template's
* `email.button` block) and is **null on most items** — an inbox row is complete
* on its own. [triggerId] is the event that produced it, in §7.2's ONE namespace,
* so it is the same vocabulary a push tickle's `stream` speaks.
*/
@Serializable
data class NotificationItemDto(
val id: Long = 0,
val triggerId: String = "",
val title: String = "",
val body: String? = null,
val url: String? = null,
val read: Boolean = false,
val readAt: String? = null,
val createdAt: String? = null,
)
/**
* `GET /auth/me/notifications` — one page, newest first.
*
* Keyset-paged: the next page is `?before=<the last item's id>`, not an offset,
* because the list gains rows at the top while it is being read. [hasMore] comes
* from the server's take+1, so "is there another page" costs no second query.
* [unread] counts the WHOLE inbox, not the page — it rides along so a screen
* rendering both a badge and a list from one response cannot show the two
* disagreeing.
*/
@Serializable
data class NotificationInboxDto(
val items: List<NotificationItemDto> = emptyList(),
val hasMore: Boolean = false,
val unread: Int = 0,
)
/** `GET /auth/me/notifications/unread-count` — the badge, on its own. */
@Serializable
data class NotificationUnreadDto(
val unread: Int = 0,
)
/**
* What both mark-read routes answer with. [unread] is the count AFTER the write,
* so the badge follows from the response rather than from a second call.
*/
@Serializable
data class NotificationReadResultDto(
val ok: Boolean = false,
val changed: Int = 0,
val unread: Int = 0,
)
// ── Per-channel preferences (ENGAGEMENT.md phase 3) ────────────────────────
/**
* One delivery channel from the registry. [modes] is what this channel accepts —
* `["off","instant"]` for push and in-app, `["off","instant","digest"]` for email
* — and the UI renders its control from THIS, never from a hardcoded set, so a
* channel added server-side arrives without an app release.
*
* [carriesContent] is the tickle invariant stated on the wire: push is `false`,
* which is why a push item's title never leaves the server.
*/
@Serializable
data class NotificationChannelDto(
val id: String = "",
val label: String = "",
val carriesContent: Boolean = false,
val defaultMode: String = "off",
val supportsDigest: Boolean = false,
val modes: List<String> = emptyList(),
)
/**
* One subscribable id, from `GET /auth/me/notifications/channels`. The list is the
* UNION of push streams and event triggers in one namespace (§7.2), so an id may
* be a stream, a trigger, or both.
*
* [channels] is which channels apply to THIS id — a trigger-only id carries no
* `push` because nothing is registered to push it — and [modes] is the EFFECTIVE
* mode per channel: where the user has expressed nothing the server has already
* substituted that channel's default, and the client must not re-implement the
* defaulting.
*/
@Serializable
data class NotificationChannelItemDto(
val id: String = "",
val label: String = "",
val description: String = "",
val personal: Boolean = false,
val requiresLinkedAccount: Boolean = false,
val ceiling: String? = null,
val channels: List<String> = emptyList(),
val modes: Map<String, String> = emptyMap(),
)
/** `GET · PUT /auth/me/notifications/channels` — the whole stored truth. */
@Serializable
data class NotificationChannelPrefsDto(
val channels: List<NotificationChannelDto> = emptyList(),
val items: List<NotificationChannelItemDto> = emptyList(),
)
/** One (id, channel) → mode pair of a sparse update. */
@Serializable
data class NotificationChannelPrefDto(
val id: String,
val channel: String,
val mode: String,
)
/**
* `PUT /auth/me/notifications/channels` body — a SPARSE update: only the pairs
* named are written and every other pair is left alone, so one toggle saves
* without the screen holding the whole table.
*
* [prefs] has no default for the same reason [NotificationSubscriptionsDto.streams]
* has none — kotlinx omits a property equal to its default, and the validator
* requires the field. Unlike that DTO there is no empty-set case to get wrong
* here: `off` is a mode, never an omission.
*/
@Serializable
data class NotificationChannelPrefsUpdateDto(
val prefs: List<NotificationChannelPrefDto>,
)

View File

@@ -0,0 +1,205 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.Serializable
/**
* DTOs for a player's own half of `module-rust` (`docs/modules/rust/PLAN.md`
* §19, §20, §22; M15).
*
* Two surfaces, and they are deliberately separate reads rather than one:
*
* * **the links** — which Steam accounts this website account holds. A link is
* fleet-wide, because a Steam account is one person on every server an
* operator runs, while stats are per server and per wipe.
* * **what the site has given them in game** — groups and direct grants, each
* already resolved to the servers its scope reaches. It is its own read
* because an entitlement is authored against the *website* account, so it
* exists before a Steam id does; the person who has just been given something
* and has not linked yet is exactly the one who needs to see both halves at
* once.
*
* Nothing here is a write except the code redemption. A grant a player could
* change would not be a grant.
*/
/** `GET /player/rust/links` — the Steam accounts the caller holds. */
@Serializable
data class RustLinkListDto(
val links: List<RustLinkDto> = emptyList(),
)
/**
* One linked Steam account.
*
* [name] is what the player was called in game when they linked — a display
* name only, and a Rust name changes on a whim. [serverId] is where the code was
* minted, which is not part of the identity but is where a support conversation
* starts.
*/
@Serializable
data class RustLinkDto(
val steamId: String = "",
val name: String? = null,
val serverId: String? = null,
val linkedAt: String? = null,
)
/** `POST /player/rust/link` body — the six-character code `/link` gives in game. */
@Serializable
data class RustLinkRequest(val code: String)
/**
* `POST /player/rust/link` result.
*
* [already] is a second press of the button rather than an error: the code was
* good and that Steam id was already this caller's.
*/
@Serializable
data class RustLinkResultDto(
val linked: Boolean = false,
val link: RustLinkDto? = null,
val already: Boolean = false,
)
/** `DELETE /player/rust/links/{steamId}` result. */
@Serializable
data class RustUnlinkResultDto(
val unlinked: Boolean = false,
)
/**
* `GET /player/rust/permissions` — what the site has given this player in game.
*
* [accounts] is how many Steam accounts they have linked, and it is on the
* envelope for one reason: zero is why an entitlement can be authored and reach
* nobody, and the screen has to be able to say that without inferring it.
*/
@Serializable
data class RustPlayerPermissionsDto(
val accounts: Int = 0,
val groups: List<RustPlayerGroupDto> = emptyList(),
val grants: List<RustPlayerGrantDto> = emptyList(),
)
/** A rank the site holds for this player, and what it carries. */
@Serializable
data class RustPlayerGroupDto(
val name: String = "",
val title: String = "",
val scope: String = "*",
val since: String? = null,
val permissions: List<String> = emptyList(),
val reach: List<RustReachDto> = emptyList(),
)
/** One permission held directly, without a rank. */
@Serializable
data class RustPlayerGrantDto(
val permission: String = "",
val scope: String = "*",
val source: String = "admin",
val note: String? = null,
val since: String? = null,
val reach: List<RustReachDto> = emptyList(),
)
/**
* One server an entitlement's scope reaches, and whether it is there yet.
*
* **The scope arithmetic is the server's.** A client handed `scope: "*"` would
* have to know what the fleet is to say anything useful, and then the rule
* exists in two places; the website resolves it and marks each server instead.
*
* [live] is the pushed ledger rather than the authored row: a grant is not a
* privilege in a game until a sync confirmed it. `false` covers every way it has
* not arrived — the server is offline, no loaded plugin registered the name, the
* store has never seen the account — and telling those apart is an operator's
* diagnosis, not a player's.
*/
@Serializable
data class RustReachDto(
val id: String = "",
val name: String = "",
val live: Boolean = false,
)
/**
* `GET /player/rust/economics` — the caller's coin balances
* (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2.2): every server, each linked
* Steam account. Economics is optional on a server, so one without it is listed
* with [RustEconomicsServerDto.economics] false rather than left out.
*/
@Serializable
data class RustEconomicsDto(
val servers: List<RustEconomicsServerDto> = emptyList(),
)
/**
* One server's balances. [startingKnown] false means the server's Economics
* config could not be read, and [startingBalance] is Economics' default.
*/
@Serializable
data class RustEconomicsServerDto(
val serverId: String = "",
val server: String = "",
val economics: Boolean = false,
val reason: String? = null,
val startingBalance: Double? = null,
val startingKnown: Boolean = true,
val live: Boolean = false,
val accounts: List<RustBalanceDto> = emptyList(),
)
/**
* One linked account's balance on one server. [source] is `live` (the server
* answered just now), `last-known` (it did not, and this is the last balance it
* reported), or null with [balance] null when neither knows.
*/
@Serializable
data class RustBalanceDto(
val steamId: String = "",
val name: String? = null,
val balance: Double? = null,
val source: String? = null,
)
/**
* `GET /player/rust/backpacks?serverId=&steamId=` — one of the caller's own
* Backpacks backpacks on one server (`docs/modules/rust/PLAN_REDESIGNS.md`
* §11.3.2), read live and never stored. [exists] false is an account that has
* never had one, and then [capacity], [pageSize] and [items] are absent.
*/
@Serializable
data class RustBackpackDto(
val serverId: String = "",
val server: String = "",
val steamId: String = "",
val exists: Boolean = false,
val capacity: Int = 0,
val pageSize: Int = 48,
val items: List<RustBackpackStackDto> = emptyList(),
)
/**
* One stack. [slot] runs across pages; [display] is the game's English name
* (D340), and for a blueprint the name of what it makes, with [blueprint] that
* item's shortname (D341). [overflow] is a stack past the capacity, which moves
* to the owner's inventory the next time they open the backpack (D338).
* [contents] is one level deep: a weapon's attachments, a container's items.
*/
@Serializable
data class RustBackpackStackDto(
val slot: Int = 0,
val shortname: String? = null,
val display: String? = null,
val amount: Int = 0,
val itemId: Int? = null,
val blueprint: String? = null,
val skin: String? = null,
val name: String? = null,
val overflow: Boolean = false,
val contents: List<RustBackpackStackDto> = emptyList(),
)

View File

@@ -20,6 +20,47 @@ data class VersionDto(
val service: String = "",
val api: String = "",
val server: String = "",
/**
* What CORE serves beyond the baseline every backend has (events Phase 14a;
* `MODULE_API.md` §2.9). Opaque strings, the same word a module uses on
* `GET /public/modules` so a client feature-detects one way, and a **separate
* list** because core is not a module.
*
* **The value is in what is absent**, which is why the default is empty
* rather than something meaningful: a backend released before a capability
* existed omits the key entirely, and that is how the app tells an older site
* from one that simply has nothing to show. An unknown string is absent, and
* no route may be inferred from one.
*/
val capabilities: List<String> = emptyList(),
)
/**
* One installed, **started** module on `GET /public/modules`.
*
* A module that is disabled or failed to load is absent rather than listed with a
* state — its routes and its nav are absent too, so a client renders a site
* without that capability rather than one advertising a capability that 503s.
*/
@Serializable
data class InstalledModuleDto(
val id: String = "",
val name: String = "",
val version: String = "",
val capabilities: List<String> = emptyList(),
)
/**
* `GET /public/modules` — what this backend is serving beyond core.
*
* Database-free and never gated by site mode, so the app can feature-detect
* during maintenance. A **500** is the one answer that is not an answer: core
* refuses to return `[]` for a list read before its loader ran, because a caller
* cannot tell an empty list from a mis-ordered boot.
*/
@Serializable
data class ModulesDto(
val modules: List<InstalledModuleDto> = emptyList(),
)
/** `GET /public/status` — site mode + version for the first-run probe (§3). */
@@ -105,4 +146,14 @@ data class SettingsDto(
* nav.
*/
@SerialName("nav_public") val navPublic: String? = null,
/**
* The public demo's banner data, `{ resetsAt, logins: [{ username, role, … }] }`
* (`docs/website/DEMO_MODE.md` §6). Sent only while the server runs with
* `DEMO_MODE` on, so its absence is how a real shard draws no banner.
*
* Raw for the same reason as [theme]: the banner is a nicety, and a value of an
* unexpected shape must not fail the decode of the whole payload. Read by
* `DemoInfo.from`.
*/
val demo: JsonElement? = null,
)

View File

@@ -0,0 +1,255 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* DTOs for `module-rust`'s public read path (`docs/modules/rust/PLAN.md` §17,
* §18; M14).
*
* **Every one of these renders while the game is off**, which is the module's own
* promise and therefore this leg's: the website never calls a game server from a
* page, it answers from its own tables, and a server nobody can reach answers
* `online: false` with everything it last said still attached. Nothing here has
* an "unavailable" shape, because there is no such answer on this wire.
*/
/** `GET /public/rust/servers` — every server this site follows. */
@Serializable
data class RustServerListDto(
val servers: List<RustServerDto> = emptyList(),
)
/** `GET /public/rust/servers/{id}` — one of them, or a 404. */
@Serializable
data class RustServerResponse(
val server: RustServerDto = RustServerDto(),
)
/**
* One Rust server and what it last reported.
*
* **[online] and [stale] are not the same fact and the screen needs both.**
* `online` is what the last frame said; `stale` is whether anything has arrived
* recently enough to believe it. The server computes `online` as *"the row says
* up AND the row is fresh"*, so a stale row can never claim a server is up — but
* `stale` still has to come through, because a fresh row saying "down" and a row
* nobody has written in an hour are different things to say to a reader.
*
* **[lastSeenAt] is what a page means by "last reported", and [updatedAt] is
* not.** The module shipped a defect on exactly this in phase 3 and fixed it in
* phase 4: `updatedAt` moves on every poll including a FAILED one, so reading it
* as "last reported" made an offline server claim it had just checked in, every
* thirty seconds, for as long as it stayed down. Only a frame moves
* `lastSeenAt`. The app must not repeat the mistake one tier along.
*/
@Serializable
data class RustServerDto(
val id: String = "",
val name: String = "",
val online: Boolean = false,
val players: Int = 0,
val maxPlayers: Int = 0,
val hostname: String? = null,
val level: String? = null,
val worldSize: Int? = null,
val seed: Long? = null,
/** The CURRENT wipe, from the state row rather than the newest ingested wipe. */
val wipeId: String? = null,
val wipedAt: String? = null,
/** When a frame last arrived. What "last reported" means. */
val lastSeenAt: String? = null,
/** When this module last wrote the row — a failed poll moves it too. */
val updatedAt: String? = null,
val stale: Boolean = false,
/**
* When the server wipes next (module-rust phase 16, D130), or null. Null
* both when the operator set no schedule and against a module older than
* phase 16, which does not send the field — either way nothing is drawn.
*/
val nextWipe: RustNextWipeDto? = null,
)
/**
* The next wipe, as the module computed it from the operator's schedule on this
* read. [source] is `forced` (Facepunch's monthly forced wipe), `rule` (the
* server's own weekly or biweekly day) or `once` (a one-off date the operator
* set — a delayed or an extra wipe). An unknown word is shown like `rule`.
*/
@Serializable
data class RustNextWipeDto(
val at: String? = null,
val source: String? = null,
)
/**
* `GET /public/rust/servers/{id}/events` — the killfeed and everything else public.
*
* **Nothing names who is online by default** (org lead, 2026-09-22). Below the
* operator's presence audience — staff unless widened — the server withholds
* every item that says a named player was on (joins, deaths, chat, tallies) and
* says so with [presenceHidden]; [presenceAudience] is who CAN see them. The
* screen says it, so a thin feed reads as withheld rather than as a quiet server.
*/
@Serializable
data class RustEventListDto(
val events: List<RustEventDto> = emptyList(),
val presenceHidden: Boolean = false,
val presenceAudience: String? = null,
)
/**
* One stored frame.
*
* **[frame] is deliberately untyped.** The module stores the whole frame the
* bridge plugin emitted and indexes only the columns it serves, so the fields
* differ per [kind] and a later protocol adds more. A sealed hierarchy here would
* have to be extended in this repo before a server running a newer plugin could
* say anything new, and the module's own rule is the opposite: an unknown kind
* renders as itself rather than being dropped. [RustFeed] is the one place that
* knows the field names.
*
* [t] is epoch milliseconds — the stamp the plugin put on the frame, not a
* database column, so it is a number here and an ISO string everywhere else on
* this wire.
*
* [canBlock] and [hidden] are about the row rather than in the frame, and only chat
* lines carry them (`USER_SAFETY.md` §4.2). [canBlock] is sent to a signed-in reader
* only (S13): null means "not said", and the menu offers no Block on it. [hidden]
* reaches staff only — everybody else is never sent a line staff hid (S10).
*/
@Serializable
data class RustEventDto(
val id: Long = 0,
val kind: String = "",
val t: Long = 0,
val wipeId: String? = null,
val steamId: String? = null,
val frame: JsonObject = JsonObject(emptyMap()),
val canBlock: Boolean? = null,
val hidden: Boolean = false,
) {
/**
* One frame field as text, or null.
*
* **A JSON `null` answers null, not the four letters.** The plugin writes
* explicit nulls — `reason` on a clean disconnect, `weapon` on a fall — and a
* primitive's `content` is the string `"null"` for every one of them, which
* would put the word into a killfeed line. An empty string answers null too:
* the callers here all mean "is there something to show".
*/
fun str(key: String): String? = primitive(key)?.content?.takeIf { it.isNotEmpty() }
/** One frame field as a number, or null when it is absent, null or not one. */
fun num(key: String): Double? = primitive(key)?.content?.toDoubleOrNull()
/** One frame field as a flag. Absent, null and anything non-boolean are all false. */
fun flag(key: String): Boolean = primitive(key)?.content == "true"
/** The raw element, for a caller that wants to decide for itself. */
fun raw(key: String): JsonElement? = frame[key]
private fun primitive(key: String): JsonPrimitive? =
(frame[key] as? JsonPrimitive)?.takeIf { it !is JsonNull }
}
/** `GET /public/rust/servers/{id}/leaderboard` — per wipe, or all-time. */
@Serializable
data class RustLeaderboardDto(
val leaderboard: List<RustLeaderboardRowDto> = emptyList(),
)
/**
* One player's standing.
*
* All-time is these same per-wipe rows summed rather than a second set of
* counters, so the two can never disagree — which is why a player who appears
* only in an older wipe **drops out** of the current one rather than reading
* zero. The screen must not fill that gap in with zeroes.
*/
@Serializable
data class RustLeaderboardRowDto(
val steamId: String = "",
val name: String? = null,
val kills: Int = 0,
val deaths: Int = 0,
val npcKills: Int = 0,
val structures: Int = 0,
val playtimeSec: Long = 0,
val lastSeen: String? = null,
/**
* The chat titles this player holds now (module-rust phase 17, M19) — the
* ones the game shows in chat, after the server's mode. They rank the
* CURRENT wipe whichever wipe the board is showing. Absent from an older
* module, which is why it defaults to empty: nothing is drawn.
*/
val titles: List<RustTitleDto> = emptyList(),
)
/** One chat title: its text, and the operator's `#rrggbb` colour for it. */
@Serializable
data class RustTitleDto(
val text: String = "",
val color: String = "",
)
/** `GET /public/rust/servers/{id}/wipes` — every wipe this server has had, newest first. */
@Serializable
data class RustWipeListDto(
val wipes: List<RustWipeDto> = emptyList(),
)
/**
* One wipe.
*
* [wipeId] is derived by the bridge plugin from the save's creation time and
* stamped on every frame, so it is the same id the feed and the leaderboard are
* filtered by — which is what makes the per-wipe view navigable at all.
*/
@Serializable
data class RustWipeDto(
val wipeId: String = "",
val saveCreatedAt: String? = null,
val firstSeen: String? = null,
val lastSeen: String? = null,
)
/**
* `GET /public/rust/servers/{id}/online` — who is on right now.
*
* Below the operator's presence audience the names are withheld: [hidden] is
* true, [players] is empty and [count] is still the real number — a count names
* nobody, and it is already on the server line. An empty list with [hidden] set
* must never render as "nobody is on".
*/
@Serializable
data class RustOnlineDto(
val players: List<RustPresenceDto> = emptyList(),
val hidden: Boolean = false,
val count: Int = 0,
val audience: String? = null,
)
/**
* One row of the presence board.
*
* Read from the board the bridge re-sends on every connect and every minute,
* rather than counted from connect and disconnect events — so it is right even
* after the website has missed one. **An unreachable server does not clear it**,
* deliberately: these rows are still the best answer anybody has. Presented bare
* they read as *who is on right now*, which is the one thing an offline server
* cannot be saying, so the screen has to say which it is.
*/
@Serializable
data class RustPresenceDto(
val steamId: String = "",
val name: String? = null,
val sleeping: Boolean = false,
val connectedAt: String? = null,
)

View File

@@ -0,0 +1,183 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.Serializable
/**
* DTOs for `module-rust`'s map (`docs/modules/rust/PLAN.md` §30, §31; M17).
*
* **Who may see what is decided on the server, and these shapes only carry the
* answer.** Each of the four layers has its own audience, and the module removes
* a layer the viewer may not see before it answers: the layer is *absent*, not
* empty and not flagged. That is why every layer on [RustMapLiveDto] is nullable.
* A null list is "not yours"; an empty one is "yours, and nothing is there".
* The app has no gate of its own, so it has none to get wrong.
*/
/** `GET /public/rust/servers/{id}/map`: the picture, the frame, and which layers this viewer gets. */
@Serializable
data class RustMapDto(
val serverId: String = "",
/** Changes on a wipe or a new seed. A live answer naming another key means a new map. */
val mapKey: String? = null,
/** Null when the game has no picture: the layers are drawn on [RustMapGeometryDto.background]. */
val picture: RustMapPictureDto? = null,
/** Null when the server has never described its map. Nothing can be placed without it. */
val geometry: RustMapGeometryDto? = null,
/** Present only when the viewer may see the world layer. */
val monuments: List<RustMonumentDto>? = null,
val layers: RustMapLayersDto = RustMapLayersDto(),
val mates: RustMapMatesDto = RustMapMatesDto(),
val pollMs: Long? = null,
)
/**
* Where the picture is.
*
* [path] is relative to `/api/v1` and carries the picture's hash, so it is
* immutable: a hash that is no longer current is a 404, never the new bytes.
*/
@Serializable
data class RustMapPictureDto(
val path: String = "",
val source: String? = null,
val fetchedAt: String? = null,
)
/**
* How a world position reaches a pixel (§30.3). [oceanMargin] is in pixels and is
* not scaled; [gridCells] and [gridCellSize] are the game's own grid (D119).
*/
@Serializable
data class RustMapGeometryDto(
val worldSize: Double = 0.0,
val oceanMargin: Double = 0.0,
val width: Double = 0.0,
val height: Double = 0.0,
val gridCells: Int = 0,
val gridCellSize: Double = 0.0,
val background: String? = null,
)
@Serializable
data class RustMonumentDto(
val value: String = "",
val kind: String = "",
val label: String = "",
val grid: String? = null,
val x: Double = 0.0,
val z: Double = 0.0,
)
@Serializable
data class RustMapLayersDto(
val world: RustMapLayerDto = RustMapLayerDto(),
val events: RustMapLayerDto = RustMapLayerDto(),
val players: RustMapLayerDto = RustMapLayerDto(),
val bases: RustMapLayerDto = RustMapLayerDto(),
)
/**
* Whether this viewer gets one layer, and who does. A hidden layer never says
* what it holds. [cappedByPresence] is the players layer's alone: it is narrower
* than its own switch because who may see who is online is narrower (D113).
*/
@Serializable
data class RustMapLayerDto(
val visible: Boolean = false,
val audience: String? = null,
val cappedByPresence: Boolean = false,
)
/** Whether this viewer gets their own position and their online clan mates' (D115). */
@Serializable
data class RustMapMatesDto(
val visible: Boolean = false,
/** The server's switch. */
val on: Boolean = false,
val linked: Boolean = false,
val signedIn: Boolean = false,
)
/**
* `GET /public/rust/servers/{id}/map/live`: what moves, cut down to this viewer.
*
* [live] false means the game did not answer and [reason] says why; the picture
* stays up. Positions are never stored by the site, so there is no last-known
* answer to fall back on here the way the presence board has one.
*/
@Serializable
data class RustMapLiveDto(
val live: Boolean = false,
val reason: String? = null,
val mapKey: String? = null,
val world: List<RustMapWorldDto>? = null,
val events: List<RustMapEventDto>? = null,
val players: List<RustMapPlayerDto>? = null,
val playersTruncated: Boolean = false,
val bases: List<RustMapBaseDto>? = null,
val basesTruncated: Boolean = false,
val mates: List<RustMapPlayerDto>? = null,
)
/**
* A world event: `cargo`, `heli`, `chinook`, `bradley`, `supply`, `crate`, or a RunicNPC
* `boss` placed outside any event (runicnpc D302), with its [name].
*/
@Serializable
data class RustMapWorldDto(
val kind: String = "",
val x: Double = 0.0,
val z: Double = 0.0,
/** A locked crate being hacked: seconds left. */
val hackLeftSec: Int? = null,
val hacked: Boolean = false,
val name: String? = null,
)
/**
* What one of this site's events placed: a `zone`, `crate` or `npc` (phase 13a).
*
* [runId] is core's run id **as a string**, because the plugin holds it as one.
* Core's own shapes carry it as a number, so the two are matched as text.
*/
@Serializable
data class RustMapEventDto(
val kind: String = "",
val runId: String? = null,
val x: Double = 0.0,
val z: Double = 0.0,
val radius: Double? = null,
/** A zone's name, or a RunicNPC NPC's (runicnpc stage 8). */
val name: String? = null,
val prefab: String? = null,
/** A RunicNPC boss: drawn bigger (D303). Its health is not sent. */
val boss: Boolean = false,
/** One of a boss's adds, placed by the boss rather than by a step. */
val add: Boolean = false,
)
/**
* A player on the players layer, or a mate. [self] is only ever set on a mate:
* one of the viewer's own accounts.
*/
@Serializable
data class RustMapPlayerDto(
val steamId: String = "",
val name: String? = null,
val x: Double = 0.0,
val z: Double = 0.0,
val sleeping: Boolean = false,
val online: Boolean = false,
val self: Boolean = false,
)
/** A base: `tc` or `vending`. Positions only: no owner, no authorised list, no shop name. */
@Serializable
data class RustMapBaseDto(
val kind: String = "",
val x: Double = 0.0,
val z: Double = 0.0,
)

View File

@@ -0,0 +1,103 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.Serializable
/**
* Report, block and hide (`docs/website/USER_SAFETY.md` §4.1): core's routes, which
* hold for every module and the forum alike.
*
* **Every request names an item, never a person.** The server reads who wrote it
* through the same resolver a report uses, so the app can only block someone whose
* words it was shown. A Rust chat line is `{ targetType: "rust.chat", targetId:
* <the event row's id> }`.
*
* No field on a request has a default: kotlinx leaves out a property equal to its
* default, and a request missing `targetId` is a 400 nobody would see coming.
*/
@Serializable
data class SafetyTargetRequest(
val targetType: String,
val targetId: Long,
)
/**
* `POST /player/reports`. [detail] is optional on the wire, and a null one is left
* out (the app's `explicitNulls = false`) — the server's validator accepts an absent
* detail and refuses a null one.
*/
@Serializable
data class ReportRequest(
val targetType: String,
val targetId: Long,
val reason: String,
val detail: String?,
)
@Serializable
data class ReportResultDto(
val ok: Boolean = false,
val reportId: Long? = null,
)
/** `GET /auth/me/blocks`. */
@Serializable
data class BlockListDto(
val blocks: List<UserBlockDto> = emptyList(),
)
/** `POST /auth/me/blocks` answers the block it made. */
@Serializable
data class BlockResultDto(
val block: UserBlockDto? = null,
)
/**
* One block.
*
* [kind] is `account` (a member, or a game player whose identity is linked to one —
* S12) or `game` (an identity nobody has linked yet, which is when [identity] is
* set). [applies] is false when the target is now staff, who cannot be blocked (S8),
* and [reason] then says `staff`: the row stays so the member can see why it stopped.
*/
@Serializable
data class UserBlockDto(
val id: Long = 0,
val kind: String = "account",
val name: String? = null,
val identity: UserBlockIdentityDto? = null,
val applies: Boolean = true,
val reason: String? = null,
val createdAt: String? = null,
)
/** The game identity a `game` block names: `Steam` and a 17-digit id, for Rust. */
@Serializable
data class UserBlockIdentityDto(
val module: String? = null,
val kind: String? = null,
val label: String? = null,
val id: String? = null,
)
/** `GET /auth/me/hidden` — the items this member hid for themself. */
@Serializable
data class HiddenListDto(
val hidden: List<HiddenItemDto> = emptyList(),
)
@Serializable
data class HiddenItemDto(
val targetType: String = "",
val targetId: Long = 0,
val createdAt: String? = null,
)
/** The `{ ok }` most of these routes answer, and `{ ok, cleared }` for clear-all. */
@Serializable
data class SafetyOkDto(
val ok: Boolean = false,
val cleared: Int? = null,
)

View File

@@ -16,7 +16,7 @@ import kotlinx.serialization.Serializable
/**
* One entry of `GET /auth/providers` — public discovery, never secrets. [icon] is
* the provider kind (`google` | `discord` | `oidc` | `oauth2`); the app renders a
* the provider kind (`google` | `discord` | `steam` | `oidc` | `oauth2`); the app renders a
* button per provider from this list rather than hardcoding a set. [loginUrl] is
* the *website* start path (unused by the app, which builds its own
* `/auth/mobile/sso/start` URL); kept so the shape matches the backend exactly.

View File

@@ -4,10 +4,13 @@
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.DEMO_LOCKED_CODE
import com.runicgateway.app.core.result.errorCodeOf
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.DeleteAccountRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
@@ -56,8 +59,8 @@ class AccountRepository @Inject constructor(
suspend fun identities(): ApiResult<List<LinkedIdentityDto>> = safeApiCall { api.identities() }
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
safeApiCall { api.unlinkIdentity(provider) }
suspend fun unlinkIdentity(provider: String, id: Int? = null): ApiResult<Unit> =
safeApiCall { api.unlinkIdentity(provider, id) }
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) ───────────────────────────
@@ -117,4 +120,70 @@ class AccountRepository @Inject constructor(
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
// ── Deleting the account (ACCOUNT_DELETION.md §4.1, §4.3) ──────────────
/** Why the server refused a deletion, one per refusal `code` it sends. */
enum class DeleteRefusal {
USERNAME_MISMATCH,
WRONG_PASSWORD,
CODE_REQUIRED,
WRONG_CODE,
/** The account has no password yet; it sets one on Account first (AD7). */
PASSWORD_REQUIRED,
LAST_ADMIN,
/** The public demo's refusal; the shell's snackbar has already said why. */
DEMO_LOCKED,
RATE_LIMITED,
/** Any other answer — a 5xx, or a refusal this app does not know. */
OTHER,
}
sealed interface DeleteOutcome {
/** Gone on the server, with every session; the caller forgets it locally. */
data object Deleted : DeleteOutcome
data class Refused(val reason: DeleteRefusal) : DeleteOutcome
data object NetworkError : DeleteOutcome
}
/**
* Delete the signed-in account. A second factor is a [code] or a [recoveryCode],
* sent only when two-factor is on. Reads the raw response so a refusal's `code`
* survives; a wrong password is a 400, never a 401 (AD12), so the authenticator
* never replays this call.
*/
suspend fun deleteAccount(
username: String,
password: String,
code: String? = null,
recoveryCode: String? = null,
): DeleteOutcome {
val response = try {
api.deleteAccount(DeleteAccountRequest(username, password, code, recoveryCode))
} catch (e: CancellationException) {
throw e
} catch (_: IOException) {
return DeleteOutcome.NetworkError
} catch (_: Exception) {
return DeleteOutcome.Refused(DeleteRefusal.OTHER)
}
if (response.isSuccessful) return DeleteOutcome.Deleted
if (response.code() == 429) return DeleteOutcome.Refused(DeleteRefusal.RATE_LIMITED)
val refusal = runCatching { errorCodeOf(response.errorBody()?.string()) }.getOrNull()
return DeleteOutcome.Refused(deleteRefusalOf(refusal))
}
private fun deleteRefusalOf(code: String?): DeleteRefusal = when (code) {
"username_mismatch" -> DeleteRefusal.USERNAME_MISMATCH
"wrong_password" -> DeleteRefusal.WRONG_PASSWORD
"code_required" -> DeleteRefusal.CODE_REQUIRED
"wrong_code" -> DeleteRefusal.WRONG_CODE
"password_required" -> DeleteRefusal.PASSWORD_REQUIRED
"last_admin" -> DeleteRefusal.LAST_ADMIN
DEMO_LOCKED_CODE -> DeleteRefusal.DEMO_LOCKED
else -> DeleteRefusal.OTHER
}
}

View File

@@ -6,6 +6,7 @@ package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.inbox.InboxCache
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.SsoApi
@@ -16,7 +17,9 @@ import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.api.dto.TotpRequiredError
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import retrofit2.Response
import java.io.IOException
@@ -35,6 +38,7 @@ class AuthRepository @Inject constructor(
private val ssoApi: SsoApi,
private val sessionManager: SessionManager,
private val pushManager: PushManager,
private val inboxCache: InboxCache,
private val trustTokenStore: TrustTokenStore,
private val deviceNameProvider: DeviceNameProvider,
private val json: Json,
@@ -183,6 +187,18 @@ class AuthRepository @Inject constructor(
} catch (_: Exception) {
// Ignore — local session teardown proceeds regardless.
}
// Drop the cached inbox with it: those are one person's notifications, and
// they have finished with this device. This is the tidy-up, not the
// safeguard — InboxCache scopes every snapshot to (base URL, user id), so
// the paths that never reach here (a dead refresh, a server switch) cannot
// surface one account's items under another's session either.
try {
inboxCache.clear()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Ignore — same reason.
}
val refreshToken = sessionManager.currentRefreshToken()
try {
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
@@ -194,6 +210,28 @@ class AuthRepository @Inject constructor(
sessionManager.onSignedOut()
}
/**
* Forget an account the server has just deleted (ACCOUNT_DELETION.md §4.3): what
* [logout] does, without its two calls, because the server dropped the sessions,
* trusted devices and push registrations with the row and would answer 401.
*
* The trust token goes too, unlike a sign-out: it belongs to an account that no
* longer exists. The push registration goes with the session, through
* [PushManager]'s sign-out observer, as on a dead refresh.
*
* Not cancellable: the account is already gone, so a caller leaving the screen
* must not leave its session behind on the phone.
*/
suspend fun forgetDeletedAccount() = withContext(NonCancellable) {
try {
inboxCache.clear()
} catch (_: Exception) {
// Ignore — the snapshot is scoped to (base URL, user id) and is never shown again.
}
trustTokenStore.clear()
sessionManager.onSignedOut()
}
/**
* Re-validate the session against `GET /auth/me` on app resume (§4.3). A
* success refreshes the cached role (roles change server-side); a `401` that

View File

@@ -29,6 +29,7 @@ class ConnectionRepository @Inject constructor(
private val sessionManager: SessionManager,
private val trustTokenStore: TrustTokenStore,
private val shardFeaturesRepository: ShardFeaturesRepository,
private val siteCapabilitiesRepository: SiteCapabilitiesRepository,
private val pushManager: com.runicgateway.app.core.push.PushManager,
private val config: com.runicgateway.app.core.AppConfig,
) {
@@ -116,6 +117,10 @@ class ConnectionRepository @Inject constructor(
// a switch between two signed-out hosts changes no session, so nothing else
// invalidates the cache and the new shard would inherit the old one's menu.
shardFeaturesRepository.invalidate()
// Same argument, one layer up: what the OLD host served says nothing about
// the new one, and a stale "this backend has no game module" would hide the
// new host's shard rows until its first successful read.
siteCapabilitiesRepository.invalidate()
prefs.clear()
baseUrlHolder.set(null)
}

View File

@@ -0,0 +1,61 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.map
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.EventsApi
import com.runicgateway.app.data.api.dto.EventCalendarDto
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
import com.runicgateway.app.data.api.dto.EventSeriesDto
import com.runicgateway.app.data.api.dto.PublicEventDto
import javax.inject.Inject
import javax.inject.Singleton
/**
* The event calendar, event pages, arcs and the caller's own participation
* history (PLAN.md §6.1, §9 M13).
*
* The two single-object reads unwrap their envelope here rather than in a view
* model, so a screen never holds a `…Response` whose only job was to carry one
* field. The calendar and the history keep theirs: `truncated` is a fact about
* the answer that the screen renders, and the history's page is a list the pager
* appends to.
*/
@Singleton
class EventsRepository @Inject constructor(
private val api: EventsApi,
) {
/** The public calendar. Both ends optional; the server's default window is 31 days. */
suspend fun calendar(
from: String? = null,
to: String? = null,
seriesId: Long? = null,
): ApiResult<EventCalendarDto> = safeApiCall { api.getCalendar(from, to, seriesId) }
/**
* One event, optionally about one occurrence.
*
* [run] is passed through untouched — including a run that belongs to some
* other event, which the server ignores rather than refusing. Filtering it
* here would turn a stale link into a dead end instead of a page about the
* thing the link was about.
*/
suspend fun event(slug: String, run: String? = null): ApiResult<PublicEventDto> =
safeApiCall { api.getEvent(slug, run?.takeIf { it.isNotBlank() }) }.map { it.event }
/** One arc. A series with nothing listed in it answers 404, not an empty page. */
suspend fun series(slug: String): ApiResult<EventSeriesDto> =
safeApiCall { api.getSeries(slug) }.map { it.series }
/**
* One page of the caller's own participation history, newest first.
*
* [before] is the id of the last row already shown — a keyset page, not an
* offset, because the list gains rows at the top as the reader attends things.
*/
suspend fun history(limit: Int, before: Long? = null): ApiResult<List<EventHistoryEntryDto>> =
safeApiCall { api.getHistory(limit, before) }.map { it.entries }
}

View File

@@ -0,0 +1,56 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import javax.inject.Inject
import javax.inject.Singleton
/** A game module whose player surface can say whether this user has linked an account. */
enum class LinkSource { RUST, SHARD }
/**
* Which module's link read answers for this site (`docs/modules/rust/PLAN.md` D69).
*
* **Read off the capabilities, never assumed.** Before phase 11 the answer was
* always `module-uo`'s `/player/shard/accounts`, which does not exist on a Rust
* site. That call failed, the failure read as "not linked", and the raid alert
* was locked in every channel.
*
* A host that has **never** answered asks both, and a failed read is simply no
* link. A site runs one module (§24.5), so at most one of the two answers. That
* is the same fail-open direction [canUse] takes. A host that answered and named
* neither module has no link to ask about.
*/
fun linkSourcesFor(capabilities: SiteCapabilities?): List<LinkSource> {
if (capabilities == null) return LinkSource.entries
return buildList {
if (Capability.RUST in capabilities) add(LinkSource.RUST)
if (Capability.SHARD in capabilities) add(LinkSource.SHARD)
}
}
/**
* Whether the signed-in user holds at least one linked game account on this site.
*
* It exists for the settings screen's one courtesy gate: a `requiresLinkedAccount`
* stream cannot have push switched **on** without a link. It is a courtesy and not
* a boundary. The server enforces no such flag, and who is actually alerted is the
* module's recipient computation (Rust D59), so this never needs to be more than
* a best guess.
*/
@Singleton
class LinkedAccountRepository @Inject constructor(
private val capabilities: SiteCapabilitiesRepository,
private val rust: PlayerRustRepository,
private val shard: PlayerShardRepository,
) {
suspend fun hasLinkedAccount(): Boolean =
linkSourcesFor(capabilities.capabilities.value).any { source ->
when (source) {
LinkSource.RUST -> (rust.links() as? ApiResult.Ok)?.data?.isNotEmpty() == true
LinkSource.SHARD -> (shard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
}
}
}

View File

@@ -6,16 +6,23 @@ package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.NotificationsApi
import com.runicgateway.app.data.api.dto.NotificationChannelPrefDto
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto
import com.runicgateway.app.data.api.dto.NotificationInboxDto
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
import com.runicgateway.app.data.api.dto.PushDeviceDto
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
import javax.inject.Inject
import javax.inject.Singleton
/**
* Device registration + per-user stream subscriptions over the opt-in push surface
* (PLAN.md §11, M7 Part 2). Every call returns a typed [ApiResult] so the screen
* Device registration, stream subscriptions, per-channel preferences and the
* in-app inbox — the whole `/auth/me` notification surface (PLAN.md §11,
* ENGAGEMENT.md phases 3 and 7/8). Every call returns a typed [ApiResult] so the screen
* and the [com.runicgateway.app.core.push.PushManager] degrade gracefully — a `400`
* (endpoint off the shard's allow-set) or a down backend never throws (§7).
*/
@@ -37,4 +44,33 @@ class NotificationsRepository @Inject constructor(
suspend fun setSubscriptions(streams: List<String>): ApiResult<NotificationSubscriptionsDto> =
safeApiCall { api.putSubscriptions(NotificationSubscriptionsDto(streams)) }
// ── Per-channel preferences (phase 3) ──────────────────────────────────
suspend fun channelPrefs(): ApiResult<NotificationChannelPrefsDto> =
safeApiCall { api.channelPrefs() }
/**
* Write ONE (id, channel) → mode pair. The endpoint is sparse, so a screen
* saving a single toggle sends a single row and cannot disturb the others —
* including the ones it does not render.
*/
suspend fun setChannelMode(id: String, channel: String, mode: String): ApiResult<NotificationChannelPrefsDto> =
safeApiCall {
api.putChannelPrefs(
NotificationChannelPrefsUpdateDto(listOf(NotificationChannelPrefDto(id, channel, mode))),
)
}
// ── The inbox (phase 7/8) ──────────────────────────────────────────────
/** One page, newest first. [before] is the previous page's last id, never an offset. */
suspend fun inbox(before: Long? = null, unreadOnly: Boolean = false): ApiResult<NotificationInboxDto> =
safeApiCall { api.inbox(before = before, unread = if (unreadOnly) true else null) }
suspend fun unreadCount(): ApiResult<NotificationUnreadDto> = safeApiCall { api.unreadCount() }
suspend fun markRead(id: Long): ApiResult<NotificationReadResultDto> = safeApiCall { api.markRead(id) }
suspend fun markAllRead(): ApiResult<NotificationReadResultDto> = safeApiCall { api.markAllRead() }
}

View File

@@ -0,0 +1,66 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.map
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PlayerRustApi
import com.runicgateway.app.data.api.dto.RustBackpackDto
import com.runicgateway.app.data.api.dto.RustEconomicsServerDto
import com.runicgateway.app.data.api.dto.RustLinkDto
import com.runicgateway.app.data.api.dto.RustLinkRequest
import com.runicgateway.app.data.api.dto.RustLinkResultDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
import com.runicgateway.app.data.api.dto.RustServerDto
import javax.inject.Inject
import javax.inject.Singleton
/**
* A player's own Rust identity and what it earns them (PLAN.md §9 M15), over the
* bearer-gated `/player/rust/…` surface.
*
* Every call returns a typed [ApiResult] rather than throwing, like every other
* repository here — and on this surface the **status is the message**: the
* website answers a refused code `400`, a Steam account somebody else holds
* `409`, an unreachable server `503` and a capped attempt `429`, precisely so a
* client can tell a player what to do next without reading prose. The view model
* is where that mapping lives.
*
* Nothing is cached. The entitlement read in particular is a picture of what the
* site has confirmed into a game, and a stale copy of that would be the one kind
* of wrong answer this whole surface exists to avoid.
*/
@Singleton
class PlayerRustRepository @Inject constructor(
private val api: PlayerRustApi,
) {
/** The Steam accounts the caller holds, newest first. */
suspend fun links(): ApiResult<List<RustLinkDto>> =
safeApiCall { api.links() }.map { it.links }
/** Redeem a code from `/link` in game. */
suspend fun link(code: String): ApiResult<RustLinkResultDto> =
safeApiCall { api.link(RustLinkRequest(code)) }
/** Release one of the caller's own links. */
suspend fun unlink(steamId: String): ApiResult<Boolean> =
safeApiCall { api.unlink(steamId) }.map { it.unlinked }
/** Ranks and grants the site holds for the caller, resolved per server. */
suspend fun permissions(): ApiResult<RustPlayerPermissionsDto> =
safeApiCall { api.permissions() }
/** The caller's coin balances per server and linked account (§11.2.2). */
suspend fun economics(): ApiResult<List<RustEconomicsServerDto>> =
safeApiCall { api.economics() }.map { it.servers }
/** The servers the backpack section offers (§11.3.2). */
suspend fun servers(): ApiResult<List<RustServerDto>> =
safeApiCall { api.servers() }.map { it.servers }
/** One of the caller's own backpacks, read live (§11.3.2). Never cached: it is the game's state now. */
suspend fun backpack(serverId: String, steamId: String): ApiResult<RustBackpackDto> =
safeApiCall { api.backpack(serverId, steamId) }
}

View File

@@ -0,0 +1,107 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.map
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.RustApi
import com.runicgateway.app.data.api.dto.RustEventListDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustMapDto
import com.runicgateway.app.data.api.dto.RustMapLiveDto
import com.runicgateway.app.data.api.dto.RustOnlineDto
import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.api.dto.RustWipeDto
import javax.inject.Inject
import javax.inject.Singleton
/**
* The Rust module's public read path (PLAN.md §9 M14).
*
* Every read unwraps its envelope here rather than in a view model, so no screen
* holds a `…Dto` whose only job was to carry one list. Nothing is cached and
* nothing is merged: the module's tables are already the cache — the site's whole
* premise is that it answers from what a server last said rather than from the
* server — so a second copy in the app would only add a way for the two to
* disagree.
*/
@Singleton
class RustRepository @Inject constructor(
private val api: RustApi,
) {
/** Every server this site follows, with what each last reported. */
suspend fun servers(): ApiResult<List<RustServerDto>> =
safeApiCall { api.getServers() }.map { it.servers }
/** One server. A 404 here means no such server, or one an operator disabled. */
suspend fun server(id: String): ApiResult<RustServerDto> =
safeApiCall { api.getServer(id) }.map { it.server }
/**
* The feed.
*
* [kinds] is joined here rather than by a caller, so the query string this
* app sends exists in one place — and an **empty** list is sent as no `kind`
* parameter at all, which asks for the whole allowlist. Sending `kind=` would
* ask for a kind named the empty string.
*/
suspend fun events(
id: String,
kinds: List<String> = emptyList(),
wipe: String? = null,
limit: Int? = null,
): ApiResult<RustEventListDto> = safeApiCall {
api.getEvents(
id = id,
kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","),
wipe = wipe?.takeIf { it.isNotBlank() },
limit = limit,
)
}
/** The leaderboard: per wipe when [wipe] is given, all-time otherwise. */
suspend fun leaderboard(
id: String,
wipe: String? = null,
sort: String? = null,
limit: Int? = null,
): ApiResult<List<RustLeaderboardRowDto>> = safeApiCall {
api.getLeaderboard(
id = id,
wipe = wipe?.takeIf { it.isNotBlank() },
sort = sort?.takeIf { it.isNotBlank() },
limit = limit,
)
}.map { it.leaderboard }
/** Every wipe this server has had, newest first. */
suspend fun wipes(id: String): ApiResult<List<RustWipeDto>> =
safeApiCall { api.getWipes(id) }.map { it.wipes }
/**
* The presence board. Rows survive an unreachable server, by design.
*
* Answered whole rather than as its rows: `hidden` and `count` are what let
* the screen tell "withheld from you" from "nobody is on".
*/
suspend fun online(id: String): ApiResult<RustOnlineDto> =
safeApiCall { api.getOnline(id) }
/** The map's picture, frame and layer gates for this viewer. */
suspend fun map(id: String): ApiResult<RustMapDto> =
safeApiCall { api.getMap(id) }
/** What moves on the map, as this viewer may see it. */
suspend fun mapLive(id: String): ApiResult<RustMapLiveDto> =
safeApiCall { api.getMapLive(id) }
/**
* Staff's hide for everyone on one chat line, or its undo (`USER_SAFETY.md`
* §4.2, S10). The module's own route, not core's: the line and its flag live here.
*/
suspend fun setChatHidden(lineId: Long, hidden: Boolean): ApiResult<Unit> = safeApiCall {
if (hidden) api.hideChat(lineId) else api.unhideChat(lineId)
}
}

View File

@@ -0,0 +1,110 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.core.result.map
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.SafetyApi
import com.runicgateway.app.data.api.dto.UserBlockDto
import com.runicgateway.app.data.api.dto.HiddenItemDto
import com.runicgateway.app.data.api.dto.ReportRequest
import com.runicgateway.app.data.api.dto.SafetyTargetRequest
import javax.inject.Inject
import javax.inject.Singleton
/** Why the server refused a report, a block or a hide — one per answer a screen words differently. */
enum class SafetyRefusal {
/** `409`: an open report of this account's own on the item, or an existing block. */
ALREADY_DONE,
/** `404`: gone, or never something this account could see. */
GONE,
/** `403` on a block: the author is staff, who cannot be blocked (S8). */
STAFF,
/** `403` anywhere else: this account may not (a demoted moderator hiding for everyone). */
FORBIDDEN,
/** `400` on a block: their own line, or an author whose account is gone. */
NOT_BLOCKABLE,
RATE_LIMITED,
/** `503`: the game module that owns the item did not answer in time. */
UNAVAILABLE,
/** The public demo's refusal; the shell's snackbar has already said why. */
DEMO_LOCKED,
/** Anything else — a 5xx, or an answer this app does not know. */
OTHER,
}
sealed interface SafetyOutcome {
data object Done : SafetyOutcome
data class Refused(val reason: SafetyRefusal) : SafetyOutcome
data object NetworkError : SafetyOutcome
}
/**
* Report, block and hide (`docs/website/USER_SAFETY.md` §4.1, §4.3) over core's
* routes.
*
* **Nothing here keeps a list.** The server filters every feed for its reader (S11),
* so a block shows itself by the feed being read again — the app never drops a line
* on its own, and never holds a block list that could disagree with the website's.
*/
@Singleton
class SafetyRepository @Inject constructor(
private val api: SafetyApi,
) {
suspend fun report(targetType: String, targetId: Long, reason: String, detail: String?): SafetyOutcome =
outcome(blocking = false) {
api.report(ReportRequest(targetType, targetId, reason, detail?.trim()?.takeIf { it.isNotEmpty() }))
}
/** Block whoever wrote this item. The server decides who that is (S12). */
suspend fun blockAuthorOf(targetType: String, targetId: Long): SafetyOutcome =
outcome(blocking = true) { api.block(SafetyTargetRequest(targetType, targetId)) }
/** *Hide for me*. */
suspend fun hide(targetType: String, targetId: Long): SafetyOutcome =
outcome(blocking = false) { api.hide(SafetyTargetRequest(targetType, targetId)) }
suspend fun blocks(): ApiResult<List<UserBlockDto>> = safeApiCall { api.blocks() }.map { it.blocks }
suspend fun unblock(id: Long): SafetyOutcome = outcome(blocking = false) { api.unblock(id) }
suspend fun hidden(): ApiResult<List<HiddenItemDto>> = safeApiCall { api.hidden() }.map { it.hidden }
suspend fun clearHidden(): SafetyOutcome = outcome(blocking = false) { api.clearHidden() }
private suspend fun outcome(blocking: Boolean, call: suspend () -> Any): SafetyOutcome =
safetyOutcomeOf(safeApiCall { call() }, blocking)
}
/**
* One answer as a [SafetyOutcome]. [blocking] is whether the call was a block, the
* one route whose `403` means "staff" and whose `400` means "not someone you can
* block". Shared with the staff hide, which is the module's route but answers in the
* same terms.
*/
fun safetyOutcomeOf(result: ApiResult<*>, blocking: Boolean = false): SafetyOutcome = when (result) {
is ApiResult.Ok -> SafetyOutcome.Done
is ApiResult.NetworkError -> SafetyOutcome.NetworkError
is ApiResult.HttpError -> SafetyOutcome.Refused(
when {
result.isDemoLocked() -> SafetyRefusal.DEMO_LOCKED
result.status == 409 -> SafetyRefusal.ALREADY_DONE
result.status == 404 -> SafetyRefusal.GONE
result.status == 403 -> if (blocking) SafetyRefusal.STAFF else SafetyRefusal.FORBIDDEN
result.status == 400 && blocking -> SafetyRefusal.NOT_BLOCKABLE
result.status == 429 -> SafetyRefusal.RATE_LIMITED
result.status == 503 -> SafetyRefusal.UNAVAILABLE
else -> SafetyRefusal.OTHER
},
)
}

View File

@@ -0,0 +1,194 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PublicApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton
/**
* What this BACKEND serves — core's own capabilities and every started module's
* (PLAN.md §5, §9 M13; `docs/website/MODULE_API.md` §2.9).
*
* ## Why this exists at all, and why it is not [ShardFeaturesRepository]
*
* The two answer different questions and neither can answer the other's:
*
* - **Capability — is this module installed at all?** Per HOST. It changes when
* an operator installs or removes a module, so it is resolved beside the
* appearance and invalidated on a server switch.
* - **Feature — does this shard publish this surface to this viewer?** Per
* VIEWER. It changes on sign-in, which is why it is resolved on every session
* change.
*
* Without the first, the app cannot tell a module that is **not installed** from
* a lookup that failed: `GET /public/shard/features` 404s in both cases, and
* [ShardFeaturesRepository] maps every failure to "unknown", which [canSee]
* treats as visible. On a site running a different game that renders every shard
* row in the drawer and every one of them 404s when tapped.
*
* ## Absence of an answer is not an answer of absence
*
* The distinction this class exists to make, and the reason [SiteCapabilities]
* carries no "unknown" member of its own — the *absence of the whole value* is
* the unknown state:
*
* - a **successful** read that does not name a capability is an answer, and
* [canUse] hides what needs it;
* - a **failed** read keeps the last answer this host gave, because a moment
* with no connectivity is not an uninstall;
* - a host that has **never** answered leaves the value null, and [canUse]
* passes — the drawer renders as it did before this existed rather than
* flickering its rows in on every cold start.
*
* The last one is deliberately the same fail-open direction [canSee] takes, for
* the same reason: the server gates every call regardless, so the cost of
* guessing wrong is a link that briefly 404s.
*/
@Singleton
class SiteCapabilitiesRepository @Inject constructor(
private val api: PublicApi,
) {
private val _capabilities = MutableStateFlow<SiteCapabilities?>(null)
/** The current answer, or `null` while this host has never given one. */
val capabilities: StateFlow<SiteCapabilities?> = _capabilities.asStateFlow()
// Serializes concurrent refreshes: the shell refreshes on resume and the
// connect flow refreshes on first load, and two overlapping reads would race
// to publish.
private val mutex = Mutex()
/**
* Re-resolve what this backend serves.
*
* **Two calls, and one failing is not the same as both failing.** Core's list
* and a module's are separate lists (§2.9), so they are merged from separate
* reads and each is kept only if it answered. A backend released before
* events omits `capabilities` from its `version` block entirely, which is an
* answer — the empty list — and not a failure.
*/
suspend fun refresh() = mutex.withLock {
val status = safeApiCall { api.getStatus() }
val modules = safeApiCall { api.getModules() }
// Neither call answered: keep whatever this host said last, which for a
// host that has never answered is still null.
if (status !is ApiResult.Ok && modules !is ApiResult.Ok) return@withLock
val previous = _capabilities.value
val core = (status as? ApiResult.Ok)?.data?.version?.capabilities?.toSet()
?: previous?.core
?: emptySet()
val installed = (modules as? ApiResult.Ok)?.data?.modules
?.flatMap { it.capabilities }
?.toSet()
?: previous?.modules
?: emptySet()
_capabilities.value = SiteCapabilities(core = core, modules = installed)
}
/**
* Drop the answer. Called on a Settings → Server switch: capabilities belong
* to the host that reported them, and the new host must not inherit them —
* a switch between two signed-out hosts changes no session, so nothing else
* would invalidate this.
*/
fun invalidate() {
_capabilities.value = null
}
}
/**
* What one backend serves, as two lists rather than one.
*
* They are kept apart because core is not a module: merging them would leave the
* app unable to tell *"this backend has events"* from *"a module called core
* happens to be installed"*, which is exactly the distinction
* `GET /public/modules` exists to make. [canUse] looks in both, because a menu
* entry does not care which half serves it — but the halves stay separable, so a
* future caller that does care still can.
*/
data class SiteCapabilities(
/** Core's own, from the `version` block. Empty on a backend that predates them. */
val core: Set<String>,
/** Every started module's, flattened. Two modules may declare the same string. */
val modules: Set<String>,
) {
/** True when either half names [capability]. */
operator fun contains(capability: String): Boolean =
capability in core || capability in modules
}
/**
* True when [capability] may be relied on — **or when this host has not answered
* yet**.
*
* The null case is the fail-open one and it is not the same as the empty one: a
* [SiteCapabilities] that names nothing is a backend that told us it serves
* nothing extra, and that hides. See the class doc above.
*
* `null` [capability] means the caller declared none, which always passes.
*/
fun canUse(capabilities: SiteCapabilities?, capability: String?): Boolean =
capability == null || capabilities == null || capability in capabilities
/**
* The capability strings the app gates on.
*
* **Deliberately few.** `module-uo` declares eight, and gating each shard row on
* its own would be a second, worse copy of what the per-viewer feature flags
* already decide — and one that drifts, because a capability is opaque to core
* and nothing checks the two agree. One string answers the only question a
* capability can: is the module there.
*/
object Capability {
/**
* A game module serving a live shard. Declared by `module-uo`; a different
* game's module that serves the same surfaces would declare it too, which is
* the point of an opaque string.
*/
const val SHARD = "shard"
/**
* The Rust module (`docs/modules/rust/PLAN.md` D16, phase 5).
*
* A second game module, and therefore a second string rather than a second
* meaning for [SHARD]: a Rust site is a **fleet of servers** with a list
* above them, where a shard is one place — the surfaces are not the same
* shape and a client cannot render one as the other.
*
* `module-rust` also declares `servers`, `killfeed`, `leaderboard`,
* `presence` and `wipes`, and this gates on none of them. Every one of those
* names a SURFACE, and core flattens all modules' capabilities into one list
* — so `servers` is a word another module could declare tomorrow, which would
* silently reveal these rows on a site that does not run Rust. `rust` is the
* string only that module can mean, which is the same job [SHARD] does for
* `module-uo`.
*/
const val RUST = "rust"
/** Core's event system (events Phase 14a). Never a module's. */
const val EVENTS = "events"
/**
* The Rust module's live map (`docs/modules/rust/PLAN.md` phase 14, D122).
*
* **A surface name, which [RUST]'s note warns against for a menu row** — and
* that warning is about a row reachable on any site. This gates one tab on
* the Rust server screen, which is reachable only where [RUST] already
* answered, so another module declaring `map` cannot reveal it anywhere the
* Rust module is absent. What it does answer is the one question that
* matters here: a module older than phase 14 does not declare it.
*/
const val MAP = "map"
}

View File

@@ -8,6 +8,7 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact
import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.net.AuthInterceptor
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.DemoLockInterceptor
import com.runicgateway.app.core.net.HostSelectionInterceptor
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamClient
@@ -15,11 +16,15 @@ import com.runicgateway.app.core.net.TokenAuthenticator
import com.runicgateway.app.core.net.UserAgentInterceptor
import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.AuthRefreshApi
import com.runicgateway.app.data.api.EventsApi
import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.AdminApi
import com.runicgateway.app.data.api.NotificationsApi
import com.runicgateway.app.data.api.PlayerRustApi
import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.RustApi
import com.runicgateway.app.data.api.SafetyApi
import com.runicgateway.app.data.api.SsoApi
import dagger.Module
import dagger.Provides
@@ -55,7 +60,8 @@ object NetworkModule {
/**
* The main client every API and the SSE stream ride on. Order: identify (UA),
* retarget onto the configured shard host, then attach the bearer; the
* retarget onto the configured shard host, attach the bearer, then spot the
* public demo's refusal on the way back (DEMO_MODE.md §3.1); the
* [TokenAuthenticator] handles silent refresh-on-401 (§4.1). Logging sits last
* so it observes the final, authed request.
*/
@@ -65,12 +71,14 @@ object NetworkModule {
hostSelectionInterceptor: HostSelectionInterceptor,
userAgentInterceptor: UserAgentInterceptor,
authInterceptor: AuthInterceptor,
demoLockInterceptor: DemoLockInterceptor,
tokenAuthenticator: TokenAuthenticator,
): OkHttpClient {
val builder = OkHttpClient.Builder()
.addInterceptor(userAgentInterceptor)
.addInterceptor(hostSelectionInterceptor)
.addInterceptor(authInterceptor)
.addInterceptor(demoLockInterceptor)
.authenticator(tokenAuthenticator)
if (BuildConfig.DEBUG) {
@@ -116,12 +124,48 @@ object NetworkModule {
@Singleton
fun provideMeApi(retrofit: Retrofit): MeApi = retrofit.create(MeApi::class.java)
/** Report, block and hide (USER_SAFETY.md §4.1) — core's routes, bearer-authed. */
@Provides
@Singleton
fun provideSafetyApi(retrofit: Retrofit): SafetyApi = retrofit.create(SafetyApi::class.java)
/** A player's own game data + linking (§6.3) — bearer-authed on the main client. */
@Provides
@Singleton
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
retrofit.create(PlayerShardApi::class.java)
/**
* The event surface (§9 M13). Three public reads and one bearer-authed player
* read on one interface — they are all CORE routes, so none of them is a
* module path and none is under `/shard`.
*/
@Provides
@Singleton
fun provideEventsApi(retrofit: Retrofit): EventsApi = retrofit.create(EventsApi::class.java)
/**
* `module-rust`'s public read path (§9 M14).
*
* A MODULE's routes, unlike [provideEventsApi] beside it — they exist only on
* a backend where an operator installed the Rust module, and the drawer rows
* that lead to them are gated on its `rust` capability. Provided
* unconditionally all the same: a Retrofit interface costs nothing until
* something calls it, and there is nowhere at injection time to ask.
*/
@Provides
@Singleton
fun provideRustApi(retrofit: Retrofit): RustApi = retrofit.create(RustApi::class.java)
/**
* A player's own Rust identity and entitlements (§9 M15) — bearer-authed on
* the main client, like [providePlayerShardApi] one game along.
*/
@Provides
@Singleton
fun providePlayerRustApi(retrofit: Retrofit): PlayerRustApi =
retrofit.create(PlayerRustApi::class.java)
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
@Provides
@Singleton

View File

@@ -11,13 +11,16 @@ import com.runicgateway.app.core.auth.TokenStore
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
import com.runicgateway.app.core.auth.sso.PendingSsoStore
import com.runicgateway.app.core.inbox.DataStoreInboxCache
import com.runicgateway.app.core.inbox.InboxCache
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/** Binds the at-rest stores to their EncryptedSharedPreferences impls (§4.3). */
/** Binds the at-rest stores to their implementations — EncryptedSharedPreferences
* for anything secret (§4.3), plain DataStore for the inbox snapshot. */
@Module
@InstallIn(SingletonComponent::class)
abstract class StorageModule {
@@ -38,4 +41,9 @@ abstract class StorageModule {
@Binds
@Singleton
abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider
/** The inbox's offline snapshot — plain DataStore, not encrypted (ENGAGEMENT.md phase 8). */
@Binds
@Singleton
abstract fun bindInboxCache(impl: DataStoreInboxCache): InboxCache
}

View File

@@ -11,6 +11,7 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.data.repository.ConnectionRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -29,6 +30,7 @@ class AppViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val baseUrlHolder: BaseUrlHolder,
private val pushManager: PushManager,
private val siteCapabilitiesRepository: SiteCapabilitiesRepository,
) : ViewModel() {
sealed interface AppState {
@@ -76,6 +78,12 @@ class AppViewModel @Inject constructor(
fun refreshAppearance() {
if (_state.value !is AppState.Ready) return
viewModelScope.launch {
// What the backend SERVES is a per-host fact and refreshes on the same
// clock as the appearance: an operator who installs a module while the
// app is backgrounded should see its rows on the next resume. Done
// before the early return below, because a failed settings read is no
// reason to skip it — they are separate calls to separate routes.
siteCapabilitiesRepository.refresh()
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
pushManager.setNtfyUrl(settings.push.ntfyUrl)
// changeServer() may have raced us back to the connect screen while the
@@ -100,6 +108,7 @@ class AppViewModel @Inject constructor(
* or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded.
*/
private suspend fun loadAppearance(): SiteAppearance {
siteCapabilitiesRepository.refresh()
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
return SiteAppearance.from(settings)

View File

@@ -0,0 +1,110 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.runicgateway.app.core.result.ApiResult
import kotlinx.coroutines.delay
/**
* Repeated reads of a surface that changes while somebody is looking at it
* (`docs/modules/rust/PLAN.md` D14, and D17 for this leg).
*
* ## Why a refresh is not a load
*
* The app has had exactly one shape for a read until now: set [UiState.Loading],
* ask, replace. That is right for opening a screen and wrong for a poll — a
* twenty-second refresh built on it would clear the killfeed, put a spinner where
* it was and re-fill it, three times a minute, for ever. The website hit the same
* wall one tier along: core's `useAsync` blanks its data on every dependency
* change, so `module-rust` bundles its own `usePolled`. This is that hook's other
* half.
*
* The rule both ends keep: **a refresh is invisible when it succeeds, and keeps
* the rows when it fails.** A site whose whole premise is "it renders while the
* game is off" must not blank itself the first time a request does.
*/
/** How often a live surface re-reads itself while somebody is looking at it (D17). */
const val POLL_INTERVAL_MS = 20_000L
/**
* What a poll produced: the state to render, and whether the last attempt failed.
*
* Two fields rather than a wider [UiState] because they are two facts and a
* screen renders them in different places — the rows in the list, the failure as
* a quiet line above it. Collapsing them would force the choice this exists to
* avoid: show the error and lose the rows, or keep the rows and say nothing.
*/
data class Polled<out T>(
val state: UiState<T> = UiState.Loading,
/** True when the most recent refresh failed **and there were rows to keep**. */
val refreshFailed: Boolean = false,
)
/**
* Fold a refresh into what is already on screen.
*
* Three cases, and the middle one is the whole point:
*
* - **It answered.** The new data replaces the old and any previous failure
* clears. This is the ordinary path and it is silent.
* - **It failed, and there are rows.** The rows stay exactly as they are and the
* failure is reported beside them. Nothing is blanked and nothing is retried
* on the reader's behalf — the next tick is twenty seconds away.
* - **It failed, and there is nothing yet.** There is nothing to protect, so it
* becomes an ordinary error with a retry — which is what the first load
* failing means.
*
* Pure, and takes the current state rather than reading one, so the rule is
* tested without a dispatcher, a view model or Compose.
*/
fun <T> refreshInto(current: UiState<T>, result: ApiResult<T>): Polled<T> = when {
result is ApiResult.Ok -> Polled(UiState.Success(result.data), refreshFailed = false)
current is UiState.Success -> Polled(current, refreshFailed = true)
else -> Polled(result.toUiState(), refreshFailed = false)
}
/**
* Run [block] now and every [intervalMs] for as long as this screen is resumed.
*
* `repeatOnLifecycle` is what makes this the phone's version of D14's Page
* Visibility gate, and it gets three behaviours from one line:
*
* - **Nothing runs while the app is away.** The coroutine is cancelled at
* `onPause`, so a backgrounded app makes no requests at all — not a slower
* poll, none.
* - **Coming back refreshes immediately.** The block is restarted from the top
* at `onResume`, which calls [block] before the first [delay] — so the first
* thing a returning reader sees is current, not up to twenty seconds old.
* - **A dialog or the recents switcher pauses it**, because that is what RESUMED
* means. The alternative, STARTED, keeps polling behind a partially
* obscured screen, which is precisely the reader who is not reading.
*
* **Keyed on the lifecycle owner alone, and [block] is held through
* `rememberUpdatedState`.** Keying on the block would restart the loop on every
* recomposition, because a lambda is a new object each time; capturing it without
* `rememberUpdatedState` would freeze the *first* one, so a tab change or a
* newly chosen wipe would keep refreshing the question the reader has stopped
* asking. The loop is stable and what it calls is current.
*/
@Composable
fun PollWhileResumed(intervalMs: Long = POLL_INTERVAL_MS, block: suspend () -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
val current by rememberUpdatedState(block)
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
while (true) {
current()
delay(intervalMs)
}
}
}
}

View File

@@ -26,6 +26,8 @@ import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemColors
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -33,10 +35,13 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -55,13 +60,22 @@ import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.web.WebHandoff
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.data.repository.Capability
import com.runicgateway.app.ui.auth.AccountScreen
import com.runicgateway.app.ui.auth.BlockedScreen
import com.runicgateway.app.ui.auth.DeleteAccountScreen
import com.runicgateway.app.ui.auth.DeleteAccountViewModel
import com.runicgateway.app.ui.auth.LoginScreen
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
import com.runicgateway.app.ui.auth.roleLabelRes
import com.runicgateway.app.ui.components.BrandLogo
import com.runicgateway.app.ui.contact.ContactScreen
import com.runicgateway.app.ui.demo.DemoNoticeViewModel
import com.runicgateway.app.ui.events.EventScreen
import com.runicgateway.app.ui.events.EventSeriesScreen
import com.runicgateway.app.ui.events.EventsScreen
import com.runicgateway.app.ui.events.MyEventsScreen
import com.runicgateway.app.ui.home.HomeScreen
import com.runicgateway.app.ui.navigation.APP_MENU
import com.runicgateway.app.ui.navigation.NavNode
@@ -75,7 +89,9 @@ import com.runicgateway.app.ui.admin.AdminContentScreen
import com.runicgateway.app.ui.admin.AdminDashboardScreen
import com.runicgateway.app.ui.admin.AdminModerationScreen
import com.runicgateway.app.ui.admin.AdminSupportScreen
import com.runicgateway.app.ui.notifications.NotificationsScreen
import com.runicgateway.app.ui.notifications.InboxBadgeViewModel
import com.runicgateway.app.ui.notifications.InboxScreen
import com.runicgateway.app.ui.notifications.NotificationSettingsScreen
import com.runicgateway.app.ui.page.PageScreen
import com.runicgateway.app.ui.player.CharacterSheetScreen
import com.runicgateway.app.ui.player.CharactersScreen
@@ -93,10 +109,15 @@ import com.runicgateway.app.ui.shard.MarketScreen
import com.runicgateway.app.ui.shard.MarketVendorScreen
import com.runicgateway.app.ui.shard.RulesScreen
import com.runicgateway.app.ui.shard.ShardBoard
import com.runicgateway.app.ui.rust.RustBadgeViewModel
import com.runicgateway.app.ui.rust.RustAccountScreen
import com.runicgateway.app.ui.rust.RustServerScreen
import com.runicgateway.app.ui.rust.RustServersScreen
import com.runicgateway.app.ui.shard.ShardScreen
import com.runicgateway.app.ui.theme.LocalShardStructure
import com.runicgateway.app.ui.wiki.WikiPageScreen
import com.runicgateway.app.ui.wiki.WikiScreen
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/** Destinations that show the drawer (hamburger); others show a back arrow. */
@@ -106,7 +127,16 @@ private val TOP_LEVEL_ROUTES = setOf(
// on them too (M11).
Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS,
Routes.NOTIFICATIONS,
// Events (M13): the calendar and the history are drawer rows, so the drawer
// gesture works on them. The event page and an arc are detail screens and are
// deliberately absent — a back gesture there means "back", not "open the menu".
Routes.EVENTS, Routes.MY_EVENTS,
// The Rust server list is a drawer row (M14); one server's page is a detail
// screen and is deliberately absent — a back gesture there means "back".
Routes.RUST,
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
// The player's own Rust account (M15) — a drawer row like the three above it.
Routes.PLAYER_RUST,
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
)
@@ -124,8 +154,12 @@ fun RunicApp(
onChangeServer: () -> Unit,
modifier: Modifier = Modifier,
deepLinkStream: String? = null,
deepLinkRef: String? = null,
onDeepLinkConsumed: () -> Unit = {},
sessionViewModel: SessionViewModel = hiltViewModel(),
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
demoNoticeViewModel: DemoNoticeViewModel = hiltViewModel(),
) {
val brand = appearance.brand
val navController = rememberNavController()
@@ -135,17 +169,57 @@ fun RunicApp(
val session by sessionViewModel.session.collectAsStateWithLifecycle()
// What this shard publishes, independently of who the caller is (§5, M11).
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
// What this BACKEND serves at all, independently of both (§5, M13). A different
// question from the line above and gated separately — see `isEntryVisible`.
val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle()
// Re-validate the cached role each time the app returns to the foreground (§4.3).
LifecycleResumeEffect(Unit) {
// Re-validate the cached role each time the app returns to the foreground (§4.3),
// and re-read the two drawer counts with it: a tickle that arrived while the app
// was away is exactly what brings someone back to it, and a live player count is
// only live if it is re-read when somebody looks.
LifecycleResumeEffect(capabilities) {
sessionViewModel.revalidate()
inboxBadgeViewModel.refresh()
// The Rust count is a LIVE number, so it is re-read on the same clock the
// unread badge is: coming back to the app is exactly when a stale one
// would be noticed. Keyed on the capability answer as well as on resume,
// because the very first resume happens before this host has said whether
// the module is there — and asking then would either make a request on a
// site that has no Rust, or never make one at all.
capabilities?.let { rustBadgeViewModel.refresh(Capability.RUST in it) }
onPauseOrDispose { }
}
val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle()
// How many people are on the Rust fleet, for the drawer row's badge — the
// phone's answer to D15's footer count (M14). Refreshed on resume, never on a
// timer: a badge is a glance, not a feed. Gated here rather than inside the
// view model because this is the only place that knows whether the module is
// installed at all, and a host that has not answered yet asks nothing.
val rustOnline by rustBadgeViewModel.online.collectAsStateWithLifecycle()
// The badge follows the session, so signing out clears it rather than leaving
// the previous account's count on the drawer.
LaunchedEffect(session) { inboxBadgeViewModel.refresh() }
// The public demo's refusals (DEMO_MODE.md §3.1, DM19): one snackbar for every
// screen, the app's counterpart of the web's toast. The screen keeps its own
// failure line; this says why, in the server's words. A newer refusal replaces
// the one showing rather than queueing behind it.
val snackbarHostState = remember { SnackbarHostState() }
val demoLockedFallback = stringResource(R.string.error_demo_locked)
val accountDeletedNotice = stringResource(R.string.account_delete_done)
LaunchedEffect(Unit) {
demoNoticeViewModel.refusals.collectLatest { refusal ->
snackbarHostState.showSnackbar(refusal.message ?: demoLockedFallback)
}
}
// A tapped push notification deep-links to its stream's screen (§11, item 7).
LaunchedEffect(deepLinkStream) {
LaunchedEffect(deepLinkStream, deepLinkRef) {
val stream = deepLinkStream ?: return@LaunchedEffect
navController.navigate(Routes.forStream(stream)) {
// Both halves of the tickle: a `notification:` ref means there is an inbox
// row waiting, and that is where the tap goes (ENGAGEMENT.md phase 8).
navController.navigate(Routes.forTickle(stream, deepLinkRef)) {
popUpTo(Routes.HOME) { saveState = true }
launchSingleTop = true
}
@@ -162,7 +236,7 @@ fun RunicApp(
// `pruneNav` still decides what this caller may see and remains the boundary
// (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
isEntryVisible(it, session, shardFeatures)
isEntryVisible(it, session, shardFeatures, capabilities)
}
val context = LocalContext.current
@@ -234,12 +308,23 @@ fun RunicApp(
),
)
node.items.forEach { child ->
NavRow(child, currentRoute, drawerItemColors, indented = true) {
openNode(child)
}
NavRow(
node = child,
currentRoute = currentRoute,
colors = drawerItemColors,
indented = true,
unread = unread,
rustOnline = rustOnline,
) { openNode(child) }
}
} else {
NavRow(node, currentRoute, drawerItemColors) { openNode(node) }
NavRow(
node = node,
currentRoute = currentRoute,
colors = drawerItemColors,
unread = unread,
rustOnline = rustOnline,
) { openNode(node) }
}
}
@@ -284,6 +369,7 @@ fun RunicApp(
) {
Scaffold(
modifier = modifier,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
@@ -335,6 +421,15 @@ fun RunicApp(
session = session,
onSignOut = { sessionViewModel.signOut() },
onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) },
onAccountDeleted = {
// To sign-in with the notice, as the web lands home with its own.
// Home stays beneath it, so back leaves sign-in for Home.
navController.navigate(Routes.LOGIN) {
popUpTo(Routes.HOME)
launchSingleTop = true
}
scope.launch { snackbarHostState.showSnackbar(accountDeletedNotice) }
},
modifier = Modifier.padding(innerPadding),
)
}
@@ -354,6 +449,8 @@ private fun NavRow(
currentRoute: String?,
colors: NavigationDrawerItemColors,
indented: Boolean = false,
unread: Int = 0,
rustOnline: Int = 0,
onClick: () -> Unit,
) {
val route = when (node) {
@@ -369,21 +466,60 @@ private fun NavRow(
is NavNode.Section -> return
}
val handsOff = node is NavNode.Link && node.route == null
// The unread count rides on whichever row leads to the inbox — including an
// admin's own nav override pointing at it, since the badge belongs to the
// destination, not to the bundled entry.
val showsUnread = !handsOff && unread > 0 && route == Routes.NOTIFICATIONS
// The live player count rides on whichever row leads to the Rust list, for the
// same reason the unread count rides on whichever leads to the inbox — the
// number belongs to the destination, not to the bundled entry, so an admin's
// own nav override pointing there carries it too.
//
// **Zero renders nothing**, rather than a `0`: an empty fleet is not a
// notification, and a badge that read `0` on a site whose servers are simply
// quiet would be worse than no badge at all.
val showsRustOnline = !handsOff && rustOnline > 0 && route == Routes.RUST
NavigationDrawerItem(
label = { Text(label) },
selected = route != null && currentRoute == route.substringBefore('?'),
onClick = onClick,
badge = if (!handsOff) {
null
} else {
{
Icon(
Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = stringResource(R.string.nav_opens_in_browser),
modifier = Modifier.size(18.dp),
)
badge = when {
handsOff -> {
{
Icon(
Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = stringResource(R.string.nav_opens_in_browser),
modifier = Modifier.size(18.dp),
)
}
}
showsUnread -> {
{
// Named for a screen reader: "7" beside "Notifications" reads as
// a count to a sighted user and as a bare number to everyone else.
val spoken = stringResource(R.string.inbox_unread_count, unread)
Text(
text = unread.toString(),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.semantics { contentDescription = spoken },
)
}
}
showsRustOnline -> {
{
// Named for a screen reader: "42" beside "Rust servers" reads
// as a count to a sighted user and as a bare number to
// everyone else.
val spoken = stringResource(R.string.rust_online_badge, rustOnline)
Text(
text = rustOnline.toString(),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.semantics { contentDescription = spoken },
)
}
}
else -> null
},
colors = colors,
// Like Card's elevation, NavigationDrawerItem takes its shape as a default
@@ -404,6 +540,7 @@ private fun RunicNavHost(
session: Session,
onSignOut: () -> Unit,
onSignOutEverywhere: () -> Unit,
onAccountDeleted: () -> Unit,
modifier: Modifier = Modifier,
) {
NavHost(
@@ -440,6 +577,50 @@ private fun RunicNavHost(
) {
PostScreen()
}
// Events (M13). CORE's routes, so these screens are reachable on a backend
// with no game module at all — which is why they sit above the shard block
// rather than inside it.
composable(Routes.EVENTS) {
EventsScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) })
}
// The app's one route with a query argument. `run` is optional and nullable:
// navigating to Routes.event(slug) with no run matches this pattern with no
// argument, which is every route in except an announcement's link.
composable(
route = Routes.EVENT_ROUTE,
arguments = listOf(
navArgument(Routes.Args.SLUG) { type = NavType.StringType },
navArgument(Routes.Args.RUN) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) {
EventScreen(
onOpenSeries = { slug -> navController.navigate(Routes.eventSeries(slug)) },
onOpenRun = { slug, runId ->
navController.navigate(Routes.event(slug, runId.toString()))
},
)
}
composable(
route = Routes.EVENT_SERIES,
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
) {
EventSeriesScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) })
}
composable(Routes.MY_EVENTS) {
// Signed out, this route is not in the drawer — but a saved back-stack
// entry can still be restored onto it, so the shell says where to go
// rather than letting the screen ask the server and render a 401.
when (session) {
is Session.SignedIn -> MyEventsScreen(onOpenRun = { slug, runId ->
navController.navigate(Routes.event(slug, runId.toString()))
})
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.SHARD) {
ShardScreen(onOpenBoard = { board ->
navController.navigate(
@@ -480,6 +661,30 @@ private fun RunicNavHost(
) { entry ->
AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty())
}
// The Rust module's two screens (M14). Not under `shard/`: a different game,
// a different shape — a fleet with a list above it rather than one place.
composable(Routes.RUST) {
RustServersScreen(onOpenServer = { id -> navController.navigate(Routes.rustServer(id)) })
}
composable(
route = Routes.RUST_SERVER,
arguments = listOf(
navArgument(Routes.Args.SERVER_ID) { type = NavType.StringType },
navArgument(Routes.Args.TAB) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) {
RustServerScreen(
onBack = { navController.navigateTopLevel(Routes.RUST) },
// D123: a site-event marker opens the app's own event page on its run.
onOpenEvent = { slug, runId -> navController.navigate(Routes.event(slug, runId)) },
// A chat line's menu signed out (S6): report and block need an account.
onSignIn = { navController.navigate(Routes.LOGIN) },
)
}
composable(Routes.WIKI) {
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
}
@@ -520,6 +725,8 @@ private fun RunicNavHost(
onSignOutEverywhere = onSignOutEverywhere,
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
onOpenDeleteAccount = { navController.navigate(Routes.ACCOUNT_DELETE) },
onOpenBlocked = { navController.navigate(Routes.ACCOUNT_BLOCKED) },
)
Session.SignedOut -> LaunchedEffect(Unit) {
navController.navigateTopLevel(Routes.HOME)
@@ -533,17 +740,50 @@ private fun RunicNavHost(
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.ACCOUNT_BLOCKED) {
when (session) {
is Session.SignedIn -> BlockedScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.ACCOUNT_RECOVERY_CODES) {
when (session) {
is Session.SignedIn -> RecoveryCodesScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.ACCOUNT_DELETE) {
// The view model is held here, not in the screen, so this route can tell a
// deletion from any other sign-out: the view model marks it deleted before
// the session drops, and both land in the same frame (ACCOUNT_DELETION.md §4.3).
val viewModel: DeleteAccountViewModel = hiltViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
when {
state.deleted -> LaunchedEffect(Unit) { onAccountDeleted() }
session is Session.SignedIn -> DeleteAccountScreen(viewModel)
else -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.NOTIFICATIONS) {
// Signed-in only; a sign-out (or demotion) sends the user home rather than
// leaving stale settings up. The backend gates every call regardless (§5).
// leaving another account's items up. The backend gates every call
// regardless, and the inbox routes are role-agnostic (§5) — staff have an
// inbox for the same reason players do, which on the web took a second
// mount to be true.
when (session) {
is Session.SignedIn -> NotificationsScreen()
is Session.SignedIn -> InboxScreen(
onOpenSettings = { navController.navigate(Routes.NOTIFICATIONS_SETTINGS) },
// A notification whose link the app can render opens in the app.
// `navigate`, not `navigateTopLevel`: the inbox is where the
// reader came from and back should return there.
onOpenRoute = { route -> navController.navigate(route) },
)
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.NOTIFICATIONS_SETTINGS) {
when (session) {
is Session.SignedIn -> NotificationSettingsScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
@@ -568,6 +808,12 @@ private fun RunicNavHost(
composable(Routes.PLAYER_HOUSES) {
PlayerGate(session, navController) { MyHousesScreen() }
}
// The player's own Rust account (M15). Behind the same gate as the three
// above: the route is `requireAuth` server-side, and the gate is here so a
// signed-out reader is sent home rather than left on a screen that 401s.
composable(Routes.PLAYER_RUST) {
PlayerGate(session, navController) { RustAccountScreen() }
}
// ── Staff operations (§1, §6.4, M10) — reached from the staff menu section.
// The backend re-checks role on every /admin/… call; these gates only mirror

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.ui
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
/**
* The load state a screen renders. View models expose a `StateFlow<UiState<T>>`;
@@ -41,6 +42,14 @@ enum class ErrorKind {
*/
FEATURE_UNAVAILABLE,
/**
* The public demo refused this (`403 DEMO_LOCKED`, `docs/website/DEMO_MODE.md`
* §3.1). Checked ahead of the status, because a `403` otherwise reads as "you
* may not", which blames the role of somebody signed in as an admin. Like
* [FEATURE_UNAVAILABLE], retrying cannot change the answer.
*/
DEMO_LOCKED,
/** Any other non-2xx server response. */
SERVER,
}
@@ -50,10 +59,11 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
is ApiResult.Ok -> UiState.Success(data)
is ApiResult.NetworkError -> UiState.Error(ErrorKind.NETWORK)
is ApiResult.HttpError -> UiState.Error(
kind = when (status) {
404 -> ErrorKind.NOT_FOUND
429 -> ErrorKind.RATE_LIMITED
503 -> ErrorKind.SHARD_OFFLINE
kind = when {
isDemoLocked() -> ErrorKind.DEMO_LOCKED
status == 404 -> ErrorKind.NOT_FOUND
status == 429 -> ErrorKind.RATE_LIMITED
status == 503 -> ErrorKind.SHARD_OFFLINE
else -> ErrorKind.SERVER
},
httpStatus = status,
@@ -76,7 +86,7 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
* player or admin route, which is not an admin's visibility setting.
*/
fun <T> ApiResult<T>.toShardUiState(): UiState<T> = when (this) {
is ApiResult.HttpError -> if (status == 403 || status == 404) {
is ApiResult.HttpError -> if ((status == 403 || status == 404) && !isDemoLocked()) {
UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status)
} else {
toUiState()

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
@@ -133,8 +134,14 @@ class AdminContentViewModel @Inject constructor(
/** Map an [ApiResult] to a [Feedback], with role/permission-aware failure copy. */
private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) {
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError ->
Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed)
is ApiResult.HttpError -> Feedback(
false,
when {
isDemoLocked() -> R.string.error_demo_locked
status == 403 -> R.string.admin_forbidden
else -> R.string.admin_action_failed
},
)
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
}
}

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
@@ -79,7 +80,11 @@ class AdminDashboardViewModel @Inject constructor(
switching = false,
feedback = Feedback(
false,
if (result.status == 403) R.string.admin_forbidden else R.string.admin_action_failed,
when {
result.isDemoLocked() -> R.string.error_demo_locked
result.status == 403 -> R.string.admin_forbidden
else -> R.string.admin_action_failed
},
),
)
}

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.data.repository.AdminRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -75,9 +76,10 @@ class AdminModerationViewModel @Inject constructor(
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError -> Feedback(
false,
when (r.status) {
403 -> R.string.admin_forbidden
503 -> R.string.admin_mod_shard_offline
when {
r.isDemoLocked() -> R.string.error_demo_locked
r.status == 403 -> R.string.admin_forbidden
r.status == 503 -> R.string.admin_mod_shard_offline
else -> R.string.admin_action_failed
},
)

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
@@ -71,10 +72,11 @@ class AdminSupportViewModel @Inject constructor(
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError -> Feedback(
false,
when (r.status) {
403 -> R.string.admin_forbidden
404 -> R.string.admin_support_unknown_page
503 -> R.string.admin_mod_shard_offline
when {
r.isDemoLocked() -> R.string.error_demo_locked
r.status == 403 -> R.string.admin_forbidden
r.status == 404 -> R.string.admin_support_unknown_page
r.status == 503 -> R.string.admin_mod_shard_offline
else -> R.string.admin_action_failed
},
)

View File

@@ -0,0 +1,39 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.annotation.StringRes
import com.runicgateway.app.R
import com.runicgateway.app.data.repository.AccountRepository.DeleteRefusal
/*
* What the Delete my account screen decides (ACCOUNT_DELETION.md §4.3), kept out
* of the composable so it can be tested. The website's `lib/accountDeletion.js`
* holds the same rules and words.
*
* The server is the guard: it checks the username, the password and the code again
* whatever this file decides. What is here only keeps a mis-tap from reaching it
* and turns its refusals into sentences.
*/
/** Delete is live once the typed name matches the account's, ignoring case. */
fun deleteUsernameMatches(typed: String, username: String): Boolean =
typed.isNotBlank() && typed.trim().equals(username, ignoreCase = true)
/** Every role but player is staff, whose username is never free again (AD9). */
fun isStaffRole(role: String): Boolean = role.isNotBlank() && !role.equals("player", ignoreCase = true)
/** The sentence for a refusal. */
@StringRes
fun deleteRefusalRes(reason: DeleteRefusal): Int = when (reason) {
DeleteRefusal.USERNAME_MISMATCH -> R.string.account_delete_error_username
DeleteRefusal.WRONG_PASSWORD -> R.string.account_delete_error_password
DeleteRefusal.CODE_REQUIRED -> R.string.account_delete_error_code_required
DeleteRefusal.WRONG_CODE -> R.string.account_delete_error_code
DeleteRefusal.PASSWORD_REQUIRED -> R.string.account_delete_no_password
DeleteRefusal.LAST_ADMIN -> R.string.account_delete_error_last_admin
DeleteRefusal.DEMO_LOCKED -> R.string.error_demo_locked
DeleteRefusal.RATE_LIMITED -> R.string.account_delete_error_rate_limited
DeleteRefusal.OTHER -> R.string.account_delete_error
}

View File

@@ -6,6 +6,7 @@ package com.runicgateway.app.ui.auth
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -67,6 +69,8 @@ fun AccountScreen(
onSignOutEverywhere: () -> Unit,
onOpenTrustedDevices: () -> Unit,
onOpenRecoveryCodes: () -> Unit,
onOpenDeleteAccount: () -> Unit,
onOpenBlocked: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AccountViewModel = hiltViewModel(),
) {
@@ -91,6 +95,8 @@ fun AccountScreen(
is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
}
BlockedRow(onOpenBlocked)
HorizontalDivider(Modifier.padding(vertical = 20.dp))
OutlinedButton(onClick = onSignOut, modifier = Modifier.fillMaxWidth()) {
@@ -102,6 +108,58 @@ fun AccountScreen(
) {
Text(stringResource(R.string.account_sign_out_all))
}
DeleteAccountRow(onOpenDeleteAccount)
}
}
/**
* Last on the screen and set apart (ACCOUNT_DELETION.md §4.3). Always shown, on the
* demo too, where the server refuses: Play's reviewer must be able to find it.
*/
@Composable
private fun DeleteAccountRow(onOpenDeleteAccount: () -> Unit) {
HorizontalDivider(
Modifier.padding(top = 20.dp, bottom = 16.dp),
color = MaterialTheme.colorScheme.error,
)
Text(
stringResource(R.string.account_delete_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
)
Text(
stringResource(R.string.account_delete_row_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
OutlinedButton(
onClick = onOpenDeleteAccount,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) { Text(stringResource(R.string.account_delete_open)) }
}
/**
* Account → Blocked (USER_SAFETY.md §4.3). Outside the loaded sections on purpose:
* the blocks are their own read, and a failed account read is no reason to hide
* the way to undo one.
*/
@Composable
private fun BlockedRow(onOpenBlocked: () -> Unit) {
SectionCard(R.string.blocked_title) {
Text(
stringResource(R.string.blocked_row_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
OutlinedButton(
onClick = onOpenBlocked,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) { Text(stringResource(R.string.blocked_open)) }
}
}
@@ -329,15 +387,18 @@ private fun IdentityRow(identity: LinkedIdentityDto, busy: Boolean, viewModel: A
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
val provider = identity.provider.replaceFirstChar { it.uppercase() }
Text(
identity.provider.replaceFirstChar { it.uppercase() },
// Steam: "Steam: CoolGuy99" — a person may hold several, so the name tells them apart.
identity.display_name?.let { stringResource(R.string.account_identity_named, provider, it) } ?: provider,
style = MaterialTheme.typography.bodyLarge,
)
identity.email?.let {
// Steam sends no email; its SteamID64 identifies the account instead.
(identity.email ?: identity.subject?.takeIf { identity.provider == "steam" })?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
TextButton(onClick = { viewModel.unlinkIdentity(identity.provider) }, enabled = !busy) {
TextButton(onClick = { viewModel.unlinkIdentity(identity) }, enabled = !busy) {
Text(stringResource(R.string.account_identity_unlink))
}
}

View File

@@ -158,11 +158,12 @@ class AccountViewModel @Inject constructor(
}
// ── Identities ───────────────────────────────────────────────────────
fun unlinkIdentity(provider: String) {
/** Unlink [identity] alone — by its id, so one of several Steam accounts goes and the rest stay. */
fun unlinkIdentity(identity: LinkedIdentityDto) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (accountRepository.unlinkIdentity(provider)) {
when (accountRepository.unlinkIdentity(identity.provider, identity.id)) {
is ApiResult.Ok -> {
finish(Section.IDENTITY, true, R.string.account_identity_unlinked)
when (val ids = accountRepository.identities()) {

View File

@@ -0,0 +1,156 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.UserBlockDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* Account → Blocked (`USER_SAFETY.md` §4.3), mirroring the website's panel: who this
* account blocked, each with *Unblock*, and *Show them again* for what it hid.
*/
@Composable
fun BlockedScreen(
modifier: Modifier = Modifier,
viewModel: BlockedViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(stringResource(R.string.blocked_title), style = MaterialTheme.typography.titleLarge)
Text(
stringResource(R.string.blocked_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
state.feedback?.let { fb ->
Text(
text = fb.arg?.let { stringResource(fb.messageRes, it) } ?: stringResource(fb.messageRes),
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
when (val blocks = state.blocks) {
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
is UiState.Error -> ErrorView(blocks.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
is UiState.Success -> {
if (blocks.data.isEmpty()) {
Text(
stringResource(R.string.blocked_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 24.dp),
)
} else {
blocks.data.forEach { block ->
BlockRow(block, state.busy, onUnblock = { viewModel.unblock(block) })
}
}
if (state.hiddenCount > 0) {
HorizontalDivider(Modifier.padding(vertical = 20.dp))
Text(
pluralStringResource(R.plurals.blocked_hidden_count, state.hiddenCount, state.hiddenCount),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(
onClick = viewModel::showHidden,
enabled = !state.busy,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) { Text(stringResource(R.string.blocked_show_hidden)) }
}
}
}
}
}
@Composable
private fun BlockRow(block: UserBlockDto, busy: Boolean, onUnblock: () -> Unit) {
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = block.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.blocked_unknown_player),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = blockSubtitle(block),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
// No line limit: a Steam block's "not linked to an account" and
// its date are the point of the row, and on the walk one line cut
// both off and two still cut the year.
)
// S8: a block whose target is now staff stays listed, and says why
// it stopped, rather than vanishing.
if (!block.applies && block.reason == "staff") {
Text(
text = stringResource(R.string.blocked_not_applied_staff),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 2.dp),
)
}
}
TextButton(onClick = onUnblock, enabled = !busy) {
Text(stringResource(R.string.blocked_unblock))
}
}
}
}
/**
* The second line of a row: what was blocked, and when. An account block is the
* usual one (S12); a game block names the identity and says it is not linked to an
* account yet, since that is why it is a Steam id and not a username.
*/
@Composable
private fun blockSubtitle(block: UserBlockDto): String {
val identity = block.identity
val what = if (block.kind == "game" && identity?.id != null) {
stringResource(R.string.blocked_kind_game, identity.label ?: identity.kind.orEmpty(), identity.id)
} else {
stringResource(R.string.blocked_kind_account)
}
val on = blockedOn(block.createdAt) ?: return what
return stringResource(R.string.blocked_subtitle_when, what, on)
}

View File

@@ -0,0 +1,116 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.time.parseWireInstant
import com.runicgateway.app.data.api.dto.UserBlockDto
import com.runicgateway.app.data.repository.SafetyOutcome
import com.runicgateway.app.data.repository.SafetyRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import javax.inject.Inject
/**
* Account → Blocked (`USER_SAFETY.md` §4.3), the app's copy of the website's panel:
* the members and players this account blocked, each with *Unblock*, and a count of
* the items it hid for itself with one *Show them again*.
*
* Blocks and hides are saved on the account (S2), so this is the same list the
* website shows, and undoing one here undoes it there.
*/
@HiltViewModel
class BlockedViewModel @Inject constructor(
private val safety: SafetyRepository,
) : ViewModel() {
/** A one-shot result line above the list. [arg] fills a name into [messageRes]. */
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int, val arg: String? = null)
data class State(
val blocks: UiState<List<UserBlockDto>> = UiState.Loading,
/** Items hidden for this account. Zero when the list could not be read: the row just isn't offered. */
val hiddenCount: Int = 0,
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(blocks = UiState.Loading) }
viewModelScope.launch { reload() }
}
fun unblock(block: UserBlockDto) = act(
call = { safety.unblock(block.id) },
done = block.name?.let { Feedback(true, R.string.blocked_unblocked_named, it) }
?: Feedback(true, R.string.blocked_unblocked),
failed = R.string.blocked_unblock_failed,
)
fun showHidden() = act(
call = { safety.clearHidden() },
done = Feedback(true, R.string.blocked_hidden_cleared),
failed = R.string.blocked_hidden_failed,
)
private fun act(call: suspend () -> SafetyOutcome, done: Feedback, @StringRes failed: Int) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
val feedback = when (call()) {
SafetyOutcome.Done -> done
SafetyOutcome.NetworkError -> Feedback(false, R.string.error_network)
is SafetyOutcome.Refused -> Feedback(false, failed)
}
reload()
_state.update { it.copy(busy = false, feedback = feedback) }
}
}
private suspend fun reload() {
// The two reads at once; a failed hidden-list read costs the one row, not the page.
val (blocks, hidden) = coroutineScope {
val b = async { safety.blocks() }
val h = async { safety.hidden() }
b.await() to h.await()
}
_state.update {
it.copy(
blocks = blocks.toUiState(),
hiddenCount = (hidden as? ApiResult.Ok)?.data?.size ?: 0,
)
}
}
}
/**
* When a block was made, as a date in the reader's zone, or null when the server
* sent no stamp it could read.
*/
fun blockedOn(createdAt: String?, zone: ZoneId = ZoneId.systemDefault()): String? =
parseWireInstant(createdAt)?.let {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withZone(zone).format(it)
}

View File

@@ -0,0 +1,218 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
/** What goes with the account. The same list as the website's and BACKEND_DESIGN.md's. */
private val WHAT_GOES = listOf(
R.string.account_delete_goes_sign_in,
R.string.account_delete_goes_devices,
R.string.account_delete_goes_identities,
R.string.account_delete_goes_game_links,
R.string.account_delete_goes_notifications,
)
/** What stays, and who sees what of it. */
private val WHAT_STAYS = listOf(
R.string.account_delete_stays_content,
R.string.account_delete_stays_reports,
R.string.account_delete_stays_events,
R.string.account_delete_stays_game_account,
)
/**
* Account → Delete my account (ACCOUNT_DELETION.md §4.3): what goes and what stays,
* then the confirm step — the typed username, the password, and a code when
* two-factor is on. Delete stays off until the username matches. On the demo the
* server refuses and the shell's snackbar says why; the screen stays reachable,
* which is what Play's reviewer needs to find.
*/
@Composable
fun DeleteAccountScreen(
viewModel: DeleteAccountViewModel,
modifier: Modifier = Modifier,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(
stringResource(R.string.account_delete_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.error,
)
Paragraph(R.string.account_delete_intro, top = 12)
WHAT_GOES.forEach { Bullet(it) }
Paragraph(R.string.account_delete_stays_title, top = 16)
WHAT_STAYS.forEach { Bullet(it) }
when (val account = state.account) {
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
is UiState.Error -> ErrorView(account.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
is UiState.Success -> ConfirmStep(account.data, state, viewModel)
}
}
}
@Composable
private fun ConfirmStep(
account: PlayerAccountDto,
state: DeleteAccountViewModel.State,
viewModel: DeleteAccountViewModel,
) {
var typed by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var code by rememberSaveable { mutableStateOf("") }
var useRecovery by rememberSaveable { mutableStateOf(false) }
val needsCode = account.totp_enabled
val ready = account.has_password &&
deleteUsernameMatches(typed, account.username) &&
password.isNotEmpty() &&
(!needsCode || code.isNotBlank())
val enabled = !state.busy && !state.deleted
if (isStaffRole(account.role)) Paragraph(R.string.account_delete_staff_note, top = 16)
if (!account.has_password) {
Text(
stringResource(R.string.account_delete_no_password),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 16.dp),
)
return
}
OutlinedTextField(
value = typed,
onValueChange = { typed = it },
singleLine = true,
enabled = enabled,
label = { Text(stringResource(R.string.account_delete_type_username, account.username)) },
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Next,
),
modifier = Modifier.fillMaxWidth().padding(top = 20.dp),
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
singleLine = true,
enabled = enabled,
label = { Text(stringResource(R.string.login_password)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
)
if (needsCode) {
OutlinedTextField(
value = code,
onValueChange = { code = if (useRecovery) it else it.filter(Char::isDigit).take(8) },
singleLine = true,
enabled = enabled,
label = {
Text(stringResource(if (useRecovery) R.string.account_delete_recovery_code else R.string.login_totp_code))
},
keyboardOptions = KeyboardOptions(
keyboardType = if (useRecovery) KeyboardType.Ascii else KeyboardType.NumberPassword,
),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
)
TextButton(
onClick = { useRecovery = !useRecovery; code = "" },
enabled = enabled,
) {
Text(
stringResource(
if (useRecovery) R.string.account_delete_use_authenticator else R.string.account_delete_use_recovery,
),
)
}
}
state.error?.let { err ->
Text(
text = stringResource(err),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
Button(
onClick = { viewModel.delete(typed, password, code, useRecovery) },
enabled = enabled && ready,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
) {
Text(stringResource(if (state.busy) R.string.account_delete_busy else R.string.account_delete_action))
}
}
@Composable
private fun Paragraph(@StringRes res: Int, top: Int) {
Text(
stringResource(res),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = top.dp),
)
}
@Composable
private fun Bullet(@StringRes res: Int) {
Row(Modifier.padding(top = 6.dp, start = 4.dp)) {
Text("•", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
stringResource(res),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
}

View File

@@ -0,0 +1,87 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.repository.AccountRepository
import com.runicgateway.app.data.repository.AccountRepository.DeleteOutcome
import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Drives Account → Delete my account (ACCOUNT_DELETION.md §4.3). It loads the
* account to know whether a code is asked for and whether there is a password at
* all, sends `DELETE /auth/me/account`, and on success forgets the account on the
* phone. There is no grace period (AD4): the confirm step is the safeguard.
*/
@HiltViewModel
class DeleteAccountViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val authRepository: AuthRepository,
) : ViewModel() {
data class State(
val account: UiState<PlayerAccountDto> = UiState.Loading,
val busy: Boolean = false,
@param:StringRes val error: Int? = null,
/**
* Set before the session is dropped, so the route sees it in the same frame as
* the sign-out and goes to sign-in with the notice rather than home.
*/
val deleted: Boolean = false,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(account = UiState.Loading) }
viewModelScope.launch {
val account = accountRepository.getAccount()
_state.update { it.copy(account = account.toUiState()) }
}
}
/** Delete the account. [useRecovery] sends [code] as a recovery code instead. */
fun delete(typed: String, password: String, code: String, useRecovery: Boolean) {
val account = (_state.value.account as? UiState.Success)?.data ?: return
if (_state.value.busy || _state.value.deleted) return
_state.update { it.copy(busy = true, error = null) }
val secondFactor = code.trim().takeIf { account.totp_enabled && it.isNotEmpty() }
viewModelScope.launch {
val outcome = accountRepository.deleteAccount(
username = typed.trim(),
password = password,
code = secondFactor.takeUnless { useRecovery },
recoveryCode = secondFactor.takeIf { useRecovery },
)
when (outcome) {
DeleteOutcome.Deleted -> {
_state.update { it.copy(busy = false, deleted = true) }
authRepository.forgetDeletedAccount()
}
is DeleteOutcome.Refused ->
_state.update { it.copy(busy = false, error = deleteRefusalRes(outcome.reason)) }
DeleteOutcome.NetworkError ->
_state.update { it.copy(busy = false, error = R.string.error_network) }
}
}
}
}

View File

@@ -39,7 +39,9 @@ fun LoadingView(modifier: Modifier = Modifier) {
*
* [ErrorKind.FEATURE_UNAVAILABLE] renders **without** the button: an admin decides
* whether the shard publishes that surface, so retrying cannot change the answer and
* offering it would read as a transient failure the user could wait out (M11).
* offering it would read as a transient failure the user could wait out (M11). The
* public demo's refusal ([ErrorKind.DEMO_LOCKED]) is the same: it is fixed until the
* site stops being a demo.
*/
@Composable
fun ErrorView(
@@ -69,7 +71,8 @@ fun ErrorView(
}
/** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */
fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE
fun isRetryable(kind: ErrorKind): Boolean =
kind != ErrorKind.FEATURE_UNAVAILABLE && kind != ErrorKind.DEMO_LOCKED
/** Centered informational message for an empty list (§7). */
@Composable
@@ -93,5 +96,6 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable
ErrorKind.DEMO_LOCKED -> R.string.error_demo_locked
ErrorKind.SERVER -> R.string.error_server
}

View File

@@ -117,5 +117,6 @@ private fun ResultMessage(result: Result) {
private fun errorText(kind: ErrorKind): String = when (kind) {
ErrorKind.NETWORK -> stringResource(R.string.error_network)
ErrorKind.RATE_LIMITED -> stringResource(R.string.error_rate_limited)
ErrorKind.DEMO_LOCKED -> stringResource(R.string.error_demo_locked)
else -> stringResource(R.string.contact_error)
}

View File

@@ -0,0 +1,65 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.demo
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.runicgateway.app.R
/**
* The public demo's strip on Home (`docs/website/DEMO_MODE.md` §6), worded like the
* web's: "**Demo site** — anything you change is wiped at 14:00 UTC. Sign-ins:
* demo-admin / demo-player — passwords at demo.runicgateway.com/account/login."
*
* Drawn only from a [DemoInfo] the server sent, so a real shard never shows it and
* the app needs no schedule of its own. [signInPage] is null when the site address
* is unknown, and the logins sentence then names the sign-in page without it.
*/
@Composable
fun DemoBanner(info: DemoInfo, signInPage: String?, modifier: Modifier = Modifier) {
val wipe = info.resetsAt
?.let { stringResource(R.string.demo_banner_wipe_at, it) }
?: stringResource(R.string.demo_banner_wipe_soon)
val logins = info.usernames.takeIf { it.isNotEmpty() }?.joinToString(" / ")?.let { names ->
if (signInPage != null) {
stringResource(R.string.demo_banner_logins_at, names, signInPage)
} else {
stringResource(R.string.demo_banner_logins, names)
}
}
val title = stringResource(R.string.demo_banner_title)
val accent = MaterialTheme.colorScheme.primary
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = accent, fontWeight = FontWeight.Bold)) { append(title) }
append(wipe)
logins?.let { append(" $it") }
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
HorizontalDivider(thickness = 1.dp, color = accent)
}
}

View File

@@ -0,0 +1,73 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.demo
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.HttpUrl
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
/**
* What the Home strip says on the public demo (`docs/website/DEMO_MODE.md` §6).
*
* @property resetsAt when the next wipe runs, already worded ("14:00 UTC"), or null
* when the server sent no readable time.
* @property usernames the published logins, in the server's order. Their passwords
* are on the site's sign-in page, not in the app (DM20).
*/
data class DemoInfo(
val resetsAt: String?,
val usernames: List<String>,
) {
companion object {
/**
* The banner data from `GET /public/settings`' raw `demo` value, or null when
* there is none, which is every real shard. Forgiving field by field: a
* missing time or a malformed login drops that part, never the banner.
*/
fun from(demo: JsonElement?): DemoInfo? {
val obj = demo as? JsonObject ?: return null
val resetsAt = (obj["resetsAt"] as? JsonPrimitive)
?.takeIf { it.isString }
?.let { formatResetTime(it.content) }
val usernames = (obj["logins"] as? JsonArray).orEmpty().mapNotNull { login ->
((login as? JsonObject)?.get("username") as? JsonPrimitive)
?.takeIf { it.isString }
?.content
?.takeIf { it.isNotBlank() }
}
return DemoInfo(resetsAt, usernames)
}
}
}
private val RESET_TIME: DateTimeFormatter =
DateTimeFormatter.ofPattern("HH:mm 'UTC'").withZone(ZoneOffset.UTC)
/**
* "14:00 UTC" for an ISO-8601 instant, or null when it does not parse. The wipe runs
* on the hour in UTC (DM7), so the strip says UTC rather than converting, the same as
* the web's: a time in the viewer's own zone reads as a different schedule from the
* one published on `/beta`.
*/
fun formatResetTime(iso: String): String? = try {
RESET_TIME.format(Instant.parse(iso))
} catch (_: DateTimeParseException) {
null
}
/**
* The site's sign-in page as the strip prints it, `demo.runicgateway.com/account/login`:
* no scheme, and the port only when it is not the scheme's default.
*/
fun signInPageLabel(base: HttpUrl): String? {
val page = base.resolve("account/login") ?: return null
val port = if (page.port == HttpUrl.defaultPort(page.scheme)) "" else ":${page.port}"
return page.host + port + page.encodedPath
}

View File

@@ -0,0 +1,22 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.demo
import androidx.lifecycle.ViewModel
import com.runicgateway.app.core.net.DemoNotices
import com.runicgateway.app.core.result.DemoRefusal
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow
import javax.inject.Inject
/**
* Hands the shell the public demo's refusals (DM19), for its one snackbar. Only a
* window onto [DemoNotices]: the shell collects while it is on screen.
*/
@HiltViewModel
class DemoNoticeViewModel @Inject constructor(
notices: DemoNotices,
) : ViewModel() {
val refusals: SharedFlow<DemoRefusal> = notices.refusals
}

View File

@@ -0,0 +1,348 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.EventOccurrenceDto
import com.runicgateway.app.data.api.dto.EventParticipantDto
import com.runicgateway.app.data.api.dto.EventProgressDto
import com.runicgateway.app.data.api.dto.PublicEventDto
import com.runicgateway.app.ui.PollWhileResumed
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.HtmlText
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* One event's public page (EVENTS.md § API surface, M13).
*
* The storyline, its arc, what is live, what is next, what happened recently, and
* a results table once an occurrence has published one.
*
* **The plan behind the event is never shown**, because the server never sends
* it: a live run carries the LABEL of the phase it is in — resolved from the
* version that run pinned, so an edit since does not relabel it — and nothing
* else. Phases, steps and actions are the operator's.
*/
@Composable
fun EventScreen(
onOpenSeries: (String) -> Unit,
onOpenRun: (String, Long) -> Unit,
modifier: Modifier = Modifier,
viewModel: EventViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// Error before content. Phase 13 found the inverse of this one tier along: a
// `if (loading || !form)` spinner above the error branch left a failed load
// spinning for ever with nothing on screen naming the problem.
when (val s = state) {
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Loading -> LoadingView(modifier)
is UiState.Success -> {
// A live event's progress lines move (MODULE_API 1.12.0, runicnpc D304),
// so the page is re-read while it is live and on screen.
if (s.data.live) PollWhileResumed(intervalMs = PROGRESS_REFRESH_MS) { viewModel.refresh() }
EventBody(s.data, onOpenSeries, onOpenRun, modifier)
}
}
}
/** How often a live event's page is re-read, as the web page does. */
private const val PROGRESS_REFRESH_MS = 15_000L
/**
* One progress line as text: "Bandits: 3 of 8 left", or "The Juggernaut: 62%".
* Anything else renders nothing. Internal for the test.
*/
internal fun progressText(line: EventProgressDto, count: (String, Int, Int) -> String, percent: (String, Int) -> String): String? {
if (line.label.isBlank()) return null
val left = line.left
val of = line.of
if (left != null && of != null) return count(line.label, left, of)
return line.percent?.let { percent(line.label, it) }
}
@Composable
private fun EventBody(
event: PublicEventDto,
onOpenSeries: (String) -> Unit,
onOpenRun: (String, Long) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item(key = "head") {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = event.title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
event.summary?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
event.series?.let { series ->
Text(
text = stringResource(R.string.events_part_of, series.name),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onOpenSeries(series.slug) },
)
}
}
}
// The one fact a visitor came for, above the storyline rather than below
// it: whether it is happening now, and if not, when it next is.
item(key = "headline") { Headline(event) }
event.body?.takeIf { it.isNotBlank() }?.let { body ->
item(key = "body") {
ShardCard(Modifier.fillMaxWidth()) {
// Sanitized on write, the treatment a wiki page and a forum
// post already get.
HtmlText(body, Modifier.padding(16.dp))
}
}
}
event.results?.let { results ->
item(key = "results-head") {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = stringResource(R.string.events_results),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = eventDateTime(results.scheduledFor, event.timezone),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (results.participants.isEmpty()) {
item(key = "results-empty") {
Text(
text = stringResource(R.string.events_results_nobody),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
items(results.participants.size, key = { "p$it" }) { index ->
ParticipantRow(results.participants[index])
}
}
}
occurrenceSection(
key = "upcoming",
titleRes = R.string.events_coming_up,
list = event.upcoming,
timezone = event.timezone,
slug = event.slug,
onOpenRun = onOpenRun,
linkResults = false,
)
occurrenceSection(
key = "past",
titleRes = R.string.events_previously,
list = event.past,
timezone = event.timezone,
slug = event.slug,
onOpenRun = onOpenRun,
linkResults = true,
)
if (event.current == null && event.next == null && event.past.isEmpty()) {
item(key = "unscheduled") {
Text(
text = stringResource(R.string.events_never_scheduled),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun Headline(event: PublicEventDto) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
val current = event.current
when {
event.live && current != null -> {
StatusPill(
text = stringResource(R.string.events_status_live),
tone = PillTone.Success,
)
Text(
// The phase LABEL, and only while it is live.
text = current.phase?.takeIf { it.isNotBlank() }
?: stringResource(R.string.events_under_way),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
// How it stands, in the words the steps' modules chose.
val countWords = stringResource(R.string.events_progress_count)
val percentWords = stringResource(R.string.events_progress_percent)
current.progress.forEach { line ->
progressText(
line,
count = { label, left, of -> countWords.format(label, left, of) },
percent = { label, p -> percentWords.format(label, p) },
)?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
event.next != null -> {
Text(
text = stringResource(R.string.events_next),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = eventDateTime(event.next.scheduledFor, event.next.timezone ?: event.timezone),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
else -> Text(
text = stringResource(R.string.events_nothing_scheduled),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* A titled list of occurrences, or nothing at all when there are none.
*
* `linkResults` is what separates the two calls: only a PAST occurrence that
* actually published results gets its own tap target, because on any other one
* `?run=` would change nothing a reader could see.
*/
private fun androidx.compose.foundation.lazy.LazyListScope.occurrenceSection(
key: String,
titleRes: Int,
list: List<EventOccurrenceDto>,
timezone: String?,
slug: String,
onOpenRun: (String, Long) -> Unit,
linkResults: Boolean,
) {
if (list.isEmpty()) return
item(key = "$key-title") {
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
items(list.size, key = { "$key-${list[it].runId}" }) { index ->
val occurrence = list[index]
val tappable = linkResults && occurrence.resultsPublishedAt != null
ShardCard(
modifier = Modifier
.fillMaxWidth()
.then(
if (tappable) Modifier.clickable { onOpenRun(slug, occurrence.runId) }
else Modifier,
),
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = eventDateTime(occurrence.scheduledFor, occurrence.timezone ?: timezone),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(
statusWordRes(occurrence.status, occurrence.scheduledFor),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ParticipantRow(participant: EventParticipantDto) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = participant.rank?.toString() ?: "—",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
modifier = Modifier.width(32.dp),
)
Text(
// A module supplies a display name in its participation meta or it does
// not; the member key is never published, so there is genuinely nothing
// else to render.
text = participant.name?.takeIf { it.isNotBlank() }
?: stringResource(R.string.events_participant_unnamed),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Text(
text = scoreText(participant.score),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}

View File

@@ -0,0 +1,113 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* One arc (EVENTS.md §I, M13).
*
* **The arc is the thing the tooling this replaces could not express at all.** A
* calendar plugin has no series field, so "Royal Spy Mission → Risky Partner →
* Message From the Void" existed only in a GM's head and in whatever the forum
* post said. This screen is that continuity, in the order an editor arranged it —
* which is why the events are numbered rather than dated: an arc has an order, and
* its parts may be months apart or run out of sequence.
*/
@Composable
fun EventSeriesScreen(
onOpenEvent: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: EventSeriesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// Error first, then loading — the order Phase 13 had to fix one tier along.
when (val s = state) {
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Loading -> LoadingView(modifier)
is UiState.Success -> {
val series = s.data
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item(key = "head") {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = series.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
series.description?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
items(series.events.size, key = { series.events[it].slug }) { index ->
val entry = series.events[index]
ShardCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenEvent(entry.slug) },
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = (index + 1).toString(),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.End,
modifier = Modifier.width(32.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = entry.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
entry.summary?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,50 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.EventSeriesDto
import com.runicgateway.app.data.repository.EventsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* One arc (PLAN.md §9 M13).
*
* A series with nothing listed in it answers 404 rather than an empty page, so
* there is no "empty arc" state to render: the error branch is the whole of it,
* and that is the server's decision rather than this screen's — an empty page
* would publish that an operator has named something they have not announced.
*/
@HiltViewModel
class EventSeriesViewModel @Inject constructor(
private val repository: EventsRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val slug: String = savedStateHandle.get<String>(Routes.Args.SLUG).orEmpty()
private val _state = MutableStateFlow<UiState<EventSeriesDto>>(UiState.Loading)
val state: StateFlow<UiState<EventSeriesDto>> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.series(slug).toUiState()
}
}
}

View File

@@ -0,0 +1,166 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.annotation.StringRes
import com.runicgateway.app.R
import com.runicgateway.app.core.time.parseWireInstant
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
* Rendering an event's instant and its status word (EVENTS.md §I).
*
* Everything here is pure and takes its clock, zone and locale as parameters, so
* the rules below are unit-tested off-device rather than eyeballed on one.
*
* ## The split, which is the one thing about event times that is easy to get wrong
*
* The server returns UTC instants and never guesses the reader's zone. The client
* places them, and it places the two halves differently:
*
* - the **day** an entry is filed under is the READER's own — "what is on this
* month" is a question about the month the person holding the phone is living
* in;
* - the **time** beside it is always the EVENT's zone, carried on the entry —
* because every listing this feature replaces is written in the shard's local
* zone, and "8pm" means the shard's evening to everyone reading it.
*
* Rendering the time in the reader's zone instead is defensible and wrong here: a
* player in Berlin told an American shard's event is at 02:00 has been told
* something true and useless, and told it in a way that makes the shard's own
* announcement look like a mistake.
*/
/**
* A participation score, as a reader should see it.
*
* Scores are `DECIMAL(18,4)` on the wire because a module may score by distance,
* time or a weighted tally — but most score by counting, and rendering a plain
* tally of kills as `12.0` reads as a rounding artefact. So a whole number prints
* whole and a fraction keeps its digits, with trailing zeros trimmed: `1420`,
* `318.5`, `0.25`.
*/
fun scoreText(score: Double, locale: Locale = Locale.getDefault()): String {
if (!score.isFinite()) return "0"
if (score == Math.floor(score) && Math.abs(score) < 1e15) {
return String.format(locale, "%d", score.toLong())
}
return String.format(locale, "%.4f", score).trimEnd('0').trimEnd('.', ',')
}
/** The event's own wall clock, with the zone named so it misreads as nothing. */
fun eventTime(
instant: String?,
timezone: String?,
locale: Locale = Locale.getDefault(),
): String {
val at = parseWireInstant(instant) ?: return ""
val zone = eventZone(timezone)
val time = DateTimeFormatter.ofPattern("HH:mm", locale).withZone(zone).format(at)
return "$time ${shortZone(timezone)}"
}
/**
* The event's own day and time together, for a screen showing one occurrence.
*
* Localized rather than patterned, because a full date's field order is the
* locale's business; only the zone stays the event's.
*/
fun eventDateTime(
instant: String?,
timezone: String?,
locale: Locale = Locale.getDefault(),
): String {
val at = parseWireInstant(instant) ?: return ""
val zone = eventZone(timezone)
val text = DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
.withLocale(locale)
.withZone(zone)
.format(at)
return "$text ${shortZone(timezone)}"
}
/** The reader's own day, for the heading an entry is filed under. */
fun readerDayLabel(
instant: String?,
zone: ZoneId = ZoneId.systemDefault(),
locale: Locale = Locale.getDefault(),
): String {
val at = parseWireInstant(instant) ?: return ""
return DateTimeFormatter
.ofLocalizedDate(FormatStyle.FULL)
.withLocale(locale)
.withZone(zone)
.format(at)
}
/**
* The zone as a reader recognises it: `America/New_York` → `New York`.
*
* Not the abbreviation (`EDT`), which is unstable across the year and unknown to
* most readers of a shard in another country.
*/
fun shortZone(timezone: String?): String {
if (timezone.isNullOrBlank()) return "UTC"
return timezone.substringAfterLast('/').replace('_', ' ')
}
/**
* The event's zone, or UTC when its column holds something `java.time` will not
* read.
*
* A typo in a definition's timezone must still render: UTC off the instant is the
* honest answer when the zone cannot be honoured, and it is what the web client
* falls back to for the same reason.
*/
private fun eventZone(timezone: String?): ZoneId = try {
if (timezone.isNullOrBlank()) ZoneId.of("UTC") else ZoneId.of(timezone)
} catch (_: Exception) {
ZoneId.of("UTC")
}
/**
* The word beside an occurrence, for the four statuses the server publishes.
*
* **`cancelled` needs the instant, and that is the whole reason this takes one.**
* The server publishes `failed` and `missed` as `cancelled` too — to a visitor the
* three are one event, and the difference between them is about the deployment —
* but the three do not share one English sentence. *Did not happen* is right for a
* past occurrence and a plain falsehood for a future one, and a run four days out
* that an operator has called off is exactly the common case: this is the defect
* Phase 14a's own calendar shipped and the live walk caught, which is why it is
* restated here rather than ported.
*
* So **the tense follows the clock, not the status**. A future call-off reads
* *Cancelled*; a past one reads *Did not happen*, which is also the honest word
* for the failed and missed runs folded in with it.
*
* An unrecognised status reads *Scheduled*, mirroring the server's own fallback:
* `publicStatus()` folds anything it does not know to `scheduled`, so a word the
* app has never seen is a contract break rather than a state, and rendering a raw
* enum at a reader is not an improvement on it.
*/
@StringRes
fun statusWordRes(status: String?, scheduledFor: String?, now: Instant = Instant.now()): Int =
when (status) {
"live" -> R.string.events_status_live
"completed" -> R.string.events_status_completed
"cancelled" -> {
val at = parseWireInstant(scheduledFor)
// An unreadable instant is treated as past, which is the safer of the
// two: "did not happen" about something unplaceable in time is vague,
// while "cancelled" about a past run implies it is still coming.
if (at != null && at.isAfter(now)) {
R.string.events_status_cancelled
} else {
R.string.events_status_did_not_happen
}
}
else -> R.string.events_status_scheduled
}

View File

@@ -0,0 +1,71 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.PublicEventDto
import com.runicgateway.app.data.repository.EventsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* One event's page (PLAN.md §9 M13, EVENTS.md § API surface).
*
* **`run` is read from the route and passed through untouched**, because that is
* what an announcement's link carries. The page lives at the definition's slug —
* one stable address, so a link posted in Discord survives a retitle — and the
* occurrence has to be in the query or a mail about last Friday's invasion would
* open next Friday's.
*
* A run that belongs to some other event is **not** filtered here. The server
* ignores it and answers with this event anyway, which turns a stale link in a
* months-old mail into the page it was about rather than a dead end; second-
* guessing that would undo it.
*/
@HiltViewModel
class EventViewModel @Inject constructor(
private val repository: EventsRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val slug: String = savedStateHandle.get<String>(Routes.Args.SLUG).orEmpty()
/** Null unless the route carried one; never an empty string forwarded to the server. */
private val run: String? = savedStateHandle.get<String>(Routes.Args.RUN)?.takeIf { it.isNotBlank() }
private val _state = MutableStateFlow<UiState<PublicEventDto>>(UiState.Loading)
val state: StateFlow<UiState<PublicEventDto>> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.event(slug, run).toUiState()
}
}
/**
* Re-read while the event is live, for its progress lines (MODULE_API 1.12.0,
* runicnpc D304). Quiet: the page stays on screen, and a failed re-read keeps
* what is shown rather than replacing the page with an error.
*/
fun refresh() {
viewModelScope.launch {
val fresh = repository.event(slug, run).toUiState()
if (fresh is UiState.Success) _state.value = fresh
}
}
}

View File

@@ -0,0 +1,191 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.EventCalendarEntryDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* The public event calendar (EVENTS.md §I, M13).
*
* **A list, not a month grid**, which is the same call the web client makes and
* for the same reason: an operator's question is "what does this month look
* like" — coverage, clashes, the gap on the third weekend — and a grid answers
* it. A visitor's question is "what is on, and when is the next one", which a
* chronological list answers in one glance and a grid answers by making them
* count squares. On a phone the grid is not even a close second.
*
* **A projection is drawn differently from a run**, one tier along from the
* operator's own reason for the distinction: past the materialisation horizon
* there is no row, nothing is committed to, and nothing can be cancelled. Drawing
* a forecast identically to a booking would be the screen promising something the
* server has not.
*/
@Composable
fun EventsScreen(
onOpenEvent: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: EventsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> {
val entries = s.data.entries
if (entries.isEmpty()) {
EmptyView(stringResource(R.string.events_empty), modifier)
} else {
Calendar(entries, s.data.truncated, onOpenEvent, modifier)
}
}
}
}
/**
* Group by the READER's day, preserving the server's order rather than re-sorting.
*
* Internal + pure so the grouping — and the fact that it never reorders — is
* unit-tested without Compose.
*/
internal fun groupByReaderDay(entries: List<EventCalendarEntryDto>): List<CalendarDayGroup> {
val days = mutableListOf<CalendarDayGroup>()
for (entry in entries) {
val label = readerDayLabel(entry.scheduledFor)
val last = days.lastOrNull()
if (last != null && last.label == label) {
last.entries.add(entry)
} else {
days.add(CalendarDayGroup(label, mutableListOf(entry)))
}
}
return days
}
/** A mutable builder shape for [groupByReaderDay]; the screen only reads it. */
internal data class CalendarDayGroup(
val label: String,
val entries: MutableList<EventCalendarEntryDto>,
)
@Composable
private fun Calendar(
entries: List<EventCalendarEntryDto>,
truncated: Boolean,
onOpenEvent: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val days = groupByReaderDay(entries)
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
days.forEach { day ->
item(key = "day-${day.label}") {
Text(
text = day.label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
items(
items = day.entries,
key = { "${it.slug}-${it.scheduledFor}-${it.kind}" },
) { entry ->
EntryCard(entry, onOpenEvent)
}
}
if (truncated) {
item(key = "truncated") {
Text(
text = stringResource(R.string.events_truncated),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun EntryCard(entry: EventCalendarEntryDto, onOpenEvent: (String) -> Unit) {
ShardCard(
modifier = Modifier
.fillMaxWidth()
// A projection has a page too — the definition's — so it opens like any
// other entry. What it does not have is an occurrence to link to.
.clickable { onOpenEvent(entry.slug) },
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = entry.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
StatusPill(
text = stringResource(statusWordRes(entry.status, entry.scheduledFor)),
tone = if (entry.live) PillTone.Success else PillTone.Neutral,
)
}
Text(
text = eventTime(entry.scheduledFor, entry.timezone),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
entry.seriesName?.takeIf { it.isNotBlank() }?.let { series ->
Text(
text = series,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (entry.isProjected) {
// Said in words rather than drawn as a dashed border, because a
// phone reader skimming a list will not decode a border and the
// distinction is worth more than the pixel it would cost.
Text(
text = stringResource(R.string.events_projected),
style = MaterialTheme.typography.bodySmall,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View File

@@ -0,0 +1,51 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.EventCalendarDto
import com.runicgateway.app.data.repository.EventsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The public event calendar (PLAN.md §9 M13, EVENTS.md §I).
*
* **No window is asked for**, and that is the whole of this view model's design.
* The server's default is now through 31 days out, so a client that computed a
* window before it could ask anything would make every deep link carry two ISO
* instants and would have to agree with the server about what "now" is. The
* window bound and the entry cap are the server's defence on the one surface with
* no login in front of it; there is nothing for the app to add.
*
* `toUiState`, not `toShardUiState`: these are CORE routes. A 404 here means the
* backend has no events at all, not that an admin switched a shard surface off,
* and offering "not published here" for it would name the wrong cause.
*/
@HiltViewModel
class EventsViewModel @Inject constructor(
private val repository: EventsRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<EventCalendarDto>>(UiState.Loading)
val state: StateFlow<UiState<EventCalendarDto>> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.calendar().toUiState()
}
}
}

View File

@@ -0,0 +1,142 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* This account's event participation (EVENTS.md §J, M13).
*
* **The screen's one real design decision is what an unranked row says.** A run
* whose participants were collected but whose results have not been published has
* a score and no rank, and that is a real state rather than an error — it is the
* same state the admin run console has shown since events Phase 10. Rendering a
* dash with nothing explaining it would read as a bug; the row says the results
* are not published, which is a fact about the event rather than about the reader.
*
* Reached by **one drawer row for every signed-in account**, players and staff
* alike. The website mounts this twice only because its `RequirePlayer` guard sits
* over `/account` and the route behind it is role-agnostic; the app has no such
* wall, so it needs no second mount.
*/
@Composable
fun MyEventsScreen(
onOpenRun: (String, Long) -> Unit,
modifier: Modifier = Modifier,
viewModel: MyEventsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val items = state.items) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(items.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> if (items.data.isEmpty()) {
EmptyView(stringResource(R.string.events_history_empty), modifier)
} else {
androidx.compose.foundation.lazy.LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items.data.size, key = { items.data[it].id }) { index ->
HistoryRow(items.data[index], onOpenRun)
}
if (state.hasMore) {
item(key = "more") {
TextButton(
onClick = viewModel::loadMore,
enabled = !state.loadingMore,
modifier = Modifier.fillMaxWidth(),
) {
Text(
stringResource(
if (state.loadingMore) R.string.events_loading
else R.string.events_show_more,
),
)
}
}
}
}
}
}
}
@Composable
private fun HistoryRow(entry: EventHistoryEntryDto, onOpenRun: (String, Long) -> Unit) {
ShardCard(
modifier = Modifier
.fillMaxWidth()
// Straight to the occurrence the reader took part in, not to whatever
// is next: `?run=` is what makes the event page answer about this one.
.clickable { onOpenRun(entry.slug, entry.runId) },
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = entry.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = eventDateTime(entry.scheduledFor, entry.timezone),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
entry.seriesName?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = entry.rank
?.let { stringResource(R.string.events_rank, it) }
?: stringResource(R.string.events_results_unpublished),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.End,
)
Text(
text = stringResource(R.string.events_score, scoreText(entry.score)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View File

@@ -0,0 +1,117 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
import com.runicgateway.app.data.repository.EventsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* This account's event participation (PLAN.md §9 M13, EVENTS.md §J).
*
* **Self-scoped by the session and nothing else.** There is no id parameter on
* the route and deliberately none here: one account never reads another's, and
* there is no argument that could later grow into one.
*
* **Keyset-paged on the participation row's own id, never an offset** — the list
* gains a row every time the reader attends something, so an offset page would
* skip and repeat rows around the seam.
*
* ## Why this watches the session, when no other screen here does
*
* **A drawer route's view model outlives a sign-out.** `navigateTopLevel` saves
* and restores back-stack state, so the `NavBackStackEntry` for this route keeps
* its `ViewModelStore` across a sign-out and a sign-in as somebody else — and a
* view model that loads only in `init` never runs again. The live walk found the
* consequence: signing out of an admin account and back in as a player showed the
* PLAYER the admin's participation history, with no request made at all.
*
* The public event screens have the same lifetime and do not care, because a
* calendar is the same for everybody. This one is per-account, so the account is
* what it keys on: the flow emits the current session immediately, which is also
* the first load, and re-emits only when the signed-in id actually changes — a
* resume revalidation returning the same user does not refetch.
*/
@HiltViewModel
class MyEventsViewModel @Inject constructor(
private val repository: EventsRepository,
sessionManager: SessionManager,
) : ViewModel() {
data class State(
val items: UiState<List<EventHistoryEntryDto>> = UiState.Loading,
val hasMore: Boolean = false,
val loadingMore: Boolean = false,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
viewModelScope.launch {
sessionManager.state
.map { (it as? Session.SignedIn)?.user?.id }
.distinctUntilChanged()
.collect { userId ->
// Signed out: drop the rows rather than leave the last
// account's on screen behind a shell that is about to
// navigate away.
if (userId == null) _state.value = State(items = UiState.Success(emptyList()))
else load()
}
}
}
fun load() {
_state.value = State()
viewModelScope.launch {
val result = repository.history(PAGE)
_state.value = State(
items = result.toUiState(),
// A full page means there is probably another; a short one is the
// end. One request rather than a count the server does not send.
hasMore = (result as? ApiResult.Ok)?.data?.size == PAGE,
)
}
}
fun loadMore() {
val current = _state.value
val shown = (current.items as? UiState.Success)?.data ?: return
val last = shown.lastOrNull() ?: return
if (current.loadingMore || !current.hasMore) return
_state.value = current.copy(loadingMore = true)
viewModelScope.launch {
when (val result = repository.history(PAGE, before = last.id)) {
is ApiResult.Ok -> _state.value = State(
items = UiState.Success(shown + result.data),
hasMore = result.data.size == PAGE,
)
// A failed NEXT page keeps the pages already read rather than
// replacing a screenful of history with an error: the reader can
// still see what loaded, and tapping again retries.
else -> _state.value = current.copy(loadingMore = false)
}
}
}
private companion object {
const val PAGE = 25
}
}

View File

@@ -32,6 +32,7 @@ import com.runicgateway.app.ui.components.FeatureCard
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.OnlineDot
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.demo.DemoBanner
/**
* Home / status (PLAN.md §6.1): the shard's name + tagline from branding, and a
@@ -44,11 +45,18 @@ fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val demo by viewModel.demo.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> HomeContent(brand, s.data, modifier)
Column(modifier = modifier.fillMaxSize()) {
// The public demo's strip (DEMO_MODE.md §6), above whatever the status read
// came back with, so it shows even while the status is loading or failed.
demo?.let { DemoBanner(info = it, signInPage = viewModel.signInPage) }
val content = Modifier.weight(1f)
when (val s = state) {
is UiState.Loading -> LoadingView(content)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = content)
is UiState.Success -> HomeContent(brand, s.data, content)
}
}
}

View File

@@ -5,9 +5,13 @@ package com.runicgateway.app.ui.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.demo.DemoInfo
import com.runicgateway.app.ui.demo.signInPageLabel
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -16,15 +20,27 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Loads site status (mode + version) for the Home screen (PLAN.md §6.1). */
/**
* Loads site status (mode + version) for the Home screen (PLAN.md §6.1), and the
* public demo's banner data when the site is one (`docs/website/DEMO_MODE.md` §6).
*/
@HiltViewModel
class HomeViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val baseUrlHolder: BaseUrlHolder,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<StatusDto>>(UiState.Loading)
val state: StateFlow<UiState<StatusDto>> = _state.asStateFlow()
private val _demo = MutableStateFlow<DemoInfo?>(null)
/** The demo strip's data; null on every real shard, and while it is unknown. */
val demo: StateFlow<DemoInfo?> = _demo.asStateFlow()
/** The site's sign-in page, for the strip's logins sentence; null if no site is set. */
val signInPage: String? get() = baseUrlHolder.current?.let(::signInPageLabel)
init { load() }
fun load() {
@@ -32,5 +48,11 @@ class HomeViewModel @Inject constructor(
viewModelScope.launch {
_state.value = settingsRepository.getStatus().toUiState()
}
// A separate read, and silent: the strip is a nicety, so a failed settings
// call keeps whatever was shown rather than surfacing an error of its own.
viewModelScope.launch {
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
_demo.value = DemoInfo.from(settings.demo)
}
}
}

View File

@@ -6,9 +6,12 @@ package com.runicgateway.app.ui.navigation
import androidx.annotation.StringRes
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.data.repository.Capability
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.SiteCapabilities
import com.runicgateway.app.data.repository.canSee
import com.runicgateway.app.data.repository.canUse
/**
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
@@ -52,6 +55,20 @@ data class MenuEntry(
* isn't shard-derived and only [access] applies.
*/
val feature: String? = null,
/**
* The backend capability this row needs, or null when it needs none (M13).
*
* **A different question from [feature], which is why it is a second field
* and not a wider one.** This asks whether the code behind the row is
* *installed at all* — a per-HOST fact, from `GET /public/modules` and core's
* own list — while [feature] asks whether this shard publishes that surface
* to *this viewer*, which is per-viewer and admin-configurable. A site with no
* game module has no `shard` capability and no shard rows, whoever is looking;
* a site with one may still hide its market from anonymous visitors.
*
* The two also fail differently, and [canUse] is where that lives.
*/
val capability: String? = null,
/**
* An admin's own label for this row, from the shard's `nav_public` override
* (THEMING_AND_NAV.md §6). Null — always, as coded — means [labelRes] stands.
@@ -71,47 +88,159 @@ data class MenuEntry(
val APP_MENU: List<MenuEntry> = listOf(
MenuEntry(Routes.HOME, R.string.menu_home),
MenuEntry(Routes.NEWS, R.string.menu_news),
// Events are CORE's, so this row is gated on core's own capability rather than
// a module's: a site with no game module still has a calendar. Placed here to
// match the website's own nav, where Events is the row after News.
MenuEntry(Routes.EVENTS, R.string.menu_events, capability = Capability.EVENTS),
MenuEntry(Routes.WIKI, R.string.menu_wiki),
MenuEntry(Routes.SHARD, R.string.menu_shard, feature = ShardFeature.STATUS),
// The shard group. Every row needs the game module INSTALLED (one capability,
// because that is the only question a capability can answer) and its own
// feature published to this viewer (M11) — both, independently.
MenuEntry(
Routes.SHARD,
R.string.menu_shard,
feature = ShardFeature.STATUS,
capability = Capability.SHARD,
),
// Protocol 3.0 shard content (M11). Each hides when the shard doesn't publish it,
// which for a brand-new install is every one of them until the plugin has swept.
MenuEntry(Routes.SHARD_RULES, R.string.menu_rules, feature = ShardFeature.RULESET),
MenuEntry(Routes.ATLAS, R.string.menu_atlas, feature = ShardFeature.ATLAS),
MenuEntry(Routes.SHARD_LEADERBOARDS, R.string.menu_leaderboards, feature = ShardFeature.LEADERBOARDS),
MenuEntry(Routes.SHARD_MARKET, R.string.menu_market, feature = ShardFeature.MARKET),
MenuEntry(
Routes.SHARD_RULES,
R.string.menu_rules,
feature = ShardFeature.RULESET,
capability = Capability.SHARD,
),
MenuEntry(
Routes.ATLAS,
R.string.menu_atlas,
feature = ShardFeature.ATLAS,
capability = Capability.SHARD,
),
MenuEntry(
Routes.SHARD_LEADERBOARDS,
R.string.menu_leaderboards,
feature = ShardFeature.LEADERBOARDS,
capability = Capability.SHARD,
),
MenuEntry(
Routes.SHARD_MARKET,
R.string.menu_market,
feature = ShardFeature.MARKET,
capability = Capability.SHARD,
),
// The Rust module (M14). ONE row, because the module's whole public surface is
// one list and one page beneath it — `/rust` IS the server list, not a hub
// above one.
//
// **No `feature`, and that is not an omission.** The visibility framework is
// `module-uo`'s own (`shardVisibility`, six files under `module-uo/server/`
// and none under core's), and §2.7 forbids a module importing another's — so
// `module-rust` has no per-viewer visibility layer yet. Its phase 14 builds
// one; until then these routes are public to everyone the site is public to,
// and a `feature` here would be gating on a flag nothing publishes.
MenuEntry(Routes.RUST, R.string.menu_rust, capability = Capability.RUST),
MenuEntry(Routes.page("about"), R.string.menu_about),
MenuEntry(Routes.CONTACT, R.string.menu_contact),
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
MenuEntry(Routes.NOTIFICATIONS, R.string.menu_notifications, MenuAccess.SIGNED_IN),
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
// Participation history: SIGNED_IN, not PLAYER. The route is `requireAuth`
// alone and self-scoped on the caller's own id, and the website needed two
// mounts for it only because `RequirePlayer` guards `/account` there. Staff
// attend events too, and event history is not game-linked data.
MenuEntry(
Routes.MY_EVENTS,
R.string.menu_my_events,
MenuAccess.SIGNED_IN,
capability = Capability.EVENTS,
),
// These three read `/player/shard/*`, which is the SAME module's player mount —
// so they need the capability for the same reason the public rows do. They
// carry no `feature`, because the visibility framework covers the public
// surfaces and these are self-service, gated by role and ownership instead.
// That asymmetry is exactly why the live walk found them and the suite did
// not: "a shard row" had been defined as "a row with a feature".
MenuEntry(
Routes.PLAYER_CHARACTERS,
R.string.menu_my_characters,
MenuAccess.PLAYER,
capability = Capability.SHARD,
),
MenuEntry(
Routes.PLAYER_VENDORS,
R.string.menu_my_vendors,
MenuAccess.PLAYER,
capability = Capability.SHARD,
),
MenuEntry(
Routes.PLAYER_HOUSES,
R.string.menu_my_houses,
MenuAccess.PLAYER,
capability = Capability.SHARD,
),
// The player's own Rust account (M15). Beside the three UO rows above and
// gated exactly as they are: the module's `rust` capability (a row needs the
// code behind it INSTALLED) and `PLAYER` access, which is `isPlayer ||
// isStaff` — staff play too, and `/player/rust/*` is `requireAuth` alone.
//
// **`rust`, not `identity`.** The module declares a surface word for each of
// its features, and D16's rule is that a capability answers one question —
// *is the module there* — so a surface word is not what a row hangs on. The
// paths these screens call are knowledge the app has from reading the
// module's router, exactly as §2.9 requires.
MenuEntry(
Routes.PLAYER_RUST,
R.string.menu_rust_account,
MenuAccess.PLAYER,
capability = Capability.RUST,
),
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
MenuEntry(Routes.ADMIN_MODERATION, R.string.menu_admin_moderation, MenuAccess.MODERATOR),
MenuEntry(Routes.ADMIN_SUPPORT, R.string.menu_admin_support, MenuAccess.MODERATOR),
// Moderation and the support queue act on the shard (`/admin/shard/*`, module-uo's
// admin mount), so they need the module installed like the player rows do. On a
// Rust-only site they once showed a UO form whose every action failed (M20).
MenuEntry(
Routes.ADMIN_MODERATION,
R.string.menu_admin_moderation,
MenuAccess.MODERATOR,
capability = Capability.SHARD,
),
MenuEntry(
Routes.ADMIN_SUPPORT,
R.string.menu_admin_support,
MenuAccess.MODERATOR,
capability = Capability.SHARD,
),
)
/**
* The entries the given [session] may see, given the shard [features] it may reach.
* Pure + side-effect-free so the gating is unit-tested without Compose.
* The entries the given [session] may see, on a backend with these [capabilities]
* and this shard's [features]. Pure + side-effect-free so the gating is unit-tested
* without Compose.
*
* Two independent filters, and both must pass:
* Three independent filters, and all three must pass:
*
* - [MenuEntry.access] against the session — who the caller is.
* - [MenuEntry.capability] against what this backend serves — whether the code
* behind the row is installed at all (M13). Per host.
* - [MenuEntry.feature] against the shard's live visibility config — what this shard
* publishes at all (M11). `null` [features] means the answer isn't known yet and
* every shard entry shows; see [canSee] for why that direction is deliberate.
* publishes to this viewer (M11). Per viewer.
*
* **The last two both fail open on an unknown answer, but "unknown" means
* different things to them.** A `null` [features] is unknown; so is a `null`
* [capabilities] — but a *non-null* [capabilities] that does not name the string
* is an ANSWER, and it hides. Without that, a site with no game module renders
* five shard rows that each 404. See [canUse].
*/
fun visibleEntries(
entries: List<MenuEntry>,
session: Session,
features: ShardFeatures? = null,
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features) }
capabilities: SiteCapabilities? = null,
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features, capabilities) }
/**
* [visibleEntries] for a single entry — the same two filters, and the same
* [visibleEntries] for a single entry — the same three filters, and the same
* boundary. Split out because the drawer is a tree once an admin groups rows into
* sections (§6.3): [pruneNav] applies this predicate inside a section as well, and
* both callers must ask exactly one question or a sectioned row could be gated by
@@ -121,6 +250,7 @@ fun isEntryVisible(
entry: MenuEntry,
session: Session,
features: ShardFeatures? = null,
capabilities: SiteCapabilities? = null,
): Boolean {
val allowedByRole = when (entry.access) {
MenuAccess.PUBLIC -> true
@@ -129,5 +259,7 @@ fun isEntryVisible(
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
}
return allowedByRole && (entry.feature == null || canSee(features, entry.feature))
return allowedByRole &&
canUse(capabilities, entry.capability) &&
(entry.feature == null || canSee(features, entry.feature))
}

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
import com.runicgateway.app.ui.rust.RustTab
/**
* The website path → app route table (THEMING_AND_NAV.md §6.2).
@@ -11,34 +12,77 @@ import com.runicgateway.app.data.repository.ContentRepository.PostCategory
* The public nav an admin edits is keyed by **website** paths, so honoring it in
* the app needs a translation. This is the one new piece of cross-repo coupling
* the milestone introduces, which is why it lives in a single file with the
* website's own array quoted right beside it — the coupling is visible and
* website's own arrays quoted right beside it — the coupling is visible and
* reviewable in one place rather than spread across the drawer's call sites.
*
* ## The nav is TWO arrays now, and that is what M13 had to correct
*
* This file was written when the website's public nav was one sixteen-row array.
* Since the module-system cutover on 2026-08-12 it is **core's eight rows plus
* every installed module's**, interleaved at render time by `withModuleNav`, and
* a module's pages are mounted by core at `/<module id>/<path>` — so the nine
* shard rows moved from `/site/champs` to `/uo/champs` and this table stopped
* resolving any of them. Three things followed, all of them true of the shipped
* app until M13: a nav override on a shard row was ignored, an added link to a
* shard page handed off to a browser instead of opening natively, and the sort
* key line below was a sixteen-row line against a nav numbered differently.
*
* **The nine `/uo/` paths are hardcoded, and they are ONE module's.** The alternative
* — reading the installed module's id from `GET /public/modules` and building
* `/<id>/shard` — is forbidden by `MODULE_API.md` §2.9 (*"a client must not infer
* a route from a capability"*) and would hardcode the same path shape less
* visibly. A site running a different game module matches none of these nine, its
* links hand off to a Custom Tab, and that is the correct answer rather than a
* gap: core cannot tell the app what another module calls its pages.
*
* Verbatim from `website/client/src/components/SiteHeader.jsx`, which is the
* exported owner of the list (`export const NAV`, and Admin → Navigation edits
* exported owner of core's list (`export const NAV`, and Admin → Navigation edits
* exactly it):
*
* ```js
* export const NAV = [
* { label: 'Home', to: '/', end: true },
* { label: 'News', to: '/site/news' },
* { label: 'Events', to: '/site/events' },
* { label: 'Screenshots', to: '/site/screenshots' },
* { label: 'Five on Friday', to: '/site/five-on-friday' },
* { label: 'Newsletter', to: '/site/newsletter' },
* { label: 'Wiki', to: '/wiki' },
* { label: 'Shard', to: '/site/shard', feature: 'status' },
* { label: 'Champions', to: '/site/champs', feature: 'champs' },
* { label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
* { label: 'Governors', to: '/site/governors', feature: 'governors' },
* { label: 'Houses', to: '/site/houses', feature: 'houses' },
* { label: 'Rules', to: '/site/rules', feature: 'ruleset' },
* { label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
* { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
* { label: 'Market', to: '/site/market', feature: 'market' },
* { label: 'About', to: '/site/about' },
* ]
* ```
*
* and from `module-uo/client/src/entry.jsx`, which registers the rest:
*
* ```jsx
* registry.registerNav(ID, {
* area: 'public',
* items: [
* { label: 'Shard', to: '/uo/shard', feature: 'status' },
* { label: 'Champions', to: '/uo/champs', feature: 'champs' },
* { label: 'Guilds', to: '/uo/guilds', feature: 'guilds' },
* { label: 'Governors', to: '/uo/governors', feature: 'governors' },
* { label: 'Houses', to: '/uo/houses', feature: 'houses' },
* { label: 'Rules', to: '/uo/rules', feature: 'ruleset' },
* { label: 'Atlas', to: '/uo/atlas', feature: 'atlas' },
* { label: 'Leaderboards', to: '/uo/leaderboards', feature: 'leaderboards' },
* { label: 'Market', to: '/uo/market', feature: 'market' },
* ],
* })
* ```
*
* and from `module-rust/client/src/entry.jsx`, which registers one (M14):
*
* ```jsx
* registry.registerNav(ID, {
* area: 'public',
* items: [{ label: 'Servers', to: '/rust' }],
* })
* ```
*
* None of those nine declares an `order`, so `mergeFlat` appends them after core's
* rows in registration order — which is the order they are listed in below.
*
* The `feature` values are **not** mirrored here on purpose. [APP_MENU] is 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. This table carries the
@@ -58,27 +102,43 @@ data class WebNavPath(val path: String, val route: String)
* *this* list (the admin's editor writes the position a row holds on the web), so
* a row the admin never moved has to take its key from the same number line or
* explicit and implicit keys would be incomparable. See `NavOverrides.kt`.
*
* Core's eight first, then the module's nine, because that is what `withModuleNav`
* renders and therefore what the admin's editor numbered.
*/
val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
WebNavPath("/", Routes.HOME),
WebNavPath("/site/news", Routes.NEWS),
WebNavPath("/site/events", Routes.EVENTS),
// The app's News screen carries all four categories as tabs, so these three
// have a route but no drawer row of their own — see the note below.
WebNavPath("/site/screenshots", Routes.news(PostCategory.SCREENSHOTS)),
WebNavPath("/site/five-on-friday", Routes.news(PostCategory.FIVE_ON_FRIDAY)),
WebNavPath("/site/newsletter", Routes.news(PostCategory.NEWSLETTER)),
WebNavPath("/wiki", Routes.WIKI),
WebNavPath("/site/shard", Routes.SHARD),
// Behind the Shard hub in the app, deliberately — no drawer row either.
WebNavPath("/site/champs", Routes.SHARD_CHAMPS),
WebNavPath("/site/guilds", Routes.SHARD_GUILDS),
WebNavPath("/site/governors", Routes.SHARD_GOVERNORS),
WebNavPath("/site/houses", Routes.SHARD_HOUSES),
WebNavPath("/site/rules", Routes.SHARD_RULES),
WebNavPath("/site/atlas", Routes.ATLAS),
WebNavPath("/site/leaderboards", Routes.SHARD_LEADERBOARDS),
WebNavPath("/site/market", Routes.SHARD_MARKET),
WebNavPath("/site/about", Routes.page("about")),
// module-uo's rows. Mounted by core at `/<module id>/<path>`, which is why
// every one of these is `/uo/` and not `/site/`.
WebNavPath("/uo/shard", Routes.SHARD),
// Behind the Shard hub in the app, deliberately — no drawer row either.
WebNavPath("/uo/champs", Routes.SHARD_CHAMPS),
WebNavPath("/uo/guilds", Routes.SHARD_GUILDS),
WebNavPath("/uo/governors", Routes.SHARD_GOVERNORS),
WebNavPath("/uo/houses", Routes.SHARD_HOUSES),
WebNavPath("/uo/rules", Routes.SHARD_RULES),
WebNavPath("/uo/atlas", Routes.ATLAS),
WebNavPath("/uo/leaderboards", Routes.SHARD_LEADERBOARDS),
WebNavPath("/uo/market", Routes.SHARD_MARKET),
// module-rust's one row (M14). It registers `{ label: 'Servers', to: '/rust' }`
// and nothing else — `/rust` IS the server list, because core strips the
// trailing separator from a module route registered with `path: ''`.
//
// **Both modules can be installed on one backend**, and then the nav is core's
// eight plus ten. This table is a superset by design: a path here for a module
// an operator has NOT installed never appears in that backend's nav and so is
// never looked up, while a path missing from it makes a link that exists hand
// off to a browser.
WebNavPath("/rust", Routes.RUST),
)
/**
@@ -134,6 +194,16 @@ private fun normalizeWebPath(path: String?): String? {
*/
private val RESERVED_TOP_LEVEL = setOf(
"admin", "account", "player", "site", "wiki", "invite", "preview", "api", "uploads",
// An installed module's pages are mounted at `/<id>/…` and are not CMS pages.
// Only ids the app knows about need listing: an unknown module's `/<id>` would
// resolve to a CMS page that 404s, which is the same answer the browser gives
// it, and core cannot enumerate them for us here anyway.
//
// `rust` is here for the opposite reason to the rest: `/rust` DOES resolve, to
// the server list, and it does so through the nav table above — this set only
// stops the CMS-page fallback claiming it. Without the entry a site with the
// module absent would open a page-not-found screen instead of the browser.
"uo", "rust",
)
/**
@@ -153,16 +223,18 @@ private val RESERVED_TOP_LEVEL = setOf(
* <Route path="/site/five-on-friday" element={<FiveOnFriday />} />
* <Route path="/site/newsletter" element={<Newsletter />} />
* <Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
* <Route path="/site/events" element={<Events />} />
* <Route path="/site/events/series/:slug" element={<EventSeries />} />
* <Route path="/site/events/:slug" element={<EventPage />} />
* <Route path="/site/about" element={<About />} />
* <Route path="/site/status" element={<Status />} />
* <Route path="/site/shard" element={<Shard />} />
* <Route path="/site/shard/activity" element={<ShardActivity />} />
* ... /site/champs, /guilds, /governors, /houses, /rules, /leaderboards, /market
* <Route path="/site/atlas" element={<Atlas />} />
* <Route path="/site/atlas/:slug" element={<AtlasCreature />} />
* <Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
* <Route path="/wiki" element={<Wiki />} />
* <Route path="/wiki/:slug" element={<WikiArticle />} />
* // Installed modules' pages, mounted at `/<module id>/<path>`:
* // /uo/shard, /uo/shard/activity, /uo/champs, /uo/guilds, /uo/guilds/:id,
* // /uo/governors, /uo/houses, /uo/rules, /uo/leaderboards, /uo/market,
* // /uo/market/vendors/:serial, /uo/atlas, /uo/atlas/:slug
* // /rust, /rust/servers/:id
* // CMS pages: top-level /:slug, matched only after the named routes above
* <Route path="/:slug" element={<CmsPage />} />
* ```
@@ -177,19 +249,40 @@ private val RESERVED_TOP_LEVEL = setOf(
* /site/{screenshots,five-on-friday,newsletter}
* → NEWS, that category's tab
* /site/newsletter/<id> → POST (the site's one post-detail route)
* /site/events → EVENTS
* /site/events/series/<slug> → EVENT_SERIES
* /site/events/<slug>[?run=<id>] → EVENT (the one route that takes a query)
* /wiki → WIKI
* /wiki/<slug> → WIKI_PAGE
* /site/<shard surface> → the mapped shard route (§6.2)
* /site/atlas/<slug> → ATLAS_CREATURE
* /site/market/vendors/<serial> → SHARD_MARKET_VENDOR
* /uo/<shard surface> → the mapped shard route (§6.2)
* /uo/atlas/<slug> → ATLAS_CREATURE
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
* /rust → RUST (module-rust's server list)
* /rust/servers/<id>[?tab=<tab>] → RUST_SERVER (a tab the app has; Rust D71)
* /player/rust → PLAYER_RUST (Rust D71)
* /site/about → PAGE("about")
* /<slug> → PAGE(slug), unless <slug> is reserved
* anything else → null, i.e. the Custom Tab
* ```
*
* **A path carrying a query or a fragment hands off**, whatever its route part
* says. No app route takes either, so a native match would quietly drop what the
* admin wrote; the browser honors it exactly.
* **A path carrying a query or a fragment hands off — with exactly one
* exception.** The rule exists because no app route took either, so a native
* match would quietly drop what the admin wrote while the browser honors it. The
* event page (M13) is the first route that takes a query, and it takes one key:
* `run`, which is what every `event.` announcement's `eventUrl` carries. So a
* `?run=` on an event path resolves natively and **anything else in a query
* string, any second parameter, and any fragment still hand off** — the carve-out
* is one key on one path, not a general "parse the query".
*
* **Rust D71 adds the second, on the same terms:** `tab` on a Rust server's path,
* which is what the *new leader* notice links to. It resolves only when the value
* is a tab the app has. `?tab=clans` still hands off, because the website has a
* Clans tab and the app does not, and opening the feed instead would be the quiet
* drop this rule exists to prevent.
*
* That narrowness is the point: an admin who writes `/site/events/x?utm=mail` gets
* the browser, which honors `utm`, rather than an app screen that silently ignored
* it.
*
* Resolving a path is not the same as being allowed to see the screen behind it.
* A link to `/site/market` on a shard that does not publish the market lands on
@@ -197,24 +290,96 @@ private val RESERVED_TOP_LEVEL = setOf(
* URL on the web does too (§6.3).
*/
fun resolveWebPath(path: String?): String? {
val normalized = normalizeWebPath(path) ?: return null
if (normalized.any { it == '?' || it == '#' }) return null
WEB_PATH_TO_ROUTE[normalized]?.let { return it }
val raw = path?.trim().orEmpty()
// A fragment is never honored natively: no app route has one to put it in.
if (raw.isEmpty() || '#' in raw) return null
val queryAt = raw.indexOf('?')
val query = if (queryAt >= 0) raw.substring(queryAt + 1) else ""
val normalized = normalizeWebPath(if (queryAt >= 0) raw.substring(0, queryAt) else raw)
?: return null
if (query.isEmpty()) WEB_PATH_TO_ROUTE[normalized]?.let { return it }
if (!normalized.startsWith("/")) return null
// Blank segments ("/site//news") mean a malformed path, not a slug.
val segments = normalized.removePrefix("/").split('/')
if (segments.any { it.isBlank() }) return null
// The one path that may carry a query, and the one key it may carry. Checked
// before the general "a query hands off" rule below, and nowhere else.
if (segments.size == 3 && segments[0] == "site" && segments[1] == "events" &&
segments[2] != "series"
) {
// No query is the ordinary case — a link to the event rather than to one
// of its occurrences. A query is honored only when it is exactly the run.
if (query.isEmpty()) return Routes.event(segments[2])
val run = runParam(query) ?: return null
return Routes.event(segments[2], run)
}
if (segments.size == 3 && segments[0] == MODULE_RUST && segments[1] == "servers" && query.isNotEmpty()) {
val tab = rustTabParam(query) ?: return null
return Routes.rustServer(segments[2], tab)
}
if (query.isNotEmpty()) return null
return when {
segments.size == 1 -> segments[0].takeIf { it !in RESERVED_TOP_LEVEL }?.let(Routes::page)
segments[0] == "wiki" && segments.size == 2 -> Routes.wikiPage(segments[1])
segments[0] != "site" -> null
segments.size == 3 && segments[1] == "newsletter" ->
segments[0] == "site" && segments.size == 3 && segments[1] == "newsletter" ->
Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2])
segments.size == 3 && segments[1] == "atlas" -> Routes.atlasCreature(segments[2])
segments.size == 4 && segments[1] == "market" && segments[2] == "vendors" ->
Routes.marketVendor(segments[3])
segments[0] == "site" && segments.size == 4 && segments[1] == "events" &&
segments[2] == "series" -> Routes.eventSeries(segments[3])
segments[0] == MODULE_UO && segments.size == 3 && segments[1] == "atlas" ->
Routes.atlasCreature(segments[2])
segments[0] == MODULE_UO && segments.size == 4 && segments[1] == "market" &&
segments[2] == "vendors" -> Routes.marketVendor(segments[3])
// module-rust's one page below the list. `/rust` itself is already
// answered by the nav table above, before this fallback is reached.
segments[0] == MODULE_RUST && segments.size == 3 && segments[1] == "servers" ->
Routes.rustServer(segments[2])
// The player's own Rust account, which the *account linked* notice links
// to (D71). Not in the nav table above, which is built from the PUBLIC
// nav; a signed-out tap is caught by the player gate like any other.
segments.size == 2 && segments[0] == "player" && segments[1] == MODULE_RUST -> Routes.PLAYER_RUST
else -> null
}
}
/**
* The `run` value of a query that consists of **exactly** `run=<something>`, or
* null for every other query — including one that merely contains a `run` among
* others.
*
* Deliberately not a query parser. A second parameter means the writer meant
* something the app cannot honor, and the honest answer to that is the browser.
* An empty value (`?run=`) is null too: it would reach the screen as a blank
* string and be forwarded to the server as one.
*/
private fun runParam(query: String): String? {
val value = query.removePrefix("run=")
if (value.length == query.length || value.isEmpty()) return null
return value.takeIf { '&' !in it && '=' !in it }
}
/**
* The tab a query of **exactly** `tab=<something>` names, when the app has that
* tab (Rust D71), or null, which hands the link to the browser. Same shape as
* [runParam], and for the same reasons.
*/
private fun rustTabParam(query: String): RustTab? {
val value = query.removePrefix("tab=")
if (value.length == query.length || value.isEmpty() || '&' in value || '=' in value) return null
return RustTab.fromWire(value)
}
/**
* The module id whose public pages this table maps.
*
* Named once rather than spelled into four branches, so what is coupled to one
* module is countable. It is a literal on purpose — see the file header.
*/
private const val MODULE_UO = "uo"
/** The second game module's id (M14). A literal for the same reason. */
private const val MODULE_RUST = "rust"

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.ui.rust.RustTab
/**
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
@@ -37,8 +38,71 @@ object Routes {
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
/** Opt-in push notification settings (§11, signed-in). */
/** Delete my account (ACCOUNT_DELETION.md §4.3), reached from Account. Signed-in only. */
const val ACCOUNT_DELETE = "account/delete"
/** Account → Blocked (USER_SAFETY.md §4.3), reached from Account. Signed-in only. */
const val ACCOUNT_BLOCKED = "account/blocked"
/**
* The in-app inbox (ENGAGEMENT.md phase 8, signed-in) and its settings.
*
* The bare route is the CONTENT and the named sub-route the preferences, which
* is exactly how the web surface is laid out (`/account/notifications` and
* `…/settings`) — and what a person means when they tap "Notifications".
*/
const val NOTIFICATIONS = "notifications"
const val NOTIFICATIONS_SETTINGS = "notifications/settings"
/**
* Events (§9 M13) — CORE's, not a module's: these screens exist on a backend
* with no game module at all, which is why they are not under `shard/`.
*
* **[EVENT_ROUTE] is the app's first route that takes a query**, and it takes
* exactly one: `run`, naming which occurrence a results table is about. The
* page lives at the definition's slug so a weekly event has one address that
* survives a retitle, and the occurrence has to live somewhere else. See
* [resolveWebPath], whose "a query hands off" rule this is the one exception
* to.
*
* **[MY_EVENTS] is `account/events` and not `events/mine`**, which is not
* cosmetic: `events/mine` and `events/{slug}` are both two segments, and a
* static-versus-argument race between two NavHost patterns is exactly the bug
* events Phase 13 shipped one tier along, where a static `events/new` outranked
* `events/:id` in React Router and made creating an event impossible for seven
* phases. Under `account/` there is no dynamic sibling and no race to lose.
*/
const val EVENTS = "events"
const val EVENT_ROUTE = "events/{slug}?run={run}"
const val EVENT_SERIES = "events/series/{slug}"
const val MY_EVENTS = "account/events"
/**
* The Rust module's surface (§9 M14, `docs/modules/rust/PLAN.md` D12, D13).
*
* **[RUST] is the server list, not a hub above one.** The module registers its
* pages with `path: ''` and core strips the trailing separator, so `/rust` on
* the website *is* the list — there is no landing page between the drawer row
* and the servers, and adding one here would invent a screen the website does
* not have.
*
* **A different game, so a different route tree.** These are deliberately not
* folded into [SHARD]: one shard is a place, and a Rust site is a fleet. The
* two can be installed on the same backend, and then both trees exist at once.
*/
const val RUST = "rust"
const val RUST_SERVER = "rust/servers/{serverId}?tab={tab}"
/**
* The player's own Rust account (§9 M15) — the Steam accounts they hold and
* what the site has given them in game.
*
* Under `player/` with the three UO rows rather than under `rust/` with the
* two public ones, because the grouping that matters to a reader is *whose
* data is this*: these are the signed-in, self-scoped screens. The website
* agrees — it serves this from the player tier, at `/player/rust`.
*/
const val PLAYER_RUST = "player/rust"
/** Public shard hub (§6.2). */
const val SHARD = "shard"
@@ -90,6 +154,9 @@ object Routes {
const val CATEGORY = "category"
const val ID_OR_SLUG = "idOrSlug"
const val SERIAL = "serial"
const val RUN = "run"
const val SERVER_ID = "serverId"
const val TAB = "tab"
}
fun page(slug: String) = "page/$slug"
@@ -109,9 +176,66 @@ object Routes {
/** One player vendor's shop, by in-game (hex) serial. */
fun marketVendor(serial: String) = "shard/market/$serial"
/**
* One Rust server's page.
*
* The id is a slug an operator chose, so it is encoded: nothing stops one
* carrying a character a path would otherwise eat, and a server nobody can
* open is a worse failure than a name nobody can read.
*
* **Encoded here rather than with `android.net.Uri`**, which is a stub in a
* JVM unit test and throws "not mocked" — this object is pure and every test
* that builds a route would have to become an instrumented one to keep it
* that way.
*
* [tab] is what the *new leader* notice's link carries (Rust D71), and it is
* dropped when absent, as `run` is on [event].
*/
fun rustServer(id: String, tab: RustTab? = null): String {
val base = "rust/servers/${encodePathSegment(id)}"
return if (tab == null) base else "$base?tab=${tab.wire}"
}
/**
* Percent-encode one path segment, allowing only the unreserved set.
*
* Deliberately stricter than it needs to be: encoding a character that did
* not need it still round-trips, where missing one that did produces a route
* NavHost matches differently from the one that was built. UTF-8 first, so a
* non-ASCII name is encoded per byte rather than per character.
*/
private fun encodePathSegment(value: String): String = buildString {
for (byte in value.toByteArray(Charsets.UTF_8)) {
val code = byte.toInt() and 0xFF
val char = code.toChar()
if (code < 128 && (char.isLetterOrDigit() || char in UNRESERVED)) {
append(char)
} else {
append('%').append(code.toString(16).uppercase().padStart(2, '0'))
}
}
}
private const val UNRESERVED = "-._~"
/** One creature's atlas page, by slug. */
fun atlasCreature(slug: String) = "atlas/$slug"
/**
* One event's page, optionally about one occurrence.
*
* [runId] is what an announcement's link carries, and it is dropped when
* absent rather than sent as an empty argument — `events/x?run=` would reach
* the screen as a blank string and be forwarded to the server as one.
*/
fun event(slug: String, runId: String? = null): String {
val base = "events/$slug"
return if (runId.isNullOrBlank()) base else "$base?run=$runId"
}
/** One arc, by slug. */
fun eventSeries(slug: String) = "events/series/$slug"
/**
* The in-app destination a tapped push notification deep-links to (§11, M7
* Part 2 work item 7). Maps a stream id to the screen that shows its content;
@@ -128,6 +252,35 @@ object Routes {
com.runicgateway.app.core.push.PushStreams.VENDOR_SALE -> PLAYER_VENDORS
com.runicgateway.app.core.push.PushStreams.HOUSE_IDOC -> PLAYER_HOUSES
com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT
else -> HOME
// An engagement rule's tickle carries the TRIGGER id as its stream
// (ENGAGEMENT.md §7.2's one namespace), and `event.run.started` is the only
// event trigger that is also a push stream. The calendar is the honest
// destination when there is no inbox row to send it to — the tickle names
// no occurrence, so there is no page to open. A row, when there is one,
// wins via [forTickle] and carries the link that does.
else -> if (streamId.startsWith(EVENT_STREAM_PREFIX)) EVENTS else HOME
}
/** What every core `event.` trigger id begins with (EVENTS.md §J). */
private const val EVENT_STREAM_PREFIX = "event."
/**
* Where a tapped tickle lands, given both halves of `{ stream, ref }`.
*
* **A `notification:<id>` ref means the engine wrote this user an inbox row**
* (`pushChannel.js` builds it), so the tap goes to the inbox whatever the
* stream is — an engagement rule's stream id is a TRIGGER id in §7.2's one
* namespace, and [forStream]'s fixed map would send most of them to Home.
* Every other tickle keeps the route it has always had, so no shipped stream
* changes where it lands.
*
* The ref is not decoded beyond that prefix and is never rendered: it is a
* hint that a row exists, and the app's contract is wake-and-pull.
*/
fun forTickle(streamId: String, ref: String?): String =
if (ref != null && ref.startsWith(INBOX_REF_PREFIX)) NOTIFICATIONS else forStream(streamId)
/** What `pushChannel.js` prefixes an inbox row's id with. */
const val INBOX_REF_PREFIX = "notification:"
}

View File

@@ -0,0 +1,60 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.inbox.InboxCache
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.repository.NotificationsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The drawer's unread badge (ENGAGEMENT.md phase 8).
*
* Its own view model, and its own endpoint: `/notifications/unread-count` exists
* precisely because this is the question asked most often and it should not make
* the server assemble a page of bodies to answer with one integer. Refreshed when
* the app resumes rather than on a timer — the tickle is what says "something
* happened", so polling would be a second, worse copy of push.
*
* Falls back to the cached count while offline, for the same reason the inbox
* does: a badge that dropped to zero because the train went into a tunnel would
* be telling the user they have read something they have not.
*/
@HiltViewModel
class InboxBadgeViewModel @Inject constructor(
private val notifications: NotificationsRepository,
private val cache: InboxCache,
private val sessionManager: SessionManager,
private val baseUrlHolder: BaseUrlHolder,
) : ViewModel() {
private val _unread = MutableStateFlow(0)
val unread: StateFlow<Int> = _unread.asStateFlow()
/** Ask the server, falling back to the snapshot. A signed-out session is zero. */
fun refresh() = viewModelScope.launch {
val user = (sessionManager.state.value as? Session.SignedIn)?.user
if (user == null) {
_unread.value = 0
return@launch
}
when (val result = notifications.unreadCount()) {
is ApiResult.Ok -> _unread.value = result.data.unread
else -> {
val owner = InboxCache.ownerKey(baseUrlHolder.current?.toString(), user.id)
cache.read(owner)?.let { _unread.value = it.unread }
}
}
}
}

View File

@@ -0,0 +1,34 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import com.runicgateway.app.core.time.parseWireInstant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* Render an inbox item's `createdAt` for display, in the device's own zone and
* locale (ENGAGEMENT.md phase 8). Pure, so it is unit-testable off-device.
*
* **Two shapes have to be accepted, and which one arrives is not the app's to
* decide** — see [parseWireInstant], which owns that trap for every screen that
* reads a timestamp, this one and the event screens (M13).
*
* Anything unparseable returns null and the row simply shows no stamp: a
* notification with an odd date is still worth reading.
*/
fun inboxTimestamp(
raw: String,
zone: ZoneId = ZoneId.systemDefault(),
formatter: DateTimeFormatter =
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT),
): String? {
val instant = parseWireInstant(raw) ?: return null
return try {
formatter.withZone(zone).format(instant)
} catch (_: Exception) {
null
}
}

View File

@@ -0,0 +1,215 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.core.web.WebHandoff
import com.runicgateway.app.data.api.dto.NotificationItemDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* The in-app inbox (ENGAGEMENT.md phase 8): what the engine's `inapp` channel
* wrote for this user, newest first.
*
* This is the drawer's "Notifications" — the settings that used to live there are
* one tap away behind the gear, mirroring exactly what phase 7 shipped on the web
* (the bare path is the inbox, `…/settings` is the preferences). It is what a
* tapped push tickle deep-links to, and the pull that follows the wake.
*
* **The list carries content, so it is deliberately plain text.** An item's body
* is the server's `toText` render, never the email HTML — that markup is table
* rows and inline hex with a light-only `color-scheme`, which in a themed app
* would be a pale card in a dark one. It also means there is no operator markup
* on this surface to sanitize.
*/
@Composable
fun InboxScreen(
onOpenSettings: () -> Unit,
onOpenRoute: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: InboxViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
Column(modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (state.unread > 0) {
stringResource(R.string.inbox_unread_count, state.unread)
} else {
stringResource(R.string.inbox_all_read)
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (state.unread > 0) {
TextButton(onClick = viewModel::markAllRead) {
Text(stringResource(R.string.inbox_mark_all_read))
}
}
IconButton(onClick = onOpenSettings) {
Icon(Icons.Filled.Settings, stringResource(R.string.inbox_open_settings))
}
}
// Showing the snapshot rather than the server's answer is said out loud: a
// notification surface that quietly showed a stale list would be lying
// about the one thing it exists to be — current.
if (state.fromCache) {
Text(
text = stringResource(R.string.inbox_offline_cached),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
when (val items = state.items) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(items.kind, onRetry = viewModel::load)
is UiState.Success -> if (items.data.isEmpty()) {
EmptyView(stringResource(R.string.inbox_empty))
} else {
InboxList(
items = items.data,
hasMore = state.hasMore && !state.fromCache,
onEndReached = viewModel::loadMore,
onOpen = { item ->
viewModel.markRead(item.id)
// Most items have no url at all — an inbox row is complete on
// its own — and the ones that do carry a SITE-RELATIVE path.
//
// A path the app has a screen for opens natively (M13): an
// event announcement's link is the case that made this worth
// doing. Everything else resolves against the configured
// shard and goes to the browser, exactly as before.
val route = viewModel.routeFor(item)
if (route != null) {
onOpenRoute(route)
} else {
viewModel.linkFor(item)?.let { WebHandoff.open(context, it) }
}
},
)
}
}
}
}
@Composable
private fun InboxList(
items: List<NotificationItemDto>,
hasMore: Boolean,
onEndReached: () -> Unit,
onOpen: (NotificationItemDto) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 12.dp),
) {
items(items, key = { it.id }) { item -> InboxCard(item, onOpen) }
if (hasMore) {
item {
// Paging by "the last row came into view" rather than a button: the
// cursor is the last id on screen, so reaching the end IS the request.
LaunchedEffect(items.lastOrNull()?.id) { onEndReached() }
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
Text(
stringResource(R.string.inbox_loading_more),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun InboxCard(item: NotificationItemDto, onOpen: (NotificationItemDto) -> Unit) {
ShardCard(Modifier.fillMaxWidth().clickable { onOpen(item) }) {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (!item.read) {
// The unread mark is a dot beside the title AND a heavier weight
// on it: colour alone would carry the whole signal, which is not
// a distinction everyone can see.
Box(
Modifier
.padding(end = 8.dp)
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
Text(
text = item.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = if (item.read) FontWeight.Normal else FontWeight.Bold,
modifier = Modifier.weight(1f),
)
}
item.body?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
}
val stamp = item.createdAt?.let { inboxTimestamp(it) }
if (stamp != null) {
Text(
text = stamp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
}

View File

@@ -0,0 +1,307 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.inbox.InboxCache
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.map
import com.runicgateway.app.data.api.dto.NotificationItemDto
import com.runicgateway.app.data.repository.NotificationsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.resolveWebPath
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Drives the in-app inbox (ENGAGEMENT.md phase 8): the items the engine's `inapp`
* channel wrote for this user, newest first, with the unread badge and the two
* mark-read writes.
*
* **The tickle contract is wake-and-pull, and this is the pull.** A push tickle
* carries `{ stream, ref }` and nothing else by design; `ref` is a HINT that an
* inbox row exists, never content, and `pushChannel.js` says so in as many words —
* the two rows are independent and either can be retried, so a client that
* rendered the ref would show nothing the first time a retry reordered them. So a
* tap deep-links here and this refreshes; the ref is not read.
*
* **Paging is keyset, not offset.** The next page is `before = the last id on
* screen`, because the list gains rows at the top while it is being read and an
* offset would show the same item twice or skip one.
*
* **It reloads when the ACCOUNT changes, not merely when it is created.** A
* drawer route's view model outlives a sign-out: `navigateTopLevel` saves and
* restores back-stack state, 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 beside it showed the new account's real
* count, because the shell refreshes that one 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.
*/
@HiltViewModel
class InboxViewModel @Inject constructor(
private val notifications: NotificationsRepository,
private val cache: InboxCache,
private val sessionManager: SessionManager,
private val baseUrlHolder: BaseUrlHolder,
) : ViewModel() {
data class State(
val items: UiState<List<NotificationItemDto>> = UiState.Loading,
val unread: Int = 0,
val hasMore: Boolean = false,
val loadingMore: Boolean = false,
val refreshing: Boolean = false,
/**
* True while what is on screen came from [InboxCache] rather than the
* server. The screen says so — an inbox that quietly showed a stale list
* would be a notification surface that lies about being current.
*/
val fromCache: Boolean = false,
/** When that snapshot was captured; only meaningful with [fromCache]. */
val cachedAt: Long? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
viewModelScope.launch {
sessionManager.state
.map { (it as? Session.SignedIn)?.user?.id }
.distinctUntilChanged()
.collect { userId ->
if (userId == null) {
// Signed out. The shell is already navigating away; drop the
// rows rather than leave them addressable behind it.
_state.value = State(items = UiState.Success(emptyList()))
} else {
// Reset BEFORE loading, not after: `load()` paints the cache
// only when there is no `Success` on screen, so the previous
// account's rows would otherwise stay up — and stay up for
// the whole round trip.
_state.value = State()
load()
}
}
}
}
/**
* Show the cached page immediately, then refresh from the server.
*
* The cache is painted first rather than after a failure so a cold open on a
* slow connection shows the last known inbox instead of a spinner; a
* successful pull replaces it, and a network failure leaves it up with
* [State.fromCache] set. A *server* error is a different thing from being
* offline and is not papered over with stale rows — unless there is nothing
* else to show, in which case the error is still what the screen reports.
*/
fun load() = viewModelScope.launch {
val owner = ownerKey()
if (owner != null && _state.value.items !is UiState.Success) {
cache.read(owner)?.let { snapshot ->
_state.update {
it.copy(
items = UiState.Success(snapshot.items),
unread = snapshot.unread,
fromCache = true,
cachedAt = snapshot.savedAt,
)
}
}
}
refresh()
}
/** Pull the newest page. Keeps whatever is on screen until it succeeds. */
fun refresh() = viewModelScope.launch {
_state.update { it.copy(refreshing = true) }
when (val result = notifications.inbox()) {
is ApiResult.Ok -> {
val page = result.data
_state.update {
it.copy(
items = UiState.Success(page.items),
unread = page.unread,
hasMore = page.hasMore,
refreshing = false,
fromCache = false,
cachedAt = null,
)
}
ownerKey()?.let { cache.write(it, page.items, page.unread) }
}
else -> {
// Nothing cached to fall back on → the error IS the screen. Something
// cached → keep it up and label it, which is the whole point of §7's
// "the app degrades, it does not fail".
val holdCache = _state.value.items is UiState.Success && _state.value.fromCache
_state.update {
it.copy(
items = if (holdCache) it.items else result.map { page -> page.items }.toUiState(),
refreshing = false,
)
}
}
}
}
/**
* Append the next page.
*
* A no-op while one is in flight, when the server said there is no next page,
* or while the list is the cached snapshot — paging a cache we know to be one
* page long would ask the server for `before` an id it may no longer have.
*/
fun loadMore() = viewModelScope.launch {
val current = _state.value
val shown = (current.items as? UiState.Success)?.data ?: return@launch
if (current.loadingMore || !current.hasMore || current.fromCache) return@launch
val cursor = shown.lastOrNull()?.id ?: return@launch
_state.update { it.copy(loadingMore = true) }
when (val result = notifications.inbox(before = cursor)) {
is ApiResult.Ok -> {
// Guard the same id arriving twice: a keyset window can shift under
// a concurrent write, and a duplicate id in a LazyColumn key crashes.
val seen = shown.mapTo(mutableSetOf()) { it.id }
val appended = result.data.items.filterNot { it.id in seen }
_state.update {
it.copy(
items = UiState.Success(shown + appended),
unread = result.data.unread,
hasMore = result.data.hasMore,
loadingMore = false,
)
}
}
// A failed "more" leaves the pages already read alone — losing them
// because the fourth page timed out would be worse than stopping.
else -> _state.update { it.copy(loadingMore = false, hasMore = false) }
}
}
/**
* Mark one item read, optimistically.
*
* The row flips locally before the call so the tap feels immediate, and the
* server's post-write `unread` replaces the local guess when it lands. A
* failure is not rolled back: read-ness is the least consequential thing in
* the app to get briefly wrong, and un-reading a row under the user's finger
* looks like a bug. The next refresh corrects it.
*/
fun markRead(id: Long) = viewModelScope.launch {
val shown = (_state.value.items as? UiState.Success)?.data ?: return@launch
if (shown.firstOrNull { it.id == id }?.read != false) return@launch
_state.update { current ->
current.copy(
items = UiState.Success(shown.map { if (it.id == id) it.copy(read = true) else it }),
unread = (current.unread - 1).coerceAtLeast(0),
)
}
when (val result = notifications.markRead(id)) {
is ApiResult.Ok -> _state.update { it.copy(unread = result.data.unread) }
else -> Unit
}
cacheCurrent()
}
/** Mark the whole inbox read. Same optimism, and the same reason for it. */
fun markAllRead() = viewModelScope.launch {
val shown = (_state.value.items as? UiState.Success)?.data ?: return@launch
_state.update {
it.copy(items = UiState.Success(shown.map { item -> item.copy(read = true) }), unread = 0)
}
notifications.markAllRead()
cacheCurrent()
}
/**
* The absolute link for an item, or null when it has none this app can open.
*
* **An item's `url` is SITE-RELATIVE** — `/guilds/the-silver-anvil/forum/403`
* is what the server writes, because it is rendered from the template's button
* block for a browser that is already on the site. A phone is not, so it has to
* be resolved against the configured base or every link in the inbox is dead;
* the live rig is what caught that.
*
* `HttpUrl.resolve` does both jobs: it absolutises a relative path and it
* returns null for anything that would not end up as http(s) — a `javascript:`
* or `intent:` url in a notification body opens nothing at all.
*/
fun linkFor(item: NotificationItemDto): String? {
val raw = item.url?.trim().orEmpty()
if (raw.isEmpty()) return null
return baseUrlHolder.current?.resolve(raw)?.toString()
}
/**
* The app route this item opens natively, or null when it has none and
* [linkFor] should hand it to a browser (M13).
*
* **Why this exists at all:** events Phase 14a gave the six public `event.`
* triggers an `eventUrl` of the form `/site/events/<slug>?run=<id>`, so an
* inbox row about an event now has a native destination — and opening a
* Custom Tab onto a page the app itself renders is a worse answer than it was
* when there was no such page.
*
* **It reuses `resolveWebPath` rather than adding a second link-routing
* mechanism.** That function is already the app's read of the site's own route
* table, it already answers null for everything it does not recognise, and
* every path it does not recognise still hands off exactly as before. Adding a
* parser here would put the decision in two places.
*
* The item's url is site-relative by contract, but an absolute one on this
* host is accepted too: the shape is the server's to change, and a link that
* opened the browser only because it arrived fully qualified would be a
* puzzle. An absolute url on ANOTHER host is not ours to route — the app has
* no screen for somebody else's site — so it falls through to the browser.
*/
fun routeFor(item: NotificationItemDto): String? {
val raw = item.url?.trim().orEmpty()
if (raw.isEmpty()) return null
val base = baseUrlHolder.current ?: return null
val resolved = base.resolve(raw) ?: return null
if (resolved.host != base.host) return null
val query = resolved.query
return resolveWebPath(resolved.encodedPath + if (query.isNullOrEmpty()) "" else "?$query")
}
/**
* Keep the snapshot in step with a local read.
*
* Without this, going offline right after reading everything would bring the
* badge back on the next cold open. Only ever written for the account that
* owns it — [InboxCache] scopes by (base URL, user id).
*/
private suspend fun cacheCurrent() {
val owner = ownerKey() ?: return
val current = _state.value
val shown = (current.items as? UiState.Success)?.data ?: return
cache.write(owner, shown, current.unread)
}
private fun ownerKey(): String? {
val user = (sessionManager.state.value as? Session.SignedIn)?.user ?: return null
return InboxCache.ownerKey(baseUrlHolder.current?.toString(), user.id)
}
}

View File

@@ -0,0 +1,270 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.NotificationChannelDto
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.SectionLabel
/**
* The notification **settings** screen (PLAN.md §11, ENGAGEMENT.md phase 8): every
* subscribable id with a control per channel that applies to it.
*
* It used to be the drawer's "Notifications"; that entry is the inbox now and this
* is behind its gear, which is the arrangement phase 7 shipped on the web. What
* changed underneath is bigger than the move: the screen asks
* `/notifications/channels` and so can express email and on-site preferences, not
* just whether a stream pushes.
*
* **A channel with two modes gets a switch and one with three gets chips**, and
* which is which comes off the wire — `email` is the one that supports `digest`
* today, and a fourth channel with its own modes would render correctly here
* without an app release.
*/
@Composable
fun NotificationSettingsScreen(
modifier: Modifier = Modifier,
viewModel: NotificationSettingsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// Ask once for POST_NOTIFICATIONS when the user first switches a push mode on
// (API 33+). Email and in-app need no permission — only push posts anything.
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { /* granted or not, the preference is already saved server-side */ }
fun ensureNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(
text = stringResource(R.string.notifications_title),
style = MaterialTheme.typography.titleLarge,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.notifications_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
state.feedback?.let { fb ->
Text(
text = stringResource(fb.messageRes),
style = MaterialTheme.typography.bodyMedium,
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 12.dp),
)
}
// A shard with no push relay still has email and on-site preferences worth
// setting, so this is a note beside the list now rather than the whole
// screen — which is what it had to be when push was all there was.
if (!state.supported) {
Text(
text = stringResource(R.string.notifications_unsupported),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontStyle = FontStyle.Italic,
modifier = Modifier.padding(bottom = 12.dp),
)
}
when (val prefs = state.prefs) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(kind = prefs.kind, onRetry = viewModel::load)
is UiState.Success -> ChannelPrefsList(
prefs = prefs.data,
hasLinkedAccount = state.hasLinkedAccount,
pushSupported = state.supported,
busy = state.busy,
onSetMode = { item, channel, mode ->
if (channel == CHANNEL_PUSH && mode != MODE_OFF) ensureNotificationPermission()
viewModel.setMode(item, channel, mode)
},
)
}
}
}
@Composable
private fun ChannelPrefsList(
prefs: NotificationChannelPrefsDto,
hasLinkedAccount: Boolean,
pushSupported: Boolean,
busy: Boolean,
onSetMode: (NotificationChannelItemDto, String, String) -> Unit,
) {
if (prefs.items.isEmpty()) {
EmptyView(message = stringResource(R.string.notifications_empty))
return
}
val channelsById = prefs.channels.associateBy { it.id }
val (personal, general) = prefs.items.partition { it.personal }
if (general.isNotEmpty()) {
SectionLabel(stringResource(R.string.notifications_section_general))
Spacer(Modifier.height(8.dp))
general.forEach { item ->
ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
HorizontalDivider()
}
Spacer(Modifier.height(20.dp))
}
if (personal.isNotEmpty()) {
SectionLabel(stringResource(R.string.notifications_section_personal))
Spacer(Modifier.height(8.dp))
personal.forEach { item ->
ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
HorizontalDivider()
}
}
}
@Composable
private fun ItemRow(
item: NotificationChannelItemDto,
channelsById: Map<String, NotificationChannelDto>,
hasLinkedAccount: Boolean,
busy: Boolean,
pushSupported: Boolean,
onSetMode: (NotificationChannelItemDto, String, String) -> Unit,
) {
// The link holds back one control, push switching on, so the row stays live
// and the hint names that one control (Rust D69). Where this device has no
// push at all, there is no control to hold back and nothing to explain.
val pushHeld = pushSupported && pushNeedsLink(item, hasLinkedAccount)
Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
Text(
text = item.label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = if (pushHeld) stringResource(R.string.notifications_requires_link) else item.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontStyle = if (pushHeld) FontStyle.Italic else FontStyle.Normal,
)
// The item's OWN channel list, in the registry's order. An id nothing can
// push carries no push control at all, rather than a dead switch.
item.channels.forEach { channelId ->
val channel = channelsById[channelId] ?: return@forEach
if (channelId == CHANNEL_PUSH && !pushSupported) return@forEach
val mode = item.modes[channelId] ?: channel.defaultMode
ChannelControl(
channel = channel,
mode = mode,
// A held push switch that is already ON stays enabled, because
// switching it off is never refused ([canSetMode]).
enabled = !busy && !(channelId == CHANNEL_PUSH && pushHeld && mode == MODE_OFF),
onSetMode = { next -> onSetMode(item, channelId, next) },
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ChannelControl(
channel: NotificationChannelDto,
mode: String,
enabled: Boolean,
onSetMode: (String) -> Unit,
) {
val modes = channel.modes.ifEmpty { listOf(MODE_OFF) }
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = channel.label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f).padding(end = 12.dp),
)
// Two modes is a yes/no question and reads best as a switch; three is a
// choice and needs its options named — `digest` means nothing as an
// unlabelled third state.
if (modes.size == 2 && modes.contains(MODE_OFF)) {
val on = modes.first { it != MODE_OFF }
Switch(
checked = mode != MODE_OFF,
onCheckedChange = { checked -> onSetMode(if (checked) on else MODE_OFF) },
enabled = enabled,
)
} else {
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
modes.forEach { candidate ->
FilterChip(
selected = candidate == mode,
onClick = { if (candidate != mode) onSetMode(candidate) },
enabled = enabled,
label = { Text(modeLabel(candidate)) },
)
}
}
}
}
}
/**
* Copy for a delivery mode. A mode this build has never heard of is labelled with
* its own wire name rather than hidden — the server accepts it, so a chip reading
* `weekly` is more use to the person in front of it than a control that vanished.
*/
@Composable
private fun modeLabel(mode: String): String = when (mode) {
MODE_OFF -> stringResource(R.string.notifications_mode_off)
"instant" -> stringResource(R.string.notifications_mode_instant)
"digest" -> stringResource(R.string.notifications_mode_digest)
else -> mode
}

View File

@@ -0,0 +1,188 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
import com.runicgateway.app.data.repository.NotificationsRepository
import com.runicgateway.app.data.repository.LinkedAccountRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/** The push channel's id — the one channel that also drives a device registration. */
const val CHANNEL_PUSH = "push"
/** The mode every channel accepts, and the one that means "do not deliver". */
const val MODE_OFF = "off"
/**
* Drives the notification **settings** screen (PLAN.md §11, ENGAGEMENT.md phase 8).
*
* **This screen moved off `/notifications/subscriptions` onto
* `/notifications/channels`.** The old endpoint asked one question — is push on
* for this stream — and there are now three channels to ask it of. The server
* keeps `notification_subscriptions` as the push projection of the new table and
* fans every write to either into the other, so the shipped APK's screen went on
* working the whole time and this one is not a migration anybody has to run.
*
* **The controls are rendered from the wire, never from a hardcoded three.** Each
* item names the channels that apply to it — a trigger-only id carries no `push`
* because nothing is registered to push it — and each channel names the modes it
* accepts, which is how `email`'s `digest` reaches the app without an app release.
* The modes the server sends are the EFFECTIVE ones (it has already substituted
* each channel's default), so this class never re-implements the defaulting.
*/
@HiltViewModel
class NotificationSettingsViewModel @Inject constructor(
private val notifications: NotificationsRepository,
private val linkedAccounts: LinkedAccountRepository,
private val pushManager: PushManager,
sessionManager: SessionManager,
) : ViewModel() {
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val prefs: UiState<NotificationChannelPrefsDto> = UiState.Loading,
/**
* Whether the user has ≥1 linked game account on this site's module, asked
* of that module (Rust D69). It only gates switching push ON (see [canSetMode]).
*/
val hasLinkedAccount: Boolean = false,
/** Whether this shard advertises a push relay at all (else the screen says so). */
val supported: Boolean = true,
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
viewModelScope.launch {
pushManager.supported.collect { supported -> _state.update { it.copy(supported = supported) } }
}
// Reloaded on an account change for the reason the inbox is, and one
// reason more: these controls are WRITTEN from. A screen still rendering
// the previous account's preferences would send this account's PUT built
// out of them, so a stale render here corrupts rather than merely
// discloses.
viewModelScope.launch {
sessionManager.state
.map { (it as? Session.SignedIn)?.user?.id }
.distinctUntilChanged()
.collect { userId -> if (userId != null) load() }
}
}
fun load() {
_state.update { it.copy(prefs = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) }
_state.update { it.copy(hasLinkedAccount = linkedAccounts.hasLinkedAccount()) }
}
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
/**
* Set one (item, channel) pair.
*
* One pair, one sparse PUT: the endpoint writes only what it is given, so a
* toggle cannot disturb a channel this screen is not showing — and the
* response is the full stored truth, which is what the screen re-renders
* from. An entry the server drops (an unknown id, an inapplicable channel)
* therefore shows up as the control springing back, not as a silent lie.
*/
fun setMode(item: NotificationChannelItemDto, channel: String, mode: String) {
val current = _state.value
if (current.busy) return
if (!canSetMode(item, channel, mode, current.hasLinkedAccount)) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (val result = notifications.setChannelMode(item.id, channel, mode)) {
is ApiResult.Ok -> {
_state.update { it.copy(prefs = UiState.Success(result.data)) }
if (channel == CHANNEL_PUSH) reconcilePush(result.data) else finish(true, R.string.notifications_saved)
}
is ApiResult.NetworkError -> finish(false, R.string.error_network)
is ApiResult.HttpError -> finish(false, R.string.notifications_save_error)
}
}
}
/**
* Register or unregister the device to match the stored push set (PLAN.md §11).
*
* Read from the RESPONSE rather than from what was just sent, because the
* server may have dropped the entry — and because "is any push mode on" is a
* question about the whole table, not about the row that changed.
*/
private suspend fun reconcilePush(prefs: NotificationChannelPrefsDto) {
val anyPushOn = prefs.items.any { item ->
val mode = item.modes[CHANNEL_PUSH]
mode != null && mode != MODE_OFF
}
if (!anyPushOn) {
pushManager.disable()
finish(true, R.string.notifications_all_off)
return
}
when (val res = pushManager.enable()) {
is PushManager.PushResult.Enabled -> finish(true, R.string.notifications_saved)
is PushManager.PushResult.Unsupported -> finish(false, R.string.notifications_unsupported)
is PushManager.PushResult.NotSignedIn -> finish(false, R.string.notifications_save_error)
is PushManager.PushResult.Failed ->
finish(false, if (res.status == 400) R.string.notifications_relay_error else R.string.notifications_save_error)
}
}
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
}
/**
* Whether this item's push is held back for want of a linked game account
* (PLAN.md §11, `docs/modules/rust/PLAN.md` D69).
*
* **Push only.** Until phase 11 the whole row went dead, every channel, which
* made a personal stream impossible to switch *off* on any site where the link
* check failed. That was every Rust site, because the check asked `module-uo`.
* In-app and email have nothing to do with the link, and neither does turning
* anything off. Pure so the gating is unit-tested without Compose.
*/
fun pushNeedsLink(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean =
item.requiresLinkedAccount && !hasLinkedAccount && CHANNEL_PUSH in item.channels
/**
* Whether [mode] may be set on [channel] for [item].
*
* The one thing refused is switching push **on** for an item [pushNeedsLink] holds
* back. Switching anything off is never refused: a gate that can trap a switch in
* the on position is worse than no gate.
*/
fun canSetMode(
item: NotificationChannelItemDto,
channel: String,
mode: String,
hasLinkedAccount: Boolean,
): Boolean = !(channel == CHANNEL_PUSH && mode != MODE_OFF && pushNeedsLink(item, hasLinkedAccount))

View File

@@ -1,182 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.NotificationStreamDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.SectionLabel
/**
* The Notifications settings screen (PLAN.md §11, M7 Part 2 work item 6): the
* subscribable catalog with per-stream toggles. Personal streams are greyed until a
* game account is linked; turning a stream on requests the POST_NOTIFICATIONS
* permission (API 33+) and registers the device, turning them all off unregisters it.
*/
@Composable
fun NotificationsScreen(
modifier: Modifier = Modifier,
viewModel: NotificationsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
// Ask once for POST_NOTIFICATIONS when the user first enables a stream (API 33+).
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { /* granted or not, the subscription is already saved server-side */ }
fun ensureNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(
text = stringResource(R.string.notifications_title),
style = MaterialTheme.typography.titleLarge,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.notifications_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
if (!state.supported) {
EmptyView(message = stringResource(R.string.notifications_unsupported))
return@Column
}
state.feedback?.let { fb ->
Text(
text = stringResource(fb.messageRes),
style = MaterialTheme.typography.bodyMedium,
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 12.dp),
)
}
when (val catalog = state.catalog) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(kind = catalog.kind, onRetry = viewModel::load)
is UiState.Success -> StreamList(
streams = catalog.data,
subscribed = state.subscribed,
hasLinkedAccount = state.hasLinkedAccount,
busy = state.busy,
onToggle = { stream, on ->
if (on) ensureNotificationPermission()
viewModel.setSubscribed(stream, on)
},
)
}
}
}
@Composable
private fun StreamList(
streams: List<NotificationStreamDto>,
subscribed: Set<String>,
hasLinkedAccount: Boolean,
busy: Boolean,
onToggle: (NotificationStreamDto, Boolean) -> Unit,
) {
if (streams.isEmpty()) {
EmptyView(message = stringResource(R.string.notifications_empty))
return
}
val (personal, general) = streams.partition { it.personal }
if (general.isNotEmpty()) {
SectionLabel(stringResource(R.string.notifications_section_general))
Spacer(Modifier.height(8.dp))
general.forEach { stream ->
StreamRow(stream, subscribed.contains(stream.id), enabled = !busy, hint = null) { on ->
onToggle(stream, on)
}
HorizontalDivider()
}
Spacer(Modifier.height(20.dp))
}
if (personal.isNotEmpty()) {
SectionLabel(stringResource(R.string.notifications_section_personal))
Spacer(Modifier.height(8.dp))
personal.forEach { stream ->
val selectable = streamSelectable(stream, hasLinkedAccount)
val hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null
StreamRow(stream, subscribed.contains(stream.id) && selectable, enabled = !busy && selectable, hint = hint) { on ->
onToggle(stream, on)
}
HorizontalDivider()
}
}
}
@Composable
private fun StreamRow(
stream: NotificationStreamDto,
checked: Boolean,
enabled: Boolean,
hint: String?,
onToggle: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = stream.label,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = hint ?: stream.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal,
)
}
Switch(checked = checked, onCheckedChange = onToggle, enabled = enabled)
}
}

View File

@@ -1,135 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.NotificationStreamDto
import com.runicgateway.app.data.repository.NotificationsRepository
import com.runicgateway.app.data.repository.PlayerShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Drives the Notifications settings screen (PLAN.md §11, M7 Part 2 work item 6):
* the stream catalog with per-stream toggles bound to
* `GET/PUT /auth/me/notifications/subscriptions`. A **personal** stream is greyed
* until the user has a linked game account (§11), and turning the opt-in set
* non-empty/empty drives the [PushManager] to register/unregister the device.
*/
@HiltViewModel
class NotificationsViewModel @Inject constructor(
private val notifications: NotificationsRepository,
private val playerShard: PlayerShardRepository,
private val pushManager: PushManager,
) : ViewModel() {
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val catalog: UiState<List<NotificationStreamDto>> = UiState.Loading,
val subscribed: Set<String> = emptySet(),
/** Whether the user has ≥1 linked game account — personal streams need it. */
val hasLinkedAccount: Boolean = false,
/** Whether this shard advertises a push relay at all (else the screen says so). */
val supported: Boolean = true,
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
viewModelScope.launch {
pushManager.supported.collect { supported -> _state.update { it.copy(supported = supported) } }
}
load()
}
fun load() {
_state.update { it.copy(catalog = UiState.Loading) }
viewModelScope.launch {
val catalog = notifications.streams().let { result ->
when (result) {
is ApiResult.Ok -> ApiResult.Ok(result.data.streams)
is ApiResult.HttpError -> result
is ApiResult.NetworkError -> result
}
}
_state.update { it.copy(catalog = catalog.toUiState()) }
when (val subs = notifications.subscriptions()) {
is ApiResult.Ok -> _state.update { it.copy(subscribed = subs.data.streams.toSet()) }
else -> Unit
}
// A linked game account gates the personal streams; failure → treat as none.
val linked = (playerShard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
_state.update { it.copy(hasLinkedAccount = linked) }
}
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
/** Toggle [stream]; refuses a personal stream with no linked account. */
fun setSubscribed(stream: NotificationStreamDto, on: Boolean) {
val s = _state.value
if (s.busy) return
if (on && !streamSelectable(stream, s.hasLinkedAccount)) return
val next = if (on) s.subscribed + stream.id else s.subscribed - stream.id
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (val result = notifications.setSubscriptions(next.toList())) {
is ApiResult.Ok -> {
val stored = result.data.streams.toSet()
_state.update { it.copy(subscribed = stored) }
reconcilePush(stored)
}
is ApiResult.NetworkError -> finish(false, R.string.error_network)
is ApiResult.HttpError -> finish(false, R.string.notifications_save_error)
}
}
}
/**
* Register or unregister the device to match the opted-in set (PLAN.md §11:
* register when signed-in + subscribed, unregister when the set empties).
*/
private suspend fun reconcilePush(subscribed: Set<String>) {
if (subscribed.isEmpty()) {
pushManager.disable()
finish(true, R.string.notifications_all_off)
return
}
when (val res = pushManager.enable()) {
is PushManager.PushResult.Enabled -> finish(true, R.string.notifications_saved)
is PushManager.PushResult.Unsupported -> finish(false, R.string.notifications_unsupported)
is PushManager.PushResult.NotSignedIn -> finish(false, R.string.notifications_save_error)
is PushManager.PushResult.Failed ->
finish(false, if (res.status == 400) R.string.notifications_relay_error else R.string.notifications_save_error)
}
}
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
}
/**
* Whether a stream's toggle is selectable for a user: a personal stream needs a
* linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose.
*/
fun streamSelectable(stream: NotificationStreamDto, hasLinkedAccount: Boolean): Boolean =
!stream.requiresLinkedAccount || hasLinkedAccount

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.core.result.map
import com.runicgateway.app.data.api.dto.RosterCharDto
import com.runicgateway.app.data.repository.PlayerShardRepository
@@ -114,7 +115,7 @@ class CharactersViewModel @Inject constructor(
load()
}
is ApiResult.HttpError -> _state.update {
it.copy(busy = false, feedback = Feedback(false, createErrorRes(result.status)))
it.copy(busy = false, feedback = Feedback(false, createErrorRes(result)))
}
is ApiResult.NetworkError -> _state.update {
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
@@ -129,12 +130,15 @@ class CharactersViewModel @Inject constructor(
else -> R.string.player_link_error
}
private fun createErrorRes(status: Int): Int = when (status) {
409 -> R.string.player_create_taken
429 -> R.string.player_create_capped
403 -> R.string.player_create_unavailable
400 -> R.string.player_create_rejected
503 -> R.string.error_shard_offline
// The demo's refusal comes first: its 403 would otherwise read as "signup is off
// on this shard", which is not what refused it (DEMO_MODE.md §3.1).
private fun createErrorRes(result: ApiResult.HttpError): Int = when {
result.isDemoLocked() -> R.string.error_demo_locked
result.status == 409 -> R.string.player_create_taken
result.status == 429 -> R.string.player_create_capped
result.status == 403 -> R.string.player_create_unavailable
result.status == 400 -> R.string.player_create_rejected
result.status == 503 -> R.string.error_shard_offline
else -> R.string.player_create_error
}
}

View File

@@ -0,0 +1,47 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import com.runicgateway.app.data.api.dto.RustBackpackDto
import com.runicgateway.app.data.api.dto.RustBackpackStackDto
/**
* A backpack as a grid (`docs/modules/rust/PLAN_REDESIGNS.md` §11.3.2) — the
* same rules as the website's `lib/backpack.js`, pure so the tests can ask them.
*
* A stack's slot runs across pages: page two of a 96-slot backpack starts at
* slot 48 when the page size is 48. A stack at or past the capacity is
* overflow, waiting for the owner's next open to be moved out (D338), so the
* grid draws those slots too, after the backpack's own, marked.
*/
data class BackpackCell(val slot: Int, val stack: RustBackpackStackDto?, val overflow: Boolean)
data class BackpackPage(val index: Int, val cells: List<BackpackCell>)
/** Enough slots for the capacity and every stack; a backpack that does not exist is no pages. */
fun backpackPages(backpack: RustBackpackDto?): List<BackpackPage> {
if (backpack == null || !backpack.exists) return emptyList()
val capacity = backpack.capacity.coerceAtLeast(0)
val pageSize = backpack.pageSize.coerceAtLeast(1)
val bySlot = backpack.items.associateBy { it.slot }
val furthest = backpack.items.maxOfOrNull { it.slot + 1 } ?: 0
val total = maxOf(capacity, furthest)
return (0 until total step pageSize).mapIndexed { index, start ->
val end = minOf(start + pageSize, total)
BackpackPage(index, (start until end).map { slot -> BackpackCell(slot, bySlot[slot], slot >= capacity) })
}
}
/**
* What a slot says (D340): the game's name, "… blueprint" for a blueprint
* (D341), then the shortname, then the bare item id for an item the game no
* longer knows.
*/
fun stackLabel(stack: RustBackpackStackDto?): String {
if (stack == null) return ""
val name = stack.display ?: stack.shortname ?: stack.itemId?.let { "item $it" } ?: "unknown item"
return if (stack.blueprint != null) "$name blueprint" else name
}

View File

@@ -0,0 +1,72 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import androidx.annotation.StringRes
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.SessionUser
import com.runicgateway.app.data.api.dto.RustEventDto
/**
* Report, block and hide on a chat line: what its menu offers (`USER_SAFETY.md`
* §4.3) — the Kotlin half of `module-rust`'s `client/src/lib/safety.js`.
*
* **Nothing here decides what a reader may see.** The server already left out what
* they blocked or hid, and staff's hidden lines for everybody else (S11). This file
* only turns what the server said about a line into which actions its menu shows,
* and it is pure so the JVM tests can ask all of it.
*/
/** The report target a chat line is filed as, registered by the module's `server/safety.js`. */
const val CHAT_TARGET = "rust.chat"
/** One reason a report can give: the wire value, and the words for it. */
data class ReportReason(val wire: String, @param:StringRes val labelRes: Int)
/**
* Core's `content_reports.reason` values, in the order and words the website's
* menus use, so a member reads one list wherever they report from.
*/
val REPORT_REASONS: List<ReportReason> = listOf(
ReportReason("abuse", R.string.safety_reason_abuse),
ReportReason("spam", R.string.safety_reason_spam),
ReportReason("sexual", R.string.safety_reason_sexual),
ReportReason("illegal", R.string.safety_reason_illegal),
ReportReason("impersonation", R.string.safety_reason_impersonation),
ReportReason("other", R.string.safety_reason_other),
)
/** Staff's action on a line: hide it for everyone, or show a hidden one again (S10). */
enum class StaffHide { HIDE, UNHIDE }
/**
* What a chat line's menu offers one reader.
*
* - [signIn] a signed-out reader gets one line telling them to sign in (S6)
* - [report] any signed-in reader, on any line
* - [hide] *Hide for me*, likewise — a staff member's line included (S8)
* - [block] only where the server said `canBlock` (S8, S13)
* - [staff] for an admin or moderator, the two roles the module's hide route admits
*/
data class ChatActions(
val signIn: Boolean,
val report: Boolean,
val hide: Boolean,
val block: Boolean,
val staff: StaffHide?,
)
/** The menu for [row] as [user] sees it, or null for a row that is not a chat line. */
fun chatActions(row: RustEventDto, user: SessionUser?): ChatActions? {
if (row.kind != "player.chat") return null
if (user == null) return ChatActions(signIn = true, report = false, hide = false, block = false, staff = null)
return ChatActions(
signIn = false,
report = true,
hide = true,
// `canBlock` absent is "not said", and not saying is not permission.
block = row.canBlock == true,
staff = if (user.isModerator) (if (row.hidden) StaffHide.UNHIDE else StaffHide.HIDE) else null,
)
}

View File

@@ -0,0 +1,209 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalBottomSheetProperties
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.runicgateway.app.R
/**
* A chat line's sheet (`USER_SAFETY.md` §4.3), opened by a long press. Its wording
* follows the website's `ChatMenu.jsx`, so a member reads one set of sentences in
* either place.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatSafetySheet(
menu: ChatMenuUi,
actions: ChatActions,
viewModel: ChatSafetyViewModel,
onSignIn: () -> Unit,
) {
val line = menu.line ?: return
val name = line.str("name") ?: stringResource(R.string.safety_this_player)
// Back on the report form or the block confirmation returns to the menu rather
// than closing the sheet. Found on the walk: Back to put the keyboard away closed
// the whole sheet and threw away a half-written report. The reason and detail
// live in the view model, so stepping back and in again keeps them.
//
// The sheet never dismisses itself on Back, and this handler decides instead:
// the sheet reads its properties once, when it opens on the menu, so a property
// that changed with the step would never take effect.
val inForm = menu.step == ChatMenuStep.REPORT || menu.step == ChatMenuStep.BLOCK
ModalBottomSheet(
onDismissRequest = viewModel::close,
// Open all the way: half-open, the report form's Send button sat below the
// fold and the walk had to drag the sheet up to find it.
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
properties = ModalBottomSheetProperties(shouldDismissOnBackPress = false),
) {
BackHandler { if (inForm) viewModel.go(ChatMenuStep.MENU) else viewModel.close() }
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) {
// The line itself, so the reader can see which one they pressed.
Text(
text = stringResource(R.string.safety_line_quote, name, line.str("message").orEmpty()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
modifier = Modifier.padding(bottom = 12.dp),
)
when (menu.step) {
ChatMenuStep.MENU -> MenuStep(actions, menu.busy, viewModel, onSignIn)
ChatMenuStep.REPORT -> ReportStep(menu, viewModel)
ChatMenuStep.BLOCK -> BlockStep(name, menu.busy, viewModel)
ChatMenuStep.DONE -> {
menu.doneRes?.let { Text(stringResource(it), style = MaterialTheme.typography.bodyMedium) }
TextButton(onClick = viewModel::close, modifier = Modifier.padding(top = 8.dp)) {
Text(stringResource(R.string.safety_close))
}
}
}
menu.errorRes?.let {
Text(
text = stringResource(it),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp),
)
}
Spacer(Modifier.height(24.dp)) // clears the gesture inset at the sheet's bottom
}
}
}
@Composable
private fun MenuStep(actions: ChatActions, busy: Boolean, viewModel: ChatSafetyViewModel, onSignIn: () -> Unit) {
if (actions.signIn) {
// S6: reporting, hiding and blocking all need an account.
Text(stringResource(R.string.safety_sign_in_hint), style = MaterialTheme.typography.bodyMedium)
Button(
onClick = {
viewModel.close()
onSignIn()
},
modifier = Modifier.padding(top = 12.dp),
) { Text(stringResource(R.string.safety_sign_in)) }
return
}
if (actions.report) {
MenuItem(R.string.safety_report, enabled = !busy) { viewModel.go(ChatMenuStep.REPORT) }
}
if (actions.hide) {
MenuItem(R.string.safety_hide_for_me, enabled = !busy, onClick = viewModel::hideForMe)
}
if (actions.block) {
MenuItem(R.string.safety_block_player, enabled = !busy) { viewModel.go(ChatMenuStep.BLOCK) }
}
when (actions.staff) {
StaffHide.HIDE -> MenuItem(R.string.safety_hide_for_everyone, enabled = !busy) { viewModel.staffHide(true) }
StaffHide.UNHIDE -> MenuItem(R.string.safety_show_to_everyone, enabled = !busy) { viewModel.staffHide(false) }
null -> Unit
}
}
@Composable
private fun MenuItem(labelRes: Int, enabled: Boolean, onClick: () -> Unit) {
TextButton(onClick = onClick, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(labelRes), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Start)
}
}
@Composable
private fun ReportStep(menu: ChatMenuUi, viewModel: ChatSafetyViewModel) {
// A report changes nothing about the line, and says so: a member who expects it
// to vanish and watches it stay will report it again.
Text(stringResource(R.string.safety_report_explainer), style = MaterialTheme.typography.bodyMedium)
Column(Modifier.selectableGroup().padding(top = 8.dp)) {
REPORT_REASONS.forEach { reason ->
Row(
Modifier
.fillMaxWidth()
.selectable(
selected = menu.reason == reason.wire,
onClick = { viewModel.setReason(reason.wire) },
role = Role.RadioButton,
),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = menu.reason == reason.wire, onClick = null)
Text(
stringResource(reason.labelRes),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 10.dp),
)
}
}
}
OutlinedTextField(
value = menu.detail,
onValueChange = viewModel::setDetail,
label = { Text(stringResource(R.string.safety_report_detail)) },
minLines = 2,
maxLines = 5,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
Row(Modifier.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = viewModel::report, enabled = !menu.busy) {
Text(stringResource(R.string.safety_report_send))
}
OutlinedButton(onClick = { viewModel.go(ChatMenuStep.MENU) }) {
Text(stringResource(R.string.safety_cancel))
}
}
}
@Composable
private fun BlockStep(name: String, busy: Boolean, viewModel: ChatSafetyViewModel) {
// S9: the blocked player is not told, and a member deciding whether to block is
// usually deciding whether it will start something.
Text(stringResource(R.string.safety_block_confirm, name), style = MaterialTheme.typography.bodyMedium)
Row(Modifier.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = viewModel::block, enabled = !busy) {
Text(stringResource(R.string.safety_block))
}
OutlinedButton(onClick = { viewModel.go(ChatMenuStep.MENU) }) {
Text(stringResource(R.string.safety_cancel))
}
}
}

View File

@@ -0,0 +1,173 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.SessionUser
import com.runicgateway.app.data.api.dto.RustEventDto
import com.runicgateway.app.data.repository.RustRepository
import com.runicgateway.app.data.repository.SafetyOutcome
import com.runicgateway.app.data.repository.SafetyRefusal
import com.runicgateway.app.data.repository.SafetyRepository
import com.runicgateway.app.data.repository.safetyOutcomeOf
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Which part of a chat line's sheet is showing. */
enum class ChatMenuStep { MENU, REPORT, BLOCK, DONE }
/**
* The open sheet, or none.
*
* [line] is the row the sheet is about, held whole so the sheet still names the
* player after the feed has been re-read without it. [error] is the last refusal,
* dropped on every move between steps: "you have already reported this" is not
* about the next action.
*/
data class ChatMenuUi(
val line: RustEventDto? = null,
val step: ChatMenuStep = ChatMenuStep.MENU,
val reason: String = REPORT_REASONS.first().wire,
val detail: String = "",
val busy: Boolean = false,
@param:StringRes val doneRes: Int? = null,
@param:StringRes val errorRes: Int? = null,
)
/**
* A chat line's long-press menu (`USER_SAFETY.md` §4.3): report, *Hide for me*, block
* the player, and for staff hide for everyone.
*
* **The feed is re-read after anything that changes what the reader sees** — a hide,
* a block, staff's hide — and never edited here. The server is what filters it (S11),
* so the honest way to show a block is to ask again; dropping the line locally would
* show something the website does not, the moment a refresh disagreed. [changes]
* tells the screen when to ask. A report changes nothing, and does not re-read.
*/
@HiltViewModel
class ChatSafetyViewModel @Inject constructor(
private val safety: SafetyRepository,
private val rust: RustRepository,
private val sessionManager: SessionManager,
) : ViewModel() {
private val _menu = MutableStateFlow(ChatMenuUi())
val menu: StateFlow<ChatMenuUi> = _menu.asStateFlow()
private val _changes = Channel<Unit>(Channel.CONFLATED)
/** One item per change to what this reader's feed should show. */
val changes: Flow<Unit> = _changes.receiveAsFlow()
/** Who is reading, for [chatActions]. Read at the moment of asking, never cached. */
val user: SessionUser? get() = (sessionManager.state.value as? Session.SignedIn)?.user
fun open(line: RustEventDto) {
if (chatActions(line, user) == null) return
_menu.value = ChatMenuUi(line = line)
}
fun close() {
_menu.value = ChatMenuUi()
}
fun go(step: ChatMenuStep) = _menu.update { it.copy(step = step, errorRes = null) }
fun setReason(reason: String) = _menu.update { it.copy(reason = reason) }
fun setDetail(detail: String) = _menu.update { it.copy(detail = detail.take(MAX_DETAIL)) }
fun report() {
val m = _menu.value
val line = m.line ?: return
run(changesFeed = false, doneRes = R.string.safety_reported) {
safety.report(CHAT_TARGET, line.id, m.reason, m.detail)
}
}
fun hideForMe() {
val line = _menu.value.line ?: return
run(changesFeed = true) { safety.hide(CHAT_TARGET, line.id) }
}
fun block() {
val line = _menu.value.line ?: return
run(changesFeed = true, blocking = true) { safety.blockAuthorOf(CHAT_TARGET, line.id) }
}
/** Staff's hide for everyone, or its undo, by what the line says it is now. */
fun staffHide(hide: Boolean) {
val line = _menu.value.line ?: return
run(changesFeed = true) { safetyOutcomeOf(rust.setChatHidden(line.id, hide)) }
}
/**
* Do one action. Success closes the sheet, or shows [doneRes] when there is
* something to say (a report, which changes nothing visible); a refusal stays
* on the step it came from, with words for why.
*/
private fun run(
changesFeed: Boolean,
blocking: Boolean = false,
@StringRes doneRes: Int? = null,
call: suspend () -> SafetyOutcome,
) {
if (_menu.value.busy) return
_menu.update { it.copy(busy = true, errorRes = null) }
viewModelScope.launch {
when (val outcome = call()) {
SafetyOutcome.Done -> {
_menu.value = if (doneRes != null) {
_menu.value.copy(busy = false, step = ChatMenuStep.DONE, doneRes = doneRes)
} else {
ChatMenuUi()
}
if (changesFeed) _changes.trySend(Unit)
}
is SafetyOutcome.Refused -> {
_menu.update { it.copy(busy = false, errorRes = refusalText(outcome.reason, blocking)) }
// A line that is gone, or a block that already exists, means the
// feed on screen is out of date: re-read it so it says what is true.
if (outcome.reason == SafetyRefusal.GONE || outcome.reason == SafetyRefusal.ALREADY_DONE) {
if (changesFeed) _changes.trySend(Unit)
}
}
SafetyOutcome.NetworkError ->
_menu.update { it.copy(busy = false, errorRes = R.string.error_network) }
}
}
}
private companion object {
/** The server's own cap on a report's detail. */
const val MAX_DETAIL = 500
}
}
/** Words for one refusal. [blocking] picks the block's own wording for a 409. */
@StringRes
fun refusalText(reason: SafetyRefusal, blocking: Boolean): Int = when (reason) {
SafetyRefusal.ALREADY_DONE -> if (blocking) R.string.safety_error_already_blocked else R.string.safety_error_already_reported
SafetyRefusal.GONE -> R.string.safety_error_gone
SafetyRefusal.STAFF -> R.string.safety_error_staff
SafetyRefusal.FORBIDDEN -> R.string.safety_error_forbidden
SafetyRefusal.NOT_BLOCKABLE -> R.string.safety_error_not_blockable
SafetyRefusal.RATE_LIMITED -> R.string.safety_error_rate_limited
SafetyRefusal.UNAVAILABLE -> R.string.safety_error_unavailable
SafetyRefusal.DEMO_LOCKED -> R.string.safety_error_demo
SafetyRefusal.OTHER -> R.string.safety_error_other
}

View File

@@ -0,0 +1,79 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.repository.EventsRepository
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Which event a run on the map belongs to (`docs/modules/rust/PLAN.md` D123, D125).
*
* A site-event marker carries core's run id and nothing else about its event, and
* the app's event page is addressed by the event's slug. Core's public calendar
* maps one to the other: each `run` entry names its run (D125) and its slug. A
* live run is in the calendar's default window however long ago it started, so
* **one read of the default window** holds every run a marker could name that the
* public may see.
*
* **What is absent stays unlinked, and that is the gate.** Rehearsals and
* unlisted events are not on the public calendar, so a rehearsal's zone resolves
* to nothing and its card says only *site event*. There is no second rule here
* for which runs to hide.
*
* **It re-reads at most once a [refreshMs], and only for an id it does not
* know.** A map with one zone on it asks the calendar once, not every ten
* seconds; a new run appearing mid-session is found within a minute. A failed
* read keeps what the last one found and still counts as a read, so a site whose
* calendar is down is asked once a minute rather than on every poll.
*
* The calendar is the same for every viewer, so nothing here is per account.
*/
class EventRunResolver(
private val events: EventsRepository,
private val now: () -> Long = System::currentTimeMillis,
private val refreshMs: Long = REFRESH_MS,
) {
private val mutex = Mutex()
private var slugs: Map<String, String> = emptyMap()
private var titles: Map<String, String> = emptyMap()
private var readAt: Long? = null
/**
* The event title for each of [runIds] the last read found (runicnpc D303: an NPC's
* card names its event). Never reads by itself: [resolve] decides when the calendar
* is asked, and this answers from what it found.
*/
suspend fun titles(runIds: Set<String>): Map<String, String> = mutex.withLock {
runIds.mapNotNull { id -> titles[id]?.let { id to it } }.toMap()
}
/**
* The slug for each of [runIds] the public calendar lists. Ids it does not
* list are absent from the answer.
*/
suspend fun resolve(runIds: Set<String>): Map<String, String> = mutex.withLock {
val unknown = runIds.any { it !in slugs }
val last = readAt
if (unknown && (last == null || now() - last >= refreshMs)) read()
runIds.mapNotNull { id -> slugs[id]?.let { id to it } }.toMap()
}
private suspend fun read() {
readAt = now()
val result = events.calendar()
if (result is ApiResult.Ok) {
// Core sends the id as a number and the plugin as a string, so they
// meet as text.
val runs = result.data.entries.filter { it.kind == "run" && it.runId != null && it.slug.isNotBlank() }
slugs = runs.associate { it.runId.toString() to it.slug }
titles = runs.filter { it.title.isNotBlank() }.associate { it.runId.toString() to it.title }
}
}
companion object {
const val REFRESH_MS = 60_000L
}
}

View File

@@ -0,0 +1,243 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import com.runicgateway.app.data.api.dto.RustMapGeometryDto
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* How a world position reaches a pixel (`docs/modules/rust/PLAN.md` §30.3, D121).
*
* Ported from the web's `mapGeometry.js`, with its test cases, so the phone and
* the page cannot place the same crate in two squares. Rust's world is centred on
* the origin, x east and z north. The picture is the world at a scale, with an
* ocean margin around it that is measured in **pixels** and is not scaled (the
* rig's 3 000 m map is 3 000 × 0.5 + 2 × 500 = 2 500 px). So:
*
* s = (width − 2 × margin) / worldSize
* px = (x + worldSize / 2) × s + margin
* py = (z + worldSize / 2) × s + margin measured UP from the bottom edge
*
* The web measures `py` up because Leaflet's simple frame grows north. A canvas
* grows **down**, so [toPixel] answers `height − py`, and that flip happens here
* and nowhere else.
*
* The grid is the **game's** (D119): [RustMapGeometryDto.gridCells] cells of
* [RustMapGeometryDto.gridCellSize] metres per side, lettered from the west and
* numbered from the north, `A0` at the north-west corner. Nothing here assumes a
* cell size, and nothing here assumes the rig's world size either.
*/
class MapFrame(val geometry: RustMapGeometryDto) {
/** Picture pixels per world metre, or 0 for a geometry that cannot place anything. */
val scale: Double = if (geometry.worldSize > 0 && geometry.width > 0) {
(geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize
} else {
0.0
}
/** True when positions can be placed at all. */
val canPlace: Boolean get() = scale > 0
val width: Double get() = geometry.width
val height: Double get() = geometry.height
private val half: Double get() = geometry.worldSize / 2
/** A world position as a point in the picture's pixels, y measured down from the top. */
fun toPixel(x: Double, z: Double): PicturePoint = PicturePoint(
x = (x + half) * scale + geometry.oceanMargin,
y = geometry.height - ((z + half) * scale + geometry.oceanMargin),
)
/** A distance on the ground as picture pixels: a zone's radius. */
fun metres(m: Double): Double = m * scale
/** The grid label for a world position, the way the in-game map writes it; null without a grid. */
fun gridLabel(x: Double, z: Double): String? {
val g = geometry
if (g.gridCells <= 0 || g.gridCellSize <= 0) return null
val col = floor((x + half) / g.gridCellSize).toInt().coerceIn(0, g.gridCells - 1)
val row = floor((half - z) / g.gridCellSize).toInt().coerceIn(0, g.gridCells - 1)
return "${column(col)}$row"
}
/**
* The grid in world metres: [MapGrid.lines] as pairs of ends, and a label at
* each cell's north-west corner.
*/
fun grid(): MapGrid {
val g = geometry
if (g.gridCells <= 0 || g.gridCellSize <= 0) return MapGrid(emptyList(), emptyList())
val n = g.gridCells
val c = g.gridCellSize
val lines = buildList {
for (i in 0..n) {
val at = -half + i * c
add(WorldLine(at, half, at, half - n * c))
add(WorldLine(-half, half - i * c, -half + n * c, half - i * c))
}
}
val labels = buildList {
for (col in 0 until n) {
for (row in 0 until n) {
add(GridLabel("${column(col)}$row", -half + col * c, half - row * c))
}
}
}
return MapGrid(lines, labels)
}
companion object {
/** A column number as Rust spells it: 0 is A, 25 is Z, 26 is AA. */
fun column(index: Int): String {
val name = StringBuilder()
var n = index + 1
while (n > 0) {
val r = (n - 1) % 26
name.insert(0, ('A' + r))
n = (n - 1) / 26
}
return name.toString()
}
}
}
/** A point in the picture's own pixels, y down. */
data class PicturePoint(val x: Double, val y: Double)
/** A line in world metres, from (x1, z1) to (x2, z2). */
data class WorldLine(val x1: Double, val z1: Double, val x2: Double, val z2: Double)
/** A grid label and the world position of its cell's north-west corner. */
data class GridLabel(val text: String, val x: Double, val z: Double)
data class MapGrid(val lines: List<WorldLine>, val labels: List<GridLabel>)
/**
* The picture's address, relative to the site's base.
*
* The module hands out `path` relative to `/api/v1`, as it does every path. It
* is joined here as **relative** (`api/v1/…`, no leading slash), the way every
* Retrofit path in this app is written, so a site served under a prefix keeps
* the prefix when [com.runicgateway.app.ui.LocalAssetResolver] resolves it.
*/
fun mapPictureUrl(path: String): String = "api/v1/" + path.trimStart('/')
/** Seconds as `m:ss`, for a locked crate's hack. */
fun countdown(seconds: Number?): String {
val s = max(0, (seconds?.toDouble() ?: 0.0).roundToInt())
return "${s / 60}:${(s % 60).toString().padStart(2, '0')}"
}
/**
* Where the picture sits on screen: screen pixels per picture pixel, and the
* screen position of the picture's top-left corner.
*
* Pure, so the gesture arithmetic is tested without Compose. **Zoom runs from
* fit-to-screen to [MAX_ZOOM] screen pixels per picture pixel** (§31.2), and a
* pan may take the view a quarter of the picture past its edges: some things sail
* off the edge of the world, and the rig's cargo ship spent phase 14's probe
* outside the picture entirely.
*/
data class MapTransform(val scale: Float, val offsetX: Float, val offsetY: Float) {
fun screenX(pictureX: Double): Float = (pictureX * scale).toFloat() + offsetX
fun screenY(pictureY: Double): Float = (pictureY * scale).toFloat() + offsetY
/** The picture pixel under a screen point. */
fun pictureX(screenX: Float): Double = ((screenX - offsetX) / scale).toDouble()
fun pictureY(screenY: Float): Double = ((screenY - offsetY) / scale).toDouble()
/**
* One step of a pinch: zoom by [zoom] about [centroidX], [centroidY] and move
* by [panX], [panY], then clamp to what the view allows.
*/
fun transformed(
view: ViewSize,
picture: ViewSize,
centroidX: Float,
centroidY: Float,
panX: Float,
panY: Float,
zoom: Float,
): MapTransform {
val next = (scale * zoom).coerceIn(minScale(view, picture), maxScale(view, picture))
val factor = next / scale
return MapTransform(
scale = next,
offsetX = centroidX - (centroidX - offsetX) * factor + panX,
offsetY = centroidY - (centroidY - offsetY) * factor + panY,
).clamped(view, picture)
}
/**
* The same view in a box of a different size: the picture pixel that was at
* the centre stays at the centre, and the zoom is clamped to what the new box
* allows.
*
* **Not a refit.** The walk found the reader's zoom thrown away when the
* status line under the map went from two lines to one: the map's box grew by
* a line's height, and a rule that refitted on every new size could not tell
* that from a rotation. A rotation keeps its centre too, which is what a
* reader turning the phone to see more of the same place wants.
*/
fun resized(from: ViewSize, to: ViewSize, picture: ViewSize): MapTransform {
val centreX = pictureX(from.width / 2)
val centreY = pictureY(from.height / 2)
val s = scale.coerceIn(minScale(to, picture), maxScale(to, picture))
return MapTransform(
scale = s,
offsetX = to.width / 2 - (centreX * s).toFloat(),
offsetY = to.height / 2 - (centreY * s).toFloat(),
).clamped(to, picture)
}
/**
* Keep the view's centre within the picture plus a quarter of it on every
* side, so a reader can follow something off the edge and cannot lose the map.
*/
fun clamped(view: ViewSize, picture: ViewSize): MapTransform {
val cx = view.width / 2
val cy = view.height / 2
val w = picture.width * scale
val h = picture.height * scale
return copy(
offsetX = offsetX.coerceIn(cx - w * (1 + OVERSCROLL), cx + w * OVERSCROLL),
offsetY = offsetY.coerceIn(cy - h * (1 + OVERSCROLL), cy + h * OVERSCROLL),
)
}
companion object {
/** Screen pixels per picture pixel at the closest zoom. */
const val MAX_ZOOM = 4f
/** How far past its edges the view may be taken, as a share of the picture. */
const val OVERSCROLL = 0.25f
/** The whole picture, centred. */
fun fit(view: ViewSize, picture: ViewSize): MapTransform {
val s = minScale(view, picture)
return MapTransform(
scale = s,
offsetX = (view.width - picture.width * s) / 2,
offsetY = (view.height - picture.height * s) / 2,
)
}
fun minScale(view: ViewSize, picture: ViewSize): Float {
if (picture.width <= 0 || picture.height <= 0) return 1f
return min(view.width / picture.width, view.height / picture.height)
}
/** Never less than fit: a tiny picture on a large screen still fits. */
fun maxScale(view: ViewSize, picture: ViewSize): Float = max(MAX_ZOOM, minScale(view, picture))
}
}
/** A width and height in pixels. */
data class ViewSize(val width: Float, val height: Float)

View File

@@ -0,0 +1,171 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import com.runicgateway.app.data.api.dto.RustMapDto
import com.runicgateway.app.data.api.dto.RustMapLiveDto
import kotlin.math.hypot
import kotlin.math.max
/**
* The switches on the legend. [GRID] is the reader's own and always offered;
* [MATES] is offered only when the server sends the viewer's own position.
*/
enum class MapLayer { GRID, WORLD, EVENTS, PLAYERS, BASES, MATES }
/**
* One thing drawn on the map, in world metres, carrying only what its layer sent.
*
* One flat shape rather than one per layer so that drawing, tapping and the card
* all read the same list, and a marker can never be tappable in a place it is not
* drawn. Nothing here is looked up: a player's name is the one the players layer
* carried to a viewer entitled to it (§31.4).
*/
data class MapMarker(
val layer: MapLayer,
/** `monument`, a world kind (`cargo`, `crate`…), an event kind (`zone`, `npc`…), `tc`, `vending`, `player` or `mate`. */
val kind: String,
val x: Double,
val z: Double,
/** A monument's label, a zone's name, a player's or mate's name. */
val name: String? = null,
/** A monument's grid square as the game wrote it; others are computed from the frame. */
val grid: String? = null,
/** Core's run id, for something an event placed. */
val runId: String? = null,
/** A zone's reach, in metres on the ground. */
val radiusMetres: Double? = null,
val hackLeftSec: Int? = null,
val hacked: Boolean = false,
val online: Boolean = false,
val sleeping: Boolean = false,
val self: Boolean = false,
/** A RunicNPC boss, in an event or the world layer: drawn bigger (runicnpc D303). */
val boss: Boolean = false,
/** One of a boss's adds. */
val add: Boolean = false,
) {
/** A zone is ground, not a point: it is drawn and tapped as an area. */
val isZone: Boolean get() = layer == MapLayer.EVENTS && kind == "zone"
}
/**
* Every marker to draw, in drawing order, bottom first: monuments, world events,
* site events, bases, players, and the viewer's own and their mates' on top.
*
* A layer is drawn when the server sent it **and** the reader has not switched it
* off. A layer the server did not send is null on [live] and contributes nothing,
* which is the whole gate; there is no second check here to disagree with it.
*/
fun mapMarkers(map: RustMapDto, live: RustMapLiveDto?, shown: Set<MapLayer>): List<MapMarker> = buildList {
if (MapLayer.WORLD in shown) {
map.monuments.orEmpty().forEach {
add(MapMarker(MapLayer.WORLD, "monument", it.x, it.z, name = it.label, grid = it.grid))
}
live?.world.orEmpty().forEach {
add(
MapMarker(
MapLayer.WORLD,
it.kind,
it.x,
it.z,
name = it.name,
hackLeftSec = it.hackLeftSec,
hacked = it.hacked,
boss = it.kind == "boss",
),
)
}
}
if (MapLayer.EVENTS in shown) {
live?.events.orEmpty().forEach {
add(
MapMarker(
MapLayer.EVENTS,
it.kind,
it.x,
it.z,
name = it.name,
runId = it.runId?.takeIf { id -> id.isNotBlank() },
radiusMetres = it.radius,
boss = it.boss,
add = it.add,
),
)
}
}
if (MapLayer.BASES in shown) {
live?.bases.orEmpty().forEach { add(MapMarker(MapLayer.BASES, it.kind, it.x, it.z)) }
}
if (MapLayer.PLAYERS in shown) {
live?.players.orEmpty().forEach {
add(
MapMarker(
MapLayer.PLAYERS,
"player",
it.x,
it.z,
name = it.name?.takeIf { n -> n.isNotBlank() } ?: it.steamId,
online = it.online,
sleeping = it.sleeping,
),
)
}
}
if (MapLayer.MATES in shown) {
live?.mates.orEmpty().forEach {
add(
MapMarker(
MapLayer.MATES,
"mate",
it.x,
it.z,
name = it.name?.takeIf { n -> n.isNotBlank() },
online = it.online,
sleeping = it.sleeping,
self = it.self,
),
)
}
}
}
/**
* The marker a tap at ([tapX], [tapY]) on screen means, or null.
*
* **A point wins over a zone.** The nearest point within [reachPx] is the answer;
* only when there is none does a zone answer, and then one whose ground the tap
* is on or within reach of. Otherwise a zone drawn round a monument would swallow
* every tap on the monument, and on the players standing in it. Among equals the
* one drawn last, which is the one on top, wins.
*/
fun nearestMarker(
markers: List<MapMarker>,
frame: MapFrame,
transform: MapTransform,
tapX: Float,
tapY: Float,
reachPx: Float,
): MapMarker? {
fun distance(m: MapMarker): Double {
val p = frame.toPixel(m.x, m.z)
val d = hypot(transform.screenX(p.x) - tapX.toDouble(), transform.screenY(p.y) - tapY.toDouble())
if (!m.isZone) return d
val radius = frame.metres(m.radiusMetres ?: 0.0) * transform.scale
return max(0.0, d - radius)
}
fun closest(candidates: List<MapMarker>): MapMarker? = candidates
.asReversed()
.map { it to distance(it) }
.filter { it.second <= reachPx }
.minByOrNull { it.second }
?.first
return closest(markers.filterNot { it.isZone }) ?: closest(markers.filter { it.isZone })
}
/** The distinct run ids the events layer names, for [EventRunResolver]. */
fun eventRunIds(live: RustMapLiveDto?): Set<String> =
live?.events.orEmpty().mapNotNull { it.runId?.takeIf { id -> id.isNotBlank() } }.toSet()

View File

@@ -0,0 +1,542 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.RustBackpackDto
import com.runicgateway.app.data.api.dto.RustEconomicsServerDto
import com.runicgateway.app.data.api.dto.RustLinkDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
import com.runicgateway.app.data.api.dto.RustReachDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* The player's own Rust account (PLAN.md §9 M15) — the app's mirror of the
* module's `/player/rust` page, and the same shape as
* [com.runicgateway.app.ui.player.CharactersScreen] one game along: the code
* card first, then what the code got them.
*
* Two independent reads. The accounts can fail with the entitlements on screen,
* and the other way round, because an entitlement is authored against the
* website account and exists before a Steam id does.
*/
@Composable
fun RustAccountScreen(
modifier: Modifier = Modifier,
viewModel: RustAccountViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
LinkCard(state, viewModel)
when (val links = state.links) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(links.kind, onRetry = viewModel::load)
is UiState.Success -> {
if (links.data.isEmpty()) {
Text(
stringResource(R.string.rust_account_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
links.data.forEach { link ->
LinkRow(
link = link,
busy = state.unlinking == link.steamId,
onUnlink = { viewModel.unlink(link.steamId) },
)
}
Text(
stringResource(R.string.rust_account_fleet_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Text(
stringResource(R.string.rust_held_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 8.dp),
)
when (val held = state.held) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(held.kind, onRetry = viewModel::load)
is UiState.Success -> Held(held.data)
}
// Coins (PLAN_REDESIGNS §11.2.2). A 404 is a module older than protocol
// 14, which has no balances to give: the section is left out rather than
// shown as an error the player can do nothing about.
val coins = state.coins
if (!(coins is UiState.Error && coins.httpStatus == 404)) {
Text(
stringResource(R.string.rust_coins_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 8.dp),
)
when (coins) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(coins.kind, onRetry = viewModel::load)
is UiState.Success -> Coins(coins.data)
}
// Backpacks (PLAN_REDESIGNS §11.3.2) shipped in the same protocol
// as Economics, so the coins read's 404 gates both (R10).
Text(
stringResource(R.string.rust_backpack_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 8.dp),
)
Backpacks(state, viewModel)
}
}
}
/**
* Your own backpack, one linked account on one server at a time, read when you
* press Open and not again until you press it again (§11.3.2). No icons (D340):
* each slot is the game's own name for the item.
*/
@Composable
private fun Backpacks(state: RustAccountViewModel.State, viewModel: RustAccountViewModel) {
val links = (state.links as? UiState.Success)?.data.orEmpty()
val servers = (state.backpackServers as? UiState.Success)?.data.orEmpty()
if (links.isEmpty() || servers.isEmpty()) {
Text(
stringResource(R.string.rust_backpack_unlinked),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
servers.forEach { server ->
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(server.name.ifBlank { server.id }, style = MaterialTheme.typography.titleSmall)
links.forEach { link ->
val key = RustAccountViewModel.BackpackKey(server.id, link.steamId)
val open = state.backpackOpen == key
Row(verticalAlignment = Alignment.CenterVertically) {
if (links.size > 1) {
Text(
playerLabel(link.name, link.steamId),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
}
TextButton(onClick = { viewModel.openBackpack(server.id, link.steamId) }) {
Text(stringResource(if (open) R.string.rust_backpack_read_again else R.string.rust_backpack_open))
}
if (open) {
TextButton(onClick = viewModel::closeBackpack) {
Text(stringResource(R.string.rust_backpack_close))
}
}
}
if (open) {
when (val read = state.backpack) {
null, is UiState.Loading -> LoadingView()
is UiState.Error -> BackpackRefusal(read) { viewModel.openBackpack(server.id, link.steamId) }
is UiState.Success -> BackpackGrid(read.data)
}
}
}
}
}
}
}
/** 409 and 404 are sentences, not retries: Backpacks is missing, or the site cannot show it yet. */
@Composable
private fun BackpackRefusal(error: UiState.Error, onRetry: () -> Unit) {
val sentence = when (error.httpStatus) {
409 -> R.string.rust_backpack_missing
404 -> R.string.rust_backpack_unavailable
else -> null
}
if (sentence == null) {
ErrorView(error.kind, onRetry = onRetry)
return
}
Text(
stringResource(sentence),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
/** Pages of four-wide rows: a phone is narrow, and a slot needs room for an item's name. */
@Composable
private fun BackpackGrid(backpack: RustBackpackDto) {
if (!backpack.exists) {
Text(
stringResource(R.string.rust_backpack_none),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
return
}
val pages = backpackPages(backpack)
val waiting = backpack.items.count { it.overflow }
Text(
stringResource(R.string.rust_backpack_summary, backpack.items.size, backpack.capacity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
if (waiting > 0) {
Text(
stringResource(R.string.rust_backpack_overflow, waiting),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
pages.forEach { page ->
if (pages.size > 1) {
Text(
stringResource(R.string.rust_backpack_page, page.index + 1),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(top = 8.dp),
)
}
page.cells.chunked(BACKPACK_COLUMNS).forEach { row ->
Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
row.forEach { cell -> BackpackSlot(cell, Modifier.weight(1f)) }
repeat(BACKPACK_COLUMNS - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
private const val BACKPACK_COLUMNS = 4
@Composable
private fun BackpackSlot(cell: BackpackCell, modifier: Modifier) {
val stack = cell.stack
val outline = if (cell.overflow) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.outlineVariant
Column(
modifier
.heightIn(min = 56.dp)
.border(1.dp, outline, RoundedCornerShape(4.dp))
.padding(4.dp),
) {
if (stack != null) {
Text(stackLabel(stack), style = MaterialTheme.typography.labelSmall, maxLines = 3, overflow = TextOverflow.Ellipsis)
stack.contents.forEach { inner ->
Text("+ ${stackLabel(inner)}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (stack.amount > 1) {
Text("×${stack.amount}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
/**
* One card per server: each linked account's balance where the server runs
* Economics, and the reason where it does not (D199) — a player asking where
* their coins are is owed the answer, not an empty space.
*/
@Composable
private fun Coins(servers: List<RustEconomicsServerDto>) {
if (servers.none { it.accounts.isNotEmpty() }) {
Text(
stringResource(R.string.rust_coins_unlinked),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
servers.forEach { server ->
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(server.server.ifBlank { server.serverId }, style = MaterialTheme.typography.titleSmall)
if (!server.economics) {
Text(
stringResource(R.string.rust_coins_missing),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
} else {
server.accounts.forEach { account ->
Text(
stringResource(R.string.rust_coins_balance, playerLabel(account.name, account.steamId), coinAmount(account.balance)),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 4.dp),
)
if (account.source == "last-known") {
Text(
stringResource(R.string.rust_coins_last_known),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
/**
* The code form.
*
* The three-step instruction is not decoration: nothing else in the app tells a
* player that the code comes from the game, and a code field with no explanation
* is a code field nobody can use.
*/
@Composable
private fun LinkCard(state: RustAccountViewModel.State, viewModel: RustAccountViewModel) {
var code by rememberSaveable { mutableStateOf("") }
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.rust_link_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.rust_link_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
OutlinedTextField(
value = code,
onValueChange = { code = it.uppercase() },
singleLine = true,
enabled = !state.busy,
label = { Text(stringResource(R.string.rust_link_code)) },
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
Button(
onClick = { viewModel.link(code); code = "" },
enabled = !state.busy && code.isNotBlank(),
modifier = Modifier.padding(top = 12.dp),
) { Text(stringResource(R.string.rust_link_action)) }
// Beside the button that caused it. A refusal at the top of a long
// scroll is a press that visibly did nothing (PLAN.md §21.5).
state.feedback?.let { feedback ->
Text(
text = stringResource(feedback.messageRes),
style = MaterialTheme.typography.bodySmall,
color = if (feedback.ok) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
},
modifier = Modifier.padding(top = 10.dp),
)
}
}
}
}
/** One linked Steam account, and the button that releases it. */
@Composable
private fun LinkRow(link: RustLinkDto, busy: Boolean, onUnlink: () -> Unit) {
ShardCard(Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = link.name?.takeIf { it.isNotBlank() } ?: link.steamId,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = link.steamId,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// When, and which server minted the code. Not part of the
// identity — a link is fleet-wide — but it is where a support
// conversation starts, and the website's own row says it.
val when_ = rustAgo(link.linkedAt)
val where = link.serverId?.takeIf { it.isNotBlank() }
if (when_ != null || where != null) {
Text(
text = listOfNotNull(
when_?.let { stringResource(R.string.rust_account_linked_when, it) },
where,
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextButton(onClick = onUnlink, enabled = !busy) {
Text(stringResource(R.string.rust_unlink_action))
}
}
}
}
/** Ranks and grants, drawn the same way because they read the same. */
@Composable
private fun Held(held: RustPlayerPermissionsDto) {
if (held.groups.isEmpty() && held.grants.isEmpty()) {
Text(
stringResource(R.string.rust_held_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
held.groups.forEach { group ->
HeldCard(
title = group.title.ifBlank { group.name },
detail = group.permissions.joinToString(" · ").takeIf { it.isNotBlank() },
reach = group.reach,
)
}
held.grants.forEach { grant ->
HeldCard(
title = grant.permission,
detail = grant.note?.takeIf { it.isNotBlank() },
reach = grant.reach,
)
}
// Zero linked accounts is WHY everything above is waiting, and the screen
// says so rather than leaving a page of hollow pills to be read as a fault.
if (held.accounts == 0) {
Text(
stringResource(R.string.rust_held_unlinked),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (held.groups.any { it.reach.any { s -> !s.live } } ||
held.grants.any { it.reach.any { s -> !s.live } }
) {
Text(
stringResource(R.string.rust_held_waiting_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun HeldCard(title: String, detail: String?, reach: List<RustReachDto>) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleSmall)
detail?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
if (reach.isEmpty()) {
Text(
stringResource(R.string.rust_held_no_servers),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
} else {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 10.dp),
) {
reach.forEach { server ->
// The tone IS the state: a server that has it and one
// that has not are the two things this screen exists to
// tell apart, and colour alone would not say which — so
// the label carries the word as well.
StatusPill(
text = stringResource(
if (server.live) R.string.rust_reach_live else R.string.rust_reach_waiting,
server.name.ifBlank { server.id },
),
tone = if (server.live) PillTone.Success else PillTone.Neutral,
)
}
}
}
}
}
}

View File

@@ -0,0 +1,210 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.RustBackpackDto
import com.runicgateway.app.data.api.dto.RustEconomicsServerDto
import com.runicgateway.app.data.api.dto.RustLinkDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.repository.PlayerRustRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The player's own Rust account (PLAN.md §9 M15; `docs/modules/rust/PLAN.md`
* §19, §20, §22): link a Steam account with the code `/link` hands out in game,
* release one, and see what the site has given them on which servers.
*
* **Two reads, one screen, and neither blocks the other.** The entitlement read
* carries its own state because an entitlement is authored against the *website*
* account: it exists before a Steam id does, and the person who has just been
* given something and has not linked yet is exactly the one who needs to see
* both halves at once. A failure on either side leaves the other on screen.
*
* **A refusal is chosen by status, not by prose** (the convention
* [com.runicgateway.app.ui.player.CharactersViewModel] set one game along). The
* four the website distinguishes are four different pieces of advice, and
* flattening them is the failure worth naming: a player told to get a new code
* when the server their code came from was merely unreachable will go and get
* another code from the same unreachable server.
*/
@HiltViewModel
class RustAccountViewModel @Inject constructor(
private val repository: PlayerRustRepository,
) : ViewModel() {
/** A one-shot banner for the code form, rendered beside the button that caused it. */
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
/** Which backpack: one linked account on one server. */
data class BackpackKey(val serverId: String, val steamId: String)
data class State(
val links: UiState<List<RustLinkDto>> = UiState.Loading,
val held: UiState<RustPlayerPermissionsDto> = UiState.Loading,
/** Coin balances (PLAN_REDESIGNS §11.2.2), a third independent read. */
val coins: UiState<List<RustEconomicsServerDto>> = UiState.Loading,
/** The servers the backpack section offers (PLAN_REDESIGNS §11.3.2). */
val backpackServers: UiState<List<RustServerDto>> = UiState.Loading,
/** The one backpack open, by server and account, or null when none is. */
val backpackOpen: BackpackKey? = null,
/** That backpack, read live when opened; null before the first open. */
val backpack: UiState<RustBackpackDto>? = null,
val busy: Boolean = false,
val feedback: Feedback? = null,
/** The Steam id currently being released, so only its own row shows it. */
val unlinking: String? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(links = UiState.Loading, held = UiState.Loading, coins = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(links = repository.links().toUiState()) }
}
viewModelScope.launch {
_state.update { it.copy(held = repository.permissions().toUiState()) }
}
reloadCoins()
viewModelScope.launch {
_state.update { it.copy(backpackServers = repository.servers().toUiState()) }
}
}
/**
* Open one backpack (PLAN_REDESIGNS §11.3.2): **one read per press, and
* nothing polls**, because a read runs on the game's main thread. Pressing
* the open one again reads it again; opening another closes the first.
*/
fun openBackpack(serverId: String, steamId: String) {
val key = BackpackKey(serverId, steamId)
if (_state.value.backpackOpen == key && _state.value.backpack is UiState.Loading) return
_state.update { it.copy(backpackOpen = key, backpack = UiState.Loading) }
viewModelScope.launch {
val read = repository.backpack(serverId, steamId).toUiState()
// A slow answer for a backpack the player has since closed or swapped is dropped.
_state.update { if (it.backpackOpen == key) it.copy(backpack = read) else it }
}
}
fun closeBackpack() = _state.update { it.copy(backpackOpen = null, backpack = null) }
/** The balances follow the links: a new link is a new account to read. */
private fun reloadCoins() {
viewModelScope.launch {
_state.update { it.copy(coins = repository.economics().toUiState()) }
}
}
/** Re-read what the site holds without blanking the accounts above it. */
private fun reloadHeld() {
viewModelScope.launch {
_state.update { it.copy(held = repository.permissions().toUiState()) }
}
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
fun link(code: String) {
if (_state.value.busy || code.isBlank()) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (val result = repository.link(code.trim())) {
is ApiResult.Ok -> {
// `already` is a second press of the button, not an error:
// the code was good and that account was already theirs.
val res =
if (result.data.already) R.string.rust_link_already else R.string.rust_link_ok
_state.update { it.copy(busy = false, feedback = Feedback(true, res)) }
refreshAfterChange()
}
is ApiResult.HttpError -> _state.update {
it.copy(busy = false, feedback = Feedback(false, linkErrorRes(result.status)))
}
is ApiResult.NetworkError -> _state.update {
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
}
}
}
}
fun unlink(steamId: String) {
if (_state.value.unlinking != null) return
_state.update { it.copy(unlinking = steamId, feedback = null) }
viewModelScope.launch {
val result = repository.unlink(steamId)
_state.update { it.copy(unlinking = null) }
when (result) {
is ApiResult.Ok -> refreshAfterChange()
is ApiResult.HttpError -> _state.update {
it.copy(feedback = Feedback(false, R.string.rust_unlink_error))
}
is ApiResult.NetworkError -> _state.update {
it.copy(feedback = Feedback(false, R.string.error_network))
}
}
}
}
/**
* Both halves, after the caller changed one of them.
*
* Linking an account does not change what the site has authored — but it
* changes what reaches a game, and the next sync is what makes that true. So
* the entitlement list is re-read too: its `live` marks are the only thing on
* this screen that a link can silently invalidate.
*/
private fun refreshAfterChange() {
viewModelScope.launch {
_state.update { it.copy(links = repository.links().toUiState()) }
}
reloadHeld()
reloadCoins()
}
private fun linkErrorRes(status: Int): Int = when (status) {
// Unknown or expired: the code is spent, and the way out is a new one.
400 -> R.string.rust_link_bad_code
// Another website account holds that Steam id. It is never moved
// silently; `/unlink` in game is the release (D23).
409 -> R.string.rust_link_taken
429 -> R.string.rust_link_capped
// A server could not be reached. **The code is still good**, which is
// why this may not say "get a new one".
503 -> R.string.rust_link_unreachable
else -> R.string.rust_link_error
}
}

View File

@@ -0,0 +1,88 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.repository.RustRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The live player count on the drawer's Rust row — the phone's answer to D15
* (`docs/modules/rust/PLAN.md` §17.4).
*
* ## Why the drawer and not a footer
*
* D15 put "2 servers · 42 online" in core's `site.footer.status` slot, which
* exists because every page of the website renders the same footer. The app has
* no footer and no slot; what it has is a drawer row per surface and, already, a
* precedent for a number beside one — the inbox's unread badge, in the same
* `NavigationDrawerItem` badge slot, with the same screen-reader treatment. So
* the count rides there.
*
* **The number is players, not servers.** A badge is one integer, and of the two
* halves of D15's line the live one is how many people are on: a server count
* changes when an operator edits configuration, which is not news, and is visible
* on the page the row opens anyway.
*
* ## What keeps it honest
*
* The website's version renders nothing until it has an answer, nothing at all if
* the request fails, and never polls — because one request per page view is a
* cost and a timer in a footer on every page is a different kind of thing. All
* three rules hold here:
*
* - **Zero renders nothing.** No badge, rather than a `0` — an empty server is
* not a notification.
* - **A failure leaves the last count** rather than dropping to zero. A moment
* with no connectivity is not everybody logging off.
* - **It refreshes on resume, with the unread badge**, and never on a timer. The
* count is a glance, not a feed.
*
* It is asked for **only when the module is installed** — the caller gates on the
* `rust` capability — so a site running a different game makes no request at all.
*/
@HiltViewModel
class RustBadgeViewModel @Inject constructor(
private val repository: RustRepository,
) : ViewModel() {
private val _online = MutableStateFlow(0)
/** How many people are on across every server, or 0 when there is nothing to say. */
val online: StateFlow<Int> = _online.asStateFlow()
/**
* Ask, if the Rust module is there.
*
* [installed] is passed in rather than read here so this holds no opinion
* about capabilities: the drawer already knows, and a view model that
* re-derived it would be a second copy of a rule that lives in one place.
* Absent — the host has not answered yet — makes no request and keeps
* whatever is showing.
*/
fun refresh(installed: Boolean) {
if (!installed) {
_online.value = 0
return
}
viewModelScope.launch {
when (val result = repository.servers()) {
// A server that is stale or unreachable already answers `online:
// false` with `players: 0`, so summing the whole list needs no
// second staleness rule here.
is ApiResult.Ok -> _online.value = result.data.sumOf { it.players }
// Keep the last number. See the class doc.
else -> Unit
}
}
}
}

View File

@@ -0,0 +1,199 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import com.runicgateway.app.data.api.dto.RustEventDto
/**
* One stored frame as one line of a feed — the Kotlin half of `module-rust`'s
* `client/src/lib/feed.js` (PLAN.md §9 M14).
*
* `GET /public/rust/servers/{id}/events` answers rows shaped
* `{ id, kind, t, wipeId, steamId, frame }`, where `frame` is the whole frame the
* bridge plugin emitted. Everything a killfeed line needs is in there, under the
* names the plugin wrote, and **this file is the one place in the app that knows
* them**.
*
* ## It returns parts, not a sentence
*
* A row wants the names emphasised and the detail muted, and a function returning
* `"Alice killed Bob"` would force the screen to re-parse its own output to style
* it. Parts also make this testable without Compose, which is the only way this
* leg has real coverage of what a feed row says.
*
* ## The rule for an unknown kind
*
* **It renders as itself.** A later protocol adds kinds and an operator's module
* may be older than their game host, so a feed that dropped what it did not
* recognise would be a screen quietly saying less than the truth. The server's
* allowlist has already decided the row may be seen; what is left here is
* presentation, and the honest presentation of a kind we have no words for is its
* own name.
*/
/** The row's category, for the small colour a screen gives it — never for meaning. */
enum class FeedTone { KILL, DEATH, JOIN, LEAVE, CHAT, SERVER, OTHER }
/**
* One row, ready to render.
*
* [actor] and [subject] are names and are emphasised; [verb] and [detail] are
* prose. Any of them may be null or empty.
*
* [join] is what goes between the actor and the verb, and it exists for exactly
* one case: chat. "Brannock see you in september" is not a sentence anybody
* writes, and putting the colon in the message would put presentation inside text
* a player typed.
*/
data class FeedLine(
val tone: FeedTone,
val actor: String? = null,
val join: String = " ",
val verb: String = "",
val subject: String? = null,
val detail: String = "",
)
/** One filter the feed offers, and the kinds it asks the API for. */
data class FeedFilter(val id: String, val label: String, val kinds: List<String>)
/**
* Kinds this feed asks for.
*
* `player.tally` is public and deliberately **not** here: it is an aggregate the
* plugin flushes every sixty seconds per active player, so a feed including it
* would be mostly wood counts. It is the leaderboard's input, and the leaderboard
* is where it shows up.
*/
val FEED_KINDS: List<String> = listOf(
"player.death",
"player.connected",
"player.disconnected",
"player.respawned",
"player.chat",
"server.wipe",
"server.initialized",
"server.shutdown",
)
/** The filters the feed offers. The first is the default and asks for everything. */
val FEED_FILTERS: List<FeedFilter> = listOf(
FeedFilter("all", "Everything", FEED_KINDS),
FeedFilter("kills", "Kills", listOf("player.death")),
FeedFilter("chat", "Chat", listOf("player.chat")),
FeedFilter(
"sessions",
"Comings and goings",
listOf("player.connected", "player.disconnected", "player.respawned"),
),
FeedFilter("server", "Server", listOf("server.wipe", "server.initialized", "server.shutdown")),
)
/** The kinds a filter id asks for; an id nobody offers falls back to everything. */
fun kindsFor(filterId: String): List<String> =
(FEED_FILTERS.firstOrNull { it.id == filterId } ?: FEED_FILTERS.first()).kinds
/** One row as the parts a screen renders. */
fun describe(row: RustEventDto): FeedLine {
val name = row.str("name")
return when (row.kind) {
"player.death" -> death(row, name)
"player.connected" ->
FeedLine(FeedTone.JOIN, actor = name, verb = "connected")
"player.disconnected" -> FeedLine(
tone = FeedTone.LEAVE,
actor = name,
verb = "disconnected",
// Two optional halves, and the session is the interesting one. The
// plugin OMITS `sessionSec` for a player who was already on when it
// loaded, so an absent value means "unknown" and never zero — which is
// why this reads the parsed number rather than trusting a default.
detail = listOfNotNull(
row.str("reason"),
row.num("sessionSec")?.takeIf { it > 0 }?.let { "after ${playtime(it.toLong())}" },
).joinToString(" · "),
)
"player.respawned" ->
FeedLine(FeedTone.JOIN, actor = name, verb = "respawned")
"player.chat" -> FeedLine(
tone = FeedTone.CHAT,
actor = name,
join = ": ",
// The message is the row, so it goes in `verb` where a screen renders
// it unemphasised — and it is the one field on this wire whose bytes a
// player chooses. Compose renders it as text and never as markup;
// nothing here may ever stop doing that.
verb = row.str("message").orEmpty(),
detail = row.str("channel")?.takeIf { it != "Global" }.orEmpty(),
)
"server.wipe" -> FeedLine(
tone = FeedTone.SERVER,
verb = "The map was wiped",
detail = row.str("wipeId")?.let { "new wipe $it" }.orEmpty(),
)
"server.initialized" -> FeedLine(FeedTone.SERVER, verb = "The server came up")
"server.shutdown" -> FeedLine(FeedTone.SERVER, verb = "The server went down")
else -> FeedLine(
tone = FeedTone.OTHER,
actor = name,
verb = row.kind.takeIf { it.isNotBlank() } ?: "unknown",
)
}
}
/**
* A death, which is four different sentences.
*
* The plugin distinguishes `player`, `self`, `npc` and `environment` precisely so
* a reader does not have to guess from an absent field, and collapsing any two of
* them loses something. A killfeed reporting a fall as a kill by nobody is the
* failure this avoids.
*/
private fun death(row: RustEventDto, name: String?): FeedLine {
val where = listOfNotNull(
row.str("weapon")?.let { "with ${weaponName(it, row.str("weaponName"))}" },
row.num("distance")?.let { "${Math.round(it)}m" },
row.str("grid"),
if (row.flag("sleeping")) "while sleeping" else null,
).joinToString(" · ")
return when (row.str("attackerType")) {
"player" -> FeedLine(
tone = FeedTone.KILL,
actor = row.str("attackerName"),
verb = "killed",
subject = name,
detail = where,
)
"self" -> FeedLine(
tone = FeedTone.DEATH,
actor = name,
verb = "died by their own hand",
detail = where,
)
"npc" -> FeedLine(
tone = FeedTone.DEATH,
actor = prefabName(row.str("attackerName")).ifBlank { "Something" },
verb = "killed",
subject = name,
detail = where,
)
// `environment` and anything else: falling, drowning, the world. The
// plugin legitimately has no attacker on this path, so an ABSENT type is
// this case rather than a missing field to complain about.
else -> FeedLine(tone = FeedTone.DEATH, actor = name, verb = "died", detail = where)
}
}

Some files were not shown because too many files have changed in this diff Show More