Compare commits
10 Commits
a6677d5bf9
...
edge
| Author | SHA1 | Date | |
|---|---|---|---|
| adf9547e01 | |||
| 640b423dfa | |||
| 8e3c62d966 | |||
| f695eb6c45 | |||
| 59d955a11d | |||
| a079bd481e | |||
| 4878b74e09 | |||
| 37a828736e | |||
| 4b22ab3756 | |||
| daf483f514 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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() })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 site’s staff can see who they are.</string>
|
||||||
|
<string name="rust_online_hidden_signin">Sign in to see who they are.</string>
|
||||||
|
<string name="rust_online_hidden_public">This site is not showing who they are right now.</string>
|
||||||
|
<string name="rust_feed_presence_hidden_staff">Joins, deaths and chat are not shown. Only this site’s staff can see what players did.</string>
|
||||||
|
<string name="rust_feed_presence_hidden_signin">Joins, deaths and chat are not shown. Sign in to see what players did.</string>
|
||||||
<string name="rust_nobody_on">The server is up and the island is empty. Somebody has to be first.</string>
|
<string name="rust_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>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) ───────────────
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")))
|
||||||
|
|||||||
Reference in New Issue
Block a user