10 Commits

Author SHA1 Message Date
adf9547e01 Merge pull request 'feat(notifications): a Rust notification on a phone (module-rust phase 11)' (#50) from feat/rust-phase-11-notifications into edge
Reviewed-on: #50
2026-09-23 20:49:28 +00:00
640b423dfa feat(notifications): a Rust notification on a phone (module-rust phase 11)
All checks were successful
PR Checks / android-build (pull_request) Successful in 8m54s
Three places the app still assumed its site was a UO shard (D69-D71):

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 20:19:13 -05:00
37a828736e Merge pull request 'feat(rust): the Rust server list and one server's page — M14 (module-rust phase 5, Android leg A)' (#47) from feature/rust-p5-android-a into edge
Reviewed-on: #47
2026-09-17 09:22:00 +00:00
4b22ab3756 chore(ci): re-run
All checks were successful
PR Checks / android-build (pull_request) Successful in 11m23s
Run 76 hung in `compileDebugKotlin` for thirteen minutes and was failed with no
error in its log, where the last good run finished that task in two. Nothing
about that reads as a compile error, and this repo's Gitea has no rerun
endpoint — so this empty commit is the re-run, to tell a transient runner
problem from a real one before bisecting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-17 04:10:44 -05:00
daf483f514 fix(ci): stop setup-android installing a package Google has removed
Some checks failed
PR Checks / android-build (pull_request) Failing after 18m49s
**Unrelated to this PR's feature work**, and fixed here because it blocks
verifying it (org lead, 2026-09-17). PR #46 passed on this workflow yesterday;
every Android PR fails now.

`android-actions/setup-android@v3` is a floating tag and the action's `packages`
input defaults to `tools` — an obsolete package Google has since removed from the
SDK repository. So the step runs `sdkmanager tools`, gets `Warning: Failed to
find package 'tools'`, exits 1, and CI fails in **Set up Android SDK**, before a
line of this repo is compiled.

`packages: ''` turns that install off. It was always redundant here: the very
next step installs exactly what the build targets — `platform-tools`,
`platforms;android-35`, `build-tools;35.0.0` — precisely so the build never
depends on what some action decided to fetch.

Not addressed here, and worth its own decision: `@v3` is a floating major tag, so
the next upstream change can break CI the same way without warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-17 03:50:04 -05:00
32 changed files with 1909 additions and 91 deletions

View File

@@ -45,8 +45,15 @@ jobs:
- uses: actions/checkout@v4 - 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 - name: Set up Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with:
packages: ''
# Install exactly what the build targets so it never depends on AGP's # Install exactly what the build targets so it never depends on AGP's
# build-time auto-download. `yes |` accepts any license prompts; `set # build-time auto-download. `yes |` accepts any license prompts; `set

View File

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

View File

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

View File

@@ -16,7 +16,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -33,6 +32,7 @@ class PushService : Service() {
@Inject lateinit var streamClient: NtfyStreamClient @Inject lateinit var streamClient: NtfyStreamClient
@Inject lateinit var notifier: PushNotifier @Inject lateinit var notifier: PushNotifier
@Inject lateinit var content: PushContentResolver
@Inject lateinit var prefs: PushPreferences @Inject lateinit var prefs: PushPreferences
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -67,8 +67,15 @@ class PushService : Service() {
stopSelf() stopSelf()
return return
} }
streamClient.events(snapshot.ntfyUrl, topic).collectLatest { event -> // Each tickle in its own job (D70). Titling one from its inbox row
if (event is NtfyStreamClient.Event.Message) notifier.notify(event.tickle) // suspends on a network read, and inside `collectLatest` the next tickle
// would cancel it. A burst (two owners' raid alerts, then a restart) would
// then post only the last.
streamClient.events(snapshot.ntfyUrl, topic).collect { event ->
if (event is NtfyStreamClient.Event.Message) {
val tickle = event.tickle
scope.launch { notifier.notify(tickle, content.itemFor(tickle)) }
}
} }
} }

View File

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

View File

@@ -0,0 +1,127 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.Serializable
/**
* DTOs for a player's own half of `module-rust` (`docs/modules/rust/PLAN.md`
* §19, §20, §22; M15).
*
* Two surfaces, and they are deliberately separate reads rather than one:
*
* * **the links** — which Steam accounts this website account holds. A link is
* fleet-wide, because a Steam account is one person on every server an
* operator runs, while stats are per server and per wipe.
* * **what the site has given them in game** — groups and direct grants, each
* already resolved to the servers its scope reaches. It is its own read
* because an entitlement is authored against the *website* account, so it
* exists before a Steam id does; the person who has just been given something
* and has not linked yet is exactly the one who needs to see both halves at
* once.
*
* Nothing here is a write except the code redemption. A grant a player could
* change would not be a grant.
*/
/** `GET /player/rust/links` — the Steam accounts the caller holds. */
@Serializable
data class RustLinkListDto(
val links: List<RustLinkDto> = emptyList(),
)
/**
* One linked Steam account.
*
* [name] is what the player was called in game when they linked — a display
* name only, and a Rust name changes on a whim. [serverId] is where the code was
* minted, which is not part of the identity but is where a support conversation
* starts.
*/
@Serializable
data class RustLinkDto(
val steamId: String = "",
val name: String? = null,
val serverId: String? = null,
val linkedAt: String? = null,
)
/** `POST /player/rust/link` body — the six-character code `/link` gives in game. */
@Serializable
data class RustLinkRequest(val code: String)
/**
* `POST /player/rust/link` result.
*
* [already] is a second press of the button rather than an error: the code was
* good and that Steam id was already this caller's.
*/
@Serializable
data class RustLinkResultDto(
val linked: Boolean = false,
val link: RustLinkDto? = null,
val already: Boolean = false,
)
/** `DELETE /player/rust/links/{steamId}` result. */
@Serializable
data class RustUnlinkResultDto(
val unlinked: Boolean = false,
)
/**
* `GET /player/rust/permissions` — what the site has given this player in game.
*
* [accounts] is how many Steam accounts they have linked, and it is on the
* envelope for one reason: zero is why an entitlement can be authored and reach
* nobody, and the screen has to be able to say that without inferring it.
*/
@Serializable
data class RustPlayerPermissionsDto(
val accounts: Int = 0,
val groups: List<RustPlayerGroupDto> = emptyList(),
val grants: List<RustPlayerGrantDto> = emptyList(),
)
/** A rank the site holds for this player, and what it carries. */
@Serializable
data class RustPlayerGroupDto(
val name: String = "",
val title: String = "",
val scope: String = "*",
val since: String? = null,
val permissions: List<String> = emptyList(),
val reach: List<RustReachDto> = emptyList(),
)
/** One permission held directly, without a rank. */
@Serializable
data class RustPlayerGrantDto(
val permission: String = "",
val scope: String = "*",
val source: String = "admin",
val note: String? = null,
val since: String? = null,
val reach: List<RustReachDto> = emptyList(),
)
/**
* One server an entitlement's scope reaches, and whether it is there yet.
*
* **The scope arithmetic is the server's.** A client handed `scope: "*"` would
* have to know what the fleet is to say anything useful, and then the rule
* exists in two places; the website resolves it and marks each server instead.
*
* [live] is the pushed ledger rather than the authored row: a grant is not a
* privilege in a game until a sync confirmed it. `false` covers every way it has
* not arrived — the server is offline, no loaded plugin registered the name, the
* store has never seen the account — and telling those apart is an operator's
* diagnosis, not a player's.
*/
@Serializable
data class RustReachDto(
val id: String = "",
val name: String = "",
val live: Boolean = false,
)

View File

@@ -70,10 +70,20 @@ data class RustServerDto(
val stale: Boolean = false, val stale: Boolean = false,
) )
/** `GET /public/rust/servers/{id}/events` — the killfeed and everything else public. */ /**
* `GET /public/rust/servers/{id}/events` — the killfeed and everything else public.
*
* **Nothing names who is online by default** (org lead, 2026-09-22). Below the
* operator's presence audience — staff unless widened — the server withholds
* every item that says a named player was on (joins, deaths, chat, tallies) and
* says so with [presenceHidden]; [presenceAudience] is who CAN see them. The
* screen says it, so a thin feed reads as withheld rather than as a quiet server.
*/
@Serializable @Serializable
data class RustEventListDto( data class RustEventListDto(
val events: List<RustEventDto> = emptyList(), val events: List<RustEventDto> = emptyList(),
val presenceHidden: Boolean = false,
val presenceAudience: String? = null,
) )
/** /**
@@ -171,10 +181,20 @@ data class RustWipeDto(
val lastSeen: String? = null, val lastSeen: String? = null,
) )
/** `GET /public/rust/servers/{id}/online` — who is on right now. */ /**
* `GET /public/rust/servers/{id}/online` — who is on right now.
*
* Below the operator's presence audience the names are withheld: [hidden] is
* true, [players] is empty and [count] is still the real number — a count names
* nobody, and it is already on the server line. An empty list with [hidden] set
* must never render as "nobody is on".
*/
@Serializable @Serializable
data class RustOnlineDto( data class RustOnlineDto(
val players: List<RustPresenceDto> = emptyList(), val players: List<RustPresenceDto> = emptyList(),
val hidden: Boolean = false,
val count: Int = 0,
val audience: String? = null,
) )
/** /**

View File

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

View File

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

View File

@@ -7,9 +7,9 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.map import com.runicgateway.app.core.result.map
import com.runicgateway.app.core.result.safeApiCall import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.RustApi import com.runicgateway.app.data.api.RustApi
import com.runicgateway.app.data.api.dto.RustEventDto import com.runicgateway.app.data.api.dto.RustEventListDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustPresenceDto import com.runicgateway.app.data.api.dto.RustOnlineDto
import com.runicgateway.app.data.api.dto.RustServerDto import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.api.dto.RustWipeDto import com.runicgateway.app.data.api.dto.RustWipeDto
import javax.inject.Inject import javax.inject.Inject
@@ -50,14 +50,14 @@ class RustRepository @Inject constructor(
kinds: List<String> = emptyList(), kinds: List<String> = emptyList(),
wipe: String? = null, wipe: String? = null,
limit: Int? = null, limit: Int? = null,
): ApiResult<List<RustEventDto>> = safeApiCall { ): ApiResult<RustEventListDto> = safeApiCall {
api.getEvents( api.getEvents(
id = id, id = id,
kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","), kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","),
wipe = wipe?.takeIf { it.isNotBlank() }, wipe = wipe?.takeIf { it.isNotBlank() },
limit = limit, limit = limit,
) )
}.map { it.events } }
/** The leaderboard: per wipe when [wipe] is given, all-time otherwise. */ /** The leaderboard: per wipe when [wipe] is given, all-time otherwise. */
suspend fun leaderboard( suspend fun leaderboard(
@@ -78,7 +78,12 @@ class RustRepository @Inject constructor(
suspend fun wipes(id: String): ApiResult<List<RustWipeDto>> = suspend fun wipes(id: String): ApiResult<List<RustWipeDto>> =
safeApiCall { api.getWipes(id) }.map { it.wipes } 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>> = * The presence board. Rows survive an unreachable server, by design.
safeApiCall { api.getOnline(id) }.map { it.players } *
* Answered whole rather than as its rows: `hidden` and `count` are what let
* the screen tell "withheld from you" from "nobody is on".
*/
suspend fun online(id: String): ApiResult<RustOnlineDto> =
safeApiCall { api.getOnline(id) }
} }

View File

@@ -19,6 +19,7 @@ import com.runicgateway.app.data.api.EventsApi
import com.runicgateway.app.data.api.MeApi import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.AdminApi import com.runicgateway.app.data.api.AdminApi
import com.runicgateway.app.data.api.NotificationsApi import com.runicgateway.app.data.api.NotificationsApi
import com.runicgateway.app.data.api.PlayerRustApi
import com.runicgateway.app.data.api.PlayerShardApi import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.PublicApi import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.RustApi import com.runicgateway.app.data.api.RustApi
@@ -146,6 +147,15 @@ object NetworkModule {
@Singleton @Singleton
fun provideRustApi(retrofit: Retrofit): RustApi = retrofit.create(RustApi::class.java) fun provideRustApi(retrofit: Retrofit): RustApi = retrofit.create(RustApi::class.java)
/**
* A player's own Rust identity and entitlements (§9 M15) — bearer-authed on
* the main client, like [providePlayerShardApi] one game along.
*/
@Provides
@Singleton
fun providePlayerRustApi(retrofit: Retrofit): PlayerRustApi =
retrofit.create(PlayerRustApi::class.java)
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */ /** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
@Provides @Provides
@Singleton @Singleton

View File

@@ -103,6 +103,7 @@ import com.runicgateway.app.ui.shard.MarketVendorScreen
import com.runicgateway.app.ui.shard.RulesScreen import com.runicgateway.app.ui.shard.RulesScreen
import com.runicgateway.app.ui.shard.ShardBoard import com.runicgateway.app.ui.shard.ShardBoard
import com.runicgateway.app.ui.rust.RustBadgeViewModel import com.runicgateway.app.ui.rust.RustBadgeViewModel
import com.runicgateway.app.ui.rust.RustAccountScreen
import com.runicgateway.app.ui.rust.RustServerScreen import com.runicgateway.app.ui.rust.RustServerScreen
import com.runicgateway.app.ui.rust.RustServersScreen import com.runicgateway.app.ui.rust.RustServersScreen
import com.runicgateway.app.ui.shard.ShardScreen import com.runicgateway.app.ui.shard.ShardScreen
@@ -126,6 +127,8 @@ private val TOP_LEVEL_ROUTES = setOf(
// screen and is deliberately absent — a back gesture there means "back". // screen and is deliberately absent — a back gesture there means "back".
Routes.RUST, Routes.RUST,
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES, Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
// The player's own Rust account (M15) — a drawer row like the three above it.
Routes.PLAYER_RUST,
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT, Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
) )
@@ -632,7 +635,14 @@ private fun RunicNavHost(
} }
composable( composable(
route = Routes.RUST_SERVER, route = Routes.RUST_SERVER,
arguments = listOf(navArgument(Routes.Args.SERVER_ID) { type = NavType.StringType }), arguments = listOf(
navArgument(Routes.Args.SERVER_ID) { type = NavType.StringType },
navArgument(Routes.Args.TAB) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { ) {
RustServerScreen(onBack = { navController.navigateTopLevel(Routes.RUST) }) RustServerScreen(onBack = { navController.navigateTopLevel(Routes.RUST) })
} }
@@ -739,6 +749,12 @@ private fun RunicNavHost(
composable(Routes.PLAYER_HOUSES) { composable(Routes.PLAYER_HOUSES) {
PlayerGate(session, navController) { MyHousesScreen() } PlayerGate(session, navController) { MyHousesScreen() }
} }
// The player's own Rust account (M15). Behind the same gate as the three
// above: the route is `requireAuth` server-side, and the gate is here so a
// signed-out reader is sent home rather than left on a screen that 401s.
composable(Routes.PLAYER_RUST) {
PlayerGate(session, navController) { RustAccountScreen() }
}
// ── Staff operations (§1, §6.4, M10) — reached from the staff menu section. // ── Staff operations (§1, §6.4, M10) — reached from the staff menu section.
// The backend re-checks role on every /admin/… call; these gates only mirror // The backend re-checks role on every /admin/… call; these gates only mirror

View File

@@ -177,6 +177,22 @@ val APP_MENU: List<MenuEntry> = listOf(
MenuAccess.PLAYER, MenuAccess.PLAYER,
capability = Capability.SHARD, capability = Capability.SHARD,
), ),
// The player's own Rust account (M15). Beside the three UO rows above and
// gated exactly as they are: the module's `rust` capability (a row needs the
// code behind it INSTALLED) and `PLAYER` access, which is `isPlayer ||
// isStaff` — staff play too, and `/player/rust/*` is `requireAuth` alone.
//
// **`rust`, not `identity`.** The module declares a surface word for each of
// its features, and D16's rule is that a capability answers one question —
// *is the module there* — so a surface word is not what a row hangs on. The
// paths these screens call are knowledge the app has from reading the
// module's router, exactly as §2.9 requires.
MenuEntry(
Routes.PLAYER_RUST,
R.string.menu_rust_account,
MenuAccess.PLAYER,
capability = Capability.RUST,
),
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call. // 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_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF), MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.ui.navigation package com.runicgateway.app.ui.navigation
import com.runicgateway.app.data.repository.ContentRepository.PostCategory import com.runicgateway.app.data.repository.ContentRepository.PostCategory
import com.runicgateway.app.ui.rust.RustTab
/** /**
* The website path → app route table (THEMING_AND_NAV.md §6.2). * The website path → app route table (THEMING_AND_NAV.md §6.2).
@@ -257,7 +258,8 @@ private val RESERVED_TOP_LEVEL = setOf(
* /uo/atlas/<slug> → ATLAS_CREATURE * /uo/atlas/<slug> → ATLAS_CREATURE
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR * /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
* /rust → RUST (module-rust's server list) * /rust → RUST (module-rust's server list)
* /rust/servers/<id> → RUST_SERVER * /rust/servers/<id>[?tab=<tab>] → RUST_SERVER (a tab the app has; Rust D71)
* /player/rust → PLAYER_RUST (Rust D71)
* /site/about → PAGE("about") * /site/about → PAGE("about")
* /<slug> → PAGE(slug), unless <slug> is reserved * /<slug> → PAGE(slug), unless <slug> is reserved
* anything else → null, i.e. the Custom Tab * anything else → null, i.e. the Custom Tab
@@ -272,6 +274,12 @@ private val RESERVED_TOP_LEVEL = setOf(
* string, any second parameter, and any fragment still hand off** — the carve-out * 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". * is one key on one path, not a general "parse the query".
* *
* **Rust D71 adds the second, on the same terms:** `tab` on a Rust server's path,
* which is what the *new leader* notice links to. It resolves only when the value
* is a tab the app has. `?tab=clans` still hands off, because the website has a
* Clans tab and the app does not, and opening the feed instead would be the quiet
* drop this rule exists to prevent.
*
* That narrowness is the point: an admin who writes `/site/events/x?utm=mail` gets * 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 * the browser, which honors `utm`, rather than an app screen that silently ignored
* it. * it.
@@ -309,6 +317,10 @@ fun resolveWebPath(path: String?): String? {
val run = runParam(query) ?: return null val run = runParam(query) ?: return null
return Routes.event(segments[2], run) return Routes.event(segments[2], run)
} }
if (segments.size == 3 && segments[0] == MODULE_RUST && segments[1] == "servers" && query.isNotEmpty()) {
val tab = rustTabParam(query) ?: return null
return Routes.rustServer(segments[2], tab)
}
if (query.isNotEmpty()) return null if (query.isNotEmpty()) return null
return when { return when {
@@ -326,6 +338,10 @@ fun resolveWebPath(path: String?): String? {
// answered by the nav table above, before this fallback is reached. // answered by the nav table above, before this fallback is reached.
segments[0] == MODULE_RUST && segments.size == 3 && segments[1] == "servers" -> segments[0] == MODULE_RUST && segments.size == 3 && segments[1] == "servers" ->
Routes.rustServer(segments[2]) Routes.rustServer(segments[2])
// The player's own Rust account, which the *account linked* notice links
// to (D71). Not in the nav table above, which is built from the PUBLIC
// nav; a signed-out tap is caught by the player gate like any other.
segments.size == 2 && segments[0] == "player" && segments[1] == MODULE_RUST -> Routes.PLAYER_RUST
else -> null else -> null
} }
} }
@@ -346,6 +362,17 @@ private fun runParam(query: String): String? {
return value.takeIf { '&' !in it && '=' !in it } return value.takeIf { '&' !in it && '=' !in it }
} }
/**
* The tab a query of **exactly** `tab=<something>` names, when the app has that
* tab (Rust D71), or null, which hands the link to the browser. Same shape as
* [runParam], and for the same reasons.
*/
private fun rustTabParam(query: String): RustTab? {
val value = query.removePrefix("tab=")
if (value.length == query.length || value.isEmpty() || '&' in value || '=' in value) return null
return RustTab.fromWire(value)
}
/** /**
* The module id whose public pages this table maps. * The module id whose public pages this table maps.
* *

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.ui.navigation package com.runicgateway.app.ui.navigation
import com.runicgateway.app.data.repository.ContentRepository import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.ui.rust.RustTab
/** /**
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are * Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
@@ -84,7 +85,18 @@ object Routes {
* two can be installed on the same backend, and then both trees exist at once. * two can be installed on the same backend, and then both trees exist at once.
*/ */
const val RUST = "rust" const val RUST = "rust"
const val RUST_SERVER = "rust/servers/{serverId}" const val RUST_SERVER = "rust/servers/{serverId}?tab={tab}"
/**
* The player's own Rust account (§9 M15) — the Steam accounts they hold and
* what the site has given them in game.
*
* Under `player/` with the three UO rows rather than under `rust/` with the
* two public ones, because the grouping that matters to a reader is *whose
* data is this*: these are the signed-in, self-scoped screens. The website
* agrees — it serves this from the player tier, at `/player/rust`.
*/
const val PLAYER_RUST = "player/rust"
/** Public shard hub (§6.2). */ /** Public shard hub (§6.2). */
const val SHARD = "shard" const val SHARD = "shard"
@@ -138,6 +150,7 @@ object Routes {
const val SERIAL = "serial" const val SERIAL = "serial"
const val RUN = "run" const val RUN = "run"
const val SERVER_ID = "serverId" const val SERVER_ID = "serverId"
const val TAB = "tab"
} }
fun page(slug: String) = "page/$slug" fun page(slug: String) = "page/$slug"
@@ -168,8 +181,14 @@ object Routes {
* JVM unit test and throws "not mocked" — this object is pure and every test * 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 builds a route would have to become an instrumented one to keep it
* that way. * that way.
*
* [tab] is what the *new leader* notice's link carries (Rust D71), and it is
* dropped when absent, as `run` is on [event].
*/ */
fun rustServer(id: String) = "rust/servers/${encodePathSegment(id)}" fun rustServer(id: String, tab: RustTab? = null): String {
val base = "rust/servers/${encodePathSegment(id)}"
return if (tab == null) base else "$base?tab=${tab.wire}"
}
/** /**
* Percent-encode one path segment, allowing only the unreserved set. * Percent-encode one path segment, allowing only the unreserved set.

View File

@@ -153,7 +153,7 @@ private fun ChannelPrefsList(
SectionLabel(stringResource(R.string.notifications_section_general)) SectionLabel(stringResource(R.string.notifications_section_general))
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
general.forEach { item -> general.forEach { item ->
ItemRow(item, channelsById, hint = null, enabled = !busy, pushSupported = pushSupported, onSetMode = onSetMode) ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
HorizontalDivider() HorizontalDivider()
} }
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
@@ -163,15 +163,7 @@ private fun ChannelPrefsList(
SectionLabel(stringResource(R.string.notifications_section_personal)) SectionLabel(stringResource(R.string.notifications_section_personal))
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
personal.forEach { item -> personal.forEach { item ->
val selectable = itemSelectable(item, hasLinkedAccount) ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
ItemRow(
item = item,
channelsById = channelsById,
hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null,
enabled = !busy && selectable,
pushSupported = pushSupported,
onSetMode = onSetMode,
)
HorizontalDivider() HorizontalDivider()
} }
} }
@@ -181,33 +173,40 @@ private fun ChannelPrefsList(
private fun ItemRow( private fun ItemRow(
item: NotificationChannelItemDto, item: NotificationChannelItemDto,
channelsById: Map<String, NotificationChannelDto>, channelsById: Map<String, NotificationChannelDto>,
hint: String?, hasLinkedAccount: Boolean,
enabled: Boolean, busy: Boolean,
pushSupported: Boolean, pushSupported: Boolean,
onSetMode: (NotificationChannelItemDto, String, String) -> Unit, onSetMode: (NotificationChannelItemDto, String, String) -> Unit,
) { ) {
// The link holds back one control, push switching on, so the row stays live
// and the hint names that one control (Rust D69). Where this device has no
// push at all, there is no control to hold back and nothing to explain.
val pushHeld = pushSupported && pushNeedsLink(item, hasLinkedAccount)
Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) { Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
Text( Text(
text = item.label, text = item.label,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
text = hint ?: item.description, text = if (pushHeld) stringResource(R.string.notifications_requires_link) else item.description,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal, fontStyle = if (pushHeld) FontStyle.Italic else FontStyle.Normal,
) )
// The item's OWN channel list, in the registry's order. An id nothing can // 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. // push carries no push control at all, rather than a dead switch.
item.channels.forEach { channelId -> item.channels.forEach { channelId ->
val channel = channelsById[channelId] ?: return@forEach val channel = channelsById[channelId] ?: return@forEach
if (channelId == CHANNEL_PUSH && !pushSupported) return@forEach if (channelId == CHANNEL_PUSH && !pushSupported) return@forEach
val mode = item.modes[channelId] ?: channel.defaultMode
ChannelControl( ChannelControl(
channel = channel, channel = channel,
mode = item.modes[channelId] ?: channel.defaultMode, mode = mode,
enabled = enabled, // A held push switch that is already ON stays enabled, because
onSetMode = { mode -> onSetMode(item, channelId, mode) }, // switching it off is never refused ([canSetMode]).
enabled = !busy && !(channelId == CHANNEL_PUSH && pushHeld && mode == MODE_OFF),
onSetMode = { next -> onSetMode(item, channelId, next) },
) )
} }
} }

View File

@@ -14,7 +14,7 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
import com.runicgateway.app.data.repository.NotificationsRepository import com.runicgateway.app.data.repository.NotificationsRepository
import com.runicgateway.app.data.repository.PlayerShardRepository import com.runicgateway.app.data.repository.LinkedAccountRepository
import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -53,7 +53,7 @@ const val MODE_OFF = "off"
@HiltViewModel @HiltViewModel
class NotificationSettingsViewModel @Inject constructor( class NotificationSettingsViewModel @Inject constructor(
private val notifications: NotificationsRepository, private val notifications: NotificationsRepository,
private val playerShard: PlayerShardRepository, private val linkedAccounts: LinkedAccountRepository,
private val pushManager: PushManager, private val pushManager: PushManager,
sessionManager: SessionManager, sessionManager: SessionManager,
) : ViewModel() { ) : ViewModel() {
@@ -62,7 +62,10 @@ class NotificationSettingsViewModel @Inject constructor(
data class State( data class State(
val prefs: UiState<NotificationChannelPrefsDto> = UiState.Loading, val prefs: UiState<NotificationChannelPrefsDto> = UiState.Loading,
/** Whether the user has ≥1 linked game account — personal streams need it. */ /**
* Whether the user has ≥1 linked game account on this site's module, asked
* of that module (Rust D69). It only gates switching push ON (see [canSetMode]).
*/
val hasLinkedAccount: Boolean = false, val hasLinkedAccount: Boolean = false,
/** Whether this shard advertises a push relay at all (else the screen says so). */ /** Whether this shard advertises a push relay at all (else the screen says so). */
val supported: Boolean = true, val supported: Boolean = true,
@@ -94,9 +97,7 @@ class NotificationSettingsViewModel @Inject constructor(
_state.update { it.copy(prefs = UiState.Loading) } _state.update { it.copy(prefs = UiState.Loading) }
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) } _state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) }
// A linked game account gates the personal streams; failure → treat as none. _state.update { it.copy(hasLinkedAccount = linkedAccounts.hasLinkedAccount()) }
val linked = (playerShard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
_state.update { it.copy(hasLinkedAccount = linked) }
} }
} }
@@ -114,7 +115,7 @@ class NotificationSettingsViewModel @Inject constructor(
fun setMode(item: NotificationChannelItemDto, channel: String, mode: String) { fun setMode(item: NotificationChannelItemDto, channel: String, mode: String) {
val current = _state.value val current = _state.value
if (current.busy) return if (current.busy) return
if (channel == CHANNEL_PUSH && !itemSelectable(item, current.hasLinkedAccount)) return if (!canSetMode(item, channel, mode, current.hasLinkedAccount)) return
_state.update { it.copy(busy = true, feedback = null) } _state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch { viewModelScope.launch {
@@ -160,8 +161,28 @@ class NotificationSettingsViewModel @Inject constructor(
} }
/** /**
* Whether an item's controls are selectable for a user: a personal stream needs a * Whether this item's push is held back for want of a linked game account
* linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose. * (PLAN.md §11, `docs/modules/rust/PLAN.md` D69).
*
* **Push only.** Until phase 11 the whole row went dead, every channel, which
* made a personal stream impossible to switch *off* on any site where the link
* check failed. That was every Rust site, because the check asked `module-uo`.
* In-app and email have nothing to do with the link, and neither does turning
* anything off. Pure so the gating is unit-tested without Compose.
*/ */
fun itemSelectable(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean = fun pushNeedsLink(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean =
!item.requiresLinkedAccount || hasLinkedAccount item.requiresLinkedAccount && !hasLinkedAccount && CHANNEL_PUSH in item.channels
/**
* Whether [mode] may be set on [channel] for [item].
*
* The one thing refused is switching push **on** for an item [pushNeedsLink] holds
* back. Switching anything off is never refused: a gate that can trap a switch in
* the on position is worse than no gate.
*/
fun canSetMode(
item: NotificationChannelItemDto,
channel: String,
mode: String,
hasLinkedAccount: Boolean,
): Boolean = !(channel == CHANNEL_PUSH && mode != MODE_OFF && pushNeedsLink(item, hasLinkedAccount))

View File

@@ -0,0 +1,310 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.RustLinkDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
import com.runicgateway.app.data.api.dto.RustReachDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* The player's own Rust account (PLAN.md §9 M15) — the app's mirror of the
* module's `/player/rust` page, and the same shape as
* [com.runicgateway.app.ui.player.CharactersScreen] one game along: the code
* card first, then what the code got them.
*
* Two independent reads. The accounts can fail with the entitlements on screen,
* and the other way round, because an entitlement is authored against the
* website account and exists before a Steam id does.
*/
@Composable
fun RustAccountScreen(
modifier: Modifier = Modifier,
viewModel: RustAccountViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
LinkCard(state, viewModel)
when (val links = state.links) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(links.kind, onRetry = viewModel::load)
is UiState.Success -> {
if (links.data.isEmpty()) {
Text(
stringResource(R.string.rust_account_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
links.data.forEach { link ->
LinkRow(
link = link,
busy = state.unlinking == link.steamId,
onUnlink = { viewModel.unlink(link.steamId) },
)
}
Text(
stringResource(R.string.rust_account_fleet_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Text(
stringResource(R.string.rust_held_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 8.dp),
)
when (val held = state.held) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(held.kind, onRetry = viewModel::load)
is UiState.Success -> Held(held.data)
}
}
}
/**
* The code form.
*
* The three-step instruction is not decoration: nothing else in the app tells a
* player that the code comes from the game, and a code field with no explanation
* is a code field nobody can use.
*/
@Composable
private fun LinkCard(state: RustAccountViewModel.State, viewModel: RustAccountViewModel) {
var code by rememberSaveable { mutableStateOf("") }
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.rust_link_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.rust_link_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
OutlinedTextField(
value = code,
onValueChange = { code = it.uppercase() },
singleLine = true,
enabled = !state.busy,
label = { Text(stringResource(R.string.rust_link_code)) },
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
Button(
onClick = { viewModel.link(code); code = "" },
enabled = !state.busy && code.isNotBlank(),
modifier = Modifier.padding(top = 12.dp),
) { Text(stringResource(R.string.rust_link_action)) }
// Beside the button that caused it. A refusal at the top of a long
// scroll is a press that visibly did nothing (PLAN.md §21.5).
state.feedback?.let { feedback ->
Text(
text = stringResource(feedback.messageRes),
style = MaterialTheme.typography.bodySmall,
color = if (feedback.ok) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
},
modifier = Modifier.padding(top = 10.dp),
)
}
}
}
}
/** One linked Steam account, and the button that releases it. */
@Composable
private fun LinkRow(link: RustLinkDto, busy: Boolean, onUnlink: () -> Unit) {
ShardCard(Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = link.name?.takeIf { it.isNotBlank() } ?: link.steamId,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = link.steamId,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// When, and which server minted the code. Not part of the
// identity — a link is fleet-wide — but it is where a support
// conversation starts, and the website's own row says it.
val when_ = rustAgo(link.linkedAt)
val where = link.serverId?.takeIf { it.isNotBlank() }
if (when_ != null || where != null) {
Text(
text = listOfNotNull(
when_?.let { stringResource(R.string.rust_account_linked_when, it) },
where,
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextButton(onClick = onUnlink, enabled = !busy) {
Text(stringResource(R.string.rust_unlink_action))
}
}
}
}
/** Ranks and grants, drawn the same way because they read the same. */
@Composable
private fun Held(held: RustPlayerPermissionsDto) {
if (held.groups.isEmpty() && held.grants.isEmpty()) {
Text(
stringResource(R.string.rust_held_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
held.groups.forEach { group ->
HeldCard(
title = group.title.ifBlank { group.name },
detail = group.permissions.joinToString(" · ").takeIf { it.isNotBlank() },
reach = group.reach,
)
}
held.grants.forEach { grant ->
HeldCard(
title = grant.permission,
detail = grant.note?.takeIf { it.isNotBlank() },
reach = grant.reach,
)
}
// Zero linked accounts is WHY everything above is waiting, and the screen
// says so rather than leaving a page of hollow pills to be read as a fault.
if (held.accounts == 0) {
Text(
stringResource(R.string.rust_held_unlinked),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (held.groups.any { it.reach.any { s -> !s.live } } ||
held.grants.any { it.reach.any { s -> !s.live } }
) {
Text(
stringResource(R.string.rust_held_waiting_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun HeldCard(title: String, detail: String?, reach: List<RustReachDto>) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleSmall)
detail?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
if (reach.isEmpty()) {
Text(
stringResource(R.string.rust_held_no_servers),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
} else {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 10.dp),
) {
reach.forEach { server ->
// The tone IS the state: a server that has it and one
// that has not are the two things this screen exists to
// tell apart, and colour alone would not say which — so
// the label carries the word as well.
StatusPill(
text = stringResource(
if (server.live) R.string.rust_reach_live else R.string.rust_reach_waiting,
server.name.ifBlank { server.id },
),
tone = if (server.live) PillTone.Success else PillTone.Neutral,
)
}
}
}
}
}
}

View File

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

View File

@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -33,8 +34,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.RustEventDto import com.runicgateway.app.data.api.dto.RustEventDto
import com.runicgateway.app.data.api.dto.RustEventListDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustPresenceDto import com.runicgateway.app.data.api.dto.RustOnlineDto
import com.runicgateway.app.data.api.dto.RustServerDto import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.api.dto.RustWipeDto import com.runicgateway.app.data.api.dto.RustWipeDto
import com.runicgateway.app.ui.ErrorKind import com.runicgateway.app.ui.ErrorKind
@@ -252,7 +254,7 @@ private fun WipeFilter(
@Composable @Composable
private fun FeedPanel( private fun FeedPanel(
feed: Polled<List<RustEventDto>>, feed: Polled<RustEventListDto>,
filterId: String, filterId: String,
onFilter: (String) -> Unit, onFilter: (String) -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
@@ -275,7 +277,25 @@ private fun FeedPanel(
when (val s = feed.state) { when (val s = feed.state) {
is UiState.Loading -> LoadingView() is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry) is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
is UiState.Success -> if (s.data.isEmpty()) { is UiState.Success -> {
// Said once, above the rows, so a thin feed reads as withheld
// rather than as a quiet server (org lead: nothing names who is
// online by default).
if (s.data.presenceHidden) {
Text(
text = stringResource(
if (s.data.presenceAudience == "signed_in") {
R.string.rust_feed_presence_hidden_signin
} else {
R.string.rust_feed_presence_hidden_staff
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
if (s.data.events.isEmpty()) {
EmptyView(stringResource(R.string.rust_feed_empty)) EmptyView(stringResource(R.string.rust_feed_empty))
} else { } else {
LazyColumn( LazyColumn(
@@ -285,7 +305,8 @@ private fun FeedPanel(
if (feed.refreshFailed) { if (feed.refreshFailed) {
item { RefreshFailedLine() } item { RefreshFailedLine() }
} }
items(s.data, key = { it.id }) { FeedRow(it) } items(s.data.events, key = { it.id }) { FeedRow(it) }
}
} }
} }
} }
@@ -444,14 +465,29 @@ private fun LeaderboardPanel(
@Composable @Composable
private fun OnlinePanel( private fun OnlinePanel(
online: Polled<List<RustPresenceDto>>, online: Polled<RustOnlineDto>,
serverOnline: Boolean, serverOnline: Boolean,
onRetry: () -> Unit, onRetry: () -> Unit,
) { ) {
when (val s = online.state) { when (val s = online.state) {
is UiState.Loading -> LoadingView() is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry) is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
is UiState.Success -> if (s.data.isEmpty()) { // Withheld is not empty. Below the operator's audience the server sends
// the count and no names, and an empty list rendered as "nobody is on"
// would be a false statement about a full server.
is UiState.Success -> if (s.data.hidden) {
val count = s.data.count
EmptyView(
pluralStringResource(R.plurals.rust_online_hidden_count, count, count) + "\n" +
stringResource(
when (s.data.audience) {
"signed_in" -> R.string.rust_online_hidden_signin
"public" -> R.string.rust_online_hidden_public
else -> R.string.rust_online_hidden_staff
},
),
)
} else if (s.data.players.isEmpty()) {
EmptyView( EmptyView(
stringResource( stringResource(
if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline, if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline,
@@ -479,7 +515,7 @@ private fun OnlinePanel(
if (online.refreshFailed) { if (online.refreshFailed) {
item { RefreshFailedLine() } item { RefreshFailedLine() }
} }
items(s.data, key = { it.steamId }) { player -> items(s.data.players, key = { it.steamId }) { player ->
ShardCard(Modifier.fillMaxWidth()) { ShardCard(Modifier.fillMaxWidth()) {
Row( Row(
Modifier.fillMaxWidth().padding(16.dp), Modifier.fillMaxWidth().padding(16.dp),

View File

@@ -6,9 +6,9 @@ package com.runicgateway.app.ui.rust
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.RustEventDto import com.runicgateway.app.data.api.dto.RustEventListDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustPresenceDto import com.runicgateway.app.data.api.dto.RustOnlineDto
import com.runicgateway.app.data.api.dto.RustServerDto import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.api.dto.RustWipeDto import com.runicgateway.app.data.api.dto.RustWipeDto
import com.runicgateway.app.data.repository.RustRepository import com.runicgateway.app.data.repository.RustRepository
@@ -26,7 +26,20 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** The four sections of a server's page (D13). */ /** The four sections of a server's page (D13). */
enum class RustTab { FEED, LEADERBOARD, ONLINE, WIPES } enum class RustTab {
FEED, LEADERBOARD, ONLINE, WIPES;
/** The website's name for this tab, as `?tab=` carries it (`ServerDetail.jsx`). */
val wire: String get() = name.lowercase()
companion object {
/**
* The tab the website calls [wire], or null for one the app does not have.
* The website also has `clans`, and a link to it is not a link to the feed.
*/
fun fromWire(wire: String?): RustTab? = entries.firstOrNull { it.wire == wire }
}
}
/** What a leaderboard column sorts by — the API's own vocabulary, not the app's. */ /** What a leaderboard column sorts by — the API's own vocabulary, not the app's. */
object RustSort { object RustSort {
@@ -52,8 +65,10 @@ data class RustServerUi(
val sort: String = RustSort.KILLS, val sort: String = RustSort.KILLS,
/** The wipe every panel is filtered to. **Null is all time**, not "unknown". */ /** The wipe every panel is filtered to. **Null is all time**, not "unknown". */
val selectedWipe: String? = null, val selectedWipe: String? = null,
val feed: Polled<List<RustEventDto>> = Polled(), /** The whole answer, not its rows: `presenceHidden` is part of what it says. */
val online: Polled<List<RustPresenceDto>> = Polled(), val feed: Polled<RustEventListDto> = Polled(),
/** Likewise — `hidden` and `count` are what tell "withheld" from "nobody". */
val online: Polled<RustOnlineDto> = Polled(),
val leaderboard: UiState<List<RustLeaderboardRowDto>> = UiState.Loading, val leaderboard: UiState<List<RustLeaderboardRowDto>> = UiState.Loading,
val wipes: UiState<List<RustWipeDto>> = UiState.Loading, val wipes: UiState<List<RustWipeDto>> = UiState.Loading,
) )
@@ -89,11 +104,15 @@ class RustServerViewModel @Inject constructor(
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: "" private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
/** The tab a link asked for (D71), or null for the feed every page opens on. */
private val initialTab: RustTab? = RustTab.fromWire(savedStateHandle[Routes.Args.TAB])
private val _state = MutableStateFlow(RustServerUi(serverId = serverId)) private val _state = MutableStateFlow(RustServerUi(serverId = serverId))
val state: StateFlow<RustServerUi> = _state.asStateFlow() val state: StateFlow<RustServerUi> = _state.asStateFlow()
init { init {
load() load()
initialTab?.takeIf { it != RustTab.FEED }?.let(::selectTab)
} }
/** A first load or a retry of the whole page. */ /** A first load or a retry of the whole page. */

View File

@@ -490,7 +490,7 @@
<string name="notifications_subtitle">Choose what this shard notifies you about, and how it reaches you. Nothing is sent unless you turn it on.</string> <string name="notifications_subtitle">Choose what this 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_general">General</string>
<string name="notifications_section_personal">Your game account</string> <string name="notifications_section_personal">Your game account</string>
<string name="notifications_requires_link">Link a game account to enable this.</string> <string name="notifications_requires_link">Link a game account to get this as a push notification.</string>
<string name="notifications_empty">This shard offers no notification streams yet.</string> <string name="notifications_empty">This shard offers no notification streams yet.</string>
<string name="notifications_unsupported">This shard hasn\'t set up push notifications yet.</string> <string name="notifications_unsupported">This shard hasn\'t set up push notifications yet.</string>
<string name="notifications_saved">Notification settings saved.</string> <string name="notifications_saved">Notification settings saved.</string>
@@ -593,6 +593,17 @@
<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_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_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_wipes_empty">This server has not reported a wipe yet.</string>
<!-- Nothing names who is online by default (org lead, 2026-09-22). Below the
operator's audience the server sends a count and no names. -->
<plurals name="rust_online_hidden_count">
<item quantity="one">%1$d player online</item>
<item quantity="other">%1$d players online</item>
</plurals>
<string name="rust_online_hidden_staff">Only this sites staff can see who they are.</string>
<string name="rust_online_hidden_signin">Sign in to see who they are.</string>
<string name="rust_online_hidden_public">This site is not showing who they are right now.</string>
<string name="rust_feed_presence_hidden_staff">Joins, deaths and chat are not shown. Only this sites staff can see what players did.</string>
<string name="rust_feed_presence_hidden_signin">Joins, deaths and chat are not shown. Sign in to see what players did.</string>
<string name="rust_nobody_on">The server is up and the island is empty. Somebody has to be first.</string> <string name="rust_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_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_live">On the server right now.</string>
@@ -605,4 +616,30 @@
<string name="rust_col_structures">Built</string> <string name="rust_col_structures">Built</string>
<string name="rust_col_played">Played</string> <string name="rust_col_played">Played</string>
<string name="rust_online_badge">%1$d players online</string> <string name="rust_online_badge">%1$d players online</string>
<!-- The player's own Rust account (M15 — module-rust phase 8) -->
<string name="menu_rust_account">My Rust account</string>
<string name="rust_link_title">Link your Steam account</string>
<string name="rust_link_hint">Join any of our Rust servers and type /link in chat. The server replies with a six-character code only you can see, good for five minutes. Enter it here — it works once.</string>
<string name="rust_link_code">Link code</string>
<string name="rust_link_action">Link account</string>
<string name="rust_link_ok">Linked. Your play on our servers now appears under your name here.</string>
<string name="rust_link_already">That account was already linked to you.</string>
<string name="rust_link_bad_code">That code is unknown or has expired. Type /link in game for a new one.</string>
<string name="rust_link_taken">That Steam account is linked to another website account. Run /unlink in game to release it.</string>
<string name="rust_link_capped">Too many attempts just now. Try again in a few minutes.</string>
<string name="rust_link_unreachable">A server could not be reached, so that code could not be checked. Your code is still good — try again in a minute.</string>
<string name="rust_link_error">Could not link that code.</string>
<string name="rust_unlink_action">Unlink</string>
<string name="rust_unlink_error">Could not unlink that account.</string>
<string name="rust_account_linked_when">linked %1$s</string>
<string name="rust_account_none">No Steam account is linked to this profile yet.</string>
<string name="rust_account_fleet_note">A link covers every server this community runs — a Steam account is one person wherever they play. You can also type /unlink in game.</string>
<string name="rust_held_title">What you can do in game</string>
<string name="rust_held_empty">Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on the servers they cover.</string>
<string name="rust_held_unlinked">None of this reaches the game yet — link a Steam account above and the site pushes it across on its next sync.</string>
<string name="rust_held_waiting_note">A server marked waiting has not confirmed it yet. One that is offline catches up when it comes back.</string>
<string name="rust_held_no_servers">No servers are configured yet.</string>
<string name="rust_reach_live">%1$s · has it</string>
<string name="rust_reach_waiting">%1$s · waiting</string>
</resources> </resources>

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.PlayerRustApi
import com.runicgateway.app.data.api.dto.RustLinkListDto
import com.runicgateway.app.data.api.dto.RustLinkRequest
import com.runicgateway.app.data.api.dto.RustLinkResultDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
import com.runicgateway.app.data.api.dto.RustUnlinkResultDto
/**
* A configurable fake of [PlayerRustApi] (M15).
*
* [linkError] is its own field rather than a shared [error]: the tests that
* matter here are about a **redemption** that fails while the reads around it
* succeed — a refused code must leave the accounts and entitlements on screen,
* and one `error` for the whole interface could not express that.
*/
class FakePlayerRustApi : PlayerRustApi {
/** Thrown by every call — the "the site is down" case. */
var error: Throwable? = null
/** Thrown by one call each, so one half of the screen can fail alone. */
var linksError: Throwable? = null
var linkError: Throwable? = null
var unlinkError: Throwable? = null
var permissionsError: Throwable? = null
var links: RustLinkListDto = RustLinkListDto()
var linkResult: RustLinkResultDto = RustLinkResultDto(linked = true)
var permissions: RustPlayerPermissionsDto = RustPlayerPermissionsDto()
var linksCalls: Int = 0
var permissionsCalls: Int = 0
/** The code the last redemption carried, exactly as the screen sent it. */
var lastCode: String? = null
/** The Steam id the last release named. */
var lastUnlinked: String? = null
override suspend fun links(): RustLinkListDto {
linksCalls++
linksError?.let { throw it }
error?.let { throw it }
return links
}
override suspend fun link(body: RustLinkRequest): RustLinkResultDto {
lastCode = body.code
linkError?.let { throw it }
return linkResult
}
override suspend fun unlink(steamId: String): RustUnlinkResultDto {
lastUnlinked = steamId
unlinkError?.let { throw it }
return RustUnlinkResultDto(unlinked = true)
}
override suspend fun permissions(): RustPlayerPermissionsDto {
permissionsCalls++
permissionsError?.let { throw it }
error?.let { throw it }
return permissions
}
}

View File

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

View File

@@ -123,13 +123,27 @@ class MenuCapabilityGatingTest {
// rather than by the visibility framework. They rendered on a backend with // rather than by the visibility framework. They rendered on a backend with
// no module installed and answered "This content couldn't be found", // no module installed and answered "This content couldn't be found",
// through a green suite. // through a green suite.
//
// **It asks which module, not merely whether one.** M15 put a second
// game's self-service row under `player/`, and a row that declared the
// wrong module's capability would render on a site running the other
// game and answer 404 — the same failure in a new place.
val onAModulePath = APP_MENU.filter { val onAModulePath = APP_MENU.filter {
it.route.startsWith("shard") || it.route.startsWith("player/") || it.route == Routes.ATLAS it.route.startsWith("shard") || it.route.startsWith("player/") || it.route == Routes.ATLAS
} }
assertEquals(8, onAModulePath.size) assertEquals(9, onAModulePath.size)
assertTrue(
onAModulePath.filter { it.capability != Capability.SHARD }.map { it.route }.toString(), val expected = onAModulePath.associate { entry ->
onAModulePath.all { it.capability == Capability.SHARD }, entry.route to if (entry.route.startsWith("player/rust")) {
Capability.RUST
} else {
Capability.SHARD
}
}
assertEquals(
expected,
onAModulePath.associate { it.route to it.capability },
) )
} }

View File

@@ -3,9 +3,12 @@
*/ */
package com.runicgateway.app.ui.navigation 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.Session
import com.runicgateway.app.core.auth.SessionUser
import com.runicgateway.app.data.repository.Capability import com.runicgateway.app.data.repository.Capability
import com.runicgateway.app.data.repository.SiteCapabilities import com.runicgateway.app.data.repository.SiteCapabilities
import com.runicgateway.app.ui.rust.RustTab
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -102,6 +105,43 @@ class RustNavigationTest {
assertNull("a deeper unknown Rust path hands off", resolveWebPath("/rust/servers/main/extra")) assertNull("a deeper unknown Rust path hands off", resolveWebPath("/rust/servers/main/extra"))
} }
// ── The player's own row (M15) ────────────────────────────────────────
private fun player() = signedIn(Role.PLAYER)
private fun staff() = signedIn(Role.ADMIN)
private fun signedIn(role: Role) =
Session.SignedIn(SessionUser(id = 1, username = "u", role = role))
private fun routesFor(session: Session, capabilities: SiteCapabilities?) =
visibleEntries(APP_MENU, session, features = null, capabilities = capabilities)
.map { it.route }
@Test fun theAccountRowNeedsBothASessionAndTheModule() {
// Signed out, the row is not there whatever the backend serves: every
// route behind it is `requireAuth`.
assertFalse(Routes.PLAYER_RUST in routesFor(serving(Capability.RUST)))
assertTrue(Routes.PLAYER_RUST in routesFor(player(), serving(Capability.RUST)))
assertFalse(
"a UO-only site has no Rust account row",
Routes.PLAYER_RUST in routesFor(player(), serving(Capability.SHARD)),
)
}
@Test fun staffSeeTheAccountRowToo() {
// `PLAYER` access is `isPlayer || isStaff` — staff play the game as well,
// and `/player/rust/*` is `requireAuth` with no role above it.
assertTrue(Routes.PLAYER_RUST in routesFor(staff(), serving(Capability.RUST)))
}
@Test fun theAccountRowHangsOnTheModuleNotOnASurfaceWord() {
// `identity` is one of the module's surface words, like `servers` above.
// A capability answers *is the module there*, and only `rust` does.
assertFalse(Routes.PLAYER_RUST in routesFor(player(), serving("identity")))
}
@Test fun aRustPathWithAQueryHandsOff() { @Test fun aRustPathWithAQueryHandsOff() {
// The website keeps tab, filter, wipe and sort in the URL; the app keeps // 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 // them in a view model. Resolving `?tab=wipes` natively would silently drop
@@ -109,4 +149,38 @@ class RustNavigationTest {
assertNull(resolveWebPath("/rust?tab=wipes")) assertNull(resolveWebPath("/rust?tab=wipes"))
assertNull(resolveWebPath("/rust/servers/main?wipe=w1")) assertNull(resolveWebPath("/rust/servers/main?wipe=w1"))
} }
// ── The notification links (phase 11, D71) ────────────────────────────
@Test fun theNewLeaderNoticeOpensTheLeaderboardTab() {
// `leaderboardPath` in module-rust's triggers.js.
assertEquals("rust/servers/main?tab=leaderboard", resolveWebPath("/rust/servers/main?tab=leaderboard"))
assertEquals(Routes.rustServer("main", RustTab.LEADERBOARD), resolveWebPath("/rust/servers/main?tab=leaderboard"))
assertEquals(Routes.rustServer("main", RustTab.WIPES), resolveWebPath("/rust/servers/main?tab=wipes"))
}
@Test fun onlyATabTheAppHasResolves() {
// The website has a Clans tab and the app does not: the browser, not the feed.
assertNull(resolveWebPath("/rust/servers/main?tab=clans"))
assertNull(resolveWebPath("/rust/servers/main?tab="))
assertNull(resolveWebPath("/rust/servers/main?tab=wipes&wipe=w1"))
assertNull(resolveWebPath("/rust/servers/main?sort=kills"))
assertNull(resolveWebPath("/rust/servers/main?tab=LEADERBOARD"))
}
@Test fun aTablessRouteStaysTheOneEveryOtherCallerBuilds() {
assertEquals("rust/servers/main", Routes.rustServer("main"))
assertEquals(RustTab.ONLINE, RustTab.fromWire("online"))
assertNull(RustTab.fromWire("clans"))
assertNull(RustTab.fromWire(null))
}
@Test fun theAccountLinkedNoticeOpensThePlayersOwnScreen() {
// `PATHS.account` in module-rust's triggers.js. M15 deferred this to the
// phase that would need it.
assertEquals(Routes.PLAYER_RUST, resolveWebPath("/player/rust"))
assertEquals(Routes.PLAYER_RUST, resolveWebPath("/player/rust/"))
assertNull(resolveWebPath("/player/rust?x=1"))
assertNull(resolveWebPath("/player/shard"))
}
} }

View File

@@ -14,7 +14,8 @@ import org.junit.Test
/** /**
* Tests the pure notification helpers: the stream → deep-link route map (PLAN.md * 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, * §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). * and the personal-item gating (a personal id needs a linked game account to
* switch push on, and nothing else).
*/ */
class NotificationRoutingTest { class NotificationRoutingTest {
@@ -33,15 +34,23 @@ class NotificationRoutingTest {
assertEquals(Routes.HOME, Routes.forStream("something.new")) assertEquals(Routes.HOME, Routes.forStream("something.new"))
} }
@Test fun personalItemNeedsLinkedAccount() { @Test fun personalItemNeedsALinkOnlyToSwitchPushOn() {
val personal = NotificationChannelItemDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true) val personal = NotificationChannelItemDto(
assertFalse(itemSelectable(personal, hasLinkedAccount = false)) id = "vendor.sale", personal = true, requiresLinkedAccount = true,
assertTrue(itemSelectable(personal, hasLinkedAccount = true)) channels = listOf("push", "inapp", "email"),
)
assertTrue(pushNeedsLink(personal, hasLinkedAccount = false))
assertFalse(pushNeedsLink(personal, hasLinkedAccount = true))
assertFalse(canSetMode(personal, CHANNEL_PUSH, "instant", hasLinkedAccount = false))
assertTrue(canSetMode(personal, CHANNEL_PUSH, "instant", hasLinkedAccount = true))
} }
@Test fun generalItemIsAlwaysSelectable() { @Test fun generalItemIsNeverHeldBack() {
val general = NotificationChannelItemDto(id = "news.post", personal = false, requiresLinkedAccount = false) val general = NotificationChannelItemDto(
assertTrue(itemSelectable(general, hasLinkedAccount = false)) id = "news.post", personal = false, requiresLinkedAccount = false, channels = listOf("push"),
)
assertFalse(pushNeedsLink(general, hasLinkedAccount = false))
assertTrue(canSetMode(general, CHANNEL_PUSH, "instant", hasLinkedAccount = false))
} }
// ── The tickle → destination map (ENGAGEMENT.md phase 8) ─────────────── // ── The tickle → destination map (ENGAGEMENT.md phase 8) ───────────────

View File

@@ -64,10 +64,36 @@ class NotificationSettingsViewModelTest {
assertEquals(listOf("off", "instant"), push.modes) assertEquals(listOf("off", "instant"), push.modes)
} }
@Test fun personalItemsStillNeedALinkedAccount() { @Test fun aTriggerOnlyPersonalItemIsNotHeldBackAtAll() {
// Nothing can push it, so there is no push control for the link to hold,
// and the hint would describe a control that is not on the screen.
val personal = prefs().items.first { it.personal } val personal = prefs().items.first { it.personal }
assertEquals(false, itemSelectable(personal, hasLinkedAccount = false)) assertEquals(false, pushNeedsLink(personal, hasLinkedAccount = false))
assertEquals(true, itemSelectable(personal, hasLinkedAccount = true)) assertEquals(true, canSetMode(personal, "email", "instant", hasLinkedAccount = false))
}
// ── Rust D69: a Rust site's raid alert ─────────────────────────────────
private val raid = NotificationChannelItemDto(
id = "rust.base.destroyed", label = "Your base was raided",
personal = true, requiresLinkedAccount = true,
channels = listOf("push", "inapp", "email"),
modes = mapOf("push" to "instant", "inapp" to "instant", "email" to "off"),
)
@Test fun withALinkPushCanBeSwitchedOn() {
assertEquals(true, canSetMode(raid, CHANNEL_PUSH, "instant", hasLinkedAccount = true))
}
@Test fun withoutALinkEveryChannelCanStillBeSwitchedOff() {
// Phase 10's criterion ends "and can be switched off there". Before phase
// 11 the whole row was disabled whenever the link check failed, and it
// always failed on a Rust site.
listOf(CHANNEL_PUSH, "inapp", "email").forEach { channel ->
assertEquals(channel, true, canSetMode(raid, channel, MODE_OFF, hasLinkedAccount = false))
}
assertEquals(true, canSetMode(raid, "inapp", "instant", hasLinkedAccount = false))
assertEquals(false, canSetMode(raid, CHANNEL_PUSH, "instant", hasLinkedAccount = false))
} }
@Test fun oneToggleSendsExactlyOnePair() = kotlinx.coroutines.runBlocking { @Test fun oneToggleSendsExactlyOnePair() = kotlinx.coroutines.runBlocking {

View File

@@ -0,0 +1,205 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.rust
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.RustLinkDto
import com.runicgateway.app.data.api.dto.RustLinkListDto
import com.runicgateway.app.data.api.dto.RustLinkResultDto
import com.runicgateway.app.data.api.dto.RustPlayerGrantDto
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
import com.runicgateway.app.data.api.dto.RustReachDto
import com.runicgateway.app.data.api.fake.FakePlayerRustApi
import com.runicgateway.app.data.repository.PlayerRustRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import java.io.IOException
/**
* The player's own Rust account (M15).
*
* The properties worth a test are the ones a screenshot cannot show: that the
* four refusals stay four different pieces of advice, that a failure on one half
* of the screen leaves the other half standing, and that a link re-reads what the
* site holds — because linking an account is the one action here that changes
* what reaches a game without changing anything the site authored.
*/
class RustAccountViewModelTest {
@get:Rule
val dispatcherRule = MainDispatcherRule()
private val api = FakePlayerRustApi()
private val repository = PlayerRustRepository(api)
private fun viewModel() = RustAccountViewModel(repository)
private fun linked(vararg ids: String) =
RustLinkListDto(ids.map { RustLinkDto(steamId = it, name = "Wanderer") })
@Test
fun `it reads both halves on open`() {
api.links = linked("7656119")
api.permissions = RustPlayerPermissionsDto(accounts = 1)
val state = viewModel().state.value
assertEquals(1, api.linksCalls)
assertEquals(1, api.permissionsCalls)
assertTrue(state.links is UiState.Success)
assertTrue(state.held is UiState.Success)
}
@Test
fun `entitlements still render when the account list fails, and the other way round`() {
api.linksError = httpError(500)
api.permissions = RustPlayerPermissionsDto(
accounts = 0,
grants = listOf(RustPlayerGrantDto(permission = "kits.vip")),
)
// Only the links read throws; the permission read answers. An entitlement exists before a Steam id does, so a
// failed account read must not take it off the screen.
val state = viewModel().state.value
assertTrue(state.links is UiState.Error)
assertTrue(state.held is UiState.Success)
assertEquals(1, (state.held as UiState.Success).data.grants.size)
}
@Test
fun `a refused code is four different pieces of advice, never one`() {
val cases = mapOf(
400 to R.string.rust_link_bad_code,
409 to R.string.rust_link_taken,
429 to R.string.rust_link_capped,
// The code is STILL GOOD here. A player told to get a new one would
// go back to the same unreachable server for it.
503 to R.string.rust_link_unreachable,
)
for ((status, expected) in cases) {
val vm = viewModel()
api.linkError = httpError(status)
vm.link("K7M2PQ")
assertEquals("status $status", expected, vm.state.value.feedback?.messageRes)
assertFalse(vm.state.value.feedback!!.ok)
}
}
@Test
fun `a dead network is not a refused code`() {
val vm = viewModel()
api.linkError = IOException("down")
vm.link("K7M2PQ")
assertEquals(R.string.error_network, vm.state.value.feedback?.messageRes)
}
@Test
fun `linking again with an account already held is a success, not an error`() {
val vm = viewModel()
api.linkResult = RustLinkResultDto(linked = true, already = true)
vm.link("K7M2PQ")
assertEquals(R.string.rust_link_already, vm.state.value.feedback?.messageRes)
assertTrue(vm.state.value.feedback!!.ok)
}
@Test
fun `a successful link re-reads what the site holds, not only the accounts`() {
val vm = viewModel()
val linksBefore = api.linksCalls
val permissionsBefore = api.permissionsCalls
vm.link("K7M2PQ")
assertEquals(linksBefore + 1, api.linksCalls)
assertEquals(
"a link changes what REACHES a game; the live marks are the only thing here it invalidates",
permissionsBefore + 1,
api.permissionsCalls,
)
}
@Test
fun `the code is trimmed and sent as typed`() {
val vm = viewModel()
vm.link(" k7m2pq ")
assertEquals("k7m2pq", api.lastCode)
}
@Test
fun `a blank code asks nothing at all`() {
val vm = viewModel()
vm.link(" ")
assertEquals(null, api.lastCode)
}
@Test
fun `releasing a link names that account and re-reads both halves`() {
val vm = viewModel()
val linksBefore = api.linksCalls
val permissionsBefore = api.permissionsCalls
vm.unlink("7656119")
assertEquals("7656119", api.lastUnlinked)
assertEquals(linksBefore + 1, api.linksCalls)
assertEquals(permissionsBefore + 1, api.permissionsCalls)
assertEquals(null, vm.state.value.unlinking)
}
@Test
fun `a failed release says so and leaves the row alone`() {
val vm = viewModel()
api.unlinkError = httpError(404)
vm.unlink("7656119")
assertEquals(R.string.rust_unlink_error, vm.state.value.feedback?.messageRes)
assertEquals(null, vm.state.value.unlinking)
}
@Test
fun `the servers an entitlement reaches arrive resolved, marks and all`() {
// The app does no scope arithmetic: `*` never reaches a screen. What
// arrives is a list of servers already marked, and this asserts the app
// keeps it that way rather than deriving anything of its own.
api.permissions = RustPlayerPermissionsDto(
accounts = 1,
grants = listOf(
RustPlayerGrantDto(
permission = "kits.vip",
scope = "*",
reach = listOf(
RustReachDto(id = "main", name = "Main", live = true),
RustReachDto(id = "creative", name = "Creative", live = false),
),
),
),
)
val held = (viewModel().state.value.held as UiState.Success).data
assertNotNull(held.grants.first().reach.first { it.id == "main" })
assertTrue(held.grants.first().reach.first { it.id == "main" }.live)
assertFalse(held.grants.first().reach.first { it.id == "creative" }.live)
}
}

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.ui.rust package com.runicgateway.app.ui.rust
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import com.runicgateway.app.data.api.dto.RustEventListDto
import com.runicgateway.app.data.api.dto.RustLeaderboardDto import com.runicgateway.app.data.api.dto.RustLeaderboardDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustOnlineDto import com.runicgateway.app.data.api.dto.RustOnlineDto
@@ -100,6 +101,35 @@ class RustServerViewModelTest {
assertEquals(onlineBefore + 1, api.onlineCalls) assertEquals(onlineBefore + 1, api.onlineCalls)
} }
@Test
fun `withheld names arrive as withheld, with the count, never as an empty list`() {
// Nothing names who is online by default (org lead, 2026-09-22). The
// screen's whole job with this answer is to say "12 online" rather than
// "nobody is on", so the state must carry `hidden` and `count` intact.
api.online = RustOnlineDto(players = emptyList(), hidden = true, count = 12, audience = "staff")
val vm = viewModel()
vm.selectTab(RustTab.ONLINE)
val state = vm.state.value.online.state
assertTrue(state is UiState.Success)
val answer = (state as UiState.Success).data
assertTrue(answer.hidden)
assertEquals(12, answer.count)
assertEquals("staff", answer.audience)
}
@Test
fun `a feed with its players withheld says so in the state`() {
api.events = RustEventListDto(events = emptyList(), presenceHidden = true, presenceAudience = "signed_in")
val vm = viewModel()
val state = vm.state.value.feed.state
assertTrue(state is UiState.Success)
assertTrue((state as UiState.Success).data.presenceHidden)
assertEquals("signed_in", state.data.presenceAudience)
}
@Test @Test
fun `a poll on a still panel asks for nothing but the server line`() { fun `a poll on a still panel asks for nothing but the server line`() {
api.wipes = RustWipeListDto(listOf(RustWipeDto(wipeId = "w1"))) api.wipes = RustWipeListDto(listOf(RustWipeDto(wipeId = "w1")))