14 Commits
v0.5.0 ... edge

Author SHA1 Message Date
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
ac2d75c3f9 Merge pull request 'fix(notifications): reload the inbox and its settings when the account changes' (#45) from fix/inbox-session-scope into edge
All checks were successful
PR Checks / android-build (pull_request) Successful in 7m41s
Reviewed-on: #45
2026-09-08 22:45:16 +00:00
aa055469a8 fix(notifications): reload the inbox and its settings when the account changes
All checks were successful
PR Checks / android-build (pull_request) Successful in 8m12s
The defect Phase 14b found in `MyEventsViewModel` and flagged next door: the
notifications surface has the identical shape, and it leaks the same way.

A drawer route's view model outlives a sign-out. `navigateTopLevel` uses
`popUpTo(HOME) { saveState = true }` with `restoreState = true`, so the
`NavBackStackEntry` keeps its `ViewModelStore` and a view model that loaded only
in `init` never runs again. Signing out and back in as somebody else showed the
second account the FIRST account's inbox — titles and body text written for
another person — with no request made at all, while the badge above the list
showed the new account's real unread count, because the shell refreshes that on
every session change.

`InboxCache` was never the hole: it is keyed by (base URL, user id) and a snapshot
has never crossed an account. The hole was the in-memory state, which nothing
invalidated.

Both view models now key on the signed-in account id, so a resume revalidation
that returns the same user does not refetch. The inbox resets its state *before*
loading rather than after, because `load()` paints the cache only when there is no
`Success` on screen — otherwise the previous account's rows stay up for the whole
round trip.

The settings screen behind the inbox's gear is fixed with it, and there the stale
render is worse than disclosure: those controls are written from, so a screen
still showing the previous account's preferences would send this account's PUT
built out of them.

Walked on the emulator against a local website, before and after: two accounts
with deliberately different inboxes, signed out and in within one process. Before,
the second account saw the first's rows and the server logged no inbox fetch;
after, it logs the fetch and shows its own.

572 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 17:13:53 -05:00
f3d90b189d Merge pull request 'feat(events): the app's events screens, and the module rows that were never gated (Phase 14b)' (#44) from feature/events-p14b-app into edge
Reviewed-on: #44
2026-09-08 21:59:23 +00:00
e10e1f1617 feat(events): the app's events screens, and the module rows that were never gated (Phase 14b)
All checks were successful
PR Checks / android-build (pull_request) Successful in 12m7s
Events Phase 14b, the app half — recorded as M13 in docs/android/PLAN.md.

Four screens on the four routes Phase 14a shipped: the public calendar, an event
page carrying `?run=`, an arc, and participation history. One drawer row for the
history, at SIGNED_IN rather than PLAYER: the route is `requireAuth` alone and
self-scoped, and the website needed two mounts for it only because `RequirePlayer`
guards `/account` there.

The prerequisite fix is the larger half. The app read `/public/modules` nowhere
and mapped every `/public/shard/features` failure to "unknown", which `canSee`
treats as visible — so on a site with no `uo` module every shard row rendered and
every one of them 404'd. Absence of an answer is not an answer of absence: a
successful module list that omits `shard` hides the rows, a failed read keeps the
last answer the host gave, and a host that has never answered leaves the gate
open. Capability and feature compose as two gates and answer different questions:
whether the module is installed (per host) and whether this shard publishes the
surface to this viewer (per viewer).

Also corrects the website path → route table, wrong since the module-system
cutover on 2026-08-12: core's NAV is eight rows, not sixteen, and the nine shard
rows moved to `/uo/*`. A nav override on any shard row was ignored, an added link
to one handed off to a browser, and the sort-key line was wrong. Two existing
tests had been passing vacuously since that day.

An inbox link to an event now opens the app rather than a Custom Tab, through
`resolveWebPath` rather than a second mechanism — so its "a query hands off" rule
gains exactly one exception, `run` on an event page.

The emulator walk found three defects that 563 green tests did not:

- the three player game-data rows read `/player/shard/*` and were not gated, so
  they rendered and 404'd; the test meant to catch that asked whether every row
  *with a feature* declared the capability, and those three have none. It now
  asks by route.
- `score` is DECIMAL(18,4) and was declared an integer, so one `318.5` made
  kotlinx refuse the entire body and a 200 rendered as a server error — latent on
  the public results table for every visitor.
- a drawer route's view model outlives a sign-out, so signing in as a second
  account showed it the first account's participation history with no request
  made at all.

570 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 13:08:43 -05:00
80441c3367 Merge pull request 'feat(notifications): the in-app inbox — cutover 6 of 7 (edgemain)' (#43) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in -46s
SonarQube / analysis (push) Successful in 4m58s
Reviewed-on: #43
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-01 14:00:58 +00:00
d3bf4853de Merge pull request 'feat(notifications): the in-app inbox, and per-channel preferences (engagement Phase 8)' (#42) from feature/engagement-inapp-android into edge
All checks were successful
PR Checks / android-build (pull_request) Successful in 13m50s
Reviewed-on: #42
2026-08-31 14:37:35 +00:00
21b6ddc29b fix(notifications): resolve an item's relative url, and document the CI trigger
Some checks failed
PR Checks / android-build (pull_request) Failing after 42m5s
Two things the live rig found, and the README half of the trigger change.

Phase 7 specifies an inbox item's `url` is RELATIVE-ONLY and validates it as
such — right for a browser already on the site, a dead link on a phone. The
first cut here only opened `http(s)`-prefixed strings, so on the rig every link
in the inbox did nothing at all. `InboxViewModel.linkFor` now resolves against
the configured base with OkHttp's `HttpUrl.resolve`, which absolutises the path
and returns null for anything that would not end up http(s) — so a `javascript:`
or `intent:` url in a notification body opens nothing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 09:27:39 -05:00
d393cf022e feat(notifications): the in-app inbox, and per-channel preferences (engagement Phase 8)
The app's half of the in-app channel. Phase 7 shipped four inbox routes with no
consumer on either platform; this is the Android one, plus the per-channel
preferences Phase 3 added and the shipped screen could not express.

The drawer's "Notifications" is the INBOX now, with the preferences one tap away
behind its gear — the arrangement Phase 7 shipped on the web, and what a person
means when they tap the word. The settings screen moved off
/notifications/subscriptions onto /notifications/channels: it renders a control
per channel that applies to each id (from the item's own `channels`, never a
hardcoded three) and per mode that channel accepts, which is how email's
`digest` reaches the app. The old endpoint is the push projection of the new
table server-side, so the shipped APK went on working the whole time.

A tapped tickle whose `ref` starts with `notification:` lands on the inbox
whatever its stream is — an engagement rule's stream id is a TRIGGER id in the
one namespace, and `forStream`'s fixed map would have sent most of them Home.
Every other tickle keeps the route it has always had. The ref is not decoded
beyond that prefix and never rendered: it is a hint that a row exists, and the
contract stays wake-and-pull.

PLAN.md §7's "no Room cache in v1" stands; the offline snapshot is its one named
exception, settled with the org lead. The inbox is a short, read-only,
newest-first list with a server-side cursor, so what "works offline" needs is the
newest page and the badge, not a database — one JSON blob in the DataStore the
push code already uses. Every snapshot is scoped to (base URL, user id) and only
handed back to that pair: that, not the clear-on-logout, is what stops a cache
surviving into another account on the paths that never reach a logout at all.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 08:59:55 -05:00
21e235a07f ci(pr-checks): run the gate on pull requests into edge too
ENGAGEMENT.md §7.1 Q8. `pr-checks.yml` triggered only on PRs into `main`, so a
workstream that lands its phases on `edge` before one cutover PR got no CI at
all until the cutover — all nine M12 phase PRs merged without a single run, and
engagement Phase 8 was about to do the same. A phase should fail on its own PR.

Sonar is untouched: `sonarqube.yml` is a push-on-`main` analysis, not a PR gate,
so no phase PR was ever expected to run it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 02:40:39 -05:00
78 changed files with 8711 additions and 481 deletions

View File

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

View File

@@ -48,10 +48,15 @@ any shard's website — there is no compiled-in API host.
## CI
`.gitea/workflows/pr-checks.yml` gates PRs into `main` with `./gradlew lint test assembleDebug` on the
org's self-hosted runner (JDK 17 + Android SDK). Debug builds are auto-signed, so the gate needs no
secrets. **This pipeline is verified green end-to-end on the runner** (M0). A signed **release** APK
attached to a Gitea release comes at M6.
`.gitea/workflows/pr-checks.yml` gates PRs into `main` **and `edge`** with
`./gradlew lint test assembleDebug` on the org's self-hosted runner (JDK 17 + Android SDK). Debug
builds are auto-signed, so the gate needs no secrets. **This pipeline is verified green end-to-end on
the runner** (M0). A signed **release** APK attached to a Gitea release comes at M6.
**`edge` is in the trigger deliberately**: a workstream that lands its phases on a working branch
before one cutover PR into `main` otherwise gets no CI at all until the cutover — which is what
happened to all nine M12 phase PRs (`docs/website/ENGAGEMENT.md` §7.1 Q8). `sonarqube.yml` is
unaffected: it is a push-on-`main` analysis, not a PR gate.
The workflow carries a few runner-specific accommodations (each explained in comments in the file),
because this self-hosted runner differs from a stock GitHub runner:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,94 @@
/*
* 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.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.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
}

View File

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

View File

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

View File

@@ -20,6 +20,47 @@ data class VersionDto(
val service: String = "",
val api: String = "",
val server: String = "",
/**
* What CORE serves beyond the baseline every backend has (events Phase 14a;
* `MODULE_API.md` §2.9). Opaque strings, the same word a module uses on
* `GET /public/modules` so a client feature-detects one way, and a **separate
* list** because core is not a module.
*
* **The value is in what is absent**, which is why the default is empty
* rather than something meaningful: a backend released before a capability
* existed omits the key entirely, and that is how the app tells an older site
* from one that simply has nothing to show. An unknown string is absent, and
* no route may be inferred from one.
*/
val capabilities: List<String> = emptyList(),
)
/**
* One installed, **started** module on `GET /public/modules`.
*
* A module that is disabled or failed to load is absent rather than listed with a
* state — its routes and its nav are absent too, so a client renders a site
* without that capability rather than one advertising a capability that 503s.
*/
@Serializable
data class InstalledModuleDto(
val id: String = "",
val name: String = "",
val version: String = "",
val capabilities: List<String> = emptyList(),
)
/**
* `GET /public/modules` — what this backend is serving beyond core.
*
* Database-free and never gated by site mode, so the app can feature-detect
* during maintenance. A **500** is the one answer that is not an answer: core
* refuses to return `[]` for a list read before its loader ran, because a caller
* cannot tell an empty list from a mis-ordered boot.
*/
@Serializable
data class ModulesDto(
val modules: List<InstalledModuleDto> = emptyList(),
)
/** `GET /public/status` — site mode + version for the first-run probe (§3). */

View File

@@ -0,0 +1,196 @@
/*
* 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,
)
/** `GET /public/rust/servers/{id}/events` — the killfeed and everything else public. */
@Serializable
data class RustEventListDto(
val events: List<RustEventDto> = emptyList(),
)
/**
* 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.
*/
@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()),
) {
/**
* 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,
)
/** `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. */
@Serializable
data class RustOnlineDto(
val players: List<RustPresenceDto> = emptyList(),
)
/**
* 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

@@ -6,6 +6,7 @@ package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.inbox.InboxCache
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.SsoApi
@@ -35,6 +36,7 @@ class AuthRepository @Inject constructor(
private val ssoApi: SsoApi,
private val sessionManager: SessionManager,
private val pushManager: PushManager,
private val inboxCache: InboxCache,
private val trustTokenStore: TrustTokenStore,
private val deviceNameProvider: DeviceNameProvider,
private val json: Json,
@@ -183,6 +185,18 @@ class AuthRepository @Inject constructor(
} catch (_: Exception) {
// Ignore — local session teardown proceeds regardless.
}
// Drop the cached inbox with it: those are one person's notifications, and
// they have finished with this device. This is the tidy-up, not the
// safeguard — InboxCache scopes every snapshot to (base URL, user id), so
// the paths that never reach here (a dead refresh, a server switch) cannot
// surface one account's items under another's session either.
try {
inboxCache.clear()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Ignore — same reason.
}
val refreshToken = sessionManager.currentRefreshToken()
try {
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
/*
* 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.RustEventDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustPresenceDto
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<List<RustEventDto>> = safeApiCall {
api.getEvents(
id = id,
kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","),
wipe = wipe?.takeIf { it.isNotBlank() },
limit = limit,
)
}.map { it.events }
/** 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. */
suspend fun online(id: String): ApiResult<List<RustPresenceDto>> =
safeApiCall { api.getOnline(id) }.map { it.players }
}

View File

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

View File

@@ -15,11 +15,13 @@ import com.runicgateway.app.core.net.TokenAuthenticator
import com.runicgateway.app.core.net.UserAgentInterceptor
import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.AuthRefreshApi
import com.runicgateway.app.data.api.EventsApi
import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.AdminApi
import com.runicgateway.app.data.api.NotificationsApi
import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.RustApi
import com.runicgateway.app.data.api.SsoApi
import dagger.Module
import dagger.Provides
@@ -122,6 +124,28 @@ object NetworkModule {
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
retrofit.create(PlayerShardApi::class.java)
/**
* The event surface (§9 M13). Three public reads and one bearer-authed player
* read on one interface — they are all CORE routes, so none of them is a
* module path and none is under `/shard`.
*/
@Provides
@Singleton
fun provideEventsApi(retrofit: Retrofit): EventsApi = retrofit.create(EventsApi::class.java)
/**
* `module-rust`'s public read path (§9 M14).
*
* A MODULE's routes, unlike [provideEventsApi] beside it — they exist only on
* a backend where an operator installed the Rust module, and the drawer rows
* that lead to them are gated on its `rust` capability. Provided
* unconditionally all the same: a Retrofit interface costs nothing until
* something calls it, and there is nowhere at injection time to ask.
*/
@Provides
@Singleton
fun provideRustApi(retrofit: Retrofit): RustApi = retrofit.create(RustApi::class.java)
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
@Provides
@Singleton

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -55,6 +57,7 @@ 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.LoginScreen
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
@@ -62,6 +65,10 @@ 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.events.EventScreen
import com.runicgateway.app.ui.events.EventSeriesScreen
import com.runicgateway.app.ui.events.EventsScreen
import com.runicgateway.app.ui.events.MyEventsScreen
import com.runicgateway.app.ui.home.HomeScreen
import com.runicgateway.app.ui.navigation.APP_MENU
import com.runicgateway.app.ui.navigation.NavNode
@@ -75,7 +82,9 @@ import com.runicgateway.app.ui.admin.AdminContentScreen
import com.runicgateway.app.ui.admin.AdminDashboardScreen
import com.runicgateway.app.ui.admin.AdminModerationScreen
import com.runicgateway.app.ui.admin.AdminSupportScreen
import com.runicgateway.app.ui.notifications.NotificationsScreen
import com.runicgateway.app.ui.notifications.InboxBadgeViewModel
import com.runicgateway.app.ui.notifications.InboxScreen
import com.runicgateway.app.ui.notifications.NotificationSettingsScreen
import com.runicgateway.app.ui.page.PageScreen
import com.runicgateway.app.ui.player.CharacterSheetScreen
import com.runicgateway.app.ui.player.CharactersScreen
@@ -93,6 +102,9 @@ 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.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
@@ -106,6 +118,13 @@ private val TOP_LEVEL_ROUTES = setOf(
// on them too (M11).
Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS,
Routes.NOTIFICATIONS,
// Events (M13): the calendar and the history are drawer rows, so the drawer
// gesture works on them. The event page and an arc are detail screens and are
// deliberately absent — a back gesture there means "back", not "open the menu".
Routes.EVENTS, Routes.MY_EVENTS,
// The Rust server list is a drawer row (M14); one server's page is a detail
// screen and is deliberately absent — a back gesture there means "back".
Routes.RUST,
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
)
@@ -124,8 +143,11 @@ fun RunicApp(
onChangeServer: () -> Unit,
modifier: Modifier = Modifier,
deepLinkStream: String? = null,
deepLinkRef: String? = null,
onDeepLinkConsumed: () -> Unit = {},
sessionViewModel: SessionViewModel = hiltViewModel(),
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
) {
val brand = appearance.brand
val navController = rememberNavController()
@@ -135,17 +157,44 @@ fun RunicApp(
val session by sessionViewModel.session.collectAsStateWithLifecycle()
// What this shard publishes, independently of who the caller is (§5, M11).
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
// What this BACKEND serves at all, independently of both (§5, M13). A different
// question from the line above and gated separately — see `isEntryVisible`.
val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle()
// Re-validate the cached role each time the app returns to the foreground (§4.3).
LifecycleResumeEffect(Unit) {
// Re-validate the cached role each time the app returns to the foreground (§4.3),
// and re-read the two drawer counts with it: a tickle that arrived while the app
// was away is exactly what brings someone back to it, and a live player count is
// only live if it is re-read when somebody looks.
LifecycleResumeEffect(capabilities) {
sessionViewModel.revalidate()
inboxBadgeViewModel.refresh()
// The Rust count is a LIVE number, so it is re-read on the same clock the
// unread badge is: coming back to the app is exactly when a stale one
// would be noticed. Keyed on the capability answer as well as on resume,
// because the very first resume happens before this host has said whether
// the module is there — and asking then would either make a request on a
// site that has no Rust, or never make one at all.
capabilities?.let { rustBadgeViewModel.refresh(Capability.RUST in it) }
onPauseOrDispose { }
}
val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle()
// How many people are on the Rust fleet, for the drawer row's badge — the
// phone's answer to D15's footer count (M14). Refreshed on resume, never on a
// timer: a badge is a glance, not a feed. Gated here rather than inside the
// view model because this is the only place that knows whether the module is
// installed at all, and a host that has not answered yet asks nothing.
val rustOnline by rustBadgeViewModel.online.collectAsStateWithLifecycle()
// The badge follows the session, so signing out clears it rather than leaving
// the previous account's count on the drawer.
LaunchedEffect(session) { inboxBadgeViewModel.refresh() }
// A tapped push notification deep-links to its stream's screen (§11, item 7).
LaunchedEffect(deepLinkStream) {
LaunchedEffect(deepLinkStream, deepLinkRef) {
val stream = deepLinkStream ?: return@LaunchedEffect
navController.navigate(Routes.forStream(stream)) {
// Both halves of the tickle: a `notification:` ref means there is an inbox
// row waiting, and that is where the tap goes (ENGAGEMENT.md phase 8).
navController.navigate(Routes.forTickle(stream, deepLinkRef)) {
popUpTo(Routes.HOME) { saveState = true }
launchSingleTop = true
}
@@ -162,7 +211,7 @@ fun RunicApp(
// `pruneNav` still decides what this caller may see and remains the boundary
// (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
isEntryVisible(it, session, shardFeatures)
isEntryVisible(it, session, shardFeatures, capabilities)
}
val context = LocalContext.current
@@ -234,12 +283,23 @@ fun RunicApp(
),
)
node.items.forEach { child ->
NavRow(child, currentRoute, drawerItemColors, indented = true) {
openNode(child)
}
NavRow(
node = child,
currentRoute = currentRoute,
colors = drawerItemColors,
indented = true,
unread = unread,
rustOnline = rustOnline,
) { openNode(child) }
}
} else {
NavRow(node, currentRoute, drawerItemColors) { openNode(node) }
NavRow(
node = node,
currentRoute = currentRoute,
colors = drawerItemColors,
unread = unread,
rustOnline = rustOnline,
) { openNode(node) }
}
}
@@ -354,6 +414,8 @@ private fun NavRow(
currentRoute: String?,
colors: NavigationDrawerItemColors,
indented: Boolean = false,
unread: Int = 0,
rustOnline: Int = 0,
onClick: () -> Unit,
) {
val route = when (node) {
@@ -369,21 +431,60 @@ private fun NavRow(
is NavNode.Section -> return
}
val handsOff = node is NavNode.Link && node.route == null
// The unread count rides on whichever row leads to the inbox — including an
// admin's own nav override pointing at it, since the badge belongs to the
// destination, not to the bundled entry.
val showsUnread = !handsOff && unread > 0 && route == Routes.NOTIFICATIONS
// The live player count rides on whichever row leads to the Rust list, for the
// same reason the unread count rides on whichever leads to the inbox — the
// number belongs to the destination, not to the bundled entry, so an admin's
// own nav override pointing there carries it too.
//
// **Zero renders nothing**, rather than a `0`: an empty fleet is not a
// notification, and a badge that read `0` on a site whose servers are simply
// quiet would be worse than no badge at all.
val showsRustOnline = !handsOff && rustOnline > 0 && route == Routes.RUST
NavigationDrawerItem(
label = { Text(label) },
selected = route != null && currentRoute == route.substringBefore('?'),
onClick = onClick,
badge = if (!handsOff) {
null
} else {
{
Icon(
Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = stringResource(R.string.nav_opens_in_browser),
modifier = Modifier.size(18.dp),
)
badge = when {
handsOff -> {
{
Icon(
Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = stringResource(R.string.nav_opens_in_browser),
modifier = Modifier.size(18.dp),
)
}
}
showsUnread -> {
{
// Named for a screen reader: "7" beside "Notifications" reads as
// a count to a sighted user and as a bare number to everyone else.
val spoken = stringResource(R.string.inbox_unread_count, unread)
Text(
text = unread.toString(),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.semantics { contentDescription = spoken },
)
}
}
showsRustOnline -> {
{
// Named for a screen reader: "42" beside "Rust servers" reads
// as a count to a sighted user and as a bare number to
// everyone else.
val spoken = stringResource(R.string.rust_online_badge, rustOnline)
Text(
text = rustOnline.toString(),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.semantics { contentDescription = spoken },
)
}
}
else -> null
},
colors = colors,
// Like Card's elevation, NavigationDrawerItem takes its shape as a default
@@ -440,6 +541,50 @@ private fun RunicNavHost(
) {
PostScreen()
}
// Events (M13). CORE's routes, so these screens are reachable on a backend
// with no game module at all — which is why they sit above the shard block
// rather than inside it.
composable(Routes.EVENTS) {
EventsScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) })
}
// The app's one route with a query argument. `run` is optional and nullable:
// navigating to Routes.event(slug) with no run matches this pattern with no
// argument, which is every route in except an announcement's link.
composable(
route = Routes.EVENT_ROUTE,
arguments = listOf(
navArgument(Routes.Args.SLUG) { type = NavType.StringType },
navArgument(Routes.Args.RUN) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) {
EventScreen(
onOpenSeries = { slug -> navController.navigate(Routes.eventSeries(slug)) },
onOpenRun = { slug, runId ->
navController.navigate(Routes.event(slug, runId.toString()))
},
)
}
composable(
route = Routes.EVENT_SERIES,
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
) {
EventSeriesScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) })
}
composable(Routes.MY_EVENTS) {
// Signed out, this route is not in the drawer — but a saved back-stack
// entry can still be restored onto it, so the shell says where to go
// rather than letting the screen ask the server and render a 401.
when (session) {
is Session.SignedIn -> MyEventsScreen(onOpenRun = { slug, runId ->
navController.navigate(Routes.event(slug, runId.toString()))
})
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.SHARD) {
ShardScreen(onOpenBoard = { board ->
navController.navigate(
@@ -480,6 +625,17 @@ 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 }),
) {
RustServerScreen(onBack = { navController.navigateTopLevel(Routes.RUST) })
}
composable(Routes.WIKI) {
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
}
@@ -541,9 +697,24 @@ private fun RunicNavHost(
}
composable(Routes.NOTIFICATIONS) {
// Signed-in only; a sign-out (or demotion) sends the user home rather than
// leaving stale settings up. The backend gates every call regardless (§5).
// leaving another account's items up. The backend gates every call
// regardless, and the inbox routes are role-agnostic (§5) — staff have an
// inbox for the same reason players do, which on the web took a second
// mount to be true.
when (session) {
is Session.SignedIn -> NotificationsScreen()
is Session.SignedIn -> InboxScreen(
onOpenSettings = { navController.navigate(Routes.NOTIFICATIONS_SETTINGS) },
// A notification whose link the app can render opens in the app.
// `navigate`, not `navigateTopLevel`: the inbox is where the
// reader came from and back should return there.
onOpenRoute = { route -> navController.navigate(route) },
)
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.NOTIFICATIONS_SETTINGS) {
when (session) {
is Session.SignedIn -> NotificationSettingsScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ${prefabName(it)}" },
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,154 @@
/*
* 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))
}
/**
* "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()
}
/** 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)

View File

@@ -0,0 +1,566 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
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.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
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.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustPresenceDto
import com.runicgateway.app.data.api.dto.RustServerDto
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, 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,
modifier: Modifier = Modifier,
viewModel: RustServerViewModel = hiltViewModel(),
) {
val ui by viewModel.state.collectAsStateWithLifecycle()
PollWhileResumed { viewModel.refresh() }
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, 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,
modifier: Modifier = Modifier,
) {
Column(modifier.fillMaxSize()) {
ServerHeader(server, ui.selectedWipe, viewModel::selectWipe, ui.wipes)
val tabs = RustTab.entries
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)
RustTab.LEADERBOARD -> LeaderboardPanel(
ui.leaderboard,
ui.sort,
viewModel::selectSort,
viewModel::retryLeaderboard,
)
RustTab.ONLINE -> OnlinePanel(ui.online, server.online, viewModel::retryOnline)
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.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),
)
}
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<List<RustEventDto>>,
filterId: String,
onFilter: (String) -> Unit,
onRetry: () -> 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 -> if (s.data.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, key = { it.id }) { FeedRow(it) }
}
}
}
}
}
@Composable
private fun FeedRow(row: RustEventDto) {
val line = describe(row)
Row(verticalAlignment = Alignment.Top) {
// 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,
)
}
}
}
}
// ── 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) },
)
@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.
Text(
text = playerLabel(row.name, row.steamId),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(NAME_WEIGHT).padding(end = 8.dp),
)
RUST_COLUMNS.forEach { column ->
Text(
text = column.value(row),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
}
// ── Online ────────────────────────────────────────────────────────────────
@Composable
private fun OnlinePanel(
online: Polled<List<RustPresenceDto>>,
serverOnline: Boolean,
onRetry: () -> Unit,
) {
when (val s = online.state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
is UiState.Success -> if (s.data.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, 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,268 @@
/*
* 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.RustEventDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustPresenceDto
import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.api.dto.RustWipeDto
import com.runicgateway.app.data.repository.RustRepository
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 four sections of a server's page (D13). */
enum class RustTab { FEED, LEADERBOARD, ONLINE, WIPES }
/** 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,
val feed: Polled<List<RustEventDto>> = Polled(),
val online: Polled<List<RustPresenceDto>> = Polled(),
val leaderboard: UiState<List<RustLeaderboardRowDto>> = UiState.Loading,
val wipes: UiState<List<RustWipeDto>> = UiState.Loading,
)
/**
* 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,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
private val _state = MutableStateFlow(RustServerUi(serverId = serverId))
val state: StateFlow<RustServerUi> = _state.asStateFlow()
init {
load()
}
/** 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()
RustTab.LEADERBOARD, RustTab.WIPES -> 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
_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()
}
}
}
/** 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() }
}
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,191 @@
/*
* 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),
)
}
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(" · ")
}
/**
* "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

@@ -10,6 +10,8 @@ import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.ShardFeaturesRepository
import com.runicgateway.app.data.repository.SiteCapabilities
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -26,6 +28,7 @@ class SessionViewModel @Inject constructor(
sessionManager: SessionManager,
private val authRepository: AuthRepository,
shardFeaturesRepository: ShardFeaturesRepository,
siteCapabilitiesRepository: SiteCapabilitiesRepository,
) : ViewModel() {
val session: StateFlow<Session> = sessionManager.state
@@ -38,6 +41,19 @@ class SessionViewModel @Inject constructor(
*/
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
/**
* What this BACKEND serves — core's capabilities and every installed module's
* (M13). Exposed here for the reason [shardFeatures] is: the shared menu is
* the consumer, and a row is filtered by both.
*
* **Read-only here, and deliberately not refreshed here.** This answer is per
* HOST, not per viewer: signing in does not install a module. It is resolved
* beside the appearance in [com.runicgateway.app.ui.AppViewModel], which is
* what owns the host's lifecycle — first load, resume, and the Settings →
* Server switch that invalidates it.
*/
val capabilities: StateFlow<SiteCapabilities?> = siteCapabilitiesRepository.capabilities
init {
// The answer is per-viewer, so it is re-resolved on every session change.
// A StateFlow conflates equal values, so a resume revalidation that returns

View File

@@ -41,8 +41,10 @@
<string name="nav_opens_in_browser">Opens in your browser</string>
<string name="menu_home">Home</string>
<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_rust">Rust servers</string>
<string name="menu_rules">Rules</string>
<string name="menu_atlas">Atlas</string>
<string name="menu_leaderboards">Leaderboards</string>
@@ -50,6 +52,7 @@
<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>
@@ -484,7 +487,7 @@
<!-- ── 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. Nothing is sent unless you turn it on.</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_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>
@@ -495,6 +498,18 @@
<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>
<!-- The in-app inbox and the per-channel settings (ENGAGEMENT.md phase 8). -->
<string name="notifications_mode_off">Off</string>
<string name="notifications_mode_instant">As it happens</string>
<string name="notifications_mode_digest">Daily summary</string>
<string name="inbox_empty">Nothing here yet. Notifications you\'re sent will show up here.</string>
<string name="inbox_all_read">All caught up</string>
<string name="inbox_unread_count">%1$d unread</string>
<string name="inbox_mark_all_read">Mark all read</string>
<string name="inbox_open_settings">Notification settings</string>
<string name="inbox_loading_more">Loading more…</string>
<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>
@@ -513,4 +528,81 @@
<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) ─────────────────────────── -->
<!--
The four status words. `cancelled` has TWO, chosen by the clock rather than
the status: "did not happen" is right for a past occurrence and false for a
future one, and a run four days out that an operator called off is the common
case. See EventTimes.statusWordRes.
-->
<string name="events_status_live">Happening now</string>
<string name="events_status_scheduled">Scheduled</string>
<string name="events_status_completed">Finished</string>
<string name="events_status_cancelled">Cancelled</string>
<string name="events_status_did_not_happen">Did not happen</string>
<string name="events_empty">Nothing on the calendar just yet — check back soon.</string>
<!-- A forecast past the materialisation horizon: nothing is committed to it. -->
<string name="events_projected">Expected — not yet confirmed</string>
<string name="events_truncated">Showing the first part of a busy calendar.</string>
<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_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>
<string name="events_previously">Previously</string>
<string name="events_results">Results</string>
<string name="events_results_nobody">Results were published with nobody recorded.</string>
<!-- A module puts a display name in its participation meta or it does not; the
member key is never published, so there is nothing else to render. -->
<string name="events_participant_unnamed">Unnamed</string>
<string name="events_history_empty">You have not taken part in an event yet.</string>
<string name="events_rank">Rank %1$d</string>
<!-- Not a dash: an unranked row is a real state, not a missing value. -->
<string name="events_results_unpublished">Results not published</string>
<string name="events_score">Score %1$s</string>
<string name="events_show_more">Show more</string>
<string name="events_loading">Loading…</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_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_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>
<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>
</resources>

View File

@@ -82,4 +82,71 @@ class NotificationsDtoTest {
)
assertNull(dto.push.ntfyUrl)
}
// ── The inbox + per-channel prefs (ENGAGEMENT.md phases 3, 7/8) ────────
@Test fun inboxPageDecodesWithItsUnreadCount() {
val dto = json.decodeFromString<NotificationInboxDto>(
"""{"items":[{"id":42,"triggerId":"team.post.created","title":"New post",
"body":"Someone posted in your team.","url":"https://shard.example/teams/1",
"read":false,"readAt":null,"createdAt":"2026-08-31T12:30:00.000Z"}],
"hasMore":true,"unread":3}""",
)
assertEquals(1, dto.items.size)
assertEquals(42L, dto.items.first().id)
assertEquals("team.post.created", dto.items.first().triggerId)
assertFalse(dto.items.first().read)
assertTrue(dto.hasMore)
// The whole inbox, not the page — the badge and the list come from one response.
assertEquals(3, dto.unread)
}
@Test fun anItemWithNoBodyOrUrlDecodes() {
// Most items have neither: an inbox row is complete on its own.
val dto = json.decodeFromString<NotificationItemDto>(
"""{"id":7,"triggerId":"news.post","title":"Patch notes","body":null,"url":null,
"read":true,"readAt":"2026-08-31T13:00:00.000Z","createdAt":"2026-08-31T12:30:00.000Z"}""",
)
assertNull(dto.body)
assertNull(dto.url)
assertTrue(dto.read)
}
@Test fun channelPrefsDecodeTheirModesAndPerItemChannels() {
val dto = json.decodeFromString<NotificationChannelPrefsDto>(
"""{"channels":[
{"id":"push","label":"Push","carriesContent":false,"defaultMode":"off",
"supportsDigest":false,"modes":["off","instant"]},
{"id":"email","label":"Email","carriesContent":true,"defaultMode":"off",
"supportsDigest":true,"modes":["off","instant","digest"]}],
"items":[
{"id":"uo.house.idoc_warning","label":"House in danger","description":"",
"personal":true,"requiresLinkedAccount":true,"ceiling":"authenticated",
"channels":["email","inapp"],"modes":{"email":"digest","inapp":"instant"}}]}""",
)
assertFalse(dto.channels.first { it.id == "push" }.carriesContent)
assertTrue(dto.channels.first { it.id == "email" }.supportsDigest)
val item = dto.items.single()
// A trigger-only id carries no push facet at all — the UI renders controls
// from THIS list, never from a hardcoded three.
assertFalse(item.channels.contains("push"))
assertEquals("digest", item.modes["email"])
assertNull(item.modes["push"])
}
@Test fun theSparseUpdateAlwaysCarriesItsPrefsField() {
// Same reasoning as the subscriptions DTO: kotlinx omits a property equal
// to its default, and the validator requires the field.
val body = json.encodeToString(NotificationChannelPrefsUpdateDto(emptyList()))
assertEquals("""{"prefs":[]}""", body)
}
@Test fun theSparseUpdateSendsOnlyThePairItNames() {
val body = json.encodeToString(
NotificationChannelPrefsUpdateDto(
listOf(NotificationChannelPrefDto(id = "news.post", channel = "email", mode = "digest")),
),
)
assertEquals("""{"prefs":[{"id":"news.post","channel":"email","mode":"digest"}]}""", body)
}
}

View File

@@ -0,0 +1,60 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.EventsApi
import com.runicgateway.app.data.api.dto.EventCalendarDto
import com.runicgateway.app.data.api.dto.EventHistoryDto
import com.runicgateway.app.data.api.dto.EventSeriesResponse
import com.runicgateway.app.data.api.dto.PublicEventResponse
/**
* A configurable fake of [EventsApi] (M13). Set the `var` a call should answer
* with; set [error] to make every call throw.
*
* [lastRun] and [lastBefore] are what the tests that matter assert on: the run a
* page was asked about, and the keyset cursor a history page walked back from.
*/
class FakeEventsApi : EventsApi {
var error: Throwable? = null
var calendar: EventCalendarDto = EventCalendarDto()
var event: PublicEventResponse = PublicEventResponse()
var series: EventSeriesResponse = EventSeriesResponse()
var history: EventHistoryDto = EventHistoryDto()
/** The `run` the last event read carried, so a test can assert a blank was dropped. */
var lastRun: String? = null
var lastSlug: String? = null
/** The keyset cursor the last history page asked for; null on a first page. */
var lastBefore: Long? = null
var historyCalls: Int = 0
private fun <T> reply(value: T): T {
error?.let { throw it }
return value
}
override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto =
reply(calendar)
override suspend fun getEvent(slug: String, run: String?): PublicEventResponse {
lastSlug = slug
lastRun = run
return reply(event)
}
override suspend fun getSeries(slug: String): EventSeriesResponse {
lastSlug = slug
return reply(series)
}
override suspend fun getHistory(limit: Int?, before: Long?): EventHistoryDto {
historyCalls++
lastBefore = before
return reply(history)
}
}

View File

@@ -0,0 +1,106 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.NotificationsApi
import com.runicgateway.app.data.api.dto.NotificationChannelPrefDto
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto
import com.runicgateway.app.data.api.dto.NotificationInboxDto
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
import com.runicgateway.app.data.api.dto.PushDeviceDto
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
/**
* A configurable fake of [NotificationsApi] for the inbox and settings ViewModel
* tests. Read endpoints return their `var`; [error] makes every call throw, which
* is how the offline and server-error branches are driven.
*
* [pages] keys the inbox by its cursor — `null` is the first page — so a test can
* describe a two-page inbox without a callback, and [lastPrefsUpdate] records the
* body of the sparse PUT so a test can assert that ONE pair was sent.
*/
class FakeNotificationsApi : NotificationsApi {
var error: Throwable? = null
var streams: NotificationStreamsDto = NotificationStreamsDto()
var subscriptions: NotificationSubscriptionsDto = NotificationSubscriptionsDto(emptyList())
var channelPrefs: NotificationChannelPrefsDto = NotificationChannelPrefsDto()
var pages: Map<Long?, NotificationInboxDto> = mapOf(null to NotificationInboxDto())
var unread: NotificationUnreadDto = NotificationUnreadDto()
var readResult: NotificationReadResultDto = NotificationReadResultDto(ok = true)
var lastPrefsUpdate: List<NotificationChannelPrefDto>? = null
var markedRead: MutableList<Long> = mutableListOf()
var markAllReadCalls: Int = 0
var inboxCalls: MutableList<Long?> = mutableListOf()
private fun failIfSet() { error?.let { throw it } }
override suspend fun registerDevice(body: RegisterDeviceRequest): PushDeviceDto {
failIfSet()
return PushDeviceDto(id = 1)
}
override suspend fun listDevices(): List<PushDeviceDto> {
failIfSet()
return emptyList()
}
override suspend fun deleteDevice(id: Long) = failIfSet()
override suspend fun streams(): NotificationStreamsDto {
failIfSet()
return streams
}
override suspend fun subscriptions(): NotificationSubscriptionsDto {
failIfSet()
return subscriptions
}
override suspend fun putSubscriptions(body: NotificationSubscriptionsDto): NotificationSubscriptionsDto {
failIfSet()
subscriptions = body
return body
}
override suspend fun channelPrefs(): NotificationChannelPrefsDto {
failIfSet()
return channelPrefs
}
override suspend fun putChannelPrefs(body: NotificationChannelPrefsUpdateDto): NotificationChannelPrefsDto {
failIfSet()
lastPrefsUpdate = body.prefs
return channelPrefs
}
override suspend fun inbox(limit: Int?, before: Long?, unread: Boolean?): NotificationInboxDto {
failIfSet()
inboxCalls.add(before)
return pages[before] ?: NotificationInboxDto()
}
override suspend fun unreadCount(): NotificationUnreadDto {
failIfSet()
return unread
}
override suspend fun markRead(id: Long): NotificationReadResultDto {
failIfSet()
markedRead.add(id)
return readResult
}
override suspend fun markAllRead(): NotificationReadResultDto {
failIfSet()
markAllReadCalls++
return readResult
}
}

View File

@@ -13,6 +13,7 @@ import com.runicgateway.app.data.api.dto.GovernorDto
import com.runicgateway.app.data.api.dto.GovernorTermDto
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.api.dto.ModulesDto
import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
@@ -67,6 +68,19 @@ class FakePublicApi : PublicApi {
var houses: List<HouseDto> = emptyList()
var shardFeatures: ShardFeaturesDto = ShardFeaturesDto()
/**
* `GET /public/modules` (M13). Empty by default, which is a real answer: a
* backend serving no modules at all.
*/
var modules: ModulesDto = ModulesDto()
/**
* Per-call failures, for the one thing [error] cannot express: capability
* resolution reads TWO routes and one failing is not the same as both.
*/
var statusError: Throwable? = null
var modulesError: Throwable? = null
// Protocol 3.0 content (M11). `ruleset` is nullable on the wire: null means the
// shard has never published one, which is a success, not a failure.
var ruleset: RulesetDto? = null
@@ -94,9 +108,17 @@ class FakePublicApi : PublicApi {
}
override suspend fun probeStatus(absoluteStatusUrl: String): StatusDto = reply(status)
override suspend fun getStatus(): StatusDto = reply(status)
override suspend fun getStatus(): StatusDto {
statusError?.let { throw it }
return reply(status)
}
override suspend fun getSettings(): SettingsDto = reply(settings)
override suspend fun getModules(): ModulesDto {
modulesError?.let { throw it }
return reply(modules)
}
override suspend fun getPosts(category: String): List<PostDto> = reply(posts)
override suspend fun getPost(category: String, idOrSlug: String): PostDto = reply(post)
override suspend fun getPage(slug: String): PageDto = reply(page)

View File

@@ -0,0 +1,97 @@
/*
* 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.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 serversCalls: Int = 0
var eventCalls: Int = 0
var leaderboardCalls: Int = 0
var onlineCalls: Int = 0
var wipeCalls: 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)
}
}

View File

@@ -0,0 +1,160 @@
/*
* 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.StatusDto
import com.runicgateway.app.data.api.dto.VersionDto
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.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
/**
* What this backend serves, and — the point of the class — the three different
* things "we don't know" can mean (PLAN.md §9 M13).
*
* **Absence of an answer is not an answer of absence.** Before M13 the app
* collapsed a 404, a dead network and "no such module" into one `null` and
* treated all three as "show everything", which rendered five shard rows that
* each 404 on a site running a different game.
*/
class SiteCapabilitiesRepositoryTest {
private val api = FakePublicApi()
private val repository = SiteCapabilitiesRepository(api)
private fun serving(core: List<String>, moduleCaps: List<String>) {
api.status = StatusDto(version = VersionDto(capabilities = core))
api.modules = ModulesDto(
modules = listOf(InstalledModuleDto(id = "uo", capabilities = moduleCaps)),
)
}
@Test fun bothListsAreMergedAndStaySeparable() = runTest {
serving(core = listOf("events"), moduleCaps = listOf("shard", "atlas"))
repository.refresh()
val answer = repository.capabilities.value!!
assertEquals(setOf("events"), answer.core)
assertEquals(setOf("shard", "atlas"), answer.modules)
// A menu entry does not care which half serves it.
assertTrue("events" in answer)
assertTrue("shard" in answer)
assertFalse("market" in answer)
}
@Test fun aBackendWithNoModulesAnswersRatherThanFailing() = runTest {
api.status = StatusDto(version = VersionDto(capabilities = listOf("events")))
api.modules = ModulesDto(modules = emptyList())
repository.refresh()
val answer = repository.capabilities.value!!
assertTrue("events" in answer)
// The answer that hides the shard rows, and the whole reason for the class.
assertFalse("shard" in answer)
assertFalse(canUse(answer, Capability.SHARD))
assertTrue(canUse(answer, Capability.EVENTS))
}
@Test fun aBackendOlderThanEventsOmitsTheKeyAndThatIsAnAnswer() = runTest {
// No `capabilities` in the version block at all — the value is in what is
// absent, and it must not read as "unknown".
api.status = StatusDto(version = VersionDto(service = "runic-gateway"))
api.modules = ModulesDto(modules = listOf(InstalledModuleDto(id = "uo", capabilities = listOf("shard"))))
repository.refresh()
val answer = repository.capabilities.value!!
assertTrue(answer.core.isEmpty())
assertFalse(canUse(answer, Capability.EVENTS))
assertTrue(canUse(answer, Capability.SHARD))
}
// ── The three failure directions ─────────────────────────────────────
@Test fun aHostThatHasNeverAnsweredLeavesTheGateOpen() = runTest {
api.error = IOException("offline")
repository.refresh()
// Null, not empty. The drawer renders as it did before this existed rather
// than flickering its rows in on every cold start.
assertNull(repository.capabilities.value)
assertTrue(canUse(repository.capabilities.value, Capability.SHARD))
assertTrue(canUse(repository.capabilities.value, Capability.EVENTS))
}
@Test fun aFailedRefreshKeepsTheLastAnswer() = runTest {
serving(core = listOf("events"), moduleCaps = listOf("shard"))
repository.refresh()
api.error = IOException("offline")
repository.refresh()
// A moment with no connectivity is not an uninstall.
val answer = repository.capabilities.value!!
assertTrue("shard" in answer)
assertTrue("events" in answer)
}
@Test fun oneCallFailingKeepsThatHalfAndUpdatesTheOther() = runTest {
serving(core = listOf("events"), moduleCaps = listOf("shard"))
repository.refresh()
// The module list answers with the game module gone; the status call is down.
api.statusError = httpError(500)
api.modules = ModulesDto(modules = emptyList())
repository.refresh()
val answer = repository.capabilities.value!!
// The half that answered is believed…
assertFalse("shard" in answer)
// …and the half that did not keeps what it last said.
assertTrue("events" in answer)
}
@Test fun aFiveHundredOnTheModuleListIsNotAnEmptyList() = runTest {
serving(core = listOf("events"), moduleCaps = listOf("shard"))
repository.refresh()
// Core answers 500 for a module list read before its loader ran, precisely
// so a caller cannot read it as "no modules installed".
api.modulesError = httpError(500)
repository.refresh()
assertTrue("shard" in repository.capabilities.value!!)
}
@Test fun aServerSwitchDropsTheAnswerEntirely() = runTest {
serving(core = listOf("events"), moduleCaps = listOf("shard"))
repository.refresh()
assertNotNull(repository.capabilities.value)
repository.invalidate()
// Not "empty" — unknown. The new host has said nothing, and inheriting the
// old one's answer would hide its shard rows until its first read lands.
assertNull(repository.capabilities.value)
assertTrue(canUse(repository.capabilities.value, Capability.SHARD))
}
@Test fun twoModulesMayDeclareTheSameString() = runTest {
api.status = StatusDto()
api.modules = ModulesDto(
modules = listOf(
InstalledModuleDto(id = "uo", capabilities = listOf("shard")),
InstalledModuleDto(id = "other", capabilities = listOf("shard", "cards")),
),
)
repository.refresh()
assertEquals(setOf("shard", "cards"), repository.capabilities.value!!.modules)
}
}

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

View File

@@ -0,0 +1,151 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import com.runicgateway.app.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
import java.time.ZoneId
import java.util.Locale
/**
* Rendering an event's instant and its status word (EVENTS.md §I).
*
* Two of these are regression tests for defects the WEBSITE shipped and its live
* walk caught in events Phase 14a — restated in Kotlin because a rule that is
* only written down in another language gets re-derived wrong.
*/
class EventTimesTest {
private val uk = Locale.UK
// ── The zone split: the day is the reader's, the time is the event's ──
@Test fun theTimeIsTheEventsZoneNotTheReaders() {
// 2026-09-11T00:00Z is 20:00 the previous evening in New York. A shard's
// 8pm event is 8pm to everyone reading about it; rendering the reader's
// 02:00 would be true and useless.
assertEquals("20:00 New York", eventTime("2026-09-11T00:00:00Z", "America/New_York", uk))
assertEquals("02:00 Berlin", eventTime("2026-09-11T00:00:00Z", "Europe/Berlin", uk))
}
@Test fun theDayHeadingIsTheReadersOwn() {
// The same instant files under different days for two readers, which is the
// other half of the split: "what is on this month" is about the month the
// person holding the phone is living in.
val instant = "2026-09-11T00:30:00Z"
val london = readerDayLabel(instant, ZoneId.of("Europe/London"), uk)
val newYork = readerDayLabel(instant, ZoneId.of("America/New_York"), uk)
assertNotEquals(london, newYork)
assertTrue(london, london.contains("11"))
assertTrue(newYork, newYork.contains("10"))
}
@Test fun anUnknownZoneFallsBackToUtcRatherThanThrowing() {
// A typo in a definition's timezone column must still render.
assertEquals("00:00 Nowhere", eventTime("2026-09-11T00:00:00Z", "Mars/Nowhere", uk))
assertEquals("00:00 UTC", eventTime("2026-09-11T00:00:00Z", null, uk))
}
@Test fun aZonelessStampIsReadAsUtc() {
// MariaDB DATETIME read back as a string reaches the wire with no zone. It
// is what the server stored, so it is UTC — reading it as local time would
// shift every event by the device's offset.
assertEquals("00:00 UTC", eventTime("2026-09-11 00:00:00", "UTC", uk))
}
@Test fun anUnreadableInstantRendersNothingRatherThanCrashing() {
assertEquals("", eventTime("not a date", "UTC", uk))
assertEquals("", eventDateTime(null, "UTC", uk))
assertEquals("", readerDayLabel("", ZoneId.of("UTC"), uk))
}
@Test fun theZoneIsNamedAsAReaderRecognisesIt() {
assertEquals("New York", shortZone("America/New_York"))
assertEquals("Berlin", shortZone("Europe/Berlin"))
assertEquals("UTC", shortZone(null))
assertEquals("UTC", shortZone(" "))
}
// ── Scores are fractional, and the walk is why we know ───────────────
@Test fun aWholeScorePrintsWhole() {
// Most modules score by counting, and `12.0` reads as a rounding artefact.
assertEquals("1420", scoreText(1420.0, uk))
assertEquals("0", scoreText(0.0, uk))
assertEquals("-5", scoreText(-5.0, uk))
}
@Test fun aFractionalScoreKeepsItsDigits() {
// The live walk's first history row was 318.5. Declaring this field `Long`
// did not round it — kotlinx refused the whole body, and a 200 rendered as
// "Something went wrong on the server."
assertEquals("318.5", scoreText(318.5, uk))
assertEquals("0.25", scoreText(0.25, uk))
// DECIMAL(18,4): four places, and no trailing zeros past the last digit.
assertEquals("1.0625", scoreText(1.0625, uk))
}
@Test fun aNonFiniteScoreDoesNotReachTheScreen() {
assertEquals("0", scoreText(Double.NaN, uk))
assertEquals("0", scoreText(Double.POSITIVE_INFINITY, uk))
}
// ── The status word: the tense follows the CLOCK, not the status ──────
@Test fun aFutureCancellationReadsCancelled() {
// Phase 14a's own defect: the calendar told a visitor an event four days
// away "DID NOT HAPPEN". It had been cancelled, not missed.
val now = Instant.parse("2026-09-08T12:00:00Z")
assertEquals(
R.string.events_status_cancelled,
statusWordRes("cancelled", "2026-09-12T20:00:00Z", now),
)
}
@Test fun aPastCancellationReadsDidNotHappen() {
// Which is also the honest word for the `failed` and `missed` runs the
// server folds into `cancelled`.
val now = Instant.parse("2026-09-08T12:00:00Z")
assertEquals(
R.string.events_status_did_not_happen,
statusWordRes("cancelled", "2026-09-01T20:00:00Z", now),
)
}
@Test fun anUnreadableInstantOnACancellationReadsPast() {
val now = Instant.parse("2026-09-08T12:00:00Z")
assertEquals(
R.string.events_status_did_not_happen,
statusWordRes("cancelled", null, now),
)
}
@Test fun theOtherThreeStatusesDoNotDependOnTheClock() {
val past = Instant.parse("2027-01-01T00:00:00Z")
val future = Instant.parse("2020-01-01T00:00:00Z")
for (now in listOf(past, future)) {
assertEquals(R.string.events_status_live, statusWordRes("live", "2026-09-12T20:00:00Z", now))
assertEquals(R.string.events_status_completed, statusWordRes("completed", "2026-09-01T20:00:00Z", now))
assertEquals(R.string.events_status_scheduled, statusWordRes("scheduled", "2026-09-12T20:00:00Z", now))
}
}
@Test fun anUnknownStatusFallsBackTheWayTheServerDoes() {
// `publicStatus()` folds anything it does not know to `scheduled`, so a word
// the app has never seen is a contract break rather than a state — and
// rendering a raw enum at a reader is not an improvement on it.
assertEquals(
R.string.events_status_scheduled,
statusWordRes("starting", "2026-09-12T20:00:00Z", Instant.now()),
)
assertEquals(
R.string.events_status_scheduled,
statusWordRes(null, null, Instant.now()),
)
}
}

View File

@@ -0,0 +1,261 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.lifecycle.SavedStateHandle
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.StoredSession
import com.runicgateway.app.core.auth.TokenStore
import com.runicgateway.app.data.api.dto.SafeUserDto
import com.runicgateway.app.data.api.dto.EventCalendarDto
import com.runicgateway.app.data.api.dto.EventCalendarEntryDto
import com.runicgateway.app.data.api.dto.EventHistoryDto
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
import com.runicgateway.app.data.api.dto.EventSeriesDto
import com.runicgateway.app.data.api.dto.EventSeriesResponse
import com.runicgateway.app.data.api.dto.PublicEventDto
import com.runicgateway.app.data.api.dto.PublicEventResponse
import com.runicgateway.app.data.api.fake.FakeEventsApi
import com.runicgateway.app.data.repository.EventsRepository
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import java.io.IOException
/** The four event screens' view models (PLAN.md §9 M13). */
class EventsViewModelsTest {
@get:Rule val dispatcher = MainDispatcherRule()
private val api = FakeEventsApi()
private val repository = EventsRepository(api)
private fun entry(slug: String, at: String, kind: String = "run") =
EventCalendarEntryDto(kind = kind, title = slug, slug = slug, scheduledFor = at)
// ── The calendar ─────────────────────────────────────────────────────
@Test fun theCalendarAsksForNoWindow() = runTest {
api.calendar = EventCalendarDto(entries = listOf(entry("a", "2026-09-11T20:00:00Z")))
val state = EventsViewModel(repository).state.value
assertTrue(state is UiState.Success)
assertEquals(1, (state as UiState.Success).data.entries.size)
}
@Test fun aFourOhFourOnTheCalendarIsNotAFeatureBeingSwitchedOff() = runTest {
// These are CORE routes: `toShardUiState`'s "not published here" would name
// the wrong cause, and offer an explanation an admin cannot act on.
api.error = httpError(404)
val state = EventsViewModel(repository).state.value
assertEquals(ErrorKind.NOT_FOUND, (state as UiState.Error).kind)
}
@Test fun theServersOrderIsPreservedByTheDayGrouping() {
// The server already sorted by instant; grouping must not re-sort. Two
// entries on one reader-day share a heading, a third on another starts one.
val entries = listOf(
entry("a", "2026-09-11T20:00:00Z"),
entry("b", "2026-09-11T21:00:00Z"),
entry("c", "2026-09-14T20:00:00Z"),
)
val days = groupByReaderDay(entries)
assertEquals(2, days.size)
assertEquals(listOf("a", "b"), days[0].entries.map { it.slug })
assertEquals(listOf("c"), days[1].entries.map { it.slug })
}
@Test fun anEntrySaysWhetherItIsAForecast() {
assertTrue(entry("a", "2026-09-11T20:00:00Z", kind = "projected").isProjected)
assertTrue(!entry("a", "2026-09-11T20:00:00Z").isProjected)
}
// ── One event ────────────────────────────────────────────────────────
@Test fun theRunIsPassedThroughUntouched() = runTest {
api.event = PublicEventResponse(PublicEventDto(slug = "yew"))
val handle = SavedStateHandle(mapOf("slug" to "yew", "run" to "3692"))
EventViewModel(repository, handle)
assertEquals("yew", api.lastSlug)
assertEquals("3692", api.lastRun)
}
@Test fun aBlankRunIsDroppedRatherThanForwarded() = runTest {
api.event = PublicEventResponse(PublicEventDto(slug = "yew"))
val handle = SavedStateHandle(mapOf("slug" to "yew", "run" to " "))
EventViewModel(repository, handle)
assertNull(api.lastRun)
}
@Test fun anAbsentRunIsNotSent() = runTest {
api.event = PublicEventResponse(PublicEventDto(slug = "yew"))
EventViewModel(repository, SavedStateHandle(mapOf("slug" to "yew")))
assertNull(api.lastRun)
}
@Test fun theEnvelopeIsUnwrappedForTheScreen() = runTest {
api.event = PublicEventResponse(PublicEventDto(slug = "yew", title = "The Yew Invasion"))
val state = EventViewModel(repository, SavedStateHandle(mapOf("slug" to "yew"))).state.value
assertEquals("The Yew Invasion", (state as UiState.Success).data.title)
}
// ── An arc ───────────────────────────────────────────────────────────
@Test fun anArcWithNothingListedIsAnErrorRatherThanAnEmptyPage() = runTest {
// The server's decision, not the screen's: a page for an empty arc would
// publish that an operator has named something they have not announced.
api.error = httpError(404)
val state = EventSeriesViewModel(repository, SavedStateHandle(mapOf("slug" to "void"))).state.value
assertEquals(ErrorKind.NOT_FOUND, (state as UiState.Error).kind)
}
@Test fun anArcUnwrapsItsEnvelope() = runTest {
api.series = EventSeriesResponse(EventSeriesDto(name = "The Void", slug = "void"))
val state = EventSeriesViewModel(repository, SavedStateHandle(mapOf("slug" to "void"))).state.value
assertEquals("The Void", (state as UiState.Success).data.name)
}
// ── Participation history ────────────────────────────────────────────
private fun rows(vararg ids: Long) = EventHistoryDto(
entries = ids.map { EventHistoryEntryDto(id = it, runId = it, slug = "e$it") },
)
@Test fun aFractionalScoreDecodesRatherThanFailingTheWholeBody() {
// The regression the live walk found: `score` is DECIMAL(18,4) on the wire
// and a `Long` field makes kotlinx refuse the ENTIRE response, so a 200
// reaches the screen as a server error. Decoded from real JSON so the DTO's
// type is what is under test, not a hand-built object.
val json = Json { ignoreUnknownKeys = true; explicitNulls = false }
val history = json.decodeFromString<EventHistoryDto>(
"""{"entries":[{"id":2,"runId":3667,"title":"Midsummer Fair","slug":"mf","score":318.5,"rank":null}]}""",
)
assertEquals(318.5, history.entries.single().score, 0.0)
val event = json.decodeFromString<PublicEventResponse>(
"""{"event":{"slug":"mf","results":{"runId":1,"participants":[{"name":"A","score":318.5}]}}}""",
)
assertEquals(318.5, event.event.results!!.participants.single().score, 0.0)
}
// A signed-in session manager, so the history view model has an account to
// scope to. The screen is unreachable signed out.
private class FakeTokenStore(private var stored: StoredSession?) : TokenStore {
override fun load(): StoredSession? = stored
override fun save(session: StoredSession) { stored = session }
override fun clear() { stored = null }
}
private fun playerDto(userId: Long) =
SafeUserDto(id = userId, username = "u$userId", role = "player")
private fun sessionFor(userId: Long) =
SessionManager(FakeTokenStore(StoredSession("a", "r", userId, "u$userId", "player")))
@Test fun switchingAccountDoesNotShowThePreviousOnesHistory() {
// **The leak the live walk found, and the suite could not.** A drawer
// route's view model survives a sign-out: `navigateTopLevel` saves and
// restores back-stack state, so the entry keeps its ViewModelStore and a
// view model that loaded only in `init` never runs again. Signing out of
// an admin and in as a player showed the player the admin's rows, with no
// request made at all.
val sessions = sessionFor(33)
api.history = rows(9, 8)
val vm = MyEventsViewModel(repository, sessions)
assertEquals(2, (vm.state.value.items as UiState.Success).data.size)
api.history = rows(1)
sessions.onSignedOut()
// Signed out, the previous account's rows are gone rather than left up.
assertEquals(0, (vm.state.value.items as UiState.Success).data.size)
sessions.onSignedIn("a", "r", playerDto(35))
assertEquals(listOf(1L), (vm.state.value.items as UiState.Success).data.map { it.id })
}
@Test fun aResumeRevalidationReturningTheSameUserDoesNotRefetch() {
// The other half: the gate is the account, not every session emission.
val sessions = sessionFor(33)
api.history = rows(9, 8)
val vm = MyEventsViewModel(repository, sessions)
val callsAfterFirstLoad = api.historyCalls
sessions.onUserRefreshed(playerDto(33))
assertEquals(callsAfterFirstLoad, api.historyCalls)
assertEquals(2, (vm.state.value.items as UiState.Success).data.size)
}
@Test fun aShortFirstPageIsTheEnd() = runTest {
api.history = rows(3, 2, 1)
val state = MyEventsViewModel(repository, sessionFor(1)).state.value
assertEquals(3, (state.items as UiState.Success).data.size)
assertTrue(!state.hasMore)
}
@Test fun aFullPageWalksBackOnTheLastRowsOwnId() = runTest {
// Keyset, never an offset: the list gains rows at the top as the reader
// attends things, so an offset page would skip and repeat around the seam.
api.history = rows(*(1L..25L).reversed().toList().toLongArray())
val vm = MyEventsViewModel(repository, sessionFor(1))
assertTrue(vm.state.value.hasMore)
api.history = rows(0)
vm.loadMore()
assertEquals(1L, api.lastBefore)
assertEquals(26, (vm.state.value.items as UiState.Success).data.size)
assertTrue(!vm.state.value.hasMore)
}
@Test fun aFailedNextPageKeepsThePagesAlreadyRead() = runTest {
api.history = rows(*(1L..25L).reversed().toList().toLongArray())
val vm = MyEventsViewModel(repository, sessionFor(1))
api.error = IOException("offline")
vm.loadMore()
// Not an error screen replacing a screenful of history.
assertEquals(25, (vm.state.value.items as UiState.Success).data.size)
assertTrue(!vm.state.value.loadingMore)
}
@Test fun loadMoreDoesNothingWithoutAFullFirstPage() = runTest {
api.history = rows(2, 1)
val vm = MyEventsViewModel(repository, sessionFor(1))
val callsAfterLoad = api.historyCalls
vm.loadMore()
assertEquals(callsAfterLoad, api.historyCalls)
}
}

View File

@@ -0,0 +1,175 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.core.auth.Role
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionUser
import com.runicgateway.app.data.repository.Capability
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.SiteCapabilities
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The third gate on a drawer row (PLAN.md §5, §9 M13): whether the code behind it
* is installed on this backend at all.
*
* **A different question from the feature flag, which is why it is a third
* filter.** Capability is per HOST — it changes when an operator installs or
* removes a module. A feature is per VIEWER — it changes on sign-in. The two also
* fail differently, and the difference is the bug this milestone fixed.
*/
class MenuCapabilityGatingTest {
private fun signedIn(role: Role) =
Session.SignedIn(SessionUser(id = 1, username = "u", role = role))
private fun serving(vararg caps: String) =
SiteCapabilities(core = emptySet(), modules = caps.toSet())
private val shardEntry = MenuEntry(
"shard",
0,
MenuAccess.PUBLIC,
feature = ShardFeature.STATUS,
capability = Capability.SHARD,
)
private val eventsEntry = MenuEntry("events", 0, MenuAccess.PUBLIC, capability = Capability.EVENTS)
private val plainEntry = MenuEntry("news", 0, MenuAccess.PUBLIC)
private fun everyFeature() = ShardFeatures(level = "anonymous", visible = setOf(ShardFeature.STATUS))
@Test fun aRowHidesWhenTheBackendSaysItsModuleIsNotInstalled() {
// The whole point. On a site with no game module `/public/shard/features`
// 404s, so the FEATURE answer is unknown and fails open — and before M13
// that was the only answer the app had, so the row rendered and 404'd.
val entries = listOf(plainEntry, shardEntry, eventsEntry)
val visible = visibleEntries(
entries,
Session.SignedOut,
features = null,
capabilities = serving("events"),
).map { it.route }
assertEquals(listOf("news", "events"), visible)
}
@Test fun anUnknownCapabilityAnswerLeavesEveryRowShowing() {
// A host that has never answered. Same fail-open direction the feature gate
// takes, and for the same reason: the server gates every call regardless.
val entries = listOf(plainEntry, shardEntry, eventsEntry)
val visible = visibleEntries(
entries,
Session.SignedOut,
features = everyFeature(),
capabilities = null,
).map { it.route }
assertEquals(listOf("news", "shard", "events"), visible)
}
@Test fun anEmptyAnswerIsNotAnUnknownAnswer() {
// The distinction the whole milestone rests on, as one assertion.
assertTrue(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), null))
assertFalse(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), serving()))
}
@Test fun bothGatesMustPassAndNeitherCanOverrideTheOther() {
val installed = serving(Capability.SHARD)
// Installed but not published to this viewer: hidden.
assertFalse(
isEntryVisible(shardEntry, Session.SignedOut, ShardFeatures("anonymous", emptySet()), installed),
)
// Published but the module is gone: hidden. (Not a state a real backend
// reaches, and the gate must not depend on that.)
assertFalse(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), serving()))
// Both: shown.
assertTrue(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), installed))
}
@Test fun theRoleGateStillOutranksBoth() {
// An admin row is an admin row on a backend that serves everything.
val adminEntry = MenuEntry("admin/dashboard", 0, MenuAccess.STAFF)
assertFalse(
isEntryVisible(adminEntry, Session.SignedOut, everyFeature(), serving(Capability.SHARD)),
)
assertTrue(
isEntryVisible(adminEntry, signedIn(Role.ADMIN), everyFeature(), serving(Capability.SHARD)),
)
}
@Test fun aRowWithNoCapabilityIsNeverGatedByOne() {
// Every row that predates M13 keeps the behaviour it had.
assertTrue(isEntryVisible(plainEntry, Session.SignedOut, null, serving()))
assertTrue(isEntryVisible(plainEntry, Session.SignedOut, null, null))
}
// ── The shipped menu, as coded ───────────────────────────────────────
@Test fun everyRowOnAModulePathDeclaresTheShardCapability() {
// **Defined by ROUTE, not by "has a feature", and the live walk is why.**
// The first cut of this test asked whether every row with a `feature`
// declared the capability — which is true and insufficient: the three
// player game-data rows read `/player/shard/*`, the same module's player
// mount, and carry no feature at all because they are gated by ownership
// rather than by the visibility framework. They rendered on a backend with
// no module installed and answered "This content couldn't be found",
// through a green suite.
val onAModulePath = APP_MENU.filter {
it.route.startsWith("shard") || it.route.startsWith("player/") || it.route == Routes.ATLAS
}
assertEquals(8, onAModulePath.size)
assertTrue(
onAModulePath.filter { it.capability != Capability.SHARD }.map { it.route }.toString(),
onAModulePath.all { it.capability == Capability.SHARD },
)
}
@Test fun aModuleLessBackendShowsNoModuleRowToAnybody() {
// The walk's assertion, as a test: every rung, and not one module row.
val core = SiteCapabilities(core = setOf(Capability.EVENTS), modules = emptySet())
for (session in listOf(
Session.SignedOut,
signedIn(Role.PLAYER),
signedIn(Role.ADMIN),
)) {
val visible = visibleEntries(APP_MENU, session, everyFeature(), core).map { it.route }
assertTrue(
visible.toString(),
visible.none {
it.startsWith("shard") || it.startsWith("player/") || it == Routes.ATLAS
},
)
// …and the core rows are all still there.
assertTrue(Routes.EVENTS in visible)
assertTrue(Routes.NEWS in visible)
}
}
@Test fun bothEventRowsDeclareCoresCapabilityAndNoFeature() {
// Events are core's. A `feature` on one of them would gate a core screen on
// a module's visibility config, which is the coupling this separation exists
// to prevent.
val eventRows = APP_MENU.filter { it.capability == Capability.EVENTS }
assertEquals(listOf(Routes.EVENTS, Routes.MY_EVENTS), eventRows.map { it.route })
assertTrue(eventRows.all { it.feature == null })
}
@Test fun myEventsIsSignedInRatherThanPlayer() {
// The route is `requireAuth` alone and self-scoped; staff attend events too,
// and the website needed two mounts only because of its own /account guard.
val row = APP_MENU.first { it.route == Routes.MY_EVENTS }
assertEquals(MenuAccess.SIGNED_IN, row.access)
assertTrue(isEntryVisible(row, signedIn(Role.ADMIN), null, serving(Capability.EVENTS)))
assertTrue(isEntryVisible(row, signedIn(Role.PLAYER), null, serving(Capability.EVENTS)))
assertFalse(isEntryVisible(row, Session.SignedOut, null, serving(Capability.EVENTS)))
}
}

View File

@@ -44,12 +44,30 @@ class NavOverridesTest {
private fun routes(nav: JsonObject?) = applyNavOverrides(APP_MENU, nav).map { it.route }
/** The public block's routes, in coded order — the first nine of APP_MENU. */
private val codedPublic = listOf(
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.page("about"),
/**
* The public block once a stored row has made the merge sort it — the website's
* number line, not the app's coded order.
*
* **About sits above the shard rows here, and that is the corrected table
* showing through** (M13): About is core's last nav row at index 7 and the
* module's nine append after it at 8-16. Under the stale sixteen-row table
* About was index 15 and came last, which is what these assertions used to say.
*
* **The Rust row is last, at 17** (M14). These tests carry no capability
* answer, which fails open, so both game modules' rows appear here — a state
* no real backend is in and exactly the one this merge has to be correct for,
* since the sort key is the website's number line and not what happens to be
* installed.
*/
private val mergedPublic = listOf(
Routes.HOME, Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.page("about"),
Routes.SHARD, Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS,
Routes.SHARD_MARKET, Routes.RUST,
)
/** How many rows that block holds, so the take/drop below say why. */
private val publicBlock = mergedPublic.size
// ── AC-1: the untouched instance ─────────────────────────────────────
@Test fun noStoredRowReturnsTheCodedMenuItself() {
@@ -71,7 +89,7 @@ class NavOverridesTest {
"/site/news" to entry(hidden = false),
"/admin/appearance" to entry(label = "Nope"),
"/site/screenshots" to entry(label = "Shots", order = 0),
"/site/champs" to entry(hidden = true),
"/uo/champs" to entry(hidden = true),
)
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
@@ -85,7 +103,7 @@ class NavOverridesTest {
val merged = applyNavOverrides(APP_MENU, stored)
assertEquals(codedPublic, merged.take(9).map { it.route })
assertEquals(mergedPublic, merged.take(publicBlock).map { it.route })
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
assertNull(merged.first { it.route == Routes.NEWS }.label)
}
@@ -93,14 +111,16 @@ class NavOverridesTest {
// ── Labels ───────────────────────────────────────────────────────────
@Test fun aLabelOverridesTheBundledString() {
val merged = applyNavOverrides(APP_MENU, nav("/site/shard" to entry(label = " The Realm ")))
// `/uo/shard`, not `/site/shard`: the row belongs to module-uo and core
// mounts a module's pages at `/<id>/<path>` (M13).
val merged = applyNavOverrides(APP_MENU, nav("/uo/shard" to entry(label = " The Realm ")))
val shard = merged.first { it.route == Routes.SHARD }
assertEquals("The Realm", shard.label)
// The override lands on `label` and nothing else — the gates are untouched.
assertEquals(ShardFeature.STATUS, shard.feature)
assertEquals(MenuAccess.PUBLIC, shard.access)
assertEquals(codedPublic, merged.take(9).map { it.route })
assertEquals(mergedPublic, merged.take(publicBlock).map { it.route })
}
@Test fun aNonStringLabelIsIgnored() {
@@ -112,7 +132,7 @@ class NavOverridesTest {
// ── Hidden ───────────────────────────────────────────────────────────
@Test fun hiddenDropsTheRow() {
val routes = routes(nav("/site/market" to entry(hidden = true)))
val routes = routes(nav("/uo/market" to entry(hidden = true)))
assertTrue(Routes.SHARD_MARKET !in routes)
assertEquals(APP_MENU.size - 1, routes.size)
@@ -128,7 +148,7 @@ class NavOverridesTest {
}
@Test fun hiddenFalseHidesNothing() {
assertSame(APP_MENU, applyNavOverrides(APP_MENU, nav("/site/market" to entry(hidden = false))))
assertSame(APP_MENU, applyNavOverrides(APP_MENU, nav("/uo/market" to entry(hidden = false))))
}
@Test fun hiddenWinsOverALabelOnTheSameRow() {
@@ -140,44 +160,51 @@ class NavOverridesTest {
// ── Order ────────────────────────────────────────────────────────────
@Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() {
// The website's own indices: About is 15 and Home is 0, so swapping them
// is what an admin dragging About to the top writes.
// The website's own indices: About is 7, Market is 16, and the Rust row —
// the last of all, now that a second game module's row appends after the
// first's nine — is 17. Dragging About to the top and Home past the end
// writes exactly this.
val routes = routes(
nav(
"/site/about" to entry(order = 0),
"/" to entry(order = 15),
"/" to entry(order = 18),
),
)
assertEquals(
listOf(
Routes.page("about"), Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.HOME,
Routes.page("about"), Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.SHARD,
Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET,
Routes.RUST, Routes.HOME,
),
routes.take(9),
routes.take(publicBlock),
)
}
@Test fun anUntouchedRowKeepsItsPlaceOnTheWebsitesNumberLine() {
// The tie-break that needs the website's order rather than the app's: an
// explicit 5 meets Wiki's implicit 5 (its index in the site's nav, where
// the three news categories sit between News and Wiki). Explicit wins.
val routes = routes(nav("/site/about" to entry(order = 5)))
// explicit 6 meets Wiki's implicit 6 (its index in the site's nav, where
// Events and the three news categories sit between News and Wiki). Explicit
// wins. That the number moved from 5 to 6 when the site gained a row is the
// whole reason this table has to track the site's nav rather than the app's.
val routes = routes(nav("/site/about" to entry(order = 6)))
assertEquals(
listOf(Routes.HOME, Routes.NEWS, Routes.page("about"), Routes.WIKI),
routes.take(4),
listOf(Routes.HOME, Routes.NEWS, Routes.EVENTS, Routes.page("about"), Routes.WIKI),
routes.take(5),
)
}
@Test fun theAppsOwnRowsKeepTheirCodedOrderAfterThePublicBlock() {
// Contact, Account, Notifications, the three player groups and the four
// staff rows have no website counterpart to be reordered against (§6.2).
val tail = APP_MENU.drop(9).map { it.route }
// Contact, Account, Notifications, My Events, the three player groups and
// the four staff rows have no website counterpart to be reordered against
// (§6.2) — My Events because `/account/events` is behind the site's own
// auth guard and is not on its public nav at all.
val tail = APP_MENU.drop(publicBlock).map { it.route }
val merged = routes(nav("/site/about" to entry(order = 0)))
assertEquals(tail, merged.drop(9))
assertEquals(tail, merged.drop(publicBlock))
}
@Test fun reorderingAndHidingCompose() {
@@ -224,7 +251,7 @@ class NavOverridesTest {
@Test fun anOverrideCannotUnhideAFeatureGatedRow() {
val stored = nav(
"/site/market" to entry(label = "Bazaar", hidden = false, order = 0),
"/uo/market" to entry(label = "Bazaar", hidden = false, order = 0),
)
val visible = visibleEntries(

View File

@@ -19,13 +19,27 @@ import org.junit.Test
class NavPathsTest {
@Test fun everyWebsiteNavPathIsMapped() {
// The sixteen rows of SiteHeader.jsx's NAV, quoted in NavPaths.kt. If the
// site adds one, this is the test that says so — a path with no mapping is
// silently unresolvable in phase 6's link handling.
assertEquals(16, WEBSITE_PUBLIC_NAV.size)
// Core's eight rows, module-uo's nine and module-rust's one, all quoted in
// NavPaths.kt. If any of the three adds one, this is the test that says so
// — a path with no mapping is silently unresolvable in phase 6's link
// handling, which is exactly how the nine shard rows went stale for a month
// after the module-system cutover moved them from /site/ to /uo/ (M13).
assertEquals(18, WEBSITE_PUBLIC_NAV.size)
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size)
}
@Test fun theShardRowsAreTheModulesPathsNotCores() {
// The defect M13 fixed, kept as an assertion: these nine belong to
// module-uo and core mounts a module's pages at `/<id>/<path>`. A `/site/`
// spelling here is the stale table coming back.
val shard = WEBSITE_PUBLIC_NAV.map { it.path }.filter { it.startsWith("/uo/") }
assertEquals(9, shard.size)
assertTrue(WEBSITE_PUBLIC_NAV.none { it.path.startsWith("/site/shard") })
assertTrue(WEBSITE_PUBLIC_NAV.none { it.path == "/site/champs" })
assertNull(appRouteForWebPath("/site/champs"))
assertEquals(Routes.SHARD_CHAMPS, appRouteForWebPath("/uo/champs"))
}
@Test fun everyMappedRouteIsDistinct() {
// WEB_ROUTE_ORDER is keyed by route, so a duplicate would silently drop a
// row's position from the sort.
@@ -34,24 +48,36 @@ class NavPathsTest {
}
@Test fun theWebsitesOrderIsPreserved() {
// Load-bearing: a stored `order` is an index into this list.
// Load-bearing: a stored `order` is an index into this list. Core numbers
// 0-7 and `mergeFlat` appends the module's rows after them, none of which
// declares an `order` of its own.
assertEquals(0, WEB_ROUTE_ORDER[Routes.HOME])
assertEquals(1, WEB_ROUTE_ORDER[Routes.NEWS])
assertEquals(5, WEB_ROUTE_ORDER[Routes.WIKI])
assertEquals(15, WEB_ROUTE_ORDER[Routes.page("about")])
assertEquals(2, WEB_ROUTE_ORDER[Routes.EVENTS])
assertEquals(6, WEB_ROUTE_ORDER[Routes.WIKI])
assertEquals(7, WEB_ROUTE_ORDER[Routes.page("about")])
assertEquals(8, WEB_ROUTE_ORDER[Routes.SHARD])
assertEquals(16, WEB_ROUTE_ORDER[Routes.SHARD_MARKET])
}
@Test fun theNineDrawerRowsAreTheIntersectionWithAppMenu() {
// Nine of the sixteen have a drawer row. The other seven are mapped but not
// surfaced — three news category tabs and the four Shard hub boards — and
// an override for one of them is ignored rather than obeyed (§6.2).
@Test fun theDrawerRowsAreTheIntersectionWithAppMenu() {
// Eleven of the eighteen have a drawer row. The other seven are mapped but
// not surfaced — three news category tabs and the four Shard hub boards —
// and an override for one of them is ignored rather than obeyed (§6.2).
//
// **Both game modules appear here, and no backend serves both lists.** The
// table is a superset on purpose: a path for a module an operator has not
// installed never appears in that backend's nav and is never looked up,
// while a path MISSING from it makes a link that does exist hand off to a
// browser.
val coded = APP_MENU.map { it.route }.toSet()
val surfaced = WEBSITE_PUBLIC_NAV.filter { it.route in coded }.map { it.path }
assertEquals(
listOf(
"/", "/site/news", "/wiki", "/site/shard", "/site/rules",
"/site/atlas", "/site/leaderboards", "/site/market", "/site/about",
"/", "/site/news", "/site/events", "/wiki", "/site/about",
"/uo/shard", "/uo/rules", "/uo/atlas", "/uo/leaderboards", "/uo/market",
"/rust",
),
surfaced,
)
@@ -62,7 +88,7 @@ class NavPathsTest {
// tab or a hub board is a perfectly good destination.
val unsurfaced = listOf(
"/site/screenshots", "/site/five-on-friday", "/site/newsletter",
"/site/champs", "/site/guilds", "/site/governors", "/site/houses",
"/uo/champs", "/uo/guilds", "/uo/governors", "/uo/houses",
)
assertTrue(unsurfaced.all { appRouteForWebPath(it) != null })
@@ -113,13 +139,13 @@ class NavPathsTest {
@Test fun aTrailingSlashIsTolerated() {
// A hand-edited settings row may carry one; the root is left alone.
assertEquals(Routes.WIKI, appRouteForWebPath("/wiki/"))
assertEquals(Routes.SHARD, appRouteForWebPath(" /site/shard/ "))
assertEquals(Routes.SHARD, appRouteForWebPath(" /uo/shard/ "))
assertEquals(Routes.HOME, appRouteForWebPath("/"))
}
// ── resolveWebPath: an added link may name any page on the site (§6.3) ──
@Test fun theNavTablesSixteenPathsResolveTheSameWay() {
@Test fun theNavTablesPathsResolveTheSameWay() {
// An added link to a path the nav already knows must land where the nav row
// does, or the same destination would behave differently depending on how
// the admin reached it.
@@ -132,9 +158,71 @@ class NavPathsTest {
// Read off website/client/src/App.jsx. Note what is NOT here: the site has
// no /site/news/<id> route — its one post-detail route is the newsletter's.
assertEquals(Routes.wikiPage("smithing"), resolveWebPath("/wiki/smithing"))
assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/site/atlas/dragon"))
assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/site/market/vendors/0x24C"))
assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/uo/atlas/dragon"))
assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/uo/market/vendors/0x24C"))
assertEquals(Routes.post("newsletter", "12"), resolveWebPath("/site/newsletter/12"))
// The module's detail routes are the module's; the old /site/ spelling is
// not a second address for them.
assertNull(resolveWebPath("/site/atlas/dragon"))
assertNull(resolveWebPath("/site/market/vendors/0x24C"))
}
// ── Events (M13) ───────────────────────────────────────
@Test fun theEventPagesResolve() {
assertEquals(Routes.EVENTS, resolveWebPath("/site/events"))
assertEquals(Routes.event("the-yew-invasion"), resolveWebPath("/site/events/the-yew-invasion"))
assertEquals(
Routes.eventSeries("the-void"),
resolveWebPath("/site/events/series/the-void"),
)
}
@Test fun anEventUrlsRunIsCarriedThrough() {
// The one exception to "a query hands off", and the whole reason for it:
// this is the exact shape events Phase 14a's `eventUrl` writes into every
// announcement. Dropping the run would open next Friday's occurrence from a
// mail about last Friday's.
assertEquals(
Routes.event("the-yew-invasion", "3692"),
resolveWebPath("/site/events/the-yew-invasion?run=3692"),
)
assertEquals("events/the-yew-invasion?run=3692", Routes.event("the-yew-invasion", "3692"))
}
@Test fun theRunCarveOutIsOneKeyOnOnePath() {
// Narrow on purpose. Anything the app cannot honor natively hands off, so
// the browser gets the parameter the author actually wrote.
assertNull(resolveWebPath("/site/events/x?utm=mail"))
assertNull(resolveWebPath("/site/events/x?run=3&utm=mail"))
assertNull(resolveWebPath("/site/events/x?run="))
assertNull(resolveWebPath("/site/events/x#results"))
assertNull(resolveWebPath("/site/events?seriesId=3"))
assertNull(resolveWebPath("/site/events/series/the-void?run=3"))
// And no OTHER path gained a query: the rule is one path's, not general.
assertNull(resolveWebPath("/wiki/smithing?x=1"))
}
@Test fun anEventRouteWithNoRunCarriesNoEmptyArgument() {
// `events/x?run=` would reach the screen as a blank string and be forwarded
// to the server as one.
assertEquals("events/x", Routes.event("x"))
assertEquals("events/x", Routes.event("x", null))
assertEquals("events/x", Routes.event("x", " "))
}
@Test fun theEventRoutePatternStripsToTheTopLevelRoute() {
// Same rule the News hub needs: `destination.route` is the pattern, and the
// drawer compares on the part before the query.
assertEquals(Routes.EVENTS, Routes.EVENT_ROUTE.substringBefore('?').substringBefore('/'))
assertEquals("events/{slug}", Routes.EVENT_ROUTE.substringBefore('?'))
}
@Test fun myEventsHasNoDynamicSibling() {
// `events/mine` would race `events/{slug}` — both two segments — which is
// the static-versus-argument bug events Phase 13 shipped one tier along.
assertTrue(Routes.MY_EVENTS.startsWith("account/"))
assertNull(resolveWebPath("/account/events"))
}
@Test fun aTopLevelSlugIsACmsPage() {
@@ -157,15 +245,18 @@ class NavPathsTest {
@Test fun aPathTheAppHasNoScreenForHandsOff() {
assertNull(resolveWebPath("/site/status"))
assertNull(resolveWebPath("/site/shard/activity"))
assertNull(resolveWebPath("/uo/shard/activity"))
assertNull(resolveWebPath("/uo/guilds/12"))
// `/uo` is a module's namespace, not a CMS page slug.
assertNull(resolveWebPath("/uo"))
assertNull(resolveWebPath("/account/login"))
assertNull(resolveWebPath("/admin/navigation"))
assertNull(resolveWebPath("/site/atlas/dragon/extra"))
}
@Test fun aQueryOrFragmentHandsOff() {
// No app route takes either, so a native match would quietly drop what the
// admin wrote. The browser honors it exactly.
// No app route but the event page takes either, so a native match would
// quietly drop what the admin wrote. The browser honors it exactly.
assertNull(resolveWebPath("/site/news?tag=patch"))
assertNull(resolveWebPath("/donate#tiers"))
assertEquals(Routes.NEWS, resolveWebPath("/site/news"))

View File

@@ -166,15 +166,17 @@ class NavTreeTest {
val shape = tree(row).shape()
// Eight public rows are left at the top level (Wiki moved into the section),
// then the section, then the app's own rows.
assertEquals("section:lore", shape[8])
assertEquals(Routes.CONTACT, shape[9])
// Ten public rows are left at the top level (Wiki moved into the section),
// then the section, then the app's own rows. The tenth is the Rust row:
// these tests carry no capability answer, which fails open, so both game
// modules' rows are present — see NavOverridesTest's `mergedPublic`.
assertEquals("section:lore", shape[10])
assertEquals(Routes.CONTACT, shape[11])
}
@Test fun aSectionsOrderPlacesItAmongTheCodedRows() {
// Sections sort on the same number line as everything else: the website's
// sixteen indices, then admin-created entities after them.
// seventeen indices, then admin-created entities after them.
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore", order = 0)),
@@ -252,7 +254,7 @@ class NavTreeTest {
// deliberately, and grouping is no more an invitation to surface one than
// relabelling was (§6.2).
val row = stored(
items = items("/site/champs" to item(section = "lore", label = "Champs")),
items = items("/uo/champs" to item(section = "lore", label = "Champs")),
sections = listOf(section("lore")),
)
@@ -324,7 +326,9 @@ class NavTreeTest {
val shape = tree(row).shape()
assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") })
assertEquals(Routes.page("about"), shape[shape.indexOf("link:a") - 1])
// The Rust row, not About: both modules' rows append after core's eight on
// the website's number line, and Rust's is last at 17.
assertEquals(Routes.RUST, shape[shape.indexOf("link:a") - 1])
}
@Test fun aLinksOrderPlacesItAmongTheCodedRows() {
@@ -364,7 +368,7 @@ class NavTreeTest {
// The case the rule exists for: a group whose every member is withheld by
// the shard's visibility config must not draw as a header over nothing.
val row = stored(
items = items("/site/market" to item(section = "lore")),
items = items("/uo/market" to item(section = "lore")),
sections = listOf(section("lore")),
)
@@ -380,7 +384,7 @@ class NavTreeTest {
@Test fun aSectionKeepsTheMembersThisCallerMaySee() {
val row = stored(
items = items(
"/site/market" to item(section = "lore"),
"/uo/market" to item(section = "lore"),
"/wiki" to item(section = "lore"),
),
sections = listOf(section("lore")),
@@ -400,7 +404,7 @@ class NavTreeTest {
// section of its own — and still not shown, because the shard does not
// publish the market and an admin does not outrank that.
val row = stored(
items = items("/site/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")),
items = items("/uo/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")),
sections = listOf(section("lore", order = 0)),
)
@@ -418,7 +422,7 @@ class NavTreeTest {
// Links carry no gate — the page behind one enforces its own access — so a
// section holding one is never emptied by the caller's role.
val row = stored(
items = items("/site/market" to item(section = "lore")),
items = items("/uo/market" to item(section = "lore")),
sections = listOf(section("lore")),
links = listOf(link(section = "lore")),
)

View File

@@ -0,0 +1,112 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.data.repository.Capability
import com.runicgateway.app.data.repository.SiteCapabilities
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The Rust row, its routes, and the two ways a Rust link can reach the app
* (M14, `docs/modules/rust/PLAN.md` §18).
*
* The question this file really asks is the phase criterion's other half: **a UO
* site is unchanged.** Two game modules can be installed on one backend, and
* neither one's rows may appear on a site running only the other.
*/
class RustNavigationTest {
private fun serving(vararg caps: String) =
SiteCapabilities(core = emptySet(), modules = caps.toSet())
private fun routesFor(capabilities: SiteCapabilities?) =
visibleEntries(APP_MENU, Session.SignedOut, features = null, capabilities = capabilities)
.map { it.route }
@Test fun aRustSiteShowsTheRustRowAndNoShardRows() {
val routes = routesFor(serving(Capability.RUST))
assertTrue(Routes.RUST in routes)
assertFalse("a Rust site has no shard", Routes.SHARD in routes)
assertFalse(Routes.ATLAS in routes)
}
@Test fun aUoSiteIsExactlyAsItWas() {
// The other half of the criterion. Adding a second game module must not
// put a row on a site that does not run it.
val routes = routesFor(serving(Capability.SHARD))
assertFalse("a UO site has no Rust row", Routes.RUST in routes)
assertTrue(Routes.SHARD in routes)
}
@Test fun bothModulesInstalledShowsBothTrees() {
val routes = routesFor(serving(Capability.SHARD, Capability.RUST))
assertTrue(Routes.SHARD in routes)
assertTrue(Routes.RUST in routes)
}
@Test fun theSurfaceCapabilitiesDoNotRevealTheRow() {
// `module-rust` declares `servers`, `killfeed`, `leaderboard`, `presence`
// and `wipes` as well, and the app gates on NONE of them — every one names
// a surface, and core flattens all modules' capabilities into one list, so
// another module declaring `servers` would otherwise reveal these screens
// on a site with no Rust at all.
val routes = routesFor(serving("servers", "killfeed", "leaderboard", "presence", "wipes"))
assertFalse(Routes.RUST in routes)
}
@Test fun aHostThatHasNeverAnsweredStillShowsEverything() {
// Fail-open on an UNKNOWN answer, which is not the same as an empty one.
// The server gates every call regardless, so the cost of guessing wrong is
// a link that briefly 404s.
assertTrue(Routes.RUST in routesFor(null))
}
@Test fun aServerIdIsEncodedIntoItsRoute() {
assertEquals("rust/servers/main", Routes.rustServer("main"))
assertEquals("rust/servers/eu-main", Routes.rustServer("eu-main"))
// An operator names these, and nothing stops one carrying a character a
// path would otherwise eat.
assertEquals("rust/servers/a%2Fb", Routes.rustServer("a/b"))
assertEquals("rust/servers/two%20words", Routes.rustServer("two words"))
}
@Test fun theWebsiteNavRowOpensNatively() {
// `module-rust` registers exactly one nav item, `{ label: 'Servers', to:
// '/rust' }`. Without this mapping an admin's nav override on that row —
// or an added link to it — hands off to a browser instead.
assertEquals(Routes.RUST, appRouteForWebPath("/rust"))
assertEquals(Routes.RUST, appRouteForWebPath("/rust/"))
}
@Test fun anAddedLinkToOneServerResolves() {
assertEquals(Routes.rustServer("main"), resolveWebPath("/rust/servers/main"))
}
@Test fun theRustPrefixIsNotMistakenForACmsPage() {
// The site serves CMS pages from a top-level `/<slug>`, and `/rust` would
// otherwise fall into that rule and open a page-not-found screen. It is
// reserved so the nav table above answers it — and so that a site WITHOUT
// the module hands off to the browser, which gives the same answer the web
// would.
assertEquals(Routes.RUST, resolveWebPath("/rust"))
assertNull("a deeper unknown Rust path hands off", resolveWebPath("/rust/servers/main/extra"))
}
@Test fun aRustPathWithAQueryHandsOff() {
// The website keeps tab, filter, wipe and sort in the URL; the app keeps
// them in a view model. Resolving `?tab=wipes` natively would silently drop
// what the admin wrote, so it goes to the browser, which honors it.
assertNull(resolveWebPath("/rust?tab=wipes"))
assertNull(resolveWebPath("/rust/servers/main?wipe=w1"))
}
}

View File

@@ -0,0 +1,43 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* The inbox timestamp (ENGAGEMENT.md phase 8). Both wire shapes have to be read,
* and the zoneless one has to be read as UTC — reading it as local time would
* shift every stamp by the device's offset and nobody would notice until they
* travelled.
*/
class InboxFormattingTest {
private val zone = ZoneId.of("America/New_York")
private val format: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm", Locale.US)
@Test fun readsAnIsoStampWithAZone() {
// 12:30 UTC is 08:30 in New York on that date (EDT).
assertEquals("2026-08-31 08:30", inboxTimestamp("2026-08-31T12:30:00.000Z", zone, format))
}
@Test fun readsAZonelessStampAsUtc() {
assertEquals("2026-08-31 08:30", inboxTimestamp("2026-08-31 12:30:00", zone, format))
}
@Test fun readsAZonelessStampWithATSeparator() {
assertEquals("2026-08-31 08:30", inboxTimestamp("2026-08-31T12:30:00", zone, format))
}
@Test fun anUnparseableStampShowsNothingRatherThanFailing() {
assertNull(inboxTimestamp("sometime last week", zone, format))
assertNull(inboxTimestamp("", zone, format))
assertNull(inboxTimestamp(" ", zone, format))
}
}

View File

@@ -0,0 +1,282 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.StoredSession
import com.runicgateway.app.core.auth.TokenStore
import com.runicgateway.app.core.inbox.InboxCache
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.dto.NotificationInboxDto
import com.runicgateway.app.data.api.dto.NotificationItemDto
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
import com.runicgateway.app.data.api.dto.SafeUserDto
import com.runicgateway.app.data.api.fake.FakeNotificationsApi
import com.runicgateway.app.data.repository.NotificationsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.FakeInboxCache
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import java.io.IOException
/**
* The inbox view model (ENGAGEMENT.md phase 8): the pull behind the tickle, the
* keyset paging, the optimistic reads, and the offline snapshot — including the
* one property that makes caching a person's notifications safe at all, that a
* snapshot never crosses an account.
*/
class InboxViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private class FakeTokenStore(private var stored: StoredSession?) : TokenStore {
override fun load(): StoredSession? = stored
override fun save(session: StoredSession) { stored = session }
override fun clear() { stored = null }
}
private val api = FakeNotificationsApi()
private val cache = FakeInboxCache()
private val baseUrl = BaseUrlHolder().apply { set("https://shard.example/".toHttpUrl()) }
private fun session(userId: Long = 7) =
SessionManager(FakeTokenStore(StoredSession("access", "refresh", userId, "alice", "player")))
private fun viewModel(sessionManager: SessionManager = session()) =
InboxViewModel(NotificationsRepository(api), cache, sessionManager, baseUrl)
private fun item(id: Long, read: Boolean = false) =
NotificationItemDto(id = id, triggerId = "team.post.created", title = "Post $id", read = read)
private fun shown(vm: InboxViewModel) = (vm.state.value.items as? UiState.Success)?.data
@Test fun loadsTheFirstPageAndCachesIt() {
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2))
val vm = viewModel()
assertEquals(listOf(2L, 1L), shown(vm)?.map { it.id })
assertEquals(2, vm.state.value.unread)
assertFalse(vm.state.value.fromCache)
assertEquals(1, cache.writes)
}
@Test fun offlineFallsBackToTheSnapshotAndSaysSo() {
runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) }
api.error = IOException("offline")
val vm = viewModel()
assertEquals(listOf(9L), shown(vm)?.map { it.id })
assertEquals(1, vm.state.value.unread)
assertTrue(vm.state.value.fromCache)
}
@Test fun aSnapshotIsNeverShownToAnotherAccount() {
// The property the whole cache design rests on: user 7's items must not
// appear under user 8's session, on a device both have signed into.
runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) }
api.error = IOException("offline")
val vm = viewModel(session(userId = 8))
assertNull(shown(vm))
assertTrue(vm.state.value.items is UiState.Error)
assertEquals(0, vm.state.value.unread)
}
@Test fun aServerErrorWithNothingCachedIsTheScreen() {
api.error = httpError(500)
val vm = viewModel()
assertTrue(vm.state.value.items is UiState.Error)
assertFalse(vm.state.value.fromCache)
}
@Test fun loadMorePagesOnTheLastIdNotAnOffset() {
api.pages = mapOf(
null to NotificationInboxDto(items = listOf(item(9), item(8)), hasMore = true, unread = 2),
8L to NotificationInboxDto(items = listOf(item(7)), hasMore = false, unread = 2),
)
val vm = viewModel()
vm.loadMore()
assertEquals(listOf(null, 8L), api.inboxCalls)
assertEquals(listOf(9L, 8L, 7L), shown(vm)?.map { it.id })
assertFalse(vm.state.value.hasMore)
}
@Test fun loadMoreDropsAnIdAlreadyOnScreen() {
// A keyset window can shift under a concurrent write; a duplicate id in a
// LazyColumn key is a crash, not a cosmetic problem.
api.pages = mapOf(
null to NotificationInboxDto(items = listOf(item(9), item(8)), hasMore = true),
8L to NotificationInboxDto(items = listOf(item(8), item(7))),
)
val vm = viewModel()
vm.loadMore()
assertEquals(listOf(9L, 8L, 7L), shown(vm)?.map { it.id })
}
@Test fun loadMoreDoesNothingWhileShowingTheCache() {
runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) }
api.error = IOException("offline")
val vm = viewModel()
api.inboxCalls.clear()
vm.loadMore()
assertTrue(api.inboxCalls.isEmpty())
}
@Test fun markReadFlipsTheRowAndTakesTheServersCount() {
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2))
api.readResult = NotificationReadResultDto(ok = true, unread = 1)
val vm = viewModel()
vm.markRead(2)
assertEquals(listOf(2L), api.markedRead)
assertTrue(shown(vm)!!.first { it.id == 2L }.read)
assertEquals(1, vm.state.value.unread)
}
@Test fun markReadIsNotSentTwiceForAnItemAlreadyRead() {
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2, read = true)), unread = 0))
val vm = viewModel()
vm.markRead(2)
assertTrue(api.markedRead.isEmpty())
}
@Test fun markAllReadEmptiesTheBadgeAndUpdatesTheSnapshot() {
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2))
val vm = viewModel()
val writesAfterLoad = cache.writes
vm.markAllRead()
assertEquals(1, api.markAllReadCalls)
assertEquals(0, vm.state.value.unread)
assertTrue(shown(vm)!!.all { it.read })
// Without this write, going offline right after reading everything would
// bring the badge back on the next cold open.
assertEquals(writesAfterLoad + 1, cache.writes)
}
// ── The item link (found by the live rig, not by a test) ──────────────
@Test fun aSiteRelativeUrlIsResolvedAgainstTheShard() {
// What the server actually writes: the template's button block renders a
// path, because on the web the reader is already on the site.
val vm = viewModel()
val item = item(1).copy(url = "/guilds/the-silver-anvil/forum/403")
assertEquals("https://shard.example/guilds/the-silver-anvil/forum/403", vm.linkFor(item))
}
@Test fun anAbsoluteUrlIsLeftAlone() {
val vm = viewModel()
assertEquals("https://elsewhere.example/x", vm.linkFor(item(1).copy(url = "https://elsewhere.example/x")))
}
@Test fun anItemWithNoUrlHasNoLink() {
val vm = viewModel()
assertNull(vm.linkFor(item(1)))
assertNull(vm.linkFor(item(1).copy(url = " ")))
}
@Test fun aUrlThatCouldNotBeOpenedSafelyResolvesToNothing() {
val vm = viewModel()
assertNull(vm.linkFor(item(1).copy(url = "javascript:alert(1)")))
assertNull(vm.linkFor(item(1).copy(url = "intent://evil#Intent;end")))
}
// ── The inbox belongs to ONE account ─────────────────────────────
@Test fun switchingAccountDoesNotShowThePreviousOnesInbox() {
// **Found on the emulator, not by a test.** A drawer route's view model
// outlives a sign-out: `navigateTopLevel` saves and restores back-stack
// state, so the entry keeps its ViewModelStore and a view model that
// loaded only in `init` never runs again. Signing out and back in as
// somebody else showed the second account the FIRST account's inbox —
// titles and body text written for another person — with no request made
// at all, while the badge beside it showed the new account's real count.
val sessions = session(userId = 7)
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(1), item(2)), unread = 2))
val vm = InboxViewModel(NotificationsRepository(api), cache, sessions, baseUrl)
assertEquals(listOf(1L, 2L), shown(vm)!!.map { it.id })
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(9)), unread = 1))
sessions.onSignedOut()
// Signed out, the previous account's rows are gone rather than left
// addressable behind a shell that is navigating away.
assertEquals(emptyList<Long>(), shown(vm)!!.map { it.id })
sessions.onSignedIn("a", "r", SafeUserDto(id = 8, username = "bob", role = "player"))
assertEquals(listOf(9L), shown(vm)!!.map { it.id })
}
@Test fun aResumeRevalidationReturningTheSameUserDoesNotRefetch() {
// The gate is the account, not every session emission — the app
// re-validates its role on every resume.
val sessions = session(userId = 7)
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(1)), unread = 1))
val vm = InboxViewModel(NotificationsRepository(api), cache, sessions, baseUrl)
val callsAfterFirstLoad = api.inboxCalls.size
sessions.onUserRefreshed(SafeUserDto(id = 7, username = "alice", role = "admin"))
assertEquals(callsAfterFirstLoad, api.inboxCalls.size)
assertEquals(listOf(1L), shown(vm)!!.map { it.id })
}
// ── Opening an item in the app rather than a browser (M13) ─────────
@Test fun anEventAnnouncementOpensNativelyAndKeepsItsRun() {
// The exact shape events Phase 14a writes into every announcement. Before
// M13 this opened a Custom Tab onto a page the app now renders itself.
val vm = viewModel()
val item = item(1).copy(url = "/site/events/the-yew-invasion?run=3692")
assertEquals("events/the-yew-invasion?run=3692", vm.routeFor(item))
}
@Test fun anAbsoluteUrlOnThisHostOpensNativelyToo() {
// The url's shape is the server's to change; a link that reached the browser
// only because it arrived fully qualified would be a puzzle.
val vm = viewModel()
val item = item(1).copy(url = "https://shard.example/site/events/yew?run=7")
assertEquals("events/yew?run=7", vm.routeFor(item))
}
@Test fun aLinkToAnotherHostIsNotOursToRoute() {
val vm = viewModel()
val item = item(1).copy(url = "https://elsewhere.example/site/events/yew")
assertNull(vm.routeFor(item))
// …and still opens, in the browser, exactly as it did before.
assertEquals("https://elsewhere.example/site/events/yew", vm.linkFor(item))
}
@Test fun everyOtherLinkStillHandsOff() {
// The change is additive: a path the app has no screen for behaves exactly
// as it did, and `linkFor` is still what opens it.
val vm = viewModel()
val forum = item(1).copy(url = "/guilds/the-silver-anvil/forum/403")
assertNull(vm.routeFor(forum))
assertEquals("https://shard.example/guilds/the-silver-anvil/forum/403", vm.linkFor(forum))
}
@Test fun anItemWithNoUrlHasNoRoute() {
val vm = viewModel()
assertNull(vm.routeFor(item(1)))
assertNull(vm.routeFor(item(1).copy(url = " ")))
assertNull(vm.routeFor(item(1).copy(url = "javascript:alert(1)")))
}
}

View File

@@ -4,7 +4,7 @@
package com.runicgateway.app.ui.notifications
import com.runicgateway.app.core.push.PushStreams
import com.runicgateway.app.data.api.dto.NotificationStreamDto
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
import com.runicgateway.app.ui.navigation.Routes
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -12,8 +12,9 @@ import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Tests the pure push helpers: the stream → deep-link route map (PLAN.md §11 work
* item 7) and the personal-stream gating (a personal stream needs a linked account).
* Tests the pure notification helpers: the stream → deep-link route map (PLAN.md
* §11 work item 7), the tickle routing that ENGAGEMENT.md phase 8 layered over it,
* and the personal-item gating (a personal id needs a linked game account).
*/
class NotificationRoutingTest {
@@ -32,14 +33,41 @@ class NotificationRoutingTest {
assertEquals(Routes.HOME, Routes.forStream("something.new"))
}
@Test fun personalStreamNeedsLinkedAccount() {
val personal = NotificationStreamDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true)
assertFalse(streamSelectable(personal, hasLinkedAccount = false))
assertTrue(streamSelectable(personal, hasLinkedAccount = true))
@Test fun personalItemNeedsLinkedAccount() {
val personal = NotificationChannelItemDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true)
assertFalse(itemSelectable(personal, hasLinkedAccount = false))
assertTrue(itemSelectable(personal, hasLinkedAccount = true))
}
@Test fun generalStreamIsAlwaysSelectable() {
val general = NotificationStreamDto(id = "news.post", personal = false, requiresLinkedAccount = false)
assertTrue(streamSelectable(general, hasLinkedAccount = false))
@Test fun generalItemIsAlwaysSelectable() {
val general = NotificationChannelItemDto(id = "news.post", personal = false, requiresLinkedAccount = false)
assertTrue(itemSelectable(general, hasLinkedAccount = false))
}
// ── The tickle → destination map (ENGAGEMENT.md phase 8) ───────────────
@Test fun inboxRefLandsOnTheInboxWhateverTheStream() {
// The engine's stream id is a TRIGGER id in the one namespace, so most of
// them are strangers to `forStream` — and every one of those would have
// dropped the user on Home if the ref were not read.
assertEquals(Routes.NOTIFICATIONS, Routes.forTickle("team.post.created", "notification:42"))
assertEquals(Routes.NOTIFICATIONS, Routes.forTickle("uo.house.idoc_warning", "notification:7"))
}
@Test fun anInboxRefWinsOverAStreamThatHasItsOwnScreen() {
assertEquals(Routes.NOTIFICATIONS, Routes.forTickle(PushStreams.NEWS_POST, "notification:1"))
}
@Test fun everyOtherTickleKeepsTheRouteItAlwaysHad() {
assertEquals(Routes.NEWS, Routes.forTickle(PushStreams.NEWS_POST, null))
assertEquals(Routes.SHARD, Routes.forTickle(PushStreams.CHAMP_START, "0x40001234"))
assertEquals(Routes.PLAYER_HOUSES, Routes.forTickle(PushStreams.HOUSE_IDOC, "britain-2026-08-31"))
assertEquals(Routes.HOME, Routes.forTickle("something.new", null))
}
@Test fun aRefThatMerelyMentionsNotificationIsNotAnInboxRef() {
// Prefix, not `contains`: a ref is opaque and another producer's could
// easily carry the word without being a row id.
assertEquals(Routes.NEWS, Routes.forTickle(PushStreams.NEWS_POST, "post-notification:3"))
}
}

View File

@@ -0,0 +1,88 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import com.runicgateway.app.data.api.dto.NotificationChannelDto
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
import com.runicgateway.app.data.api.fake.FakeNotificationsApi
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The per-channel preferences the settings screen renders (ENGAGEMENT.md phases 3
* and 8). These are the wire-shape properties the UI is built ON rather than
* around, so they are asserted here rather than trusted: the controls come from
* each item's own `channels`, and the modes from each channel's own `modes`.
*
* The view model itself needs a [com.runicgateway.app.core.push.PushManager],
* which owns a foreground service and a `Context`; the sparse-PUT shape it sends
* is asserted through the repository instead, which is the part that could be
* wrong on the wire.
*/
class NotificationSettingsViewModelTest {
private val push = NotificationChannelDto(
id = "push", label = "Push", carriesContent = false,
defaultMode = "off", supportsDigest = false, modes = listOf("off", "instant"),
)
private val email = NotificationChannelDto(
id = "email", label = "Email", carriesContent = true,
defaultMode = "off", supportsDigest = true, modes = listOf("off", "instant", "digest"),
)
private fun prefs() = NotificationChannelPrefsDto(
channels = listOf(push, email),
items = listOf(
NotificationChannelItemDto(
id = "news.post", label = "News posts",
channels = listOf("push", "email"),
modes = mapOf("push" to "instant", "email" to "off"),
),
NotificationChannelItemDto(
id = "uo.house.idoc_warning", label = "House in danger",
personal = true, requiresLinkedAccount = true,
// A trigger-only id: nothing is registered to push it, so it carries
// no push key at all — the screen must render no push control rather
// than a dead switch.
channels = listOf("email"),
modes = mapOf("email" to "digest"),
),
),
)
@Test fun aTriggerOnlyIdOffersNoPushControl() {
val item = prefs().items.first { it.id == "uo.house.idoc_warning" }
assertEquals(listOf("email"), item.channels)
assertNull(item.modes[CHANNEL_PUSH])
}
@Test fun emailIsTheChannelThatCarriesDigest() {
assertEquals(listOf("off", "instant", "digest"), email.modes)
assertEquals(listOf("off", "instant"), push.modes)
}
@Test fun personalItemsStillNeedALinkedAccount() {
val personal = prefs().items.first { it.personal }
assertEquals(false, itemSelectable(personal, hasLinkedAccount = false))
assertEquals(true, itemSelectable(personal, hasLinkedAccount = true))
}
@Test fun oneToggleSendsExactlyOnePair() = kotlinx.coroutines.runBlocking {
// The sparse PUT is the whole reason this screen can save a single control
// without holding the table: anything more in the body could clobber a
// channel it is not showing.
val api = FakeNotificationsApi()
api.channelPrefs = prefs()
val repo = com.runicgateway.app.data.repository.NotificationsRepository(api)
repo.setChannelMode("news.post", "email", "digest")
assertEquals(1, api.lastPrefsUpdate?.size)
assertEquals("news.post", api.lastPrefsUpdate?.first()?.id)
assertEquals("email", api.lastPrefsUpdate?.first()?.channel)
assertEquals("digest", api.lastPrefsUpdate?.first()?.mode)
}
}

View File

@@ -0,0 +1,80 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.api.dto.RustServerListDto
import com.runicgateway.app.data.api.fake.FakeRustApi
import com.runicgateway.app.data.repository.RustRepository
import com.runicgateway.app.util.MainDispatcherRule
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.io.IOException
/** The drawer row's live count — the phone's answer to D15 (M14). */
class RustBadgeViewModelTest {
@get:Rule
val dispatcherRule = MainDispatcherRule()
private val api = FakeRustApi()
private fun viewModel() = RustBadgeViewModel(RustRepository(api))
@Test
fun `it sums the people on every server`() {
api.servers = RustServerListDto(
listOf(
RustServerDto(id = "a", online = true, players = 12),
RustServerDto(id = "b", online = true, players = 30),
),
)
val vm = viewModel()
vm.refresh(installed = true)
assertEquals(42, vm.online.value)
}
@Test
fun `a site without the module is never asked`() {
// The gate is the caller's — the drawer already knows, from the capability
// answer. A site running a different game makes no request at all.
val vm = viewModel()
vm.refresh(installed = false)
assertEquals(0, api.serversCalls)
assertEquals(0, vm.online.value)
}
@Test
fun `an unreachable server contributes nothing on its own`() {
// A stale or offline row already answers `online: false` with `players: 0`
// server-side, so there is no second staleness rule to keep in step here.
api.servers = RustServerListDto(
listOf(
RustServerDto(id = "a", online = true, players = 5),
RustServerDto(id = "b", online = false, players = 0, stale = true),
),
)
val vm = viewModel()
vm.refresh(installed = true)
assertEquals(5, vm.online.value)
}
@Test
fun `a failed read keeps the last number rather than dropping to zero`() {
// A moment with no connectivity is not everybody logging off.
api.servers = RustServerListDto(listOf(RustServerDto(id = "a", online = true, players = 7)))
val vm = viewModel()
vm.refresh(installed = true)
api.error = IOException("offline")
vm.refresh(installed = true)
assertEquals(7, vm.online.value)
}
}

View File

@@ -0,0 +1,105 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import com.runicgateway.app.data.api.dto.RustEventDto
import com.runicgateway.app.data.api.dto.RustServerListDto
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
/**
* Decoding what the module actually answers with (M14).
*
* The JSON here is copied from `module-rust`'s own shape functions rather than
* invented, because the only thing worth testing about a DTO is whether it agrees
* with the other end.
*/
class RustDtoTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `an unreachable server decodes with everything it last said`() {
// The phase criterion in one object: `online: false`, `stale: true`, and
// every descriptive field still populated. This is what makes a page that
// renders while the game is off possible at all.
val list = json.decodeFromString<RustServerListDto>(
"""
{"servers":[{
"id":"main","name":"Main","online":false,"players":0,"maxPlayers":100,
"hostname":"Main | Vanilla","level":"Procedural Map","worldSize":4000,
"seed":1934567,"wipeId":"w-2026-09-04","wipedAt":"2026-09-04T18:00:00.000Z",
"lastSeenAt":"2026-09-14T10:12:00.000Z","updatedAt":"2026-09-16T13:59:30.000Z",
"stale":true
}]}
""".trimIndent(),
)
val server = list.servers.single()
assertFalse(server.online)
assertTrue(server.stale)
assertEquals("Procedural Map", server.level)
assertEquals(4000, server.worldSize)
// The two timestamps are two facts and both survive the wire.
assertEquals("2026-09-14T10:12:00.000Z", server.lastSeenAt)
assertEquals("2026-09-16T13:59:30.000Z", server.updatedAt)
}
@Test
fun `a server that has never connected decodes with nulls, not zeroes`() {
val list = json.decodeFromString<RustServerListDto>(
"""{"servers":[{"id":"new","name":"New","online":false,"players":0,"maxPlayers":0,
"hostname":null,"level":null,"worldSize":null,"seed":null,"wipeId":null,
"wipedAt":null,"lastSeenAt":null,"updatedAt":null,"stale":true}]}""",
)
val server = list.servers.single()
assertNull(server.level)
assertNull(server.worldSize)
assertNull(server.lastSeenAt)
}
@Test
fun `an unknown field does not break decoding`() {
// Protocol 2 is not the last one. A later plugin adds fields to a frame's
// envelope and an older app must keep reading the rest.
val list = json.decodeFromString<RustServerListDto>(
"""{"servers":[{"id":"main","name":"Main","somethingNew":{"a":1}}],"alsoNew":7}""",
)
assertEquals("main", list.servers.single().id)
}
@Test
fun `a frame keeps whatever the plugin wrote`() {
val event = json.decodeFromString<RustEventDto>(
"""{"id":9,"kind":"player.death","t":1789574400000,"wipeId":"w1","steamId":"765",
"frame":{"name":"Bob","attackerType":"player","attackerName":"Alice","distance":42.4}}""",
)
assertEquals("player.death", event.kind)
assertEquals(1789574400000L, event.t)
assertEquals("Alice", event.str("attackerName"))
assertEquals(42.4, event.num("distance")!!, 0.001)
assertNull("an absent field is null, not empty", event.str("weapon"))
assertFalse(event.flag("sleeping"))
}
@Test
fun `a frame with no fields at all still decodes`() {
// `server.initialized` carries an envelope and nothing else, and the model
// answers an EMPTY frame for a stored row whose JSON will not parse —
// deliberately, so one bad row does not fail a whole page.
val event = json.decodeFromString<RustEventDto>(
"""{"id":1,"kind":"server.initialized","t":1,"frame":{}}""",
)
assertNull(event.str("name"))
assertEquals(FeedTone.SERVER, describe(event).tone)
}
}

View File

@@ -0,0 +1,140 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import com.runicgateway.app.data.api.dto.RustEventDto
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* What a feed row says (M14).
*
* The frames here are the shapes the bridge plugin actually emits, written as
* JSON rather than built with a DTO constructor — the whole point of the untyped
* `frame` is that the app reads names off the wire, and a test that bypassed the
* parse would prove nothing about the names.
*/
class RustFeedTest {
private fun row(kind: String, frame: String = "{}", t: Long = 1_000): RustEventDto =
RustEventDto(id = 1, kind = kind, t = t, frame = Json.parseToJsonElement(frame) as JsonObject)
@Test
fun `a player kill names the killer, the victim and the weapon`() {
val line = describe(
row(
"player.death",
"""{"name":"Bob","attackerType":"player","attackerName":"Alice","weapon":"rifle.ak","distance":42.4}""",
),
)
assertEquals(FeedTone.KILL, line.tone)
assertEquals("Alice", line.actor)
assertEquals("killed", line.verb)
assertEquals("Bob", line.subject)
assertTrue(line.detail.contains("with rifle ak"))
assertTrue(line.detail.contains("42m"))
}
@Test
fun `a fall is a death by nobody, not a kill`() {
// The failure this distinction exists to avoid: `HitInfo` is legitimately
// null when the world kills somebody, so an ABSENT attacker type is the
// environment case rather than a missing field. Reporting it as a kill by
// nobody is the bug.
val line = describe(row("player.death", """{"name":"Bob"}"""))
assertEquals(FeedTone.DEATH, line.tone)
assertEquals("Bob", line.actor)
assertEquals("died", line.verb)
assertNull(line.subject)
}
@Test
fun `an NPC kill reads the prefab as words`() {
val line = describe(
row("player.death", """{"name":"Bob","attackerType":"npc","attackerName":"patrolhelicopter"}"""),
)
assertEquals("patrolhelicopter", line.actor)
assertEquals("Bob", line.subject)
}
@Test
fun `a suicide names one person once`() {
val line = describe(row("player.death", """{"name":"Bob","attackerType":"self"}"""))
assertEquals("Bob", line.actor)
assertNull(line.subject)
}
@Test
fun `chat puts the colon in the join, never in the message`() {
val line = describe(row("player.chat", """{"name":"Bob","message":"see you in september"}"""))
assertEquals(": ", line.join)
assertEquals("see you in september", line.verb)
}
@Test
fun `a disconnect with no session reports only the reason`() {
// The plugin OMITS `sessionSec` for a player who was already connected when
// it loaded, so an absent value means "unknown" and must not become "after
// 0s" — which is what a DTO default of zero would produce if it were read
// without this guard.
val line = describe(row("player.disconnected", """{"name":"Bob","reason":"Disconnected"}"""))
assertEquals("Disconnected", line.detail)
}
@Test
fun `a JSON null field does not become the word null`() {
// A primitive's `content` is literally "null" for a JSON null, and the
// plugin writes explicit nulls — so a naive read puts the four letters
// into a killfeed line.
val line = describe(row("player.disconnected", """{"name":"Bob","reason":null,"sessionSec":null}"""))
assertEquals("", line.detail)
}
@Test
fun `an unknown kind renders as itself rather than vanishing`() {
// A later protocol adds kinds and an operator's module may be older than
// their game host. The server's allowlist has already decided the row may
// be seen; dropping it here would be the screen quietly saying less than
// the truth.
val line = describe(row("player.teleported", """{"name":"Bob"}"""))
assertEquals(FeedTone.OTHER, line.tone)
assertEquals("player.teleported", line.verb)
assertEquals("Bob", line.actor)
}
@Test
fun `the tally kind is not in the feed's own list`() {
// It is an aggregate the plugin flushes every sixty seconds per active
// player, so a feed carrying it would be mostly wood counts. It is the
// leaderboard's input.
assertTrue("player.tally" !in FEED_KINDS)
}
@Test
fun `every filter asks for kinds the feed knows`() {
for (filter in FEED_FILTERS) {
assertTrue(
"${filter.id} asks for a kind the feed does not list",
FEED_KINDS.containsAll(filter.kinds),
)
}
}
@Test
fun `an unknown filter id falls back to everything`() {
assertEquals(FEED_KINDS, kindsFor("nonsense"))
}
}

View File

@@ -0,0 +1,134 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
import java.time.ZoneId
import java.util.Locale
/** Formatting rules the Rust screens depend on (M14). */
class RustFormatTest {
private val utc = ZoneId.of("UTC")
private val uk = Locale.UK
private val now = Instant.parse("2026-09-16T14:00:00Z")
@Test
fun `a row from today is a bare time`() {
val stamp = Instant.parse("2026-09-16T09:05:00Z").toEpochMilli()
val text = feedClock(value = null, epochMillis = stamp, now = now, zone = utc, locale = uk)
assertTrue(text, ':' in text)
assertTrue("today's row should carry no date: $text", "Sep" !in text)
}
@Test
fun `a row from another day carries its date`() {
// The defect this rule exists for: the feed can be filtered to a past
// wipe, and three events from six weeks ago all rendered as `02:03 PM`
// read as this afternoon. The boundary is the CALENDAR day, not a
// duration, because that is what a reader means by "what time was that".
val stamp = Instant.parse("2026-08-05T09:05:00Z").toEpochMilli()
val text = feedClock(value = null, epochMillis = stamp, now = now, zone = utc, locale = uk)
assertTrue("an older row should carry a date: $text", "Aug" in text)
}
@Test
fun `yesterday is another day even when it is minutes ago`() {
val justBeforeMidnight = Instant.parse("2026-09-15T23:58:00Z").toEpochMilli()
val shortlyAfter = Instant.parse("2026-09-16T00:02:00Z")
val text = feedClock(
value = null,
epochMillis = justBeforeMidnight,
now = shortlyAfter,
zone = utc,
locale = uk,
)
assertTrue("four minutes ago but a different day: $text", "Sep" in text)
}
@Test
fun `an unparseable stamp is empty rather than a guess`() {
assertEquals("", feedClock(value = null, epochMillis = null, now = now, zone = utc, locale = uk))
assertEquals("", feedClock(value = "not a date", now = now, zone = utc, locale = uk))
}
@Test
fun `a zoneless DATETIME is read as UTC`() {
// Express serializes a Date to ISO with a `Z`, but these values start life
// as MariaDB DATETIME columns and one read back as a string reaches the
// wire with no zone at all. Reading it as local time silently shifts every
// timestamp by the device's offset — a bug that looks right on the machine
// it was written on.
val text = feedClock(
value = "2026-08-05 09:05:00",
now = now,
zone = utc,
locale = uk,
)
assertTrue(text, "Aug" in text)
assertTrue(text, "09:05" in text)
}
@Test
fun `a server that has never reported has no ago line at all`() {
// Null rather than the word "never", so the caller decides what that looks
// like — on this surface a server that has never reported is a real and
// ordinary state, not a missing value to apologise for.
assertNull(rustAgo(null, now))
assertNull(rustAgo("", now))
}
@Test
fun `under a minute says just now rather than in zero seconds`() {
assertEquals("just now", rustAgo("2026-09-16T13:59:40Z", now))
}
@Test
fun `ago picks the largest unit that fits`() {
assertEquals("3 minutes ago", rustAgo("2026-09-16T13:57:00Z", now))
assertEquals("2 hours ago", rustAgo("2026-09-16T12:00:00Z", now))
assertEquals("1 day ago", rustAgo("2026-09-15T14:00:00Z", now))
}
@Test
fun `playtime drops seconds above a minute and keeps them below`() {
assertEquals("", playtime(null))
assertEquals("", playtime(0))
assertEquals("40s", playtime(40))
assertEquals("12m", playtime(12 * 60))
assertEquals("4h 12m", playtime(4 * 3600 + 12 * 60))
assertEquals("2h", playtime(2 * 3600))
}
@Test
fun `a prefab reads as words without a lookup table`() {
assertEquals("rifle ak", prefabName("rifle.ak"))
assertEquals("patrolhelicopter", prefabName("patrolhelicopter"))
assertEquals("", prefabName(null))
}
@Test
fun `a nameless player shows a shortened id, not the word unknown`() {
// A steam id is not a name and does not look like one, which is the point:
// the plugin knows an id before it knows anything else, and "Unknown" would
// lose the only identifier there is.
assertEquals("…345678", playerLabel(null, "76561198012345678"))
assertEquals("Bob", playerLabel("Bob", "76561198012345678"))
assertEquals("…345678", playerLabel(" ", "76561198012345678"))
}
@Test
fun `a short id is left whole`() {
assertEquals("1234", shortSteamId("1234"))
assertEquals("", shortSteamId(null))
}
}

View File

@@ -0,0 +1,193 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import androidx.lifecycle.SavedStateHandle
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustOnlineDto
import com.runicgateway.app.data.api.dto.RustPresenceDto
import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.api.dto.RustServerResponse
import com.runicgateway.app.data.api.dto.RustWipeDto
import com.runicgateway.app.data.api.dto.RustWipeListDto
import com.runicgateway.app.data.api.fake.FakeRustApi
import com.runicgateway.app.data.repository.RustRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
/** One server's page: what it asks for, and when (M14). */
class RustServerViewModelTest {
@get:Rule
val dispatcherRule = MainDispatcherRule()
private val api = FakeRustApi()
private val repository = RustRepository(api)
private fun viewModel(id: String = "main") = RustServerViewModel(
repository,
SavedStateHandle(mapOf("serverId" to id)),
)
@Test
fun `it opens on the feed and asks for nothing else`() {
api.server = RustServerResponse(RustServerDto(id = "main", name = "Main"))
val vm = viewModel()
assertEquals(RustTab.FEED, vm.state.value.tab)
assertEquals(1, api.eventCalls)
assertEquals(0, api.leaderboardCalls)
assertEquals(0, api.onlineCalls)
assertEquals(0, api.wipeCalls)
}
@Test
fun `all time sends no wipe parameter at all`() {
// `?wipe=` asks for a wipe whose id is the empty string and answers
// nothing, with no error to notice. An absent parameter must be ABSENT.
viewModel()
assertNull(api.lastFeedWipe)
}
@Test
fun `everything sends the whole kind list, and kills sends one`() {
val vm = viewModel()
assertEquals(FEED_KINDS.joinToString(","), api.lastKind)
vm.selectFilter("kills")
assertEquals("player.death", api.lastKind)
}
@Test
fun `a tab is loaded once, not on every visit`() {
// 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.
api.leaderboard = RustLeaderboardDto(listOf(RustLeaderboardRowDto(steamId = "1", kills = 3)))
val vm = viewModel()
vm.selectTab(RustTab.LEADERBOARD)
vm.selectTab(RustTab.FEED)
vm.selectTab(RustTab.LEADERBOARD)
assertEquals(1, api.leaderboardCalls)
}
@Test
fun `the poll asks only for the panel on screen`() {
api.online = RustOnlineDto(listOf(RustPresenceDto(steamId = "1")))
val vm = viewModel()
val feedBefore = api.eventCalls
vm.selectTab(RustTab.ONLINE)
val onlineBefore = api.onlineCalls
vm.refresh()
assertEquals("the feed is not on screen", feedBefore, api.eventCalls)
assertEquals(onlineBefore + 1, api.onlineCalls)
}
@Test
fun `a poll on a still panel asks for nothing but the server line`() {
api.wipes = RustWipeListDto(listOf(RustWipeDto(wipeId = "w1")))
val vm = viewModel()
vm.selectTab(RustTab.WIPES)
val wipesBefore = api.wipeCalls
val feedBefore = api.eventCalls
vm.refresh()
assertEquals(wipesBefore, api.wipeCalls)
assertEquals(feedBefore, api.eventCalls)
}
@Test
fun `choosing a wipe re-asks the feed and, if it is loaded, the leaderboard`() {
api.leaderboard = RustLeaderboardDto(listOf(RustLeaderboardRowDto(steamId = "1")))
val vm = viewModel()
vm.selectWipe("wipe-1")
assertEquals("wipe-1", api.lastFeedWipe)
// Nobody has opened the leaderboard, so nothing was fetched for it.
assertEquals(0, api.leaderboardCalls)
vm.selectTab(RustTab.LEADERBOARD)
vm.selectWipe("wipe-2")
assertEquals("wipe-2", api.lastLeaderboardWipe)
assertEquals("wipe-2", api.lastFeedWipe)
}
@Test
fun `picking the same wipe twice asks nothing`() {
val vm = viewModel()
vm.selectWipe("wipe-1")
val calls = api.eventCalls
vm.selectWipe("wipe-1")
assertEquals(calls, api.eventCalls)
}
@Test
fun `opening a wipe from the wipes tab lands on the feed`() {
// Picking a wipe there is a navigation as much as a filter: the question is
// "what happened during that map", and the answer is the feed.
val vm = viewModel()
vm.selectTab(RustTab.WIPES)
vm.openWipe("wipe-1")
assertEquals(RustTab.FEED, vm.state.value.tab)
assertEquals("wipe-1", vm.state.value.selectedWipe)
}
@Test
fun `a 404 on the server read is its own state, not a generic error`() {
// A mistyped address is not a fault. The screen reads NOT_FOUND and says
// "no such server" rather than dressing it as an outage.
api.error = HttpException(Response.error<Any>(404, "".toResponseBody(null)))
val vm = viewModel("typo")
val state = vm.state.value.server.state
assertTrue(state is UiState.Error)
assertEquals(404, (state as UiState.Error).httpStatus)
}
@Test
fun `a failed poll keeps the server line that was there`() {
api.server = RustServerResponse(RustServerDto(id = "main", name = "Main"))
val vm = viewModel()
api.error = IOException("offline")
vm.refresh()
val state = vm.state.value.server.state
assertTrue(state is UiState.Success)
assertEquals("Main", (state as UiState.Success).data.name)
assertTrue(vm.state.value.server.refreshFailed)
}
@Test
fun `sorting sends the API's own vocabulary`() {
val vm = viewModel()
vm.selectTab(RustTab.LEADERBOARD)
vm.selectSort(RustSort.PLAYTIME)
assertEquals("playtime", api.lastSort)
}
}

View File

@@ -0,0 +1,49 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.util
import com.runicgateway.app.core.inbox.InboxCache
import com.runicgateway.app.data.api.dto.NotificationItemDto
/**
* In-memory [InboxCache] for the inbox view-model tests — the same shape of stand-in
* `SessionManagerTest` uses for the encrypted token store.
*
* It keeps the real implementation's ONE load-bearing rule: a snapshot is handed
* back only to the owner that wrote it. A fake that ignored the key would let the
* cross-account test pass against a cache that leaks.
*/
class FakeInboxCache : InboxCache {
private var owner: String? = null
private var snapshot: InboxCache.Snapshot? = null
var writes: Int = 0
var cleared: Int = 0
override suspend fun read(owner: String): InboxCache.Snapshot? =
if (this.owner == owner) snapshot else null
override suspend fun write(owner: String, items: List<NotificationItemDto>, unread: Int) {
writes++
this.owner = owner
snapshot = InboxCache.Snapshot(
items = items.take(InboxCache.MAX_ITEMS),
unread = unread,
savedAt = 1_700_000_000_000,
)
}
override suspend fun clear() {
cleared++
owner = null
snapshot = null
}
/** Seed a snapshot as if a previous session had pulled one. */
suspend fun seed(owner: String, items: List<NotificationItemDto>, unread: Int) {
write(owner, items, unread)
writes = 0
}
}