45 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
134 changed files with 12930 additions and 420 deletions

View File

@@ -45,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
@@ -56,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

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

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

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

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

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

@@ -37,10 +37,15 @@ import kotlinx.serialization.Serializable
*
* [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,
@@ -83,6 +88,24 @@ data class EventOccurrenceDto(
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,
)
/**

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

@@ -146,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

@@ -17,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
@@ -208,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

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

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

@@ -159,6 +159,36 @@ object Capability {
*/
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
@@ -19,8 +20,11 @@ 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
@@ -56,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.
*/
@@ -66,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) {
@@ -117,6 +124,11 @@ 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
@@ -132,6 +144,28 @@ object NetworkModule {
@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

@@ -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,6 +35,7 @@ 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
@@ -57,13 +60,18 @@ 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
@@ -101,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. */
@@ -118,7 +131,12 @@ private val TOP_LEVEL_ROUTES = setOf(
// 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,
)
@@ -140,6 +158,8 @@ fun RunicApp(
onDeepLinkConsumed: () -> Unit = {},
sessionViewModel: SessionViewModel = hiltViewModel(),
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
demoNoticeViewModel: DemoNoticeViewModel = hiltViewModel(),
) {
val brand = appearance.brand
val navController = rememberNavController()
@@ -154,19 +174,46 @@ fun RunicApp(
val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle()
// Re-validate the cached role each time the app returns to the foreground (§4.3),
// and re-read the unread count with it: a tickle that arrived while the app was
// away is exactly what brings someone back to it.
LifecycleResumeEffect(Unit) {
// 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, deepLinkRef) {
val stream = deepLinkStream ?: return@LaunchedEffect
@@ -267,10 +314,17 @@ fun RunicApp(
colors = drawerItemColors,
indented = true,
unread = unread,
rustOnline = rustOnline,
) { openNode(child) }
}
} else {
NavRow(node, currentRoute, drawerItemColors, unread = unread) { openNode(node) }
NavRow(
node = node,
currentRoute = currentRoute,
colors = drawerItemColors,
unread = unread,
rustOnline = rustOnline,
) { openNode(node) }
}
}
@@ -315,6 +369,7 @@ fun RunicApp(
) {
Scaffold(
modifier = modifier,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
@@ -366,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),
)
}
@@ -386,6 +450,7 @@ private fun NavRow(
colors: NavigationDrawerItemColors,
indented: Boolean = false,
unread: Int = 0,
rustOnline: Int = 0,
onClick: () -> Unit,
) {
val route = when (node) {
@@ -405,6 +470,15 @@ private fun NavRow(
// 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) },
@@ -432,6 +506,19 @@ private fun NavRow(
)
}
}
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,
@@ -453,6 +540,7 @@ private fun RunicNavHost(
session: Session,
onSignOut: () -> Unit,
onSignOutEverywhere: () -> Unit,
onAccountDeleted: () -> Unit,
modifier: Modifier = Modifier,
) {
NavHost(
@@ -573,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)) })
}
@@ -613,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)
@@ -626,12 +740,30 @@ 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 another account's items up. The backend gates every call
@@ -676,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

@@ -27,7 +27,9 @@ 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
@@ -62,10 +64,30 @@ fun EventScreen(
when (val s = state) {
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Loading -> LoadingView(modifier)
is UiState.Success -> EventBody(s.data, onOpenSeries, onOpenRun, 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,
@@ -196,6 +218,22 @@ private fun Headline(event: PublicEventDto) {
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(

View File

@@ -56,4 +56,16 @@ class EventViewModel @Inject constructor(
_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

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

@@ -128,6 +128,17 @@ val APP_MENU: List<MenuEntry> = listOf(
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),
@@ -166,11 +177,40 @@ val APP_MENU: List<MenuEntry> = listOf(
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,
),
)
/**

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).
@@ -70,6 +71,15 @@ import com.runicgateway.app.data.repository.ContentRepository.PostCategory
* })
* ```
*
* 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.
*
@@ -119,6 +129,16 @@ val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
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),
)
/**
@@ -178,7 +198,12 @@ private val RESERVED_TOP_LEVEL = setOf(
// 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.
"uo",
//
// `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",
)
/**
@@ -209,6 +234,7 @@ private val RESERVED_TOP_LEVEL = setOf(
* // /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 />} />
* ```
@@ -231,6 +257,9 @@ private val RESERVED_TOP_LEVEL = setOf(
* /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
@@ -245,6 +274,12 @@ private val RESERVED_TOP_LEVEL = setOf(
* 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.
@@ -282,6 +317,10 @@ fun resolveWebPath(path: String?): String? {
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 {
@@ -295,6 +334,14 @@ fun resolveWebPath(path: String?): String? {
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
}
}
@@ -315,6 +362,17 @@ private fun runParam(query: String): String? {
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.
*
@@ -322,3 +380,6 @@ private fun runParam(query: String): String? {
* 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,6 +38,12 @@ object Routes {
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
/** 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.
*
@@ -70,6 +77,33 @@ object Routes {
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"
@@ -121,6 +155,8 @@ object Routes {
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"
@@ -140,6 +176,48 @@ 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"

View File

@@ -153,7 +153,7 @@ private fun ChannelPrefsList(
SectionLabel(stringResource(R.string.notifications_section_general))
Spacer(Modifier.height(8.dp))
general.forEach { item ->
ItemRow(item, channelsById, hint = null, enabled = !busy, pushSupported = pushSupported, onSetMode = onSetMode)
ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
HorizontalDivider()
}
Spacer(Modifier.height(20.dp))
@@ -163,15 +163,7 @@ private fun ChannelPrefsList(
SectionLabel(stringResource(R.string.notifications_section_personal))
Spacer(Modifier.height(8.dp))
personal.forEach { item ->
val selectable = itemSelectable(item, hasLinkedAccount)
ItemRow(
item = item,
channelsById = channelsById,
hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null,
enabled = !busy && selectable,
pushSupported = pushSupported,
onSetMode = onSetMode,
)
ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
HorizontalDivider()
}
}
@@ -181,33 +173,40 @@ private fun ChannelPrefsList(
private fun ItemRow(
item: NotificationChannelItemDto,
channelsById: Map<String, NotificationChannelDto>,
hint: String?,
enabled: Boolean,
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 = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = hint ?: item.description,
text = if (pushHeld) stringResource(R.string.notifications_requires_link) else item.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal,
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 = item.modes[channelId] ?: channel.defaultMode,
enabled = enabled,
onSetMode = { mode -> onSetMode(item, channelId, mode) },
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) },
)
}
}

View File

@@ -14,7 +14,7 @@ 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.PlayerShardRepository
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
@@ -53,7 +53,7 @@ const val MODE_OFF = "off"
@HiltViewModel
class NotificationSettingsViewModel @Inject constructor(
private val notifications: NotificationsRepository,
private val playerShard: PlayerShardRepository,
private val linkedAccounts: LinkedAccountRepository,
private val pushManager: PushManager,
sessionManager: SessionManager,
) : ViewModel() {
@@ -62,7 +62,10 @@ class NotificationSettingsViewModel @Inject constructor(
data class State(
val prefs: UiState<NotificationChannelPrefsDto> = UiState.Loading,
/** Whether the user has ≥1 linked game account — personal streams need it. */
/**
* 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,
@@ -94,9 +97,7 @@ class NotificationSettingsViewModel @Inject constructor(
_state.update { it.copy(prefs = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) }
// 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) }
_state.update { it.copy(hasLinkedAccount = linkedAccounts.hasLinkedAccount()) }
}
}
@@ -114,7 +115,7 @@ class NotificationSettingsViewModel @Inject constructor(
fun setMode(item: NotificationChannelItemDto, channel: String, mode: String) {
val current = _state.value
if (current.busy) return
if (channel == CHANNEL_PUSH && !itemSelectable(item, current.hasLinkedAccount)) return
if (!canSetMode(item, channel, mode, current.hasLinkedAccount)) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
@@ -160,8 +161,28 @@ class NotificationSettingsViewModel @Inject constructor(
}
/**
* Whether an item's controls are selectable for a user: a personal stream needs a
* linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose.
* 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 itemSelectable(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean =
!item.requiresLinkedAccount || hasLinkedAccount
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

@@ -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)
}
}

View File

@@ -0,0 +1,233 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
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
/**
* Formatting for the Rust screens — pure, no Compose, no Android (PLAN.md §9
* M14).
*
* The Kotlin half of `module-rust`'s `client/src/lib/format.js`, and it is a
* deliberate second implementation rather than something shared: the two clients
* have different formatting libraries under them (`Intl` there, `java.time`
* here), and the thing worth keeping identical is the **rules**, not the code.
* Those rules are restated here beside each function so a reader can check them
* against the website without opening it.
*
* Everything takes `now` as a parameter, so a boundary is testable rather than a
* property of the machine the test runs on.
*/
/**
* The stamp on a feed row.
*
* **Today's rows get a time; everything older gets a date as well.** The feed can
* be filtered to a past wipe, and a row from six weeks ago rendered as `14:03`
* reads as this afternoon — which is exactly what the website's own page walk
* found, three events from August all apparently a few minutes old. The boundary
* is the **calendar day**, not a duration, because that is what a reader means by
* "what time was that".
*/
fun feedClock(
value: String?,
epochMillis: Long? = null,
now: Instant = Instant.now(),
zone: ZoneId = ZoneId.systemDefault(),
locale: Locale = Locale.getDefault(),
): String {
val at = epochMillis?.takeIf { it > 0 }?.let(Instant::ofEpochMilli) ?: parseWireInstant(value) ?: return ""
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withLocale(locale)
.format(at.atZone(zone))
val sameDay = at.atZone(zone).toLocalDate() == now.atZone(zone).toLocalDate()
if (sameDay) return time
val date = DateTimeFormatter.ofPattern("d MMM", locale).format(at.atZone(zone))
return "$date $time"
}
/** A date, for a wipe: the thing people actually compare wipes by. */
fun wipeDay(
value: String?,
zone: ZoneId = ZoneId.systemDefault(),
locale: Locale = Locale.getDefault(),
): String? {
val at = parseWireInstant(value) ?: return null
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale)
.format(at.atZone(zone))
}
/**
* The next wipe (module-rust phase 16), as `Thu 1 Oct, 19:00 · in 6 days` in
* the PHONE's zone — or null when there is no instant to show.
*
* The module computes the instant from the operator's rule in the operator's
* zone; the phone only says it the reader's way, which is the same split the
* website's `nextWipe()` makes. The `(rescheduled)` mark for a one-off date is
* the caller's, from a string resource.
*/
fun nextWipeWhen(
value: String?,
now: Instant = Instant.now(),
zone: ZoneId = ZoneId.systemDefault(),
locale: Locale = Locale.getDefault(),
): String? {
val at = parseWireInstant(value) ?: return null
val local = at.atZone(zone)
val date = DateTimeFormatter.ofPattern("EEE d MMM", locale).format(local)
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale).format(local)
val distance = rustAgo(value, now) ?: return "$date, $time"
return "$date, $time · $distance"
}
/**
* "3 minutes ago", for a "last reported" line.
*
* Returns null rather than a word for an absent stamp, so the caller decides what
* "never" looks like in its own layout — on this surface a server that has never
* reported is a real and ordinary state, not a missing value to apologise for.
*/
fun rustAgo(value: String?, now: Instant = Instant.now()): String? {
val at = parseWireInstant(value) ?: return null
val seconds = java.time.Duration.between(at, now).seconds
// Under a minute in either direction, say the thing rather than "in 0 seconds".
if (kotlin.math.abs(seconds) < 45) return "just now"
val future = seconds < 0
val magnitude = kotlin.math.abs(seconds)
val (unit, size) = AGO_UNITS.first { magnitude >= it.second }
val amount = Math.round(magnitude.toDouble() / size)
val plural = if (amount == 1L) unit else "${unit}s"
return if (future) "in $amount $plural" else "$amount $plural ago"
}
private val AGO_UNITS = listOf(
"year" to 31_536_000L,
"month" to 2_592_000L,
"week" to 604_800L,
"day" to 86_400L,
"hour" to 3_600L,
"minute" to 60L,
"second" to 1L,
)
/**
* A session or a playtime, as `4h 12m`.
*
* Seconds are dropped above a minute and kept below it: a two-hour session
* reported to the second is noise, and a forty-second one reported as "0m" is
* wrong.
*/
fun playtime(seconds: Long?): String {
val total = seconds ?: return "—"
if (total <= 0) return "—"
if (total < 60) return "${total}s"
val hours = total / 3600
val minutes = Math.round((total % 3600) / 60.0)
return when {
hours == 0L -> "${minutes}m"
minutes == 0L -> "${hours}h"
else -> "${hours}h ${minutes}m"
}
}
/**
* A prefab short name as something readable — `patrolhelicopter` stays itself,
* `rifle.ak` becomes `rifle ak`.
*
* Deliberately a light touch rather than a lookup table: a table mapping every
* Rust prefab to a pretty name is a second copy of the game's item list that goes
* stale every wipe, and the short name is what a Rust player reads on their own
* server console anyway.
*/
fun prefabName(name: String?): String {
if (name.isNullOrBlank()) return ""
return name.replace(Regex("[_.]+"), " ").trim()
}
/** The endings a held entity's prefab name carries that no player says. */
private val WEAPON_ENDING = Regex("[._](entity|weapon|deployed)$")
/**
* The weapon in a kill, the Kotlin half of `module-rust`'s `format.js weapon()`.
* The module sends `weaponName` (Module-Rust#60), which is the game's own name for a
* gun or the prefab name as words (`server/model/titles/weaponNames.js`), and the
* app prints that. A module too old to send it gets the same fallback here: the
* `.entity` / `.weapon` / `.deployed` ending is dropped, then [prefabName] turns the
* rest into words. So the app never prints "ak47u entity".
*/
fun weaponName(weapon: String?, weaponName: String?): String {
if (weapon.isNullOrBlank()) return ""
if (!weaponName.isNullOrBlank()) return weaponName
return prefabName(weapon.trim().replace(WEAPON_ENDING, ""))
}
/**
* An Economics balance (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2.2): grouped,
* at most two places, and a dash for one the server never reported — not "0",
* which is a claim about somebody's balance that nobody made.
*/
fun coinAmount(value: Double?, locale: java.util.Locale = java.util.Locale.getDefault()): String {
val n = value ?: return "—"
if (!n.isFinite()) return "—"
return java.text.NumberFormat.getNumberInstance(locale).apply { maximumFractionDigits = 2 }.format(n)
}
/** A steam id, shortened for a table cell, without pretending it is a name. */
fun shortSteamId(steamId: String?): String {
val id = steamId.orEmpty()
return if (id.length > 10) "…${id.takeLast(6)}" else id
}
/**
* What to call a player who has no name yet.
*
* The presence board and the leaderboard both carry a nullable `name`: the plugin
* knows a steam id before it knows anything else. Showing a shortened id is
* honest — it is not a name and does not look like one — where "Unknown" would
* lose the only identifier there is.
*/
fun playerLabel(name: String?, steamId: String?): String =
name?.takeIf { it.isNotBlank() } ?: shortSteamId(steamId)
/**
* A chat title's `#rrggbb` colour as opaque ARGB, or null for anything else —
* a title with a colour this build cannot read is drawn in the theme's own chip
* colours rather than guessed at (M19).
*/
fun titleArgb(hex: String?): Long? {
val m = Regex("^#([0-9a-fA-F]{6})$").find(hex.orEmpty()) ?: return null
return 0xFF000000L or m.groupValues[1].toLong(16)
}
/**
* Whether a title chip of this colour needs DARK ink — the same rule as the
* website's `contrastInk`: black or white, whichever has the higher WCAG
* contrast. The colour is the operator's, chosen for a dark game chat, and the
* app draws in the reader's theme, so the colour becomes the chip and the ink is
* picked for it.
*/
fun titleInkIsDark(argb: Long): Boolean {
fun linear(channel: Long): Double {
val v = channel / 255.0
return if (v <= 0.03928) v / 12.92 else Math.pow((v + 0.055) / 1.055, 2.4)
}
val l = 0.2126 * linear((argb shr 16) and 0xFF) +
0.7152 * linear((argb shr 8) and 0xFF) +
0.0722 * linear(argb and 0xFF)
return (l + 0.05) / 0.05 >= 1.05 / (l + 0.05)
}

View File

@@ -0,0 +1,698 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
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.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.Saver
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.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
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.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import coil.size.Size
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.RustMapDto
import com.runicgateway.app.data.api.dto.RustMapLayerDto
import com.runicgateway.app.data.api.dto.RustMapLiveDto
import com.runicgateway.app.ui.LocalAssetResolver
import com.runicgateway.app.ui.PollWhileResumed
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
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.math.roundToInt
/**
* The Map tab (`docs/modules/rust/PLAN.md` §31, D121–D124).
*
* **The phone draws the map itself** (D121): Coil fetches the picture once per
* map into its disk cache, since the URL carries the hash, and a [Canvas] draws
* the picture, the grid and the markers, with pinch, pan and double tap.
*
* **Nothing here decides who may see what.** The server sends only the layers
* this viewer may see. The switches on the legend are the reader's convenience
* and never a boundary, and a layer the viewer was not sent is still listed,
* disabled, with who can see it: "staff only" explains an empty map where
* silence would imply an empty server (§23.3's shape).
*/
@Composable
fun RustMapPanel(
serverOnline: Boolean,
onOpenEvent: (slug: String, runId: String) -> Unit,
viewModel: RustMapViewModel = hiltViewModel(),
) {
val ui by viewModel.state.collectAsStateWithLifecycle()
when (val s = ui.map) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
is UiState.Success -> {
val map = s.data
val geometry = map.geometry
if (geometry == null) {
EmptyView(stringResource(R.string.rust_map_none))
return
}
// D124: while RESUMED and on this tab, which is exactly while this
// composable is on screen. Nothing is asked for a viewer who is sent
// nothing that moves.
if (map.anyLive) {
val interval = map.pollMs?.takeIf { it > 0 } ?: DEFAULT_POLL_MS
key(interval) { PollWhileResumed(intervalMs = interval) { viewModel.poll() } }
}
MapContent(
map = map,
frame = remember(geometry) { MapFrame(geometry) },
ui = ui,
serverOnline = serverOnline,
onToggle = viewModel::toggle,
onSelect = viewModel::select,
onOpenEvent = onOpenEvent,
)
}
}
}
private const val DEFAULT_POLL_MS = 10_000L
/** What became of the picture. */
private sealed interface Picture {
data object None : Picture
data object Loading : Picture
data object Failed : Picture
data class Ready(val bitmap: ImageBitmap) : Picture
}
@Composable
private fun MapContent(
map: RustMapDto,
frame: MapFrame,
ui: RustMapUi,
serverOnline: Boolean,
onToggle: (MapLayer) -> Unit,
onSelect: (MapMarker?) -> Unit,
onOpenEvent: (String, String) -> Unit,
) {
val picture = rememberPicture(map.picture?.path)
val markers = remember(map, ui.live, ui.shown) { mapMarkers(map, ui.live, ui.shown) }
Column(Modifier.fillMaxSize()) {
when (picture) {
Picture.None -> Note(stringResource(R.string.rust_map_no_picture))
Picture.Failed -> Note(stringResource(R.string.rust_map_picture_failed))
else -> Unit
}
Box(Modifier.fillMaxWidth().weight(1f)) {
MapCanvas(
frame = frame,
background = parseColour(frame.geometry.background) ?: OCEAN,
picture = (picture as? Picture.Ready)?.bitmap,
showGrid = MapLayer.GRID in ui.shown,
markers = markers,
selected = ui.selected,
// A new map is a new frame; the reader's zoom on the old one means nothing.
resetKey = map.mapKey,
onSelect = onSelect,
)
ui.selected?.let { marker ->
MarkerCard(
marker = marker,
grid = marker.grid ?: frame.gridLabel(marker.x, marker.z),
eventSlug = marker.runId?.let { ui.eventSlugs[it] },
eventTitle = marker.runId?.let { ui.eventTitles[it] },
onOpenEvent = onOpenEvent,
onClose = { onSelect(null) },
modifier = Modifier.align(Alignment.BottomCenter).padding(12.dp),
)
}
}
Note(liveStatus(map, ui, serverOnline))
Column(
Modifier
.fillMaxWidth()
.heightIn(max = LEGEND_MAX_HEIGHT)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
Legend(map, ui.live, ui.shown, onToggle)
}
}
}
private val LEGEND_MAX_HEIGHT = 240.dp
/**
* The picture, decoded once at its own size as `RGB_565` with hardware bitmaps
* off: half the memory of ARGB, 12.5 MB at the rig's 2 500 px (§31.2).
*/
@Composable
private fun rememberPicture(path: String?): Picture {
val context = LocalContext.current
val resolve = LocalAssetResolver.current
val url = path?.takeIf { it.isNotBlank() }?.let { resolve(mapPictureUrl(it)) }
// Keyed on the URL, which carries the picture's hash: a new map is a new
// load, and the old picture stays drawn until the new one has arrived.
var picture by remember { mutableStateOf(if (url == null) Picture.None else Picture.Loading) }
LaunchedEffect(url) {
picture = if (url == null) Picture.None else loadPicture(context, url)
}
return picture
}
private suspend fun loadPicture(context: Context, url: String): Picture {
val request = ImageRequest.Builder(context)
.data(url)
.size(Size.ORIGINAL)
.bitmapConfig(Bitmap.Config.RGB_565)
.allowHardware(false)
.build()
val result = context.imageLoader.execute(request) as? SuccessResult
val bitmap = (result?.drawable as? BitmapDrawable)?.bitmap ?: return Picture.Failed
return Picture.Ready(bitmap.asImageBitmap())
}
@Composable
private fun MapCanvas(
frame: MapFrame,
background: Color,
picture: ImageBitmap?,
showGrid: Boolean,
markers: List<MapMarker>,
selected: MapMarker?,
resetKey: String?,
onSelect: (MapMarker?) -> Unit,
) {
val pictureSize = ViewSize(frame.width.toFloat(), frame.height.toFloat())
var view by remember { mutableStateOf<ViewSize?>(null) }
// Null until the reader moves the map: until then, and after a new map
// resets it, the map is drawn fitted to the view. **Saveable**, because
// Open event leaves this screen and Back returns to it: the walk found the
// reader's zoom gone on the way back, the card still up over a whole-world
// view of the marker it described.
var moved by rememberSaveable(resetKey, pictureSize, stateSaver = TransformSaver) {
mutableStateOf<MapTransform?>(null)
}
val transform = moved ?: view?.let { MapTransform.fit(it, pictureSize) }
// Read through updated state so the gesture handlers, which are installed
// once, act on what is drawn now rather than on what was drawn when they were.
val currentMarkers by rememberUpdatedState(markers)
val currentTransform by rememberUpdatedState(transform)
val currentFrame by rememberUpdatedState(frame)
val textMeasurer = rememberTextMeasurer()
val labelStyle = TextStyle(fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = GRID_LABEL)
val grid = remember(frame) { frame.grid() }
val labels: List<Pair<GridLabel, TextLayoutResult>> = remember(grid, textMeasurer) {
grid.labels.map { it to textMeasurer.measure(it.text, labelStyle) }
}
val description = stringResource(R.string.rust_map_description)
Canvas(
Modifier
.fillMaxSize()
.clipToBounds()
.background(SURROUND)
.semantics { contentDescription = description }
.onSizeChanged {
val next = ViewSize(it.width.toFloat(), it.height.toFloat())
// A new size keeps the reader's view rather than refitting: the
// box grows and shrinks with the status line under it, and a
// rotation is still the same place. The first size after a
// return is not a new one, and keeps the restored zoom.
val previous = view
if (next != previous) {
val m = moved
if (previous != null && m != null) moved = m.resized(previous, next, pictureSize)
view = next
}
}
.pointerInput(pictureSize) {
detectTransformGestures { centroid, pan, zoom, _ ->
val v = view ?: return@detectTransformGestures
val t = currentTransform ?: return@detectTransformGestures
moved = t.transformed(v, pictureSize, centroid.x, centroid.y, pan.x, pan.y, zoom)
}
}
.pointerInput(pictureSize) {
detectTapGestures(
onTap = { at ->
val t = currentTransform ?: return@detectTapGestures
onSelect(nearestMarker(currentMarkers, currentFrame, t, at.x, at.y, TAP_REACH.toPx()))
},
onDoubleTap = { at ->
val v = view ?: return@detectTapGestures
val t = currentTransform ?: return@detectTapGestures
moved = t.transformed(v, pictureSize, at.x, at.y, 0f, 0f, 2f)
},
)
},
) {
val t = transform ?: return@Canvas
val left = t.offsetX
val top = t.offsetY
val w = pictureSize.width * t.scale
val h = pictureSize.height * t.scale
// Without a picture the geometry is filled with the game's own colour.
drawRect(background, topLeft = Offset(left, top), size = androidx.compose.ui.geometry.Size(w, h))
picture?.let {
drawImage(
image = it,
dstOffset = IntOffset(left.roundToInt(), top.roundToInt()),
dstSize = IntSize(w.roundToInt(), h.roundToInt()),
filterQuality = FilterQuality.Low,
)
}
if (showGrid) drawGrid(frame, t, grid, labels)
markers.forEach { drawMarker(frame, t, it, highlighted = it == selected) }
}
}
private fun DrawScope.drawGrid(
frame: MapFrame,
t: MapTransform,
grid: MapGrid,
labels: List<Pair<GridLabel, TextLayoutResult>>,
) {
val stroke = 1.dp.toPx()
fun at(x: Double, z: Double): Offset {
val p = frame.toPixel(x, z)
return Offset(t.screenX(p.x), t.screenY(p.y))
}
grid.lines.forEach { drawLine(GRID_LINE, at(it.x1, it.z1), at(it.x2, it.z2), strokeWidth = stroke) }
// Labels only once a cell is wide enough on screen to hold one: at the
// fitted zoom a 20-cell map's labels overlap into a wall of text (found on
// the phase 14 walk, and the web's rule).
val cellPx = frame.metres(frame.geometry.gridCellSize) * t.scale
if (cellPx < LABEL_MIN_CELL.toPx()) return
val pad = Offset(3.dp.toPx(), 2.dp.toPx())
labels.forEach { (label, layout) ->
val corner = at(label.x, label.z)
// Only the ones on screen: a zoomed-in 20-cell map has 400 and shows a few.
if (corner.x > size.width || corner.y > size.height) return@forEach
if (corner.x + cellPx < 0 || corner.y + cellPx < 0) return@forEach
drawText(layout, topLeft = corner + pad)
}
}
/**
* One marker. **Points are drawn at a fixed size on screen** whatever the zoom, so
* a dot does not grow over the monument it sits on at 4×; **a zone is drawn on
* the ground**, in metres, because how far it reaches is what it says (§31.4).
*/
private fun DrawScope.drawMarker(frame: MapFrame, t: MapTransform, m: MapMarker, highlighted: Boolean) {
val p = frame.toPixel(m.x, m.z)
val centre = Offset(t.screenX(p.x), t.screenY(p.y))
if (m.isZone) {
val radius = (frame.metres(m.radiusMetres ?: 0.0) * t.scale).toFloat()
drawCircle(EVENT.copy(alpha = 0.12f), radius, centre)
drawCircle(EVENT, radius, centre, style = Stroke(width = (if (highlighted) 3 else 2).dp.toPx()))
return
}
val style = markerStyle(m)
val radius = style.radius.dp.toPx()
drawCircle(style.colour.copy(alpha = 0.95f), radius, centre)
drawCircle(Color.Black, radius, centre, style = Stroke(width = style.outline.dp.toPx()))
if (highlighted) drawCircle(Color.White, radius + 4.dp.toPx(), centre, style = Stroke(width = 2.dp.toPx()))
}
/** The zoom and pan as three floats, so they survive leaving the screen. */
private val TransformSaver = Saver<MapTransform?, FloatArray>(
save = { t -> t?.let { floatArrayOf(it.scale, it.offsetX, it.offsetY) } },
restore = { MapTransform(it[0], it[1], it[2]) },
)
private data class MarkerStyle(val colour: Color, val radius: Int, val outline: Int = 1)
/** The web's colours and sizes (`MapView.jsx`), so the two maps read alike. */
private fun markerStyle(m: MapMarker): MarkerStyle = when (m.layer) {
MapLayer.WORLD -> when (m.kind) {
"monument" -> MarkerStyle(MONUMENT, 4)
"cargo" -> MarkerStyle(WORLD_COLOURS.getValue("cargo"), 7)
// A RunicNPC boss outside any event (runicnpc D302): as big as a ship.
"boss" -> MarkerStyle(BOSS, 7, 2)
else -> MarkerStyle(WORLD_COLOURS[m.kind] ?: WORLD_COLOURS.getValue("crate"), 5)
}
// An event's boss is bigger (runicnpc D303).
MapLayer.EVENTS -> if (m.boss) MarkerStyle(BOSS, 8, 2) else MarkerStyle(EVENT, 5)
MapLayer.PLAYERS -> if (m.online) MarkerStyle(ONLINE, 5) else MarkerStyle(SLEEPING, 4)
MapLayer.BASES -> MarkerStyle(if (m.kind == "vending") VENDING else TC, 4)
MapLayer.MATES -> if (m.self) MarkerStyle(SELF, 8, 2) else MarkerStyle(MATE, 6, 2)
MapLayer.GRID -> MarkerStyle(Color.White, 0)
}
/**
* What was tapped: what it is, its grid square, and what its layer carried.
* Nothing is looked up beyond what was sent (§31.4).
*/
@Composable
private fun MarkerCard(
marker: MapMarker,
grid: String?,
eventSlug: String?,
eventTitle: String?,
onOpenEvent: (String, String) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
ShardCard(modifier.fillMaxWidth()) {
Column(Modifier.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)) {
Text(markerTitle(marker), style = MaterialTheme.typography.titleSmall)
markerDetail(marker, eventTitle)?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
grid?.let {
Text(
stringResource(R.string.rust_map_grid, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
// D123: a run core's public calendar lists opens the app's own event
// page on that run. A run it does not list (a rehearsal, an
// unlisted event) says only what it is.
val runId = marker.runId
if (marker.layer == MapLayer.EVENTS && runId != null && eventSlug != null) {
TextButton(onClick = { onOpenEvent(eventSlug, runId) }) {
Text(stringResource(R.string.rust_map_open_event))
}
}
TextButton(onClick = onClose) { Text(stringResource(R.string.rust_map_close)) }
}
}
}
}
@Composable
private fun markerTitle(m: MapMarker): String = when (m.layer) {
MapLayer.WORLD -> when (m.kind) {
"monument" -> m.name ?: m.kind
"cargo" -> stringResource(R.string.rust_map_cargo)
"heli" -> stringResource(R.string.rust_map_heli)
"chinook" -> stringResource(R.string.rust_map_chinook)
"bradley" -> stringResource(R.string.rust_map_bradley)
"supply" -> stringResource(R.string.rust_map_supply)
"crate" -> stringResource(R.string.rust_map_crate)
"boss" -> stringResource(R.string.rust_map_boss, m.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.rust_map_boss_unnamed))
else -> prefabName(m.kind)
}
MapLayer.EVENTS -> when (m.kind) {
"zone" -> m.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.rust_map_event_zone)
// A RunicNPC NPC says its name, and whether it is a boss or a boss's add (D303).
"npc" -> {
val name = m.name?.takeIf { it.isNotBlank() }
when {
name == null -> stringResource(R.string.rust_map_event_npc)
m.boss -> stringResource(R.string.rust_map_boss, name)
m.add -> stringResource(R.string.rust_map_boss_add, name)
else -> name
}
}
else -> stringResource(R.string.rust_map_event_crate)
}
MapLayer.PLAYERS -> m.name ?: ""
MapLayer.BASES -> stringResource(if (m.kind == "vending") R.string.rust_map_vending else R.string.rust_map_tc)
MapLayer.MATES -> if (m.self) stringResource(R.string.rust_map_you) else m.name ?: stringResource(R.string.rust_map_clan_mate)
MapLayer.GRID -> ""
}
@Composable
private fun markerDetail(m: MapMarker, eventTitle: String? = null): String? = when (m.layer) {
MapLayer.WORLD -> when {
m.kind != "crate" -> null
m.hacked -> stringResource(R.string.rust_map_crate_hacked)
m.hackLeftSec != null -> stringResource(R.string.rust_map_crate_hack, countdown(m.hackLeftSec))
else -> null
}
// Every site-event marker says so, linked or not (§31.2): a run the public
// calendar does not list gets this line and no button. One it lists names its
// event (runicnpc D303).
MapLayer.EVENTS -> eventTitle?.let { stringResource(R.string.rust_map_site_event_named, it) }
?: stringResource(R.string.rust_map_site_event)
MapLayer.PLAYERS, MapLayer.MATES -> when {
!m.online -> stringResource(R.string.rust_map_player_offline)
m.sleeping -> stringResource(R.string.rust_map_player_sleeping)
else -> null
}
else -> null
}
/**
* One row for each layer. A layer the viewer was sent has a switch; one they
* were not is listed disabled with who can see it, and the players layer says
* when it is narrower than its own switch because presence is (D113).
*/
@Composable
private fun Legend(map: RustMapDto, live: RustMapLiveDto?, shown: Set<MapLayer>, onToggle: (MapLayer) -> Unit) {
LegendRow(stringResource(R.string.rust_map_layer_grid), emptyList(), true, MapLayer.GRID in shown, null) {
onToggle(MapLayer.GRID)
}
LAYER_ROWS.forEach { (layer, labelRes) ->
val gate = map.layers.of(layer)
var note = if (gate.visible) null else hiddenNote(gate)
if (layer == MapLayer.PLAYERS && gate.cappedByPresence && !gate.visible) {
note = stringResource(R.string.rust_map_capped, note.orEmpty())
}
if (gate.visible && layer == MapLayer.PLAYERS && live?.playersTruncated == true) {
note = stringResource(R.string.rust_map_players_truncated)
}
if (gate.visible && layer == MapLayer.BASES && live?.basesTruncated == true) {
note = stringResource(R.string.rust_map_bases_truncated)
}
LegendRow(stringResource(labelRes), SWATCHES.getValue(layer), gate.visible, gate.visible && layer in shown, note) {
onToggle(layer)
}
}
val mates = map.mates
if (mates.visible) {
LegendRow(
stringResource(R.string.rust_map_layer_mates),
SWATCHES.getValue(MapLayer.MATES),
true,
MapLayer.MATES in shown,
stringResource(R.string.rust_map_mates_note),
) { onToggle(MapLayer.MATES) }
} else if (mates.on && mates.signedIn && !mates.linked) {
// Offered only to a signed-in, unlinked viewer on a server with the switch
// on, as the web does: anyone else could not act on it.
LegendRow(
stringResource(R.string.rust_map_layer_mates),
SWATCHES.getValue(MapLayer.MATES),
false,
false,
stringResource(R.string.rust_map_mates_link),
) {}
}
}
@Composable
private fun hiddenNote(gate: RustMapLayerDto): String = stringResource(
when (gate.audience) {
"signed_in" -> R.string.rust_map_hidden_signin
"public" -> R.string.rust_map_hidden_public
else -> R.string.rust_map_hidden_staff
},
)
@Composable
private fun LegendRow(
label: String,
swatches: List<Color>,
enabled: Boolean,
checked: Boolean,
note: String?,
onToggle: () -> Unit,
) {
Row(
Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Switch(checked = checked, onCheckedChange = { onToggle() }, enabled = enabled)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
label,
style = MaterialTheme.typography.bodyMedium,
color = if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
swatches.forEach {
Spacer(Modifier.width(6.dp))
Box(Modifier.size(10.dp).background(it, CircleShape))
}
}
note?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun ColumnScope.Note(text: String) {
if (text.isBlank()) return
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
)
}
/** The line under the map, in the web's words (`liveStatus` in `MapView.jsx`). */
@Composable
private fun liveStatus(map: RustMapDto, ui: RustMapUi, serverOnline: Boolean): String {
if (!map.anyLive) return stringResource(R.string.rust_map_status_nothing_live)
val live = ui.live
if (live == null) {
return stringResource(if (ui.liveFailed) R.string.rust_map_status_failed else R.string.rust_map_status_asking)
}
if (ui.liveFailed) return stringResource(R.string.rust_map_status_failed_kept)
if (!live.live) {
return stringResource(if (serverOnline) R.string.rust_map_status_not_now else R.string.rust_map_status_offline)
}
val at = ui.liveAt ?: return ""
return stringResource(R.string.rust_map_status_as_of, CLOCK.format(Instant.ofEpochMilli(at)))
}
private val CLOCK: DateTimeFormatter =
DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault())
private fun com.runicgateway.app.data.api.dto.RustMapLayersDto.of(layer: MapLayer): RustMapLayerDto = when (layer) {
MapLayer.WORLD -> world
MapLayer.EVENTS -> events
MapLayer.PLAYERS -> players
MapLayer.BASES -> bases
else -> RustMapLayerDto()
}
/** `#RRGGBB` as the plugin sends it, or null for anything else. */
internal fun parseColour(value: String?): Color? {
val hex = value?.trim()?.removePrefix("#") ?: return null
if (hex.length != 6) return null
val rgb = hex.toLongOrNull(16) ?: return null
return Color(0xFF000000 or rgb)
}
private val TAP_REACH = 24.dp
private val LABEL_MIN_CELL = 30.dp
// The web's palette (`MapView.jsx` COLOURS), so a reader moving between the two
// reads the same marker as the same thing.
private val OCEAN = Color(0xFF0B3B4A)
private val SURROUND = Color(0xFF071F27)
private val GRID_LINE = Color.White.copy(alpha = 0.18f)
private val GRID_LABEL = Color.White.copy(alpha = 0.55f)
private val MONUMENT = Color(0xFFE8D9A8)
private val EVENT = Color(0xFFCE93D8)
private val BOSS = Color(0xFFD500F9)
private val ONLINE = Color(0xFFFFFFFF)
private val SLEEPING = Color(0xFF9E9E9E)
private val TC = Color(0xFFFF7043)
private val VENDING = Color(0xFF26A69A)
private val SELF = Color(0xFF00E5FF)
private val MATE = Color(0xFF7CFFB2)
private val WORLD_COLOURS = mapOf(
"cargo" to Color(0xFF4FC3F7),
"heli" to Color(0xFFEF5350),
"chinook" to Color(0xFFFFA726),
"bradley" to Color(0xFFA1887F),
"supply" to Color(0xFF66BB6A),
"crate" to Color(0xFFFFEE58),
)
private val LAYER_ROWS = listOf(
MapLayer.WORLD to R.string.rust_map_layer_world,
MapLayer.EVENTS to R.string.rust_map_layer_events,
MapLayer.PLAYERS to R.string.rust_map_layer_players,
MapLayer.BASES to R.string.rust_map_layer_bases,
)
private val SWATCHES = mapOf(
MapLayer.WORLD to listOf(MONUMENT, WORLD_COLOURS.getValue("cargo"), WORLD_COLOURS.getValue("heli"), WORLD_COLOURS.getValue("crate")),
MapLayer.EVENTS to listOf(EVENT),
MapLayer.PLAYERS to listOf(ONLINE, SLEEPING),
MapLayer.BASES to listOf(TC, VENDING),
MapLayer.MATES to listOf(SELF, MATE),
)

View File

@@ -0,0 +1,190 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import androidx.lifecycle.SavedStateHandle
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.RustMapDto
import com.runicgateway.app.data.api.dto.RustMapLiveDto
import com.runicgateway.app.data.repository.EventsRepository
import com.runicgateway.app.data.repository.RustRepository
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.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Everything the Map tab is showing. */
data class RustMapUi(
val map: UiState<RustMapDto> = UiState.Loading,
/** The last live answer, or null before the first. Kept when a later ask fails. */
val live: RustMapLiveDto? = null,
/** True when the most recent ask failed; [live] is then the one before it. */
val liveFailed: Boolean = false,
/** When [live] arrived, epoch ms. */
val liveAt: Long? = null,
/** The legend's switches: the reader's convenience, never a boundary. */
val shown: Set<MapLayer> = MapLayer.entries.toSet(),
val selected: MapMarker? = null,
/** Run id → event slug, for the runs core's public calendar lists. */
val eventSlugs: Map<String, String> = emptyMap(),
/** Run id → event title, from the same read: an NPC's card names its event (runicnpc D303). */
val eventTitles: Map<String, String> = emptyMap(),
)
/**
* One server's map (`docs/modules/rust/PLAN.md` §31, D121–D125; PLAN.md M17).
*
* ## It draws what it is sent
*
* `map` and `map/live` are projected per viewer **on the server**, from the same
* bearer token every other request carries. A layer this viewer may not see is
* absent from the answer, so nothing here decides who sees what.
*
* ## Keyed on the signed-in account
*
* Mates and the players layer are per viewer, and this view model lives as long
* as the server screen's back-stack entry, across a sign-out and a sign-in as
* somebody else. Events phase 14b found what that costs: a view model that loads
* once showed the next account the previous one's answer without asking. So the
* account is what this keys on. A change of account drops everything drawn,
* including the tapped card, and asks again, and an answer to a question asked
* for the previous account is thrown away when it lands (the [generation]).
* Signing out is a change too: the map is public, so a signed-out reader gets
* the public map rather than an empty one.
*
* ## When it asks
*
* `map` once when the tab opens, and again when a live answer names a different
* `mapKey`: a wipe or a new seed, whose picture follows without leaving the tab.
* `map/live` on the screen's poll (D124), and not at all when the server sends
* this viewer no moving layer.
*/
@HiltViewModel
class RustMapViewModel @Inject constructor(
private val repository: RustRepository,
events: EventsRepository,
sessionManager: SessionManager,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
private val resolver = EventRunResolver(events)
private val _state = MutableStateFlow(RustMapUi())
val state: StateFlow<RustMapUi> = _state.asStateFlow()
/** Bumped on every change of account; an answer from an older one is dropped. */
private var generation = 0
/** A poll still waiting for its answer: the next tick does not stack another. */
private var polling = false
init {
viewModelScope.launch {
sessionManager.state
.map { (it as? Session.SignedIn)?.user?.id }
.distinctUntilChanged()
.collect {
generation++
polling = false
_state.update { s -> RustMapUi(shown = s.shown) }
load()
}
}
}
/** The map's picture, frame and gates. A retry after a failure too. */
fun load() {
val asked = generation
_state.update { it.copy(map = UiState.Loading) }
viewModelScope.launch { askMap(asked) }
}
/**
* The poll tick. Asks only once the map has a frame to place things in and
* only when this viewer is sent something that moves.
*/
fun poll() {
val map = (_state.value.map as? UiState.Success)?.data ?: return
if (map.geometry == null || !map.anyLive || polling) return
val asked = generation
polling = true
viewModelScope.launch {
val result = repository.mapLive(serverId)
if (asked != generation) return@launch
polling = false
when (result) {
is ApiResult.Ok -> {
val answer = result.data
_state.update {
it.copy(live = answer, liveFailed = false, liveAt = System.currentTimeMillis())
}
// A new map under the same server: the picture follows.
if (answer.mapKey != null && map.mapKey != null && answer.mapKey != map.mapKey) {
askMap(asked)
}
resolveRuns(answer, asked)
}
// Positions are kept on a failure, in Polling.kt's shape: the last
// answer stays drawn and the status line says the ask failed.
else -> _state.update { it.copy(liveFailed = true) }
}
}
}
fun toggle(layer: MapLayer) {
_state.update {
val shown = if (layer in it.shown) it.shown - layer else it.shown + layer
// A card for something the reader just hid would describe a marker
// that is no longer on the map.
val selected = it.selected?.takeIf { m -> m.layer in shown }
it.copy(shown = shown, selected = selected)
}
}
fun select(marker: MapMarker?) {
_state.update { it.copy(selected = marker) }
}
private suspend fun askMap(asked: Int) {
val result = repository.map(serverId)
if (asked != generation) return
_state.update { current ->
// A failed RE-read after a new mapKey keeps the map that is drawn:
// the old picture under new positions is better than an error screen,
// and the next poll asks again.
if (result !is ApiResult.Ok && current.map is UiState.Success) current
else current.copy(map = result.toUiState())
}
}
private suspend fun resolveRuns(answer: RustMapLiveDto, asked: Int) {
val ids = eventRunIds(answer)
if (ids.isEmpty()) return
val slugs = resolver.resolve(ids)
val titles = resolver.titles(ids)
if (asked != generation) return
_state.update { it.copy(eventSlugs = slugs, eventTitles = titles) }
}
}
/** True when the server sends this viewer at least one layer that moves. */
val RustMapDto.anyLive: Boolean
get() = layers.world.visible || layers.events.visible || layers.players.visible ||
layers.bases.visible || mates.visible

View File

@@ -0,0 +1,718 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
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.RustEventDto
import com.runicgateway.app.data.api.dto.RustEventListDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustOnlineDto
import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.api.dto.RustTitleDto
import com.runicgateway.app.data.api.dto.RustWipeDto
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.ui.PollWhileResumed
import com.runicgateway.app.ui.Polled
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.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* One Rust server: the feed, the leaderboard, who is on, the map when the module
* has one (D122), and the wipes (D13).
*
* **One screen with tabs, not four destinations** — the same call the website
* makes, and more obviously right on a phone: the four panels are four questions
* about one thing, and a reader moving between them is not navigating.
*
* The phase criterion lives here. With the server unreachable this still renders
* its map, size, seed, wipe date, killfeed, leaderboards, last known presence
* board and wipe history, because every one of those is read from the website's
* own tables rather than from the game.
*/
@Composable
fun RustServerScreen(
onBack: () -> Unit,
onOpenEvent: (slug: String, runId: String) -> Unit,
onSignIn: () -> Unit,
modifier: Modifier = Modifier,
viewModel: RustServerViewModel = hiltViewModel(),
safety: ChatSafetyViewModel = hiltViewModel(),
) {
val ui by viewModel.state.collectAsStateWithLifecycle()
val menu by safety.menu.collectAsStateWithLifecycle()
PollWhileResumed { viewModel.refresh() }
// The server filters the feed for its reader (S11), so a block or a hide shows
// itself by the feed being read again, never by a line being dropped here.
LaunchedEffect(safety) { safety.changes.collect { viewModel.reloadFeed() } }
menu.line?.let { line ->
chatActions(line, safety.user)?.let { actions -> ChatSafetySheet(menu, actions, safety, onSignIn) }
}
when (val s = ui.server.state) {
is UiState.Loading -> LoadingView(modifier)
// **A mistyped address is not a fault and must not be dressed as one.**
// The website's first version put its generic error panel under this
// heading, so an unknown id read "No such server / Something went wrong"
// and sent a reader looking for an outage. A 404 is its own answer; the
// error panel is kept for a request that failed for a reason nobody can
// see. A server an operator disabled answers the same 404 — switching one
// off is not switching it into a refusal.
is UiState.Error -> if (s.kind == ErrorKind.NOT_FOUND) {
MissingServer(onBack, modifier)
} else {
ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
}
is UiState.Success -> ServerDetail(s.data, ui, viewModel, onOpenEvent, safety::open, modifier)
}
}
@Composable
private fun MissingServer(onBack: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(stringResource(R.string.rust_no_such_server), style = MaterialTheme.typography.titleLarge)
Text(
text = stringResource(R.string.rust_no_such_server_detail),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.rust_back_to_servers),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onBack).padding(top = 8.dp),
)
}
}
@Composable
private fun ServerDetail(
server: RustServerDto,
ui: RustServerUi,
viewModel: RustServerViewModel,
onOpenEvent: (slug: String, runId: String) -> Unit,
onLinePressed: (RustEventDto) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier.fillMaxSize()) {
ServerHeader(server, ui.selectedWipe, viewModel::selectWipe, ui.wipes)
val tabs = ui.tabs
ScrollableTabRow(selectedTabIndex = tabs.indexOf(ui.tab), edgePadding = 16.dp) {
tabs.forEach { tab ->
Tab(
selected = tab == ui.tab,
onClick = { viewModel.selectTab(tab) },
text = { Text(stringResource(tabLabel(tab))) },
)
}
}
when (ui.tab) {
RustTab.FEED -> FeedPanel(
ui.feed,
ui.filterId,
viewModel::selectFilter,
viewModel::retryFeed,
onLinePressed,
)
RustTab.LEADERBOARD -> LeaderboardPanel(
ui.leaderboard,
ui.sort,
viewModel::selectSort,
viewModel::retryLeaderboard,
)
RustTab.ONLINE -> OnlinePanel(ui.online, server.online, viewModel::retryOnline)
RustTab.MAP -> RustMapPanel(serverOnline = server.online, onOpenEvent = onOpenEvent)
RustTab.WIPES -> WipesPanel(
ui.wipes,
server.wipeId,
ui.selectedWipe,
viewModel::openWipe,
viewModel::retryWipes,
)
}
}
}
private fun tabLabel(tab: RustTab): Int = when (tab) {
RustTab.FEED -> R.string.rust_tab_feed
RustTab.LEADERBOARD -> R.string.rust_tab_leaderboard
RustTab.ONLINE -> R.string.rust_tab_online
RustTab.MAP -> R.string.rust_tab_map
RustTab.WIPES -> R.string.rust_tab_wipes
}
@Composable
private fun ServerHeader(
server: RustServerDto,
selectedWipe: String?,
onSelectWipe: (String?) -> Unit,
wipes: UiState<List<RustWipeDto>>,
) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(server.name.ifBlank { server.id }, style = MaterialTheme.typography.headlineSmall)
describeWorld(server)?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
nextWipeLine(server)?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (server.online) {
StatusPill(
text = stringResource(R.string.rust_online_count, server.players, server.maxPlayers),
tone = PillTone.Success,
)
} else {
StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral)
}
Text(
text = lastReported(server),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// The wipe picker sits above the tabs because it filters two of them. It
// is absent until the wipe list has loaded — offering a filter with one
// option would look like a server that has only ever had one wipe.
val available = (wipes as? UiState.Success)?.data.orEmpty()
if (available.isNotEmpty()) {
WipeFilter(available, server.wipeId, selectedWipe, onSelectWipe)
}
}
}
@Composable
private fun WipeFilter(
wipes: List<RustWipeDto>,
currentWipeId: String?,
selected: String?,
onSelect: (String?) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// **Null is all time, and it is the default.** It is a real choice rather
// than an absent filter: all-time is the per-wipe rows summed, which is
// the answer to "who plays here", where a wipe is the answer to "who is
// winning now".
FilterChip(
selected = selected == null,
onClick = { onSelect(null) },
label = { Text(stringResource(R.string.rust_all_time)) },
)
wipes.forEach { wipe ->
val label = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId
FilterChip(
selected = selected == wipe.wipeId,
onClick = { onSelect(wipe.wipeId) },
label = {
Text(
if (wipe.wipeId == currentWipeId) {
stringResource(R.string.rust_wipe_current, label)
} else {
label
},
)
},
)
}
}
}
// ── Feed ──────────────────────────────────────────────────────────────────
@Composable
private fun FeedPanel(
feed: Polled<RustEventListDto>,
filterId: String,
onFilter: (String) -> Unit,
onRetry: () -> Unit,
onLinePressed: (RustEventDto) -> Unit,
) {
Column(Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FEED_FILTERS.forEach { filter ->
FilterChip(
selected = filter.id == filterId,
onClick = { onFilter(filter.id) },
label = { Text(filter.label) },
)
}
}
when (val s = feed.state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
is UiState.Success -> {
// Said once, above the rows, so a thin feed reads as withheld
// rather than as a quiet server (org lead: nothing names who is
// online by default).
if (s.data.presenceHidden) {
Text(
text = stringResource(
if (s.data.presenceAudience == "signed_in") {
R.string.rust_feed_presence_hidden_signin
} else {
R.string.rust_feed_presence_hidden_staff
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
if (s.data.events.isEmpty()) {
EmptyView(stringResource(R.string.rust_feed_empty))
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (feed.refreshFailed) {
item { RefreshFailedLine() }
}
items(s.data.events, key = { it.id }) { FeedRow(it, onLinePressed) }
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FeedRow(row: RustEventDto, onLinePressed: (RustEventDto) -> Unit) {
val line = describe(row)
val menuLabel = stringResource(R.string.safety_line_actions)
// A chat line answers a long press with its report / hide / block menu
// (USER_SAFETY.md §4.3). Only chat: a block hides what someone says, and the
// other rows are things that happened to them. A line staff hid for everyone
// reaches staff alone, dimmed and marked (S10).
val chat = row.kind == "player.chat"
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier
.fillMaxWidth()
.then(
if (chat) {
Modifier.combinedClickable(
onClick = {},
onLongClick = { onLinePressed(row) },
onLongClickLabel = menuLabel,
)
} else {
Modifier
},
)
.then(if (row.hidden) Modifier.alpha(HIDDEN_LINE_ALPHA) else Modifier),
) {
// The stamp carries a date for anything not from today — a row from six
// weeks ago rendered as a bare time reads as this afternoon, which is
// exactly what happens the moment the feed is filtered to a past wipe.
Text(
text = feedClock(value = null, epochMillis = row.t),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 10.dp, top = 2.dp),
)
Column {
Row {
line.actor?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
Text(line.join, style = MaterialTheme.typography.bodyMedium)
}
// The chat message lands here, 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.
Text(line.verb, style = MaterialTheme.typography.bodyMedium)
line.subject?.let {
Text(" ", style = MaterialTheme.typography.bodyMedium)
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
}
if (line.detail.isNotBlank()) {
Text(
text = line.detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (row.hidden) {
Text(
text = stringResource(R.string.safety_hidden_by_staff),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
/** How far a line staff hid for everyone is faded, for the staff who still see it. */
private const val HIDDEN_LINE_ALPHA = 0.6f
// ── Leaderboard ───────────────────────────────────────────────────────────
/**
* The columns, and which of them the API can sort by.
*
* `structures` has no sort on the wire and therefore no tap here — a header that
* sorts by something other than what it says is worse than one that does not
* sort.
*/
private data class RustColumn(val labelRes: Int, val sort: String?, val value: (RustLeaderboardRowDto) -> String)
/** The name's share of the row against one numeric column's. */
private const val NAME_WEIGHT = 1.7f
/** How far a header that is not the current sort is faded. */
private const val SORTED_AWAY = 0.55f
private val RUST_COLUMNS = listOf(
RustColumn(R.string.rust_col_kills, RustSort.KILLS) { it.kills.toString() },
RustColumn(R.string.rust_col_deaths, RustSort.DEATHS) { it.deaths.toString() },
RustColumn(R.string.rust_col_npc_kills, RustSort.NPC_KILLS) { it.npcKills.toString() },
RustColumn(R.string.rust_col_structures, null) { it.structures.toString() },
RustColumn(R.string.rust_col_played, RustSort.PLAYTIME) { playtime(it.playtimeSec) },
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun LeaderboardPanel(
state: UiState<List<RustLeaderboardRowDto>>,
sort: String,
onSort: (String) -> Unit,
onRetry: () -> Unit,
) {
when (state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
is UiState.Success -> if (state.data.isEmpty()) {
// **Empty is a real answer here and is not "no data".** All-time is
// the per-wipe rows summed, so a player who appears only in an older
// wipe drops out of the current one rather than reading zero — an
// empty board for a wipe means nobody scored on that map.
EmptyView(stringResource(R.string.rust_leaderboard_empty))
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Row(Modifier.fillMaxWidth()) {
SectionLabel(
text = stringResource(R.string.rust_col_player),
modifier = Modifier.weight(NAME_WEIGHT),
)
RUST_COLUMNS.forEach { column ->
// The ACTIVE sort is marked on the header, not on the
// values: the header is the control, and tinting a
// column of numbers instead says "these are special"
// rather than "this is what the table is ordered by".
SectionLabel(
text = stringResource(column.labelRes),
modifier = Modifier
.weight(1f)
.then(
if (column.sort != null) {
Modifier.clickable { onSort(column.sort) }
} else {
Modifier
},
)
.then(
if (column.sort == sort) {
Modifier.alpha(1f)
} else {
Modifier.alpha(SORTED_AWAY)
},
),
)
}
}
}
items(state.data, key = { it.steamId }) { row ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
// A wider share for the name, and one line with an ellipsis.
// Five numeric columns beside an equal-weight name column
// left "Brannock" touching its own kill count, which the
// walk read as one field.
Column(Modifier.weight(NAME_WEIGHT).padding(end = 8.dp)) {
Text(
text = playerLabel(row.name, row.steamId),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// M19: the chat titles this player holds now, under the
// name — the column is too narrow to put them beside it.
if (row.titles.isNotEmpty()) {
FlowRow(
Modifier.padding(top = 2.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
row.titles.forEach { title -> TitleChip(title) }
}
}
}
RUST_COLUMNS.forEach { column ->
Text(
text = column.value(row),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
}
/**
* One chat title (M19): the operator's colour as the chip, and black or white
* ink chosen for it, as the website draws it. A colour this build cannot read
* falls back to the theme's own chip colours.
*/
@Composable
private fun TitleChip(title: RustTitleDto) {
val argb = titleArgb(title.color)
val background = argb?.let { Color(it) } ?: MaterialTheme.colorScheme.secondaryContainer
val ink = when {
argb == null -> MaterialTheme.colorScheme.onSecondaryContainer
titleInkIsDark(argb) -> Color.Black
else -> Color.White
}
Text(
text = title.text,
style = MaterialTheme.typography.labelSmall,
color = ink,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(background)
.padding(horizontal = 6.dp, vertical = 1.dp),
)
}
// ── Online ────────────────────────────────────────────────────────────────
@Composable
private fun OnlinePanel(
online: Polled<RustOnlineDto>,
serverOnline: Boolean,
onRetry: () -> Unit,
) {
when (val s = online.state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
// Withheld is not empty. Below the operator's audience the server sends
// the count and no names, and an empty list rendered as "nobody is on"
// would be a false statement about a full server.
is UiState.Success -> if (s.data.hidden) {
val count = s.data.count
EmptyView(
pluralStringResource(R.plurals.rust_online_hidden_count, count, count) + "\n" +
stringResource(
when (s.data.audience) {
"signed_in" -> R.string.rust_online_hidden_signin
"public" -> R.string.rust_online_hidden_public
else -> R.string.rust_online_hidden_staff
},
),
)
} else if (s.data.players.isEmpty()) {
EmptyView(
stringResource(
if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline,
),
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// **The board is the last one that ARRIVED, and an unreachable
// server does not clear it** — deliberately, because these rows
// are still the best answer anybody has. Presented bare they read
// as "these people are on right now", which is the one thing an
// offline server cannot be saying. So the panel says which it is.
item {
Text(
text = stringResource(
if (serverOnline) R.string.rust_presence_live else R.string.rust_presence_last_known,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (online.refreshFailed) {
item { RefreshFailedLine() }
}
items(s.data.players, key = { it.steamId }) { player ->
ShardCard(Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = playerLabel(player.name, player.steamId),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (player.sleeping) {
StatusPill(
text = stringResource(R.string.rust_sleeping),
tone = PillTone.Neutral,
)
}
}
}
}
}
}
}
}
// ── Wipes ─────────────────────────────────────────────────────────────────
@Composable
private fun WipesPanel(
state: UiState<List<RustWipeDto>>,
currentWipeId: String?,
selected: String?,
onOpenWipe: (String) -> Unit,
onRetry: () -> Unit,
) {
when (state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
is UiState.Success -> if (state.data.isEmpty()) {
EmptyView(stringResource(R.string.rust_wipes_empty))
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.data, key = { it.wipeId }) { wipe ->
ShardCard(
Modifier.fillMaxWidth().clickable { onOpenWipe(wipe.wipeId) },
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (wipe.wipeId == currentWipeId) {
StatusPill(
text = stringResource(R.string.rust_wipe_this_one),
tone = PillTone.Success,
)
} else if (wipe.wipeId == selected) {
StatusPill(
text = stringResource(R.string.rust_wipe_selected),
tone = PillTone.Info,
)
}
}
}
}
}
}
}
}
@Composable
private fun RefreshFailedLine() {
Text(
text = stringResource(R.string.rust_refresh_failed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}

View File

@@ -0,0 +1,326 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.RustEventListDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
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 com.runicgateway.app.data.repository.Capability
import com.runicgateway.app.data.repository.RustRepository
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
import com.runicgateway.app.data.repository.canUse
import com.runicgateway.app.ui.Polled
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.refreshInto
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 sections of a server's page (D13), in the website's order. [MAP] sits
* between Online and Wipes where the web has it (D122), and is shown only when
* the site's module declares `map`.
*/
enum class RustTab {
FEED, LEADERBOARD, ONLINE, MAP, WIPES;
/** The website's name for this tab, as `?tab=` carries it (`ServerDetail.jsx`). */
val wire: String get() = name.lowercase()
companion object {
/**
* The tab the website calls [wire], or null for one the app does not have.
* The website also has `clans`, and a link to it is not a link to the feed.
*/
fun fromWire(wire: String?): RustTab? = entries.firstOrNull { it.wire == wire }
}
}
/** What a leaderboard column sorts by — the API's own vocabulary, not the app's. */
object RustSort {
const val KILLS = "kills"
const val DEATHS = "deaths"
const val NPC_KILLS = "npcKills"
const val PLAYTIME = "playtime"
}
/**
* Everything one server's page is showing.
*
* One state object rather than eight flows: every panel on the page is about the
* same server and the same selected wipe, and a screen that collected them
* separately could render a leaderboard for one wipe beside a feed for another
* for a frame.
*/
data class RustServerUi(
val serverId: String = "",
val server: Polled<RustServerDto> = Polled(),
val tab: RustTab = RustTab.FEED,
val filterId: String = "all",
val sort: String = RustSort.KILLS,
/** The wipe every panel is filtered to. **Null is all time**, not "unknown". */
val selectedWipe: String? = null,
/** The whole answer, not its rows: `presenceHidden` is part of what it says. */
val feed: Polled<RustEventListDto> = Polled(),
/** Likewise — `hidden` and `count` are what tell "withheld" from "nobody". */
val online: Polled<RustOnlineDto> = Polled(),
val leaderboard: UiState<List<RustLeaderboardRowDto>> = UiState.Loading,
val wipes: UiState<List<RustWipeDto>> = UiState.Loading,
/** Whether the Map tab is offered, under the app's one capability rule (D122). */
val mapAvailable: Boolean = true,
) {
/** The tabs this site offers, in order. */
val tabs: List<RustTab> get() = RustTab.entries.filter { it != RustTab.MAP || mapAvailable }
}
/**
* One Rust server (PLAN.md §9 M14; `docs/modules/rust/PLAN.md` D13, D14).
*
* ## What polls and what does not
*
* D14, and it is a statement about the questions rather than about cost: the
* **feed**, **who is on** and the **server's own line** change while somebody is
* looking at the page, and the **leaderboard** and the **wipe list** do not in
* any way a reader would want to watch. A leaderboard that re-sorted itself under
* a finger every twenty seconds would be worse than a stale one.
*
* Only the **visible** live panel is polled. The website can afford to mount the
* one tab it is showing; here the tabs are one screen, so the refresh asks what
* the reader is actually looking at.
*
* ## Changing the question versus asking it again
*
* A poll is the same question asked again, so it keeps what is on screen
* ([refreshInto]). Changing the filter, the sort or the wipe is a **different
* question**, so the panel blanks and loads — what is there is an answer to
* something the reader has stopped asking, and leaving it up while the new one
* arrives would show a killfeed for last wipe under a heading naming this one.
*/
@HiltViewModel
class RustServerViewModel @Inject constructor(
private val repository: RustRepository,
capabilities: SiteCapabilitiesRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
/** The tab a link asked for (D71), or null for the feed every page opens on. */
private val initialTab: RustTab? = RustTab.fromWire(savedStateHandle[Routes.Args.TAB])
private val _state = MutableStateFlow(RustServerUi(serverId = serverId))
val state: StateFlow<RustServerUi> = _state.asStateFlow()
init {
load()
// Before the tab a link asked for, so `?tab=map` on a site without a map
// is refused by [selectTab] and opens the feed.
viewModelScope.launch {
capabilities.capabilities.collect { caps ->
val available = canUse(caps, Capability.MAP)
_state.update {
// The tab going away under the reader lands them on the feed,
// which every page has and has already loaded.
val tab = if (!available && it.tab == RustTab.MAP) RustTab.FEED else it.tab
it.copy(mapAvailable = available, tab = tab)
}
}
}
initialTab?.takeIf { it != RustTab.FEED }?.let(::selectTab)
}
/** A first load or a retry of the whole page. */
fun load() {
_state.update { it.copy(server = Polled(UiState.Loading), feed = Polled(UiState.Loading)) }
viewModelScope.launch {
askServer()
askFeed()
}
}
/**
* The poll tick.
*
* The server line always, and then whichever live panel is on screen. A tab
* showing the leaderboard or the wipes does no extra work — the reader is
* looking at something that does not move.
*/
fun refresh() {
viewModelScope.launch {
askServer()
when (_state.value.tab) {
RustTab.FEED -> askFeed()
RustTab.ONLINE -> askOnline()
// The map keeps its own cadence (D124) in [RustMapViewModel].
RustTab.LEADERBOARD, RustTab.WIPES, RustTab.MAP -> Unit
}
}
}
/**
* Open a tab, loading its panel the first time it is opened.
*
* The two that do not poll are loaded exactly once per question: re-asking on
* every tab switch would put a spinner over a leaderboard the reader has
* already read, for an answer that cannot have changed while they were three
* taps away.
*/
fun selectTab(tab: RustTab) {
val already = _state.value
if (tab !in already.tabs) return
_state.update { it.copy(tab = tab) }
viewModelScope.launch {
when (tab) {
RustTab.FEED -> if (already.feed.state !is UiState.Success) askFeed()
RustTab.ONLINE -> if (already.online.state !is UiState.Success) askOnline()
RustTab.LEADERBOARD -> if (already.leaderboard !is UiState.Success) askLeaderboard()
RustTab.WIPES -> if (already.wipes !is UiState.Success) askWipes()
// Loaded by its own view model when the tab composes.
RustTab.MAP -> Unit
}
}
}
/** A different question for the feed: blank it and ask. */
fun selectFilter(filterId: String) {
if (filterId == _state.value.filterId) return
_state.update { it.copy(filterId = filterId, feed = Polled(UiState.Loading)) }
viewModelScope.launch { askFeed() }
}
/** A different question for the leaderboard: blank it and ask. */
fun selectSort(sort: String) {
if (sort == _state.value.sort) return
_state.update { it.copy(sort = sort, leaderboard = UiState.Loading) }
viewModelScope.launch { askLeaderboard() }
}
/**
* Pick a wipe, or all time with null.
*
* It is the one selection that changes **two** panels, so both are blanked —
* and only the loaded ones are re-asked, so choosing a wipe from the Wipes tab
* does not fetch a leaderboard nobody has opened.
*/
fun selectWipe(wipeId: String?) {
if (wipeId == _state.value.selectedWipe) return
val hadLeaderboard = _state.value.leaderboard is UiState.Success
_state.update {
it.copy(
selectedWipe = wipeId,
feed = Polled(UiState.Loading),
leaderboard = if (hadLeaderboard) UiState.Loading else it.leaderboard,
)
}
viewModelScope.launch {
askFeed()
if (hadLeaderboard) askLeaderboard()
}
}
/**
* Pick a wipe from the Wipes tab, which is a navigation as much as a filter.
*
* The question it asks is "what happened during that map", and the answer is
* the feed — so it lands there rather than leaving the reader on a list of
* dates with nothing visibly changed.
*/
fun openWipe(wipeId: String) {
selectWipe(wipeId)
selectTab(RustTab.FEED)
}
/**
* Retry one panel after its own load failed.
*
* Four entry points rather than one, because a failed leaderboard is not a
* reason to re-read the feed the reader can already see — and [load] is the
* whole page, which is right for a failed *server* read and heavy-handed for
* anything else.
*/
fun retryFeed() {
_state.update { it.copy(feed = Polled(UiState.Loading)) }
viewModelScope.launch { askFeed() }
}
/**
* Ask the feed again after a report, block or hide changed what this reader
* should see (`USER_SAFETY.md` §4.3). The same question, so what is on screen
* stays up until the answer replaces it, as a poll's does.
*/
fun reloadFeed() {
viewModelScope.launch { askFeed() }
}
fun retryLeaderboard() {
_state.update { it.copy(leaderboard = UiState.Loading) }
viewModelScope.launch { askLeaderboard() }
}
fun retryOnline() {
_state.update { it.copy(online = Polled(UiState.Loading)) }
viewModelScope.launch { askOnline() }
}
fun retryWipes() {
_state.update { it.copy(wipes = UiState.Loading) }
viewModelScope.launch { askWipes() }
}
private suspend fun askServer() {
val result = repository.server(serverId)
_state.update { it.copy(server = refreshInto(it.server.state, result)) }
}
private suspend fun askFeed() {
val current = _state.value
val result = repository.events(
id = serverId,
kinds = kindsFor(current.filterId),
wipe = current.selectedWipe,
limit = FEED_LIMIT,
)
_state.update { it.copy(feed = refreshInto(it.feed.state, result)) }
}
private suspend fun askOnline() {
val result = repository.online(serverId)
_state.update { it.copy(online = refreshInto(it.online.state, result)) }
}
private suspend fun askLeaderboard() {
val current = _state.value
val result = repository.leaderboard(
id = serverId,
wipe = current.selectedWipe,
sort = current.sort,
limit = LEADERBOARD_LIMIT,
)
_state.update { it.copy(leaderboard = result.toUiState()) }
}
private suspend fun askWipes() {
_state.update { it.copy(wipes = repository.wipes(serverId).toUiState()) }
}
private companion object {
/** Matches the website's feed page size; the server caps at 200 regardless. */
const val FEED_LIMIT = 100
const val LEADERBOARD_LIMIT = 50
}
}

View File

@@ -0,0 +1,216 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
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.PaddingValues
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.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.RustServerDto
import com.runicgateway.app.ui.PollWhileResumed
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
/**
* Every Rust server this site follows — the module's landing page (D8, D12).
*
* **The phase criterion is this screen with every server off.** Nothing here is a
* live call to a game host: the website answers from its own tables, so a fleet
* that has been down for a week renders a week of last-known state rather than an
* error. The one thing that can fail is the website itself.
*/
@Composable
fun RustServersScreen(
onOpenServer: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: RustServersViewModel = hiltViewModel(),
) {
val polled by viewModel.state.collectAsStateWithLifecycle()
PollWhileResumed { viewModel.refresh() }
when (val s = polled.state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> {
if (s.data.isEmpty()) {
EmptyView(stringResource(R.string.rust_servers_empty), modifier)
} else {
ServerList(s.data, polled.refreshFailed, onOpenServer, modifier)
}
}
}
}
@Composable
private fun ServerList(
servers: List<RustServerDto>,
refreshFailed: Boolean,
onOpenServer: (String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// A failed refresh says so and changes nothing else. The rows below it are
// the last good answer and stay exactly as they were — blanking them is
// the one thing a site whose premise is "it renders while the game is off"
// must not do when a request fails.
if (refreshFailed) {
item {
Text(
text = stringResource(R.string.rust_refresh_failed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
items(servers, key = { it.id }) { server ->
ServerRow(server) { onOpenServer(server.id) }
}
}
}
@Composable
private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) {
ShardCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen)) {
// `ShardCard` is the themed Card and nothing more — it carries no padding
// of its own, so every caller pads its own content. Without this the text
// sits flush against the card's edge and the first glyph of each line
// reads as clipped, which is what the walk saw.
Column(Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = server.name.ifBlank { server.id },
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
// `online` already has staleness folded into it server-side — a row
// nobody has written recently cannot claim a server is up — so this
// renders the field rather than second-guessing it.
if (server.online) {
StatusPill(
text = stringResource(
R.string.rust_online_count,
server.players,
server.maxPlayers,
),
tone = PillTone.Success,
)
} else {
StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral)
}
}
val world = describeWorld(server)
if (world != null) {
Text(
text = world,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
nextWipeLine(server)?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
Text(
text = lastReported(server),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
/**
* The world line — the things a Rust player asks first.
*
* Null when the server has never described itself, so the caller leaves the line
* out rather than printing an empty one. A server configured this morning that
* has not connected yet is in exactly that state, and it is not an error.
*/
@Composable
internal fun describeWorld(server: RustServerDto): String? {
val parts = listOfNotNull(
server.level,
server.worldSize?.let { stringResource(R.string.rust_world_size, it) },
server.seed?.let { stringResource(R.string.rust_world_seed, it) },
wipeDay(server.wipedAt)?.let { stringResource(R.string.rust_wiped_on, it) },
)
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
}
/**
* "Next wipe Thu 1 Oct, 19:00 · in 6 days", with "(rescheduled)" for a one-off
* date — or null, and the caller draws no line, when the server has no schedule
* or the module predates phase 16 and sends no field.
*/
@Composable
internal fun nextWipeLine(server: RustServerDto): String? {
val next = server.nextWipe ?: return null
val whenText = nextWipeWhen(next.at) ?: return null
return if (next.source == "once") {
stringResource(R.string.rust_next_wipe_rescheduled, whenText)
} else {
stringResource(R.string.rust_next_wipe, whenText)
}
}
/**
* "last reported 3 minutes ago".
*
* **Reads `lastSeenAt` and never `updatedAt`.** The module shipped that exact
* confusion and fixed it in phase 4: `updatedAt` moves on every poll including a
* failed one, so an offline server claimed it had just checked in, every thirty
* seconds, for as long as it stayed down. Only a frame moves `lastSeenAt`.
*/
@Composable
internal fun lastReported(server: RustServerDto): String {
val ago = rustAgo(server.lastSeenAt)
?: return stringResource(R.string.rust_never_reported)
return if (server.stale) {
stringResource(R.string.rust_last_reported_stale, ago)
} else {
stringResource(R.string.rust_last_reported, ago)
}
}

View File

@@ -0,0 +1,56 @@
/*
* 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.data.api.dto.RustServerDto
import com.runicgateway.app.data.repository.RustRepository
import com.runicgateway.app.ui.Polled
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.refreshInto
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 Rust server list — the module's landing page, one tier along (PLAN.md §9
* M14; `docs/modules/rust/PLAN.md` D12).
*
* **`toUiState`, not `toShardUiState`.** These routes carry no `requireFeature`
* gate, so a `404` here is a genuinely missing thing and never an admin's
* visibility switch. Offering "this shard doesn't publish it" for one would name
* a cause that does not exist on this surface.
*
* [refresh] is what the screen's poll calls and [load] is what a retry calls, and
* the difference is the whole of [refreshInto]: a refresh keeps the rows when it
* fails, a load is allowed to blank them because there is nothing on screen to
* protect.
*/
@HiltViewModel
class RustServersViewModel @Inject constructor(
private val repository: RustRepository,
) : ViewModel() {
private val _state = MutableStateFlow(Polled<List<RustServerDto>>())
val state: StateFlow<Polled<List<RustServerDto>>> = _state.asStateFlow()
/** A first load or a retry: show the spinner, then replace whatever comes back. */
fun load() {
_state.value = Polled(UiState.Loading)
viewModelScope.launch { ask() }
}
/** A poll: silent on success, and it keeps the rows on failure. */
fun refresh() {
viewModelScope.launch { ask() }
}
private suspend fun ask() {
_state.value = refreshInto(_state.value.state, repository.servers())
}
}

View File

@@ -4,9 +4,14 @@
All user-facing strings are externalized from day one (PLAN.md §2): English is
the only bundled locale, but the structure invites community translations.
No hardcoded UI strings in Kotlin.
The strings are split by owner (PLAN.md M20), and Android merges the three files
into one R.string: this file is CORE — every screen any deployment can show — and
uses core's words only. A game's words live in strings_uo.xml and strings_rust.xml.
StringVocabularyTest fails the build when a game's word lands in the wrong file.
-->
<resources>
<!-- Fixed launcher name, baked at build even though in-app branding is per-shard (PLAN.md §13). -->
<!-- Fixed launcher name, baked at build even though in-app branding is per-site (PLAN.md §13). -->
<string name="app_name">Runic Gateway</string>
<!-- ── Shared actions / errors ─────────────────────────────────────── -->
@@ -15,22 +20,31 @@
<string name="error_network">Can\'t reach the site. Check your connection and try again.</string>
<string name="error_not_found">This content couldn\'t be found.</string>
<string name="error_rate_limited">Too many requests. Please try again in a moment.</string>
<string name="error_shard_offline">The shard is offline right now.</string>
<string name="error_feature_unavailable">This shard doesn\'t publish this here.</string>
<string name="error_shard_offline">The game server is offline right now.</string>
<string name="error_feature_unavailable">This site doesn\'t publish this here.</string>
<string name="error_server">Something went wrong on the server. Please try again.</string>
<!-- The public demo's refusal (DEMO_MODE.md §3.1): the server's own words, used when its answer carries none. -->
<string name="error_demo_locked">This is turned off on the demo site.</string>
<!-- The public demo's strip on Home (DEMO_MODE.md §6). The time is said in UTC, like the web's. -->
<string name="demo_banner_title">Demo site</string>
<string name="demo_banner_wipe_at">" — anything you change is wiped at %1$s."</string>
<string name="demo_banner_wipe_soon">" — anything you change is wiped every two hours."</string>
<string name="demo_banner_logins_at">Sign-ins: %1$s — passwords at %2$s.</string>
<string name="demo_banner_logins">Sign-ins: %1$s — passwords on the site\'s sign-in page.</string>
<!-- ── First-run connect (§3) ──────────────────────────────────────── -->
<string name="connect_title">Connect to your shard</string>
<string name="connect_subtitle">Enter your shard\'s website address to get started.</string>
<string name="connect_title">Connect to your community</string>
<string name="connect_subtitle">Enter your community\'s website address to get started.</string>
<string name="connect_url_label">Website address</string>
<string name="connect_url_hint">https://your-shard.example.com</string>
<string name="connect_url_hint">https://your-community.example.com</string>
<string name="connect_button">Connect</string>
<string name="connect_connecting">Connecting…</string>
<string name="connect_error_blank">Enter a website address.</string>
<string name="connect_error_malformed">That doesn\'t look like a valid web address.</string>
<string name="connect_error_scheme">Only http and https addresses are supported.</string>
<string name="connect_error_insecure">A secure https address is required.</string>
<string name="connect_error_not_runic">That site isn\'t a Runic Gateway shard.</string>
<string name="connect_error_not_runic">That site isn\'t a Runic Gateway site.</string>
<string name="connect_error_version">This app is out of date for that site (it speaks API %1$s). Update the app and try again.</string>
<string name="connect_error_unreachable">Couldn\'t reach that site. Check the address and your connection.</string>
<string name="connect_error_server">The site responded with an error (%1$d). Try again shortly.</string>
@@ -43,22 +57,12 @@
<string name="menu_news">News</string>
<string name="menu_events">Events</string>
<string name="menu_wiki">Wiki</string>
<string name="menu_shard">Shard</string>
<string name="menu_rules">Rules</string>
<string name="menu_atlas">Atlas</string>
<string name="menu_leaderboards">Leaderboards</string>
<string name="menu_market">Market</string>
<string name="menu_about">About</string>
<string name="menu_contact">Contact</string>
<string name="menu_account">My account</string>
<string name="menu_my_events">My events</string>
<string name="menu_my_characters">My characters</string>
<string name="menu_my_vendors">My vendors</string>
<string name="menu_my_houses">My houses</string>
<string name="menu_admin_dashboard">Dashboard</string>
<string name="menu_admin_content">Content</string>
<string name="menu_admin_moderation">Moderation</string>
<string name="menu_admin_support">Support queue</string>
<string name="menu_sign_in">Sign in</string>
<string name="menu_sign_out">Sign out</string>
<string name="menu_change_server">Change server</string>
@@ -109,41 +113,9 @@
<string name="admin_content_title_required">A title is required.</string>
<string name="admin_content_cat_fields_required">Slug and title are required.</string>
<!-- Staff moderation (shard write plane) -->
<string name="admin_mod_account_action">Account action</string>
<string name="admin_mod_account">Account</string>
<string name="admin_mod_serial">Serial (0x…)</string>
<string name="admin_mod_reason">Reason (ban)</string>
<string name="admin_mod_duration">Ban duration (seconds; blank = indefinite)</string>
<string name="admin_mod_kick">Kick</string>
<string name="admin_mod_ban">Ban</string>
<string name="admin_mod_unban">Unban</string>
<string name="admin_mod_broadcast_section">Broadcast</string>
<string name="admin_mod_broadcast_text">Message to everyone online</string>
<string name="admin_mod_broadcast">Broadcast</string>
<string name="admin_mod_kicked">Account kicked.</string>
<string name="admin_mod_banned">Account banned.</string>
<string name="admin_mod_unbanned">Ban cleared.</string>
<string name="admin_mod_broadcasted">Message broadcast.</string>
<string name="admin_mod_target_required">Enter an account or serial.</string>
<string name="admin_mod_text_required">Enter a message to broadcast.</string>
<string name="admin_mod_shard_offline">The shard is offline — the action couldn\'t be delivered.</string>
<!-- Staff support queue -->
<string name="admin_support_empty">No open help pages.</string>
<string name="admin_support_reply">Reply</string>
<string name="admin_support_close">Close</string>
<string name="admin_support_send">Send</string>
<string name="admin_support_message">Reply message</string>
<string name="admin_support_close_after">Close the page after replying</string>
<string name="admin_support_responded">Reply sent.</string>
<string name="admin_support_closed">Page closed.</string>
<string name="admin_support_message_required">Enter a reply message.</string>
<string name="admin_support_unknown_page">That page is no longer in the queue.</string>
<!-- ── Auth: login (§4.1) ──────────────────────────────────────────── -->
<string name="login_title">Sign in</string>
<string name="login_subtitle">Sign in with your shard account.</string>
<string name="login_subtitle">Sign in with your account on this site.</string>
<string name="login_username">Username</string>
<string name="login_password">Password</string>
<string name="login_totp_code">Authentication code</string>
@@ -151,7 +123,7 @@
<string name="login_button">Sign in</string>
<string name="login_register">Create an account</string>
<string name="login_forgot">Forgot your password?</string>
<!-- Single SSO entry point; the picker lists the shard's providers (native SSO, M9/M10). -->
<!-- Single SSO entry point; the picker lists the site's providers (native SSO, M9/M10). -->
<string name="login_sso_button">Sign in with SSO</string>
<string name="login_sso_pick_title">Choose a sign-in provider</string>
<!-- %1$s is the provider name, e.g. "Google" or "Discord". -->
@@ -207,6 +179,8 @@
<string name="account_identities_title">Linked accounts</string>
<string name="account_identities_empty">No linked sign-in providers.</string>
<string name="account_identity_unlink">Unlink</string>
<!-- A provider that reports a name (Steam): "Steam: CoolGuy99". -->
<string name="account_identity_named">%1$s: %2$s</string>
<string name="account_identity_unlinked">Account unlinked.</string>
<string name="account_identity_error">Couldn\'t unlink that account.</string>
@@ -223,6 +197,38 @@
<string name="account_security_trusted_devices">Trusted devices</string>
<string name="account_security_recovery_codes">Recovery codes</string>
<!-- Delete my account (ACCOUNT_DELETION.md §4.3). The same words as the website's lib/accountDeletion.js. -->
<string name="account_delete_title">Delete my account</string>
<string name="account_delete_row_hint">Deletes your account straight away. It cannot be restored.</string>
<string name="account_delete_open">Delete my account…</string>
<string name="account_delete_intro">Your account is deleted straight away and cannot be restored. These go with it:</string>
<string name="account_delete_goes_sign_in">your sign-in, password, email address, two-factor settings and recovery codes</string>
<string name="account_delete_goes_devices">every signed-in device, trusted device and push registration</string>
<string name="account_delete_goes_identities">your linked sign-ins, such as Google, Discord or Steam</string>
<string name="account_delete_goes_game_links">your game-account links, and anything this site granted in the game through them</string>
<string name="account_delete_goes_notifications">your notification settings and inbox</string>
<string name="account_delete_stays_title">What stays:</string>
<string name="account_delete_stays_content">What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”.</string>
<string name="account_delete_stays_reports">Reports you filed and appeals you made stay as moderation records. Staff can still see who it was, and the activity log keeps its IP addresses.</string>
<string name="account_delete_stays_events">Event results stay with the score and the in-game name, with no site account behind them.</string>
<string name="account_delete_stays_game_account">A game account stays on the game server. Only its link to this site goes.</string>
<string name="account_delete_staff_note">You are staff, so your username can never be used again, by anyone, once this account is deleted.</string>
<string name="account_delete_no_password">This account has no password yet. Set one in the Password section on My account first; the password is how you confirm a deletion.</string>
<string name="account_delete_type_username">Type %1$s to confirm</string>
<string name="account_delete_recovery_code">Recovery code</string>
<string name="account_delete_use_recovery">Use a recovery code instead</string>
<string name="account_delete_use_authenticator">Use an authenticator code instead</string>
<string name="account_delete_action">Delete my account</string>
<string name="account_delete_busy">Deleting…</string>
<string name="account_delete_done">Your account has been deleted.</string>
<string name="account_delete_error_username">Type your username exactly as it is shown.</string>
<string name="account_delete_error_password">Your password is incorrect.</string>
<string name="account_delete_error_code_required">Enter a code from your authenticator app, or a recovery code.</string>
<string name="account_delete_error_code">That code is not valid. Try again.</string>
<string name="account_delete_error_last_admin">You are the only admin. Make someone else an admin before you delete your account.</string>
<string name="account_delete_error_rate_limited">Too many attempts. Wait a few minutes and try again.</string>
<string name="account_delete_error">Could not delete your account. Try again.</string>
<!-- Trusted devices screen -->
<string name="trusted_devices_title">Trusted devices</string>
<string name="trusted_devices_subtitle">These devices can skip the authentication code at sign-in for 30 days.</string>
@@ -253,77 +259,7 @@
<string name="recovery_codes_share">Share</string>
<string name="recovery_codes_done">Done</string>
<!-- ── Player: game-account linking (§6.3) ─────────────────────────── -->
<string name="player_link_title">Link your game account</string>
<string name="player_link_hint">In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses.</string>
<string name="player_link_code">Link code</string>
<string name="player_link_action">Link account</string>
<string name="player_link_ok">Account linked.</string>
<string name="player_link_bad_code">That code is unknown or has expired. Run [link in game for a new one.</string>
<string name="player_link_error">Couldn\'t link that code. Please try again.</string>
<string name="player_create_title">Create a game account</string>
<string name="player_create_account">Account name</string>
<string name="player_create_password">Password</string>
<string name="player_create_action">Create account</string>
<string name="player_create_ok">Game account created and linked.</string>
<string name="player_create_taken">That account name is already taken.</string>
<string name="player_create_capped">The account limit for your network has been reached.</string>
<string name="player_create_unavailable">Game-account signup isn\'t available right now.</string>
<string name="player_create_rejected">That account name or password wasn\'t accepted.</string>
<string name="player_create_error">Couldn\'t create that account. Please try again.</string>
<!-- ── Player: characters (§6.3) ───────────────────────────────────── -->
<string name="player_characters_empty">Link a game account above to see your characters.</string>
<string name="player_roster_loading">Loading…</string>
<string name="player_roster_empty">No characters on this account.</string>
<string name="player_char_unknown">Unknown</string>
<string name="player_char_online">Online</string>
<string name="player_char_offline">Offline</string>
<!-- Character sheet -->
<string name="player_char_governor">Governor of %1$s</string>
<string name="player_char_guildmaster">Guildmaster, %1$s</string>
<string name="player_char_attributes">Attributes</string>
<string name="player_char_vitals">Vitals</string>
<string name="player_char_str">STR</string>
<string name="player_char_dex">DEX</string>
<string name="player_char_int">INT</string>
<string name="player_char_hits">Hits</string>
<string name="player_char_mana">Mana</string>
<string name="player_char_stam">Stamina</string>
<string name="player_char_resistances">Resistances</string>
<string name="player_char_phys">Physical</string>
<string name="player_char_fire">Fire</string>
<string name="player_char_cold">Cold</string>
<string name="player_char_pois">Poison</string>
<string name="player_char_energy">Energy</string>
<string name="player_char_skills">Skills</string>
<string name="player_char_points">Loyalty &amp; Points</string>
<!-- A point system's name followed by the character's rank on that board, e.g. "Queens Loyalty · #3". -->
<string name="player_char_points_ranked">%1$s · #%2$d</string>
<!-- A score against its cap. Only shown for capped systems; an uncapped score shows the number alone. -->
<string name="player_char_points_of">%1$d / %2$d</string>
<string name="player_char_equipment">Equipment</string>
<string name="player_char_item">Item</string>
<string name="player_char_item_id">id %1$d</string>
<string name="player_char_item_hue">hue %1$d</string>
<!-- ── Player: vendors &amp; sales (§6.3) ──────────────────────────── -->
<string name="player_sales_title">Recent sales</string>
<string name="player_sales_empty">No recent sales.</string>
<string name="player_gold_amount">%1$s gp</string>
<string name="player_vendors_no_accounts">Link a game account to see your vendors.</string>
<string name="player_vendors_empty">No vendors on this account.</string>
<string name="player_vendor_fallback">Player vendor</string>
<string name="player_vendor_hold">Holding %1$s gp</string>
<string name="player_vendor_no_listings">No items listed.</string>
<string name="player_listing_item">Item %1$d ×%2$d</string>
<!-- ── Player: houses (§6.3) ───────────────────────────────────────── -->
<string name="player_houses_empty">You don\'t have any houses on your linked accounts.</string>
<string name="player_house_fallback">A house</string>
<string name="player_house_stage">Status: %1$s</string>
<!-- ── Roles (the account screen, §5) ──────────────────────────────── -->
<string name="role_player">Player</string>
<string name="role_moderator">Moderator</string>
<string name="role_editor">Editor</string>
@@ -362,140 +298,19 @@
<string name="contact_validation">Please fill in every field.</string>
<string name="contact_error">Couldn\'t send your message. Please try again.</string>
<!-- ── Shard hub &amp; live feed (§6.2) ─────────────────────────────── -->
<string name="shard_live">Live</string>
<string name="shard_offline">Offline</string>
<string name="shard_status_online">Shard online</string>
<string name="shard_status_offline">Shard offline</string>
<string name="shard_online_count">%1$d players online</string>
<string name="shard_economy_gold">Gold supply: %1$s</string>
<string name="shard_section_staff">Staff online</string>
<string name="shard_section_activity">Live activity</string>
<string name="shard_feed_empty">No recent activity.</string>
<string name="shard_nav_champs">Champion spawns</string>
<string name="shard_nav_guilds">Guilds</string>
<string name="shard_nav_governors">Governors</string>
<string name="shard_nav_houses">Falling houses</string>
<!-- ── Champion spawns (§6.2) ──────────────────────────────────────── -->
<string name="champs_empty">No champion spawns are being tracked right now.</string>
<string name="champs_fallback_name">Champion spawn</string>
<string name="champ_level">Level %1$d</string>
<string name="champ_cooldown">Restarting</string>
<!-- ── Guilds (§6.2) ───────────────────────────────────────────────── -->
<string name="guilds_empty">No guilds are being tracked right now.</string>
<string name="guilds_fallback_name">Guild</string>
<string name="guilds_members">%1$d members</string>
<string name="guilds_leader">Led by %1$s</string>
<string name="guilds_alliance">Alliance: %1$s</string>
<!-- ── Rules / ruleset (Protocol 3.0 §5, M11) ──────────────────────── -->
<!-- A successful read of a shard that has never published its ruleset — NOT the same
as the feature being switched off, which renders as an error state. -->
<string name="rules_unpublished">This shard hasn\'t published its ruleset yet.</string>
<string name="rules_section_shard">Shard</string>
<string name="rules_section_systems">Systems</string>
<string name="rules_section_caps">Skill &amp; stat caps</string>
<string name="rules_section_accounts">Accounts &amp; housing</string>
<string name="rules_section_vendors">Vendors</string>
<string name="rules_section_schedule">Saves &amp; restarts</string>
<string name="rules_name">Name</string>
<string name="rules_expansion">Expansion</string>
<string name="rules_connect">Connect</string>
<string name="rules_skill_cap">Individual skill cap</string>
<string name="rules_total_skill_cap">Total skill cap</string>
<string name="rules_stat_cap">Total stat cap</string>
<string name="rules_str_cap">Strength cap</string>
<string name="rules_dex_cap">Dexterity cap</string>
<string name="rules_int_cap">Intelligence cap</string>
<string name="rules_char_slots">Character slots</string>
<string name="rules_per_ip">Accounts per IP</string>
<string name="rules_house_limit">Houses per account</string>
<string name="rules_restock_delay">Vendor restock delay</string>
<string name="rules_max_sell">Max sell quantity</string>
<string name="rules_autosave">Auto-save every</string>
<string name="rules_autorestart">Auto-restart at</string>
<string name="rules_minutes">%1$d min</string>
<!-- ── Leaderboards (Protocol 3.0 §7, M11) ─────────────────────────── -->
<string name="leaderboards_empty">This shard isn\'t publishing any leaderboards yet.</string>
<string name="leaderboards_board_empty">Nobody has scored here yet.</string>
<!-- Where a score would sit on the placeholder row of an unscored board. An em
dash, not "0" — nobody has scored zero, nobody has scored at all. -->
<string name="leaderboards_no_score">—</string>
<string name="leaderboards_players">%1$d players</string>
<!-- Only shown for capped systems; most systems on a real shard are uncapped. -->
<string name="leaderboards_cap">Cap: %1$d</string>
<string name="leaderboards_rank_name">#%1$d %2$s</string>
<!-- A shard may publish standings without naming who holds them (the board's one
admin-configurable field). -->
<string name="leaderboards_hidden_name">Someone</string>
<!-- ── Market (Protocol 3.0 §8, M11) ───────────────────────────────── -->
<string name="market_search_label">Search every shop</string>
<string name="market_empty">No listings match that search.</string>
<string name="market_shop_empty">This shop has nothing for sale.</string>
<!-- Required, not decoration: the shard sweeps vendors round-robin, so a price can
legitimately be a full cycle old. -->
<string name="market_staleness">Prices are refreshed in rotation and may be out of date.</string>
<string name="market_price">%1$d gp</string>
<string name="market_unnamed_item">Item %1$d</string>
<string name="market_unnamed_shop">A shop</string>
<string name="market_owner">Kept by %1$s</string>
<!-- A gated location is a real answer: the shop exists, the shard just doesn't say
where it stands. -->
<string name="market_location_hidden">This shard doesn\'t publish shop locations.</string>
<string name="market_truncated">Showing %1$d of %2$d — this shop holds more than the shard publishes.</string>
<!-- ── Spawn atlas (Protocol 3.0 §6, M11) ──────────────────────────── -->
<string name="atlas_search_label">Search creatures</string>
<string name="atlas_empty">No creatures match that search.</string>
<!-- A place can legitimately hold a single spawner, and the aggregate list is full
of them — "1 spawners" on every other row is worth a plural for. -->
<plurals name="atlas_spawner_count">
<item quantity="one">%1$d spawner</item>
<item quantity="other">%1$d spawners</item>
</plurals>
<string name="atlas_total_alive">Up to %1$d alive at once</string>
<string name="atlas_facet_count">%1$s (%2$d)</string>
<!-- The aggregate: "where is it", as opposed to the raw coordinates below it. -->
<string name="atlas_section_places">Where it spawns</string>
<string name="atlas_place_max_alive">up to %1$d at once</string>
<string name="atlas_section_spawners">Spawn points</string>
<string name="atlas_section_also_here">Also spawns here</string>
<string name="atlas_spawners_truncated">More spawn points than shown.</string>
<string name="atlas_max_count">Up to %1$d</string>
<!-- The delay is already in seconds; the server normalizes XmlSpawner's mixed units. -->
<string name="atlas_respawn">Respawn %1$s</string>
<!-- ── Governors (§6.2) ────────────────────────────────────────────── -->
<string name="governors_empty">No governors — this shard may not run the City Loyalty system.</string>
<string name="governor_current">Governed by %1$s</string>
<string name="governor_none">No governor</string>
<string name="governor_election">Election: %1$s</string>
<string name="governor_history">Term history</string>
<string name="governor_history_loading">Loading…</string>
<string name="governor_history_empty">No past terms recorded.</string>
<!-- ── Falling houses / IDOC (§6.2) ────────────────────────────────── -->
<string name="houses_empty">No houses are in danger right now.</string>
<string name="houses_fallback_name">A house</string>
<string name="houses_idoc_badge">IDOC</string>
<!-- ── Push notifications (§11, M7 Part 2) ─────────────────────────── -->
<string name="menu_notifications">Notifications</string>
<string name="notifications_title">Notifications</string>
<string name="notifications_subtitle">Choose what this shard notifies you about, and how it reaches you. Nothing is sent unless you turn it on.</string>
<string name="notifications_subtitle">Choose what this site notifies you about, and how it reaches you. Nothing is sent unless you turn it on.</string>
<string name="notifications_section_general">General</string>
<string name="notifications_section_personal">Your game account</string>
<string name="notifications_requires_link">Link a game account to enable this.</string>
<string name="notifications_empty">This shard offers no notification streams yet.</string>
<string name="notifications_unsupported">This shard hasn\'t set up push notifications yet.</string>
<string name="notifications_requires_link">Link a game account to get this as a push notification.</string>
<string name="notifications_empty">This site offers no notification streams yet.</string>
<string name="notifications_unsupported">This site hasn\'t set up push notifications yet.</string>
<string name="notifications_saved">Notification settings saved.</string>
<string name="notifications_all_off">Notifications turned off.</string>
<string name="notifications_save_error">Couldn\'t save your notification settings. Try again.</string>
<string name="notifications_relay_error">This shard\'s push relay isn\'t reachable right now.</string>
<string name="notifications_relay_error">This site\'s push relay isn\'t reachable right now.</string>
<!-- The in-app inbox and the per-channel settings (ENGAGEMENT.md phase 8). -->
<string name="notifications_mode_off">Off</string>
@@ -510,22 +325,15 @@
<string name="inbox_offline_cached">Offline — showing what was saved on this device.</string>
<!-- Notification channels + the ongoing foreground-service notification. -->
<string name="push_channel_messages">Shard notifications</string>
<string name="push_channel_messages_desc">Alerts you opted into from this shard.</string>
<string name="push_channel_messages">Notifications from your community</string>
<string name="push_channel_messages_desc">Alerts you opted into from this site.</string>
<string name="push_channel_service">Background connection</string>
<string name="push_channel_service_desc">Keeps the connection open to deliver notifications.</string>
<string name="push_service_title">Notifications active</string>
<string name="push_service_text">Listening for shard notifications.</string>
<string name="push_service_text">Listening for notifications from your community.</string>
<!-- Per-stream notification titles (content-free tickle → generic title, §11). -->
<string name="push_stream_news_post">New post</string>
<string name="push_stream_server_status">Shard status changed</string>
<string name="push_stream_idoc_warning">A house is falling (IDOC)</string>
<string name="push_stream_champ_start">Champion spawn started</string>
<string name="push_stream_governor_election">New governor elected</string>
<string name="push_stream_vendor_sale">Your vendor made a sale</string>
<string name="push_stream_house_idoc">Your house entered IDOC</string>
<string name="push_stream_account_login">Login to your account</string>
<string name="push_stream_generic">New notification</string>
<!-- ── Events (§9 M13, EVENTS.md §I) ─────────────────────────── -->
@@ -549,6 +357,8 @@
<string name="events_part_of">Part of %1$s</string>
<string name="events_next">Next</string>
<string name="events_under_way">Under way</string>
<string name="events_progress_count">%1$s: %2$d of %3$d left</string>
<string name="events_progress_percent">%1$s: %2$d%%</string>
<string name="events_nothing_scheduled">Nothing scheduled at the moment.</string>
<string name="events_never_scheduled">This event has not been scheduled yet.</string>
<string name="events_coming_up">Coming up</string>
@@ -566,4 +376,27 @@
<string name="events_score">Score %1$s</string>
<string name="events_show_more">Show more</string>
<string name="events_loading">Loading…</string>
<!-- ── Account → Blocked (USER_SAFETY.md §4.3; the web's BlockedPanel.jsx) ─ -->
<string name="blocked_title">Blocked</string>
<string name="blocked_row_hint">Players and members you blocked, and anything you hid for yourself.</string>
<string name="blocked_open">Blocked players</string>
<string name="blocked_subtitle">You don’t see anything a blocked member or player writes — forum posts and in-game chat — in the app or on the website. They aren’t told. Staff can’t be blocked.</string>
<string name="blocked_empty">You haven’t blocked anyone.</string>
<string name="blocked_unknown_player">Unknown player</string>
<string name="blocked_kind_account">Account</string>
<string name="blocked_kind_game">%1$s %2$s, not linked to an account</string>
<string name="blocked_subtitle_when">%1$s · blocked %2$s</string>
<string name="blocked_not_applied_staff">Not applied: this account is now a staff member, and staff can’t be blocked.</string>
<string name="blocked_unblock">Unblock</string>
<string name="blocked_unblocked">Unblocked.</string>
<string name="blocked_unblocked_named">%1$s is unblocked.</string>
<string name="blocked_unblock_failed">Could not unblock that.</string>
<plurals name="blocked_hidden_count">
<item quantity="one">You’ve hidden %d item for yourself.</item>
<item quantity="other">You’ve hidden %d items for yourself.</item>
</plurals>
<string name="blocked_show_hidden">Show them again</string>
<string name="blocked_hidden_cleared">Everything you hid is shown again.</string>
<string name="blocked_hidden_failed">Could not show those again.</string>
</resources>

View File

@@ -0,0 +1,201 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
The Rust module's screens (PLAN.md M20): the server list and server page, the map,
and the player's own Rust account. Every row that reaches these is gated on the rust
capability, so Rust's words are right here. Core strings live in strings.xml.
-->
<resources>
<!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
<string name="menu_rust">Rust servers</string>
<!-- Rust module (M14, docs/modules/rust/PLAN.md §18) -->
<string name="rust_servers_empty">No Rust servers are configured on this site yet.</string>
<string name="rust_offline">Offline</string>
<string name="rust_online_count">%1$d / %2$d online</string>
<string name="rust_never_reported">has never reported</string>
<string name="rust_last_reported">last reported %1$s</string>
<string name="rust_last_reported_stale">last reported %1$s — out of date, so it is shown as offline</string>
<string name="rust_world_size">size %1$d</string>
<string name="rust_world_seed">seed %1$d</string>
<string name="rust_wiped_on">wiped %1$s</string>
<string name="rust_next_wipe">Next wipe %1$s</string>
<string name="rust_next_wipe_rescheduled">Next wipe %1$s (rescheduled)</string>
<string name="rust_refresh_failed">Could not refresh just now. This is the last thing the site heard.</string>
<string name="rust_no_such_server">No such server</string>
<string name="rust_no_such_server_detail">This address does not name a server this site follows.</string>
<string name="rust_back_to_servers">Back to the server list</string>
<string name="rust_tab_feed">Feed</string>
<string name="rust_tab_leaderboard">Leaderboard</string>
<string name="rust_tab_online">Online</string>
<string name="rust_tab_wipes">Wipes</string>
<string name="rust_tab_map">Map</string>
<!-- The Map tab (Rust phase 15, D121–D125). The wording follows the web's Map
tab, so a reader moving between the two reads the same sentences. -->
<string name="rust_map_none">No map yet. This server has not told the site which map it is on. The map will appear once the server is up.</string>
<string name="rust_map_no_picture">This server has no picture of its map, so the layers are drawn on a plain background.</string>
<string name="rust_map_picture_failed">The picture of the map could not be loaded, so the layers are drawn on a plain background.</string>
<string name="rust_map_description">Map of the server</string>
<string name="rust_map_status_nothing_live">No moving layers are shown to you on this server.</string>
<string name="rust_map_status_asking">Asking the server where things are…</string>
<string name="rust_map_status_not_now">The server did not say where things are just now. The site asks again every ten seconds.</string>
<string name="rust_map_status_offline">The server is offline, so nothing is moving on its map.</string>
<string name="rust_map_status_failed">Positions could not be loaded.</string>
<string name="rust_map_status_failed_kept">Could not refresh just now. These are the last positions the site heard.</string>
<string name="rust_map_status_as_of">Positions as of %1$s. They refresh every ten seconds while this tab is open.</string>
<string name="rust_map_layer_grid">Grid</string>
<string name="rust_map_layer_world">Monuments &amp; world events</string>
<string name="rust_map_layer_events">Site events</string>
<string name="rust_map_layer_players">Players</string>
<string name="rust_map_layer_bases">Bases</string>
<string name="rust_map_layer_mates">You and your clan</string>
<string name="rust_map_hidden_signin">Sign in to see this layer.</string>
<string name="rust_map_hidden_public">Shown to everyone.</string>
<string name="rust_map_hidden_staff">Shown to staff only.</string>
<string name="rust_map_capped">%1$s Limited by who may see who is online.</string>
<string name="rust_map_players_truncated">Not every sleeper is shown.</string>
<string name="rust_map_bases_truncated">Not every base is shown.</string>
<string name="rust_map_mates_note">Your own position, and clan mates who are online.</string>
<string name="rust_map_mates_link">Link your Steam account to see yourself and your clan here.</string>
<string name="rust_map_cargo">Cargo ship</string>
<string name="rust_map_heli">Patrol helicopter</string>
<string name="rust_map_chinook">Chinook</string>
<string name="rust_map_bradley">Bradley APC</string>
<string name="rust_map_supply">Supply drop</string>
<string name="rust_map_crate">Locked crate</string>
<string name="rust_map_crate_hack">%1$s left on the hack</string>
<string name="rust_map_crate_hacked">Hacked</string>
<string name="rust_map_event_zone">Event zone</string>
<string name="rust_map_event_crate">Event crate</string>
<string name="rust_map_event_npc">Event NPC</string>
<string name="rust_map_site_event">Site event</string>
<string name="rust_map_site_event_named">Site event · %1$s</string>
<string name="rust_map_boss">%1$s (boss)</string>
<string name="rust_map_boss_unnamed">Boss</string>
<string name="rust_map_boss_add">%1$s (a boss\'s add)</string>
<string name="rust_map_open_event">Open event</string>
<string name="rust_map_tc">Tool cupboard</string>
<string name="rust_map_vending">Vending machine</string>
<string name="rust_map_player_sleeping">Sleeping</string>
<string name="rust_map_player_offline">Asleep, offline</string>
<string name="rust_map_you">You</string>
<string name="rust_map_clan_mate">Clan mate</string>
<string name="rust_map_grid">Grid %1$s</string>
<string name="rust_map_close">Close</string>
<string name="rust_all_time">All time</string>
<string name="rust_wipe_current">%1$s (this wipe)</string>
<string name="rust_wipe_this_one">Current</string>
<string name="rust_wipe_selected">Showing</string>
<string name="rust_feed_empty">Nothing has happened on this server yet — or not during the wipe you are looking at.</string>
<string name="rust_leaderboard_empty">Nobody has scored here yet.</string>
<string name="rust_wipes_empty">This server has not reported a wipe yet.</string>
<!-- Nothing names who is online by default (org lead, 2026-09-22). Below the
operator's audience the server sends a count and no names. -->
<plurals name="rust_online_hidden_count">
<item quantity="one">%1$d player online</item>
<item quantity="other">%1$d players online</item>
</plurals>
<string name="rust_online_hidden_staff">Only this site’s staff can see who they are.</string>
<string name="rust_online_hidden_signin">Sign in to see who they are.</string>
<string name="rust_online_hidden_public">This site is not showing who they are right now.</string>
<string name="rust_feed_presence_hidden_staff">Joins, deaths and chat are not shown. Only this site’s staff can see what players did.</string>
<string name="rust_feed_presence_hidden_signin">Joins, deaths and chat are not shown. Sign in to see what players did.</string>
<string name="rust_nobody_on">The server is up and the island is empty. Somebody has to be first.</string>
<string name="rust_presence_offline">Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.</string>
<string name="rust_presence_live">On the server right now.</string>
<string name="rust_presence_last_known">The last board this server sent. It is offline, so this is who was on then — not who is on now.</string>
<string name="rust_sleeping">Sleeping</string>
<string name="rust_col_player">Player</string>
<string name="rust_col_kills">Kills</string>
<string name="rust_col_deaths">Deaths</string>
<string name="rust_col_npc_kills">NPC</string>
<string name="rust_col_structures">Built</string>
<string name="rust_col_played">Played</string>
<string name="rust_online_badge">%1$d players online</string>
<!-- The player's own Rust account (M15 — module-rust phase 8) -->
<string name="menu_rust_account">My Rust account</string>
<string name="rust_link_title">Link your Steam account</string>
<string name="rust_link_hint">Join any of our Rust servers and type /link in chat. The server replies with a six-character code only you can see, good for five minutes. Enter it here — it works once.</string>
<string name="rust_link_code">Link code</string>
<string name="rust_link_action">Link account</string>
<string name="rust_link_ok">Linked. Your play on our servers now appears under your name here.</string>
<string name="rust_link_already">That account was already linked to you.</string>
<string name="rust_link_bad_code">That code is unknown or has expired. Type /link in game for a new one.</string>
<string name="rust_link_taken">That Steam account is linked to another website account. Run /unlink in game to release it.</string>
<string name="rust_link_capped">Too many attempts just now. Try again in a few minutes.</string>
<string name="rust_link_unreachable">A server could not be reached, so that code could not be checked. Your code is still good — try again in a minute.</string>
<string name="rust_link_error">Could not link that code.</string>
<string name="rust_unlink_action">Unlink</string>
<string name="rust_unlink_error">Could not unlink that account.</string>
<string name="rust_account_linked_when">linked %1$s</string>
<string name="rust_account_none">No Steam account is linked to this profile yet.</string>
<string name="rust_account_fleet_note">A link covers every server this community runs — a Steam account is one person wherever they play. You can also type /unlink in game.</string>
<string name="rust_held_title">What you can do in game</string>
<string name="rust_held_empty">Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on the servers they cover.</string>
<string name="rust_held_unlinked">None of this reaches the game yet — link a Steam account above and the site pushes it across on its next sync.</string>
<string name="rust_held_waiting_note">A server marked waiting has not confirmed it yet. One that is offline catches up when it comes back.</string>
<string name="rust_held_no_servers">No servers are configured yet.</string>
<string name="rust_coins_title">Coins</string>
<string name="rust_coins_unlinked">Link a Steam account to see your balance.</string>
<string name="rust_coins_missing">Economics is not installed on this server.</string>
<string name="rust_coins_balance">%1$s · %2$s coins</string>
<string name="rust_coins_last_known">Last known — the server did not answer just now.</string>
<string name="rust_backpack_title">Backpack</string>
<string name="rust_backpack_unlinked">Link a Steam account to see your backpack.</string>
<string name="rust_backpack_open">Open backpack</string>
<string name="rust_backpack_read_again">Read again</string>
<string name="rust_backpack_close">Close</string>
<string name="rust_backpack_missing">Backpacks is not installed on this server.</string>
<string name="rust_backpack_unavailable">This site cannot show backpacks yet.</string>
<string name="rust_backpack_none">No backpack here yet — it has never been opened.</string>
<string name="rust_backpack_summary">%1$d stacks in %2$d slots</string>
<string name="rust_backpack_overflow">%1$d past your size — they move to your inventory, or drop at your feet if it is full, the next time you open it.</string>
<string name="rust_backpack_page">Page %1$d</string>
<string name="rust_reach_live">%1$s · has it</string>
<string name="rust_reach_waiting">%1$s · waiting</string>
<!--
A chat line's long-press menu: report, hide, block (docs/website/USER_SAFETY.md
§4.3). The wording follows the website's ChatMenu.jsx, so a member reads one set
of sentences in either place. Account → Blocked is core's, in strings.xml.
-->
<string name="safety_line_actions">Report, hide or block</string>
<string name="safety_line_quote">%1$s: %2$s</string>
<string name="safety_this_player">this player</string>
<string name="safety_sign_in_hint">Sign in to report or block.</string>
<string name="safety_sign_in">Sign in</string>
<string name="safety_report">Report</string>
<string name="safety_hide_for_me">Hide for me</string>
<string name="safety_block_player">Block player</string>
<string name="safety_hide_for_everyone">Hide for everyone</string>
<string name="safety_show_to_everyone">Show to everyone again</string>
<string name="safety_hidden_by_staff">Hidden by staff</string>
<string name="safety_cancel">Cancel</string>
<string name="safety_close">Close</string>
<string name="safety_report_explainer">This goes to site staff. Reporting does not hide or change anything — it asks a staffer to look.</string>
<string name="safety_report_detail">Anything a staffer should know (optional)</string>
<string name="safety_report_send">Send report</string>
<string name="safety_reported">Reported to site staff.</string>
<string name="safety_reason_abuse">Abusive or harassing</string>
<string name="safety_reason_spam">Spam</string>
<string name="safety_reason_sexual">Sexual content</string>
<string name="safety_reason_illegal">Illegal content</string>
<string name="safety_reason_impersonation">Impersonation</string>
<string name="safety_reason_other">Something else</string>
<string name="safety_block_confirm">Block %1$s? You won’t see anything they write, here or on the website. They aren’t told. You can undo it from Account → Blocked.</string>
<string name="safety_block">Block</string>
<string name="safety_error_already_reported">You have already reported this. Staff will look at it.</string>
<string name="safety_error_already_blocked">You have already blocked this player.</string>
<string name="safety_error_gone">That line is no longer there.</string>
<string name="safety_error_staff">You can’t block a staff member.</string>
<string name="safety_error_forbidden">Your account can’t do that.</string>
<string name="safety_error_not_blockable">This player can’t be blocked.</string>
<string name="safety_error_rate_limited">Too many reports. Please give staff a chance to look at the ones you have raised.</string>
<string name="safety_error_unavailable">The game module did not answer. Try again in a moment.</string>
<string name="safety_error_demo">This is turned off on the demo site.</string>
<string name="safety_error_other">Could not do that. Try again.</string>
</resources>

View File

@@ -0,0 +1,256 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
The Ultima Online module's screens (PLAN.md M20): the shard hub and its pages, the
player's characters / vendors / houses, staff moderation and the support queue, and
the module's push streams. Every row that reaches these is gated on the shard
capability, so UO's words are right here. Core strings live in strings.xml.
-->
<resources>
<!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
<string name="menu_shard">Shard</string>
<string name="menu_rules">Rules</string>
<string name="menu_atlas">Atlas</string>
<string name="menu_leaderboards">Leaderboards</string>
<string name="menu_market">Market</string>
<string name="menu_my_characters">My characters</string>
<string name="menu_my_vendors">My vendors</string>
<string name="menu_my_houses">My houses</string>
<string name="menu_admin_moderation">Moderation</string>
<string name="menu_admin_support">Support queue</string>
<!-- ── Staff operations (§1, M10) ──────────────────────────────────── -->
<!-- Staff moderation (shard write plane) -->
<string name="admin_mod_account_action">Account action</string>
<string name="admin_mod_account">Account</string>
<string name="admin_mod_serial">Serial (0x…)</string>
<string name="admin_mod_reason">Reason (ban)</string>
<string name="admin_mod_duration">Ban duration (seconds; blank = indefinite)</string>
<string name="admin_mod_kick">Kick</string>
<string name="admin_mod_ban">Ban</string>
<string name="admin_mod_unban">Unban</string>
<string name="admin_mod_broadcast_section">Broadcast</string>
<string name="admin_mod_broadcast_text">Message to everyone online</string>
<string name="admin_mod_broadcast">Broadcast</string>
<string name="admin_mod_kicked">Account kicked.</string>
<string name="admin_mod_banned">Account banned.</string>
<string name="admin_mod_unbanned">Ban cleared.</string>
<string name="admin_mod_broadcasted">Message broadcast.</string>
<string name="admin_mod_target_required">Enter an account or serial.</string>
<string name="admin_mod_text_required">Enter a message to broadcast.</string>
<string name="admin_mod_shard_offline">The shard is offline — the action couldn\'t be delivered.</string>
<!-- Staff support queue -->
<string name="admin_support_empty">No open help pages.</string>
<string name="admin_support_reply">Reply</string>
<string name="admin_support_close">Close</string>
<string name="admin_support_send">Send</string>
<string name="admin_support_message">Reply message</string>
<string name="admin_support_close_after">Close the page after replying</string>
<string name="admin_support_responded">Reply sent.</string>
<string name="admin_support_closed">Page closed.</string>
<string name="admin_support_message_required">Enter a reply message.</string>
<string name="admin_support_unknown_page">That page is no longer in the queue.</string>
<!-- ── Player: game-account linking (§6.3) ─────────────────────────── -->
<string name="player_link_title">Link your game account</string>
<string name="player_link_hint">In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses.</string>
<string name="player_link_code">Link code</string>
<string name="player_link_action">Link account</string>
<string name="player_link_ok">Account linked.</string>
<string name="player_link_bad_code">That code is unknown or has expired. Run [link in game for a new one.</string>
<string name="player_link_error">Couldn\'t link that code. Please try again.</string>
<string name="player_create_title">Create a game account</string>
<string name="player_create_account">Account name</string>
<string name="player_create_password">Password</string>
<string name="player_create_action">Create account</string>
<string name="player_create_ok">Game account created and linked.</string>
<string name="player_create_taken">That account name is already taken.</string>
<string name="player_create_capped">The account limit for your network has been reached.</string>
<string name="player_create_unavailable">Game-account signup isn\'t available right now.</string>
<string name="player_create_rejected">That account name or password wasn\'t accepted.</string>
<string name="player_create_error">Couldn\'t create that account. Please try again.</string>
<!-- ── Player: characters (§6.3) ───────────────────────────────────── -->
<string name="player_characters_empty">Link a game account above to see your characters.</string>
<string name="player_roster_loading">Loading…</string>
<string name="player_roster_empty">No characters on this account.</string>
<string name="player_char_unknown">Unknown</string>
<string name="player_char_online">Online</string>
<string name="player_char_offline">Offline</string>
<!-- Character sheet -->
<string name="player_char_governor">Governor of %1$s</string>
<string name="player_char_guildmaster">Guildmaster, %1$s</string>
<string name="player_char_attributes">Attributes</string>
<string name="player_char_vitals">Vitals</string>
<string name="player_char_str">STR</string>
<string name="player_char_dex">DEX</string>
<string name="player_char_int">INT</string>
<string name="player_char_hits">Hits</string>
<string name="player_char_mana">Mana</string>
<string name="player_char_stam">Stamina</string>
<string name="player_char_resistances">Resistances</string>
<string name="player_char_phys">Physical</string>
<string name="player_char_fire">Fire</string>
<string name="player_char_cold">Cold</string>
<string name="player_char_pois">Poison</string>
<string name="player_char_energy">Energy</string>
<string name="player_char_skills">Skills</string>
<string name="player_char_points">Loyalty &amp; Points</string>
<!-- A point system's name followed by the character's rank on that board, e.g. "Queens Loyalty · #3". -->
<string name="player_char_points_ranked">%1$s · #%2$d</string>
<!-- A score against its cap. Only shown for capped systems; an uncapped score shows the number alone. -->
<string name="player_char_points_of">%1$d / %2$d</string>
<string name="player_char_equipment">Equipment</string>
<string name="player_char_item">Item</string>
<string name="player_char_item_id">id %1$d</string>
<string name="player_char_item_hue">hue %1$d</string>
<!-- ── Player: vendors &amp; sales (§6.3) ──────────────────────────── -->
<string name="player_sales_title">Recent sales</string>
<string name="player_sales_empty">No recent sales.</string>
<string name="player_gold_amount">%1$s gp</string>
<string name="player_vendors_no_accounts">Link a game account to see your vendors.</string>
<string name="player_vendors_empty">No vendors on this account.</string>
<string name="player_vendor_fallback">Player vendor</string>
<string name="player_vendor_hold">Holding %1$s gp</string>
<string name="player_vendor_no_listings">No items listed.</string>
<string name="player_listing_item">Item %1$d ×%2$d</string>
<!-- ── Player: houses (§6.3) ───────────────────────────────────────── -->
<string name="player_houses_empty">You don\'t have any houses on your linked accounts.</string>
<string name="player_house_fallback">A house</string>
<string name="player_house_stage">Status: %1$s</string>
<!-- ── Shard hub &amp; live feed (§6.2) ─────────────────────────────── -->
<string name="shard_live">Live</string>
<string name="shard_offline">Offline</string>
<string name="shard_status_online">Shard online</string>
<string name="shard_status_offline">Shard offline</string>
<string name="shard_online_count">%1$d players online</string>
<string name="shard_economy_gold">Gold supply: %1$s</string>
<string name="shard_section_staff">Staff online</string>
<string name="shard_section_activity">Live activity</string>
<string name="shard_feed_empty">No recent activity.</string>
<string name="shard_nav_champs">Champion spawns</string>
<string name="shard_nav_guilds">Guilds</string>
<string name="shard_nav_governors">Governors</string>
<string name="shard_nav_houses">Falling houses</string>
<!-- ── Champion spawns (§6.2) ──────────────────────────────────────── -->
<string name="champs_empty">No champion spawns are being tracked right now.</string>
<string name="champs_fallback_name">Champion spawn</string>
<string name="champ_level">Level %1$d</string>
<string name="champ_cooldown">Restarting</string>
<!-- ── Guilds (§6.2) ───────────────────────────────────────────────── -->
<string name="guilds_empty">No guilds are being tracked right now.</string>
<string name="guilds_fallback_name">Guild</string>
<string name="guilds_members">%1$d members</string>
<string name="guilds_leader">Led by %1$s</string>
<string name="guilds_alliance">Alliance: %1$s</string>
<!-- ── Rules / ruleset (Protocol 3.0 §5, M11) ──────────────────────── -->
<!-- A successful read of a shard that has never published its ruleset — NOT the same
as the feature being switched off, which renders as an error state. -->
<string name="rules_unpublished">This shard hasn\'t published its ruleset yet.</string>
<string name="rules_section_shard">Shard</string>
<string name="rules_section_systems">Systems</string>
<string name="rules_section_caps">Skill &amp; stat caps</string>
<string name="rules_section_accounts">Accounts &amp; housing</string>
<string name="rules_section_vendors">Vendors</string>
<string name="rules_section_schedule">Saves &amp; restarts</string>
<string name="rules_name">Name</string>
<string name="rules_expansion">Expansion</string>
<string name="rules_connect">Connect</string>
<string name="rules_skill_cap">Individual skill cap</string>
<string name="rules_total_skill_cap">Total skill cap</string>
<string name="rules_stat_cap">Total stat cap</string>
<string name="rules_str_cap">Strength cap</string>
<string name="rules_dex_cap">Dexterity cap</string>
<string name="rules_int_cap">Intelligence cap</string>
<string name="rules_char_slots">Character slots</string>
<string name="rules_per_ip">Accounts per IP</string>
<string name="rules_house_limit">Houses per account</string>
<string name="rules_restock_delay">Vendor restock delay</string>
<string name="rules_max_sell">Max sell quantity</string>
<string name="rules_autosave">Auto-save every</string>
<string name="rules_autorestart">Auto-restart at</string>
<string name="rules_minutes">%1$d min</string>
<!-- ── Leaderboards (Protocol 3.0 §7, M11) ─────────────────────────── -->
<string name="leaderboards_empty">This shard isn\'t publishing any leaderboards yet.</string>
<string name="leaderboards_board_empty">Nobody has scored here yet.</string>
<!-- Where a score would sit on the placeholder row of an unscored board. An em
dash, not "0" — nobody has scored zero, nobody has scored at all. -->
<string name="leaderboards_no_score">—</string>
<string name="leaderboards_players">%1$d players</string>
<!-- Only shown for capped systems; most systems on a real shard are uncapped. -->
<string name="leaderboards_cap">Cap: %1$d</string>
<string name="leaderboards_rank_name">#%1$d %2$s</string>
<!-- A shard may publish standings without naming who holds them (the board's one
admin-configurable field). -->
<string name="leaderboards_hidden_name">Someone</string>
<!-- ── Market (Protocol 3.0 §8, M11) ───────────────────────────────── -->
<string name="market_search_label">Search every shop</string>
<string name="market_empty">No listings match that search.</string>
<string name="market_shop_empty">This shop has nothing for sale.</string>
<!-- Required, not decoration: the shard sweeps vendors round-robin, so a price can
legitimately be a full cycle old. -->
<string name="market_staleness">Prices are refreshed in rotation and may be out of date.</string>
<string name="market_price">%1$d gp</string>
<string name="market_unnamed_item">Item %1$d</string>
<string name="market_unnamed_shop">A shop</string>
<string name="market_owner">Kept by %1$s</string>
<!-- A gated location is a real answer: the shop exists, the shard just doesn't say
where it stands. -->
<string name="market_location_hidden">This shard doesn\'t publish shop locations.</string>
<string name="market_truncated">Showing %1$d of %2$d — this shop holds more than the shard publishes.</string>
<!-- ── Spawn atlas (Protocol 3.0 §6, M11) ──────────────────────────── -->
<string name="atlas_search_label">Search creatures</string>
<string name="atlas_empty">No creatures match that search.</string>
<!-- A place can legitimately hold a single spawner, and the aggregate list is full
of them — "1 spawners" on every other row is worth a plural for. -->
<plurals name="atlas_spawner_count">
<item quantity="one">%1$d spawner</item>
<item quantity="other">%1$d spawners</item>
</plurals>
<string name="atlas_total_alive">Up to %1$d alive at once</string>
<string name="atlas_facet_count">%1$s (%2$d)</string>
<!-- The aggregate: "where is it", as opposed to the raw coordinates below it. -->
<string name="atlas_section_places">Where it spawns</string>
<string name="atlas_place_max_alive">up to %1$d at once</string>
<string name="atlas_section_spawners">Spawn points</string>
<string name="atlas_section_also_here">Also spawns here</string>
<string name="atlas_spawners_truncated">More spawn points than shown.</string>
<string name="atlas_max_count">Up to %1$d</string>
<!-- The delay is already in seconds; the server normalizes XmlSpawner's mixed units. -->
<string name="atlas_respawn">Respawn %1$s</string>
<!-- ── Governors (§6.2) ────────────────────────────────────────────── -->
<string name="governors_empty">No governors — this shard may not run the City Loyalty system.</string>
<string name="governor_current">Governed by %1$s</string>
<string name="governor_none">No governor</string>
<string name="governor_election">Election: %1$s</string>
<string name="governor_history">Term history</string>
<string name="governor_history_loading">Loading…</string>
<string name="governor_history_empty">No past terms recorded.</string>
<!-- ── Falling houses / IDOC (§6.2) ────────────────────────────────── -->
<string name="houses_empty">No houses are in danger right now.</string>
<string name="houses_fallback_name">A house</string>
<string name="houses_idoc_badge">IDOC</string>
<!-- ── Push notifications (§11, M7 Part 2) ─────────────────────────── -->
<string name="push_stream_server_status">Shard status changed</string>
<string name="push_stream_idoc_warning">A house is falling (IDOC)</string>
<string name="push_stream_champ_start">Champion spawn started</string>
<string name="push_stream_governor_election">New governor elected</string>
<string name="push_stream_vendor_sale">Your vendor made a sale</string>
<string name="push_stream_house_idoc">Your house entered IDOC</string>
<string name="push_stream_account_login">Login to your account</string>
</resources>

View File

@@ -3,7 +3,7 @@
<!--
The app is purely an HTTPS API client of a shard's website backend, so the base
posture forbids all cleartext (HTTP) traffic. This makes explicit what minSdk 29 /
targetSdk 35 already default to, satisfies the "usesCleartextTraffic implicitly
targetSdk 36 already default to, satisfies the "usesCleartextTraffic implicitly
enabled" scanner finding, and stops any merged library manifest from re-enabling
cleartext. It also mirrors ServerUrl's release-build rule (HTTPS required) at the
platform socket layer — defense in depth.

View File

@@ -0,0 +1,168 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.w3c.dom.Element
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
/**
* The guard on the app's words (PLAN.md M20). A game's words stay on that game's
* screens, and the screens every deployment shows use core's words. This is the
* app's half of the rule the website docs and runicgateway.com already follow
* (runicgateway.com PLAN.md §16, D63/D64).
*
* The strings are split by owner into three resource files that Android merges as
* one `R.string`: `strings.xml` (core), `strings_uo.xml` and `strings_rust.xml`.
* A string's owner is the screens that use it, not its key: a string any screen a
* Rust deployment can reach is core, even when its key says `shard_`. This test
* reads the three files and fails, naming the key and the word, when
* - the core file holds either game's word;
* - the UO file holds a Rust word;
* - the Rust file holds a UO word.
*
* It checks every `values*` directory, so a translation is held to the same rule.
* Comments are not read, because nobody using the app sees them.
*/
class StringVocabularyTest {
private enum class Owner(val file: String, val label: String) {
CORE("strings.xml", "core"),
UO("strings_uo.xml", "Ultima Online"),
RUST("strings_rust.xml", "Rust"),
}
private class Term(val game: Owner, val word: String, pattern: String) {
val regex = Regex(pattern, RegexOption.IGNORE_CASE)
}
/**
* runicgateway.com's `scripts/checkTerms.mjs` list, word for word. Each word is a game's
* name for something core names differently (shard → game server, guild and clan →
* Team), or the name of one game's component. Game NAMES (Ultima Online, Rust) are not
* on it, and neither is Steam, which is a core sign-in provider. Matching is on word
* boundaries, so "wipe" does not match "wiped" and "shard" does not match `SHARD_X`.
*/
private val terms = listOf(
Term(Owner.UO, "shard", """\bshards?\b"""),
Term(Owner.UO, "ServUO", """\bservuo\b"""),
Term(Owner.UO, "uo-link", """\buo-?link\b"""),
Term(Owner.UO, "Module-uo", """\bmodule-uo\b"""),
Term(Owner.UO, "Bridge.cfg", """\bbridge\.cfg\b"""),
Term(Owner.UO, "cliloc", """\bclilocs?\b"""),
Term(Owner.UO, "guild", """\bguilds?\b"""),
Term(Owner.UO, "IDOC", """\bidocs?\b"""),
Term(Owner.RUST, "rust-link", """\brust-?link\b"""),
Term(Owner.RUST, "Rust-Plugins", """\brust-plugins\b"""),
Term(Owner.RUST, "Module-Rust", """\bmodule-rust\b"""),
Term(Owner.RUST, "Oxide", """\boxide\b"""),
Term(Owner.RUST, "Carbon", """\bcarbon\b"""),
Term(Owner.RUST, "uMod", """\bumod\b"""),
Term(Owner.RUST, "clan", """\bclans?\b"""),
Term(Owner.RUST, "wipe", """\bwipes?\b"""),
)
/** The words [owner]'s file may not use. */
private fun forbiddenIn(owner: Owner): List<Term> = when (owner) {
Owner.CORE -> terms
Owner.UO -> terms.filter { it.game == Owner.RUST }
Owner.RUST -> terms.filter { it.game == Owner.UO }
}
/** The words in [text] that [owner]'s file may not use. */
private fun hits(owner: Owner, text: String): List<Pair<Term, String>> =
forbiddenIn(owner).flatMap { term -> term.regex.findAll(text).map { term to it.value }.toList() }
/**
* The module's `res`, wherever the test runs from: Gradle runs unit tests in the
* module directory, and an IDE may run them from the repository root.
*/
private val res: File = listOf(File("src/main/res"), File("app/src/main/res"))
.firstOrNull { it.isDirectory }
?: error("Can't find src/main/res from ${File(".").absolutePath}")
private val valuesDirs: List<File>
get() = res.listFiles { f -> f.isDirectory && f.name.startsWith("values") }!!.sortedBy { it.name }
/** Every resource in [file] that the app shows: key → its visible text. */
private fun visibleText(file: File): List<Pair<String, String>> {
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
val children = doc.documentElement.childNodes
return (0 until children.length)
.map { children.item(it) }
.filterIsInstance<Element>()
// A plural's items arrive as one text, which is all a word scan needs.
.map { it.getAttribute("name") to it.textContent }
}
@Test fun eachFileUsesOnlyItsOwnGamesWords() {
val failures = mutableListOf<String>()
for (dir in valuesDirs) {
for (owner in Owner.entries) {
val file = File(dir, owner.file)
if (!file.exists()) continue
for ((key, text) in visibleText(file)) {
for ((term, found) in hits(owner, text)) {
failures += "${dir.name}/${owner.file}: $key uses \"$found\", " +
"${term.game.label} vocabulary, in the ${owner.label} file"
}
}
}
}
if (failures.isNotEmpty()) {
fail(
failures.joinToString(
separator = "\n ",
prefix = "${failures.size} game word(s) outside their game's file:\n ",
postfix = "\n\nPLAN.md M20: a string any deployment's screen shows is core, and uses " +
"core's words (site, community, game server, Team). Reword it, or, if only one " +
"game's screens use it, move it to that game's file.",
),
)
}
}
/**
* A fourth strings file would be read by Android and skipped by this guard, so it
* is refused. A new game gets its own file and its own row in [Owner].
*/
@Test fun everyStringsFileHasAnOwner() {
val known = Owner.entries.map { it.file }.toSet()
val strays = valuesDirs.flatMap { dir ->
dir.listFiles { f -> f.name.startsWith("strings") && f.name !in known }!!.map { "${dir.name}/${it.name}" }
}
assertEquals("Strings files with no owner in StringVocabularyTest", emptyList<String>(), strays)
}
/** Without this a wrong path would let the guard pass on nothing. */
@Test fun theDefaultFilesAreAllThere() {
for (owner in Owner.entries) {
val file = File(res, "values/${owner.file}")
assertTrue("${file.path} is missing", file.exists())
assertTrue("${file.path} holds no strings", visibleText(file).isNotEmpty())
}
}
/** The matcher itself, so a broken pattern can't make every file look clean. */
@Test fun theWordListCatchesWhatItShould() {
fun words(owner: Owner, text: String) = hits(owner, text).map { it.first.word }
assertEquals(listOf("shard"), words(Owner.CORE, "Connect to your shard"))
assertEquals(listOf("guild"), words(Owner.CORE, "Guilds"))
assertEquals(listOf("clan"), words(Owner.CORE, "You and your clan"))
assertEquals(listOf("wipe"), words(Owner.UO, "Next wipe %1\$s"))
assertEquals(listOf("IDOC"), words(Owner.RUST, "Your house entered IDOC"))
// Each game's own words are fine in its own file.
assertEquals(emptyList<String>(), words(Owner.UO, "Shard online"))
assertEquals(emptyList<String>(), words(Owner.RUST, "Clan mate"))
// Word boundaries: not a prefix, not an identifier.
assertEquals(emptyList<String>(), words(Owner.CORE, "wiped %1\$s"))
assertEquals(emptyList<String>(), words(Owner.CORE, "Guildmaster"))
assertEquals(emptyList<String>(), words(Owner.CORE, "SHARD_VISIBILITY"))
}
}

View File

@@ -0,0 +1,73 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import com.runicgateway.app.core.result.DemoRefusal
import com.runicgateway.app.util.DEMO_LOCKED_BODY
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The main client's refusal spotter (DEMO_MODE.md §3.1, DM19). Runs a real OkHttp
* client whose last interceptor answers in place of the network.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class DemoLockInterceptorTest {
private val notices = DemoNotices()
private fun clientAnswering(code: Int, body: String): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(DemoLockInterceptor(notices))
.addInterceptor(Interceptor { chain ->
Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(code)
.message("")
.body(body.toResponseBody("application/json".toMediaType()))
.build()
})
.build()
private fun call(client: OkHttpClient): String =
client.newCall(Request.Builder().url("https://demo.example/api/v1/x").build())
.execute()
.use { it.body!!.string() }
@Test fun aRefusalIsReportedAndTheBodyStillReachesTheCaller() = runTest(UnconfinedTestDispatcher()) {
val seen = mutableListOf<DemoRefusal>()
backgroundScope.launch { notices.refusals.collect { seen += it } }
val body = call(clientAnswering(403, DEMO_LOCKED_BODY))
assertEquals(listOf(DemoRefusal("This is turned off on the demo site.")), seen)
// Peeked, not consumed: Retrofit still reads the same body into its error.
assertEquals(DEMO_LOCKED_BODY, body)
}
@Test fun anythingElseIsNotReported() = runTest(UnconfinedTestDispatcher()) {
val seen = mutableListOf<DemoRefusal>()
backgroundScope.launch { notices.refusals.collect { seen += it } }
call(clientAnswering(403, """{"message":"Admins only"}"""))
call(clientAnswering(403, "<html>Forbidden</html>"))
call(clientAnswering(500, DEMO_LOCKED_BODY))
call(clientAnswering(200, DEMO_LOCKED_BODY))
assertTrue(seen.isEmpty())
}
}

View File

@@ -0,0 +1,52 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.push
import android.content.Intent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** When push comes back by itself after a reboot or an update (PLAN.md M8). */
class PushBootReceiverTest {
private fun snapshot(
enabled: Boolean = true,
ntfyUrl: String? = "https://ntfy.tld",
topic: String? = "upAbc123",
) = PushPreferences.Snapshot(
enabled = enabled,
ntfyUrl = ntfyUrl,
topic = topic,
endpoint = topic?.let { "https://ntfy.tld/$it" },
deviceId = topic?.let { 7L },
)
@Test fun restartsWhenPushWasOnAndARegistrationIsSaved() {
assertTrue(PushBootReceiver.shouldRestart(snapshot()))
}
@Test fun staysOffWhenTheMemberTurnedPushOff() {
assertFalse(PushBootReceiver.shouldRestart(snapshot(enabled = false)))
}
@Test fun staysOffAfterASignOutClearedTheRegistration() {
// A sign-out keeps the opt-in but forgets the topic: no signed-in member to push to.
assertFalse(PushBootReceiver.shouldRestart(snapshot(topic = null)))
assertFalse(PushBootReceiver.shouldRestart(snapshot(topic = " ")))
}
@Test fun staysOffWhenTheServerNamesNoRelay() {
assertFalse(PushBootReceiver.shouldRestart(snapshot(ntfyUrl = null)))
}
@Test fun listensForBootAndForItsOwnUpdate() {
assertEquals(
setOf("android.intent.action.BOOT_COMPLETED", "android.intent.action.MY_PACKAGE_REPLACED"),
PushBootReceiver.RESTART_ACTIONS,
)
assertTrue(Intent.ACTION_BOOT_COMPLETED in PushBootReceiver.RESTART_ACTIONS)
}
}

View File

@@ -0,0 +1,101 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.push
import com.runicgateway.app.data.api.dto.NotificationInboxDto
import com.runicgateway.app.data.api.dto.NotificationItemDto
import com.runicgateway.app.data.api.fake.FakeNotificationsApi
import com.runicgateway.app.data.repository.NotificationsRepository
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
/**
* A tickle titled from the inbox row it points at (`docs/modules/rust/PLAN.md`
* D70), and every way that falls back to the per-stream title.
*/
class PushContentTest {
private val raid = NotificationItemDto(
id = 42,
triggerId = "rust.base.destroyed",
title = "Your base is being raided",
body = "A door was destroyed in S16 on Oxide rig.",
)
private val raidTickle = PushTickle(stream = "rust.base.destroyed", ref = "notification:42")
// ── The ref ────────────────────────────────────────────────────────────
@Test fun aRefNamesAnInboxIdOnlyWhenItIsExactlyOne() {
assertEquals(42L, inboxIdFromRef("notification:42"))
assertNull(inboxIdFromRef(null))
assertNull(inboxIdFromRef("notification:"))
assertNull(inboxIdFromRef("notification:0"))
assertNull(inboxIdFromRef("notification:-4"))
assertNull(inboxIdFromRef("notification:4a"))
assertNull(inboxIdFromRef("notification: 4"))
assertNull(inboxIdFromRef("xnotification:4"))
assertNull(inboxIdFromRef("0x24C"))
assertNull(inboxIdFromRef("notification:" + "9".repeat(40)))
}
// ── Which row ──────────────────────────────────────────────────────────
@Test fun theRowMustBeTheNamedOneFromTheNamedTrigger() {
val other = raid.copy(id = 41, title = "An older raid")
assertEquals(raid, itemForTickle(raidTickle, listOf(other, raid)))
// Same id, different trigger: the tickle disagrees with the row.
assertNull(itemForTickle(raidTickle.copy(stream = "rust.server.online"), listOf(raid)))
assertNull(itemForTickle(raidTickle.copy(ref = "notification:7"), listOf(raid)))
assertNull(itemForTickle(raidTickle.copy(ref = null), listOf(raid)))
}
// ── What the notification says ─────────────────────────────────────────
@Test fun theRowsTitleAndBodyAreUsed() {
val text = notificationText(raid, generic = "New notification")
assertEquals("Your base is being raided", text.title)
assertEquals("A door was destroyed in S16 on Oxide rig.", text.body)
}
@Test fun noRowOrABlankTitleFallsBackToTheStreamTitle() {
assertEquals(NotificationText("New notification", null), notificationText(null, "New notification"))
assertEquals(
NotificationText("New notification", null),
notificationText(raid.copy(title = " "), "New notification"),
)
assertEquals(null, notificationText(raid.copy(body = " "), "x").body)
}
// ── The pull ───────────────────────────────────────────────────────────
private val api = FakeNotificationsApi()
private val resolver = PushContentResolver(NotificationsRepository(api))
@Test fun theResolverPullsTheFirstPage() = runTest {
api.pages = mapOf(null to NotificationInboxDto(items = listOf(raid), unread = 1))
assertEquals(raid, resolver.itemFor(raidTickle))
assertEquals(listOf<Long?>(null), api.inboxCalls)
}
@Test fun aTickleWithNoInboxRefIsNeverPulled() = runTest {
// A classic M7 stream, or a push-only rule with no inbox row.
assertNull(resolver.itemFor(PushTickle(stream = "news.post", ref = "0x24C")))
assertTrue(api.inboxCalls.isEmpty())
}
@Test fun aFailedPullIsNullNotAnError() = runTest {
api.error = IOException("offline")
assertNull(resolver.itemFor(raidTickle))
}
@Test fun aRowNotOnThePageIsNull() = runTest {
api.pages = mapOf(null to NotificationInboxDto(items = listOf(raid.copy(id = 99))))
assertNull(resolver.itemFor(raidTickle))
}
}

View File

@@ -0,0 +1,78 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.result
import com.runicgateway.app.util.DEMO_LOCKED_BODY
import com.runicgateway.app.util.demoLockedError
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
/** Reading the public demo's refusal off an error body (DEMO_MODE.md §3.1). */
class DemoLockedTest {
@Test fun theServersBodyIsARefusalWithItsMessage() {
assertEquals(DemoRefusal("This is turned off on the demo site."), demoRefusalOf(DEMO_LOCKED_BODY))
}
@Test fun aModulesOwnSentenceIsKept() {
// A refused console command names the command (§3.4); the app must not
// swap that for the generic line.
val body = """{"message":"The command quit is turned off on the demo site.","code":"DEMO_LOCKED"}"""
assertEquals("The command quit is turned off on the demo site.", demoRefusalOf(body)!!.message)
}
@Test fun aRefusalWithNoMessageStillCounts() {
assertEquals(DemoRefusal(null), demoRefusalOf("""{"code":"DEMO_LOCKED"}"""))
assertEquals(DemoRefusal(null), demoRefusalOf("""{"code":"DEMO_LOCKED","message":" "}"""))
}
@Test fun anythingElseIsNotARefusal() {
assertNull(demoRefusalOf(null))
assertNull(demoRefusalOf(""))
assertNull(demoRefusalOf("{}"))
assertNull(demoRefusalOf("""{"message":"Forbidden"}"""))
assertNull(demoRefusalOf("""{"code":"FORBIDDEN"}"""))
assertNull(demoRefusalOf("""{"code":7}"""))
assertNull(demoRefusalOf("""["DEMO_LOCKED"]"""))
assertNull(demoRefusalOf("<html>403 Forbidden</html>"))
}
@Test fun safeApiCallCarriesTheCode() = runTest {
val result = safeApiCall { throw demoLockedError() }
assertEquals(DEMO_LOCKED_CODE, (result as ApiResult.HttpError).code)
assertTrue(result.isDemoLocked())
}
@Test fun anOrdinary403IsNotARefusal() = runTest {
val result = safeApiCall {
throw HttpException(Response.error<Any>(403, """{"message":"Admins only"}""".toResponseBody(null)))
}
assertEquals(403, (result as ApiResult.HttpError).status)
assertNull(result.code)
assertFalse(result.isDemoLocked())
}
@Test fun onlyA403sBodyIsRead() = runTest {
// The server answers the refusal with 403 and nothing else, so another
// status's body is never buffered, even if it happens to carry the code.
val result = safeApiCall {
throw HttpException(Response.error<Any>(500, DEMO_LOCKED_BODY.toResponseBody(null)))
}
assertNull((result as ApiResult.HttpError).code)
assertFalse(result.isDemoLocked())
}
@Test fun otherResultsAreNeverRefusals() {
assertFalse(ApiResult.Ok(1).isDemoLocked())
assertFalse(ApiResult.NetworkError(java.io.IOException()).isDemoLocked())
assertFalse(ApiResult.HttpError(403).isDemoLocked())
}
}

View File

@@ -0,0 +1,61 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Nothing names who is online by default (org lead, 2026-09-22). These are the
* answers module-rust's public routes give below and inside the operator's
* presence audience, copied from a live walk against the module — and the old
* shape, from a core running a module that predates the flag.
*/
class RustPresenceDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun withheldOnlineCarriesTheCountAndNoNames() {
val dto = json.decodeFromString<RustOnlineDto>(
"""{"players":[],"hidden":true,"count":2,"audience":"staff"}""",
)
assertTrue(dto.hidden)
assertEquals(2, dto.count)
assertEquals("staff", dto.audience)
assertTrue(dto.players.isEmpty())
}
@Test fun visibleOnlineNamesThePlayers() {
val dto = json.decodeFromString<RustOnlineDto>(
"""{"players":[{"steamId":"76561198000000002","name":"Builder Bea","sleeping":false,
"connectedAt":"2026-09-23T05:02:30.000Z"}],"hidden":false,"count":1,"audience":"staff"}""",
)
assertFalse(dto.hidden)
assertEquals("Builder Bea", dto.players.single().name)
}
@Test fun anOlderModuleWithoutTheFlagReadsAsVisible() {
val dto = json.decodeFromString<RustOnlineDto>("""{"players":[]}""")
assertFalse(dto.hidden)
assertNull(dto.audience)
}
@Test fun aWithheldFeedSaysSo() {
val dto = json.decodeFromString<RustEventListDto>(
"""{"events":[{"id":11,"kind":"server.wipe","t":1789500000000,"wipeId":"w-20260920T000000Z",
"steamId":null,"frame":{}}],"presenceHidden":true,"presenceAudience":"signed_in"}""",
)
assertTrue(dto.presenceHidden)
assertEquals("signed_in", dto.presenceAudience)
assertEquals("server.wipe", dto.events.single().kind)
}
}

View File

@@ -38,8 +38,13 @@ class FakeEventsApi : EventsApi {
return value
}
override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto =
reply(calendar)
/** How many times the calendar was read, for the Rust map's run resolver. */
var calendarCalls: Int = 0
override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto {
calendarCalls++
return reply(calendar)
}
override suspend fun getEvent(slug: String, run: String?): PublicEventResponse {
lastSlug = slug

View File

@@ -0,0 +1,105 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.PlayerRustApi
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
/**
* A configurable fake of [PlayerRustApi] (M15).
*
* [linkError] is its own field rather than a shared [error]: the tests that
* matter here are about a **redemption** that fails while the reads around it
* succeed — a refused code must leave the accounts and entitlements on screen,
* and one `error` for the whole interface could not express that.
*/
class FakePlayerRustApi : PlayerRustApi {
/** Thrown by every call — the "the site is down" case. */
var error: Throwable? = null
/** Thrown by one call each, so one half of the screen can fail alone. */
var linksError: Throwable? = null
var linkError: Throwable? = null
var unlinkError: Throwable? = null
var permissionsError: Throwable? = null
var links: RustLinkListDto = RustLinkListDto()
var linkResult: RustLinkResultDto = RustLinkResultDto(linked = true)
var permissions: RustPlayerPermissionsDto = RustPlayerPermissionsDto()
var linksCalls: Int = 0
var permissionsCalls: Int = 0
/** The code the last redemption carried, exactly as the screen sent it. */
var lastCode: String? = null
/** The Steam id the last release named. */
var lastUnlinked: String? = null
override suspend fun links(): RustLinkListDto {
linksCalls++
linksError?.let { throw it }
error?.let { throw it }
return links
}
override suspend fun link(body: RustLinkRequest): RustLinkResultDto {
lastCode = body.code
linkError?.let { throw it }
return linkResult
}
override suspend fun unlink(steamId: String): RustUnlinkResultDto {
lastUnlinked = steamId
unlinkError?.let { throw it }
return RustUnlinkResultDto(unlinked = true)
}
override suspend fun permissions(): RustPlayerPermissionsDto {
permissionsCalls++
permissionsError?.let { throw it }
error?.let { throw it }
return permissions
}
/** The coin balances (§11.2.2), and a throw of their own: a 404 is an older module. */
var economics: RustEconomicsDto = RustEconomicsDto()
var economicsError: Throwable? = null
var economicsCalls: Int = 0
override suspend fun economics(): RustEconomicsDto {
economicsCalls++
economicsError?.let { throw it }
error?.let { throw it }
return economics
}
/** The servers the backpack section offers (§11.3.2). */
var servers: RustServerListDto = RustServerListDto()
override suspend fun servers(): RustServerListDto {
error?.let { throw it }
return servers
}
/** One backpack per `serverId|steamId`; a throw of its own, so a 409 can stand alone. */
var backpacks: Map<String, RustBackpackDto> = emptyMap()
var backpackError: Throwable? = null
val backpackReads = mutableListOf<String>()
override suspend fun backpack(serverId: String, steamId: String): RustBackpackDto {
backpackReads += "$serverId|$steamId"
backpackError?.let { throw it }
error?.let { throw it }
return backpacks["$serverId|$steamId"] ?: RustBackpackDto(serverId = serverId, steamId = steamId)
}
}

View File

@@ -0,0 +1,136 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.RustApi
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
/**
* A configurable fake of [RustApi] (M14). Set the `var` a call should answer
* with; set [error] to make every call throw.
*
* **The `last…` fields are what the tests that matter assert on.** The module's
* own API has one rule nothing about a successful response can show: an absent
* query parameter must be **absent** rather than empty, because `?wipe=` asks for
* a wipe whose id is the empty string and answers nothing, with no error to
* notice. Recording what was asked is the only way to see that from here.
*/
class FakeRustApi : RustApi {
var error: Throwable? = null
var servers: RustServerListDto = RustServerListDto()
var server: RustServerResponse = RustServerResponse()
var events: RustEventListDto = RustEventListDto()
var leaderboard: RustLeaderboardDto = RustLeaderboardDto()
var wipes: RustWipeListDto = RustWipeListDto()
var online: RustOnlineDto = RustOnlineDto()
var map: RustMapDto = RustMapDto()
var mapLive: RustMapLiveDto = RustMapLiveDto()
/**
* Answers that replace [mapLive] one call at a time, for a test that needs an
* answer to arrive AFTER something else happened. Consumed first to last;
* [mapLive] answers once it is empty.
*/
val mapLiveQueue: ArrayDeque<suspend () -> RustMapLiveDto> = ArrayDeque()
var serversCalls: Int = 0
var eventCalls: Int = 0
var leaderboardCalls: Int = 0
var onlineCalls: Int = 0
var wipeCalls: Int = 0
var mapCalls: Int = 0
var mapLiveCalls: Int = 0
/** The `kind` the last feed read carried — null means it sent none at all. */
var lastKind: String? = null
/** The `wipe` the last feed read carried; null means all wipes. */
var lastFeedWipe: String? = null
var lastLeaderboardWipe: String? = null
var lastSort: String? = null
var lastId: String? = null
private fun <T> reply(value: T): T {
error?.let { throw it }
return value
}
override suspend fun getServers(): RustServerListDto {
serversCalls++
return reply(servers)
}
override suspend fun getServer(id: String): RustServerResponse {
lastId = id
return reply(server)
}
override suspend fun getEvents(id: String, kind: String?, wipe: String?, limit: Int?): RustEventListDto {
eventCalls++
lastId = id
lastKind = kind
lastFeedWipe = wipe
return reply(events)
}
override suspend fun getLeaderboard(
id: String,
wipe: String?,
sort: String?,
limit: Int?,
): RustLeaderboardDto {
leaderboardCalls++
lastId = id
lastLeaderboardWipe = wipe
lastSort = sort
return reply(leaderboard)
}
override suspend fun getWipes(id: String): RustWipeListDto {
wipeCalls++
lastId = id
return reply(wipes)
}
override suspend fun getOnline(id: String): RustOnlineDto {
onlineCalls++
lastId = id
return reply(online)
}
override suspend fun getMap(id: String): RustMapDto {
mapCalls++
lastId = id
return reply(map)
}
override suspend fun getMapLive(id: String): RustMapLiveDto {
mapLiveCalls++
lastId = id
error?.let { throw it }
return mapLiveQueue.removeFirstOrNull()?.invoke() ?: mapLive
}
/** Every hide or unhide asked for, in order: `(lineId, hidden)`. */
val chatHides: MutableList<Pair<Long, Boolean>> = mutableListOf()
override suspend fun hideChat(lineId: Long) {
error?.let { throw it }
chatHides += lineId to true
}
override suspend fun unhideChat(lineId: Long) {
error?.let { throw it }
chatHides += lineId to false
}
}

View File

@@ -0,0 +1,94 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.SafetyApi
import com.runicgateway.app.data.api.dto.BlockListDto
import com.runicgateway.app.data.api.dto.BlockResultDto
import com.runicgateway.app.data.api.dto.HiddenItemDto
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 com.runicgateway.app.data.api.dto.UserBlockDto
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.HttpException
import retrofit2.Response
/**
* A configurable fake of [SafetyApi] (USER_SAFETY.md §4.3). It keeps blocks and
* hides the way the server does, so a test can block, read the list back and
* unblock; set [error] to make the next call throw it instead.
*/
class FakeSafetyApi : SafetyApi {
/** Thrown by the next call, then cleared. */
var error: Throwable? = null
val reports: MutableList<ReportRequest> = mutableListOf()
val blockRequests: MutableList<SafetyTargetRequest> = mutableListOf()
val hides: MutableList<SafetyTargetRequest> = mutableListOf()
val blockList: MutableList<UserBlockDto> = mutableListOf()
val hiddenList: MutableList<HiddenItemDto> = mutableListOf()
var hiddenReads: Int = 0
private fun fail() {
error?.let {
error = null
throw it
}
}
override suspend fun report(body: ReportRequest): ReportResultDto {
fail()
reports += body
return ReportResultDto(ok = true, reportId = reports.size.toLong())
}
override suspend fun blocks(): BlockListDto {
fail()
return BlockListDto(blockList.toList())
}
override suspend fun block(body: SafetyTargetRequest): BlockResultDto {
fail()
blockRequests += body
val block = UserBlockDto(id = (blockList.size + 1).toLong(), kind = "game", name = "Brannock")
blockList += block
return BlockResultDto(block)
}
override suspend fun unblock(id: Long): SafetyOkDto {
fail()
if (!blockList.removeAll { it.id == id }) throw httpError(404)
return SafetyOkDto(ok = true)
}
override suspend fun hidden(): HiddenListDto {
hiddenReads++
fail()
return HiddenListDto(hiddenList.toList())
}
override suspend fun hide(body: SafetyTargetRequest): SafetyOkDto {
fail()
hides += body
hiddenList += HiddenItemDto(body.targetType, body.targetId)
return SafetyOkDto(ok = true)
}
override suspend fun clearHidden(): SafetyOkDto {
fail()
val n = hiddenList.size
hiddenList.clear()
return SafetyOkDto(ok = true, cleared = n)
}
companion object {
/** A server refusal, with an optional JSON body. */
fun httpError(status: Int, body: String = "{}"): HttpException =
HttpException(Response.error<Any>(status, body.toResponseBody()))
}
}

View File

@@ -0,0 +1,122 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
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.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
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
import com.runicgateway.app.data.api.dto.RevokedCountDto
import com.runicgateway.app.data.api.dto.RevokedFlagDto
import com.runicgateway.app.data.api.dto.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.api.dto.UsernameResponse
import com.runicgateway.app.data.repository.AccountRepository.DeleteOutcome
import com.runicgateway.app.data.repository.AccountRepository.DeleteRefusal
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import retrofit2.Response
import java.io.IOException
/**
* [AccountRepository.deleteAccount] (ACCOUNT_DELETION.md §4.1, §4.3) over a fake
* [MeApi]: each refusal the server sends becomes its own [DeleteRefusal], read off
* the body's `code`, so the screen can say which field is wrong.
*/
class AccountDeletionRepositoryTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
/** Only [deleteAccount] is wired; it answers [answer] and records the body. */
private class FakeMeApi(val answer: () -> Response<DeleteAccountResultDto>) : MeApi {
var lastBody: DeleteAccountRequest? = null
override suspend fun deleteAccount(body: DeleteAccountRequest): Response<DeleteAccountResultDto> {
lastBody = body
return answer()
}
override suspend fun getAccount(): PlayerAccountDto = error("unused")
override suspend fun changeUsername(body: ChangeUsernameRequest): UsernameResponse = error("unused")
override suspend fun changePassword(body: ChangePasswordRequest): Unit = error("unused")
override suspend fun totpSetup(): TotpSetupDto = error("unused")
override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = error("unused")
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = error("unused")
override suspend fun identities(): List<LinkedIdentityDto> = error("unused")
override suspend fun unlinkIdentity(provider: String, id: Int?): Unit = error("unused")
override suspend fun trustedDevices(): List<TrustedDeviceDto> = error("unused")
override suspend fun trustThisDevice(body: TrustDeviceRequest): Response<TrustDeviceResultDto> = error("unused")
override suspend fun revokeTrustedDevice(id: Long): RevokedFlagDto = error("unused")
override suspend fun revokeAllTrustedDevices(): RevokedCountDto = error("unused")
override suspend fun recoveryCodesStatus(): RecoveryStatusDto = error("unused")
override suspend fun generateRecoveryCodes(body: RecoveryGenerateRequest): RecoveryCodesDto = error("unused")
}
private fun refusal(status: Int, body: String) = FakeMeApi {
Response.error(status, body.toResponseBody("application/json".toMediaTypeOrNull()))
}
private suspend fun outcomeOf(api: MeApi) = AccountRepository(api, json).deleteAccount("Ravenna", "pw")
@Test fun aDeletionIsDeletedAndSendsWhatItWasGiven() = runTest {
val api = FakeMeApi { Response.success(DeleteAccountResultDto(deleted = true)) }
val outcome = AccountRepository(api, json).deleteAccount("Ravenna", "pw", code = "123456")
assertEquals(DeleteOutcome.Deleted, outcome)
assertEquals(DeleteAccountRequest("Ravenna", "pw", "123456", null), api.lastBody)
}
@Test fun aRecoveryCodeGoesInItsOwnField() = runTest {
val api = FakeMeApi { Response.success(DeleteAccountResultDto(deleted = true)) }
AccountRepository(api, json).deleteAccount("Ravenna", "pw", recoveryCode = "abcd-efgh")
assertNull(api.lastBody?.code)
assertEquals("abcd-efgh", api.lastBody?.recoveryCode)
}
@Test fun eachRefusalCodeIsItsOwnReason() = runTest {
val cases = mapOf(
refusal(400, """{"code":"username_mismatch","message":"x"}""") to DeleteRefusal.USERNAME_MISMATCH,
refusal(400, """{"code":"wrong_password","message":"x"}""") to DeleteRefusal.WRONG_PASSWORD,
refusal(400, """{"code":"code_required","message":"x"}""") to DeleteRefusal.CODE_REQUIRED,
refusal(400, """{"code":"wrong_code","message":"x"}""") to DeleteRefusal.WRONG_CODE,
refusal(409, """{"code":"password_required","message":"x"}""") to DeleteRefusal.PASSWORD_REQUIRED,
refusal(409, """{"code":"last_admin","message":"x"}""") to DeleteRefusal.LAST_ADMIN,
refusal(403, """{"code":"DEMO_LOCKED","message":"This is turned off on the demo site."}""") to
DeleteRefusal.DEMO_LOCKED,
)
cases.forEach { (api, reason) -> assertEquals(DeleteOutcome.Refused(reason), outcomeOf(api)) }
}
@Test fun tooManyAttemptsIsRateLimitedWhateverTheBody() = runTest {
assertEquals(DeleteOutcome.Refused(DeleteRefusal.RATE_LIMITED), outcomeOf(refusal(429, "Too many requests")))
}
@Test fun anUnknownOrMissingCodeIsOther() = runTest {
assertEquals(DeleteOutcome.Refused(DeleteRefusal.OTHER), outcomeOf(refusal(400, """{"code":"new_rule"}""")))
assertEquals(DeleteOutcome.Refused(DeleteRefusal.OTHER), outcomeOf(refusal(500, """{"message":"boom"}""")))
assertEquals(DeleteOutcome.Refused(DeleteRefusal.OTHER), outcomeOf(refusal(502, "<html>Bad gateway</html>")))
}
@Test fun noAnswerIsANetworkError() = runTest {
assertEquals(DeleteOutcome.NetworkError, outcomeOf(FakeMeApi { throw IOException("offline") }))
}
}

View File

@@ -7,6 +7,8 @@ import com.runicgateway.app.core.result.ApiResult
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.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
@@ -70,7 +72,9 @@ class AccountTrustedDevicesTest {
override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
override suspend fun unlinkIdentity(provider: String) = Unit
override suspend fun unlinkIdentity(provider: String, id: Int?) = Unit
override suspend fun deleteAccount(body: DeleteAccountRequest): Response<DeleteAccountResultDto> =
Response.success(DeleteAccountResultDto(deleted = true))
}
private fun repo(api: MeApi) = AccountRepository(api, json)

View File

@@ -0,0 +1,91 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.data.api.dto.InstalledModuleDto
import com.runicgateway.app.data.api.dto.ModulesDto
import com.runicgateway.app.data.api.dto.RustLinkDto
import com.runicgateway.app.data.api.dto.RustLinkListDto
import com.runicgateway.app.data.api.dto.ShardLinkDto
import com.runicgateway.app.data.api.fake.FakePlayerRustApi
import com.runicgateway.app.data.api.fake.FakePlayerShardApi
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.util.httpError
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* "Has this user linked a game account" is asked of the site's own module
* (`docs/modules/rust/PLAN.md` D69).
*
* Before phase 11 it was always `module-uo`'s `/player/shard/accounts`. A Rust
* site has no such route, so the question always answered no, and the raid
* alert's row was locked in every channel.
*/
class LinkedAccountRepositoryTest {
private val publicApi = FakePublicApi()
private val capabilities = SiteCapabilitiesRepository(publicApi)
private val rustApi = FakePlayerRustApi()
private val shardApi = FakePlayerShardApi()
private val repository = LinkedAccountRepository(
capabilities,
PlayerRustRepository(rustApi),
PlayerShardRepository(shardApi),
)
private suspend fun running(module: String, vararg caps: String) {
publicApi.modules = ModulesDto(modules = listOf(InstalledModuleDto(id = module, capabilities = caps.toList())))
capabilities.refresh()
}
@Test fun aRustSiteAsksTheRustModule() = runTest {
running("rust", "rust", "servers", "identity")
rustApi.links = RustLinkListDto(links = listOf(RustLinkDto(steamId = "76561198000000001")))
// The UO read would say no, and it must not be the one that is asked.
shardApi.error = httpError(404)
assertTrue(repository.hasLinkedAccount())
assertEquals(1, rustApi.linksCalls)
}
@Test fun aRustSiteWithNoLinkSaysNo() = runTest {
running("rust", "rust")
assertFalse(repository.hasLinkedAccount())
}
@Test fun aUoSiteStillAsksTheShardModuleAndNeverTheRustOne() = runTest {
running("uo", "shard", "atlas")
shardApi.accounts = listOf(ShardLinkDto(account = "walker"))
assertTrue(repository.hasLinkedAccount())
assertEquals(0, rustApi.linksCalls)
}
@Test fun aSiteWithNeitherModuleHasNothingToAsk() = runTest {
running("other", "something")
rustApi.links = RustLinkListDto(links = listOf(RustLinkDto(steamId = "1")))
assertFalse(repository.hasLinkedAccount())
assertEquals(0, rustApi.linksCalls)
}
@Test fun aHostThatHasNeverAnsweredAsksBothAndAFailureIsNoLink() = runTest {
// One site runs one module, so one of the two 404s; that is not an error.
shardApi.error = httpError(404)
rustApi.links = RustLinkListDto(links = listOf(RustLinkDto(steamId = "1")))
assertTrue(repository.hasLinkedAccount())
}
@Test fun theSourcesAreReadOffTheCapabilities() {
assertEquals(LinkSource.entries, linkSourcesFor(null))
assertEquals(listOf(LinkSource.RUST), linkSourcesFor(SiteCapabilities(emptySet(), setOf("rust"))))
assertEquals(listOf(LinkSource.SHARD), linkSourcesFor(SiteCapabilities(emptySet(), setOf("shard"))))
assertEquals(emptyList<LinkSource>(), linkSourcesFor(SiteCapabilities(setOf("events"), emptySet())))
}
}

View File

@@ -0,0 +1,118 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.data.api.dto.BlockListDto
import com.runicgateway.app.data.api.dto.ReportRequest
import com.runicgateway.app.data.api.fake.FakeSafetyApi
import com.runicgateway.app.data.api.fake.FakeSafetyApi.Companion.httpError
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
import java.io.IOException
/** Report, block and hide over core's routes (USER_SAFETY.md §4.1, §4.3). */
class SafetyRepositoryTest {
private val api = FakeSafetyApi()
private val repository = SafetyRepository(api)
/** The app's own wire settings (NetworkModule). */
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
@Test
fun `a report names the item and its reason, and a blank detail is left out`() = runTest {
assertEquals(SafetyOutcome.Done, repository.report("rust.chat", 42, "spam", " "))
val sent = api.reports.single()
assertEquals(ReportRequest("rust.chat", 42, "spam", null), sent)
// The server's validator accepts an absent detail and refuses a null one.
assertEquals("""{"targetType":"rust.chat","targetId":42,"reason":"spam"}""", json.encodeToString(sent))
}
@Test
fun `a report's detail is trimmed`() = runTest {
repository.report("rust.chat", 42, "abuse", " said it twice \n")
assertEquals("said it twice", api.reports.single().detail)
}
@Test
fun `a block names the item, never a person`() = runTest {
assertEquals(SafetyOutcome.Done, repository.blockAuthorOf("rust.chat", 7))
assertEquals("rust.chat", api.blockRequests.single().targetType)
assertEquals(7L, api.blockRequests.single().targetId)
}
@Test
fun `a 403 on a block is staff, and anywhere else it is forbidden`() = runTest {
api.error = httpError(403, """{"code":"staff","message":"You can’t block a staff member."}""")
assertEquals(SafetyOutcome.Refused(SafetyRefusal.STAFF), repository.blockAuthorOf("rust.chat", 7))
api.error = httpError(403)
assertEquals(SafetyOutcome.Refused(SafetyRefusal.FORBIDDEN), repository.hide("rust.chat", 7))
}
@Test
fun `the demo's refusal is told apart from a staff refusal`() = runTest {
api.error = httpError(403, """{"code":"DEMO_LOCKED","message":"This is turned off on the demo site."}""")
assertEquals(SafetyOutcome.Refused(SafetyRefusal.DEMO_LOCKED), repository.blockAuthorOf("rust.chat", 7))
}
@Test
fun `each status the server sends becomes its own refusal`() = runTest {
val cases = listOf(
409 to SafetyRefusal.ALREADY_DONE,
404 to SafetyRefusal.GONE,
429 to SafetyRefusal.RATE_LIMITED,
503 to SafetyRefusal.UNAVAILABLE,
500 to SafetyRefusal.OTHER,
)
cases.forEach { (status, refusal) ->
api.error = httpError(status)
assertEquals("status $status", SafetyOutcome.Refused(refusal), repository.report("rust.chat", 1, "spam", null))
}
}
@Test
fun `a 400 on a block is not blockable, and on a report it is not`() = runTest {
api.error = httpError(400)
assertEquals(SafetyOutcome.Refused(SafetyRefusal.NOT_BLOCKABLE), repository.blockAuthorOf("rust.chat", 1))
api.error = httpError(400)
assertEquals(SafetyOutcome.Refused(SafetyRefusal.OTHER), repository.report("rust.chat", 1, "spam", null))
}
@Test
fun `no answer at all is a network error`() = runTest {
api.error = IOException("offline")
assertEquals(SafetyOutcome.NetworkError, repository.hide("rust.chat", 1))
}
@Test
fun `a block list decodes both kinds, and a block that stopped applying says why`() {
val body = """
{"blocks":[
{"id":1,"kind":"account","name":"rowan","identity":null,"applies":true,"reason":null,
"createdAt":"2026-10-30T12:00:00.000Z"},
{"id":2,"kind":"game","name":"Brannock","applies":true,"reason":null,
"identity":{"module":"rust","kind":"steam","label":"Steam","id":"12345678900000001"}},
{"id":3,"kind":"account","name":"modsy","applies":false,"reason":"staff"}
]}
""".trimIndent()
val blocks = json.decodeFromString<BlockListDto>(body).blocks
assertNull(blocks[0].identity)
assertEquals("Steam", blocks[1].identity?.label)
assertEquals("12345678900000001", blocks[1].identity?.id)
assertFalse(blocks[2].applies)
assertEquals("staff", blocks[2].reason)
}
}

View File

@@ -4,8 +4,10 @@
package com.runicgateway.app.ui
import androidx.lifecycle.SavedStateHandle
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.data.api.dto.WikiSummaryDto
@@ -13,6 +15,7 @@ import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.data.repository.WikiRepository
import com.runicgateway.app.ui.demo.DemoInfo
import com.runicgateway.app.ui.home.HomeViewModel
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.news.NewsViewModel
@@ -22,7 +25,10 @@ import com.runicgateway.app.ui.wiki.WikiPageViewModel
import com.runicgateway.app.ui.wiki.WikiViewModel
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -128,13 +134,33 @@ class ContentViewModelTest {
// ── Home status ─────────────────────────────────────────────────────────
@Test fun homeLoadsStatus() {
api.status = StatusDto(mode = "maintenance")
val vm = HomeViewModel(settings)
val vm = homeViewModel()
assertTrue((vm.state.value as UiState.Success).data.isMaintenance)
}
@Test fun homeNetworkErrorIsUiError() {
api.error = java.io.IOException("offline")
val state = HomeViewModel(settings).state.value
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
val vm = homeViewModel()
assertEquals(ErrorKind.NETWORK, (vm.state.value as UiState.Error).kind)
assertNull(vm.demo.value)
}
// ── Home: the public demo's strip (DEMO_MODE.md §6) ──
@Test fun homeDrawsNoStripOnARealShard() {
assertNull(homeViewModel().demo.value)
}
@Test fun homeReadsTheDemoBlock() {
api.settings = SettingsDto(
demo = Json.parseToJsonElement(
"""{"resetsAt":"2026-10-26T14:00:00.000Z","logins":[{"username":"demo-admin","role":"admin"}]}""",
),
)
val vm = homeViewModel(base = "https://demo.runicgateway.com/")
assertEquals(DemoInfo("14:00 UTC", listOf("demo-admin")), vm.demo.value)
assertEquals("demo.runicgateway.com/account/login", vm.signInPage)
}
private fun homeViewModel(base: String? = null) =
HomeViewModel(settings, BaseUrlHolder().apply { set(base?.toHttpUrl()) })
}

View File

@@ -0,0 +1,81 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui
import com.runicgateway.app.core.result.ApiResult
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rule a poll lives on (M14, `docs/modules/rust/PLAN.md` D14).
*
* **A refresh is invisible when it succeeds and keeps the rows when it fails.**
* The site's whole premise is that it renders while the game is off, and an app
* that blanked itself the first time a request failed would break that one tier
* along from where it was built.
*/
class PollingTest {
@Test
fun `a successful refresh replaces the data and clears a previous failure`() {
val after = refreshInto(UiState.Success(listOf("old")), ApiResult.Ok(listOf("new")))
assertEquals(UiState.Success(listOf("new")), after.state)
assertFalse(after.refreshFailed)
}
@Test
fun `a failed refresh keeps the rows and reports the failure`() {
// The case this whole file exists for. Nothing is blanked, nothing is
// retried on the reader's behalf, and the failure is a fact the screen can
// render beside rows that are still the best answer anybody has.
val before = UiState.Success(listOf("old"))
val after = refreshInto(before, ApiResult.NetworkError(RuntimeException("offline")))
assertEquals(before, after.state)
assertTrue(after.refreshFailed)
}
@Test
fun `a failure with nothing on screen is an ordinary error`() {
// There is nothing to protect, so this is a first load that failed — and a
// screen that reported "could not refresh" over a blank page would be
// hiding the retry the reader needs.
val after = refreshInto(UiState.Loading, ApiResult.HttpError(500, "boom"))
assertTrue(after.state is UiState.Error)
assertFalse(after.refreshFailed)
}
@Test
fun `a 404 on a first load keeps its own kind`() {
val after = refreshInto(UiState.Loading, ApiResult.HttpError(404, "gone"))
assertEquals(UiState.Error(ErrorKind.NOT_FOUND, 404), after.state)
}
@Test
fun `a successful refresh over an error recovers`() {
val after = refreshInto(
UiState.Error(ErrorKind.NETWORK),
ApiResult.Ok(listOf("back")),
)
assertEquals(UiState.Success(listOf("back")), after.state)
assertFalse(after.refreshFailed)
}
@Test
fun `an empty answer is a success, not a failure to keep the old rows through`() {
// An empty list is an ANSWER — the feed filtered to a wipe nothing happened
// in, a fleet with nobody on it. Treating it as "nothing came back" and
// keeping stale rows would make an emptied board impossible to observe.
val after = refreshInto(UiState.Success(listOf("old")), ApiResult.Ok(emptyList<String>()))
assertEquals(UiState.Success(emptyList<String>()), after.state)
assertFalse(after.refreshFailed)
}
}

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