From f695eb6c454c9fbad04410f35e1f1e828bb62ceb Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 00:30:31 -0500 Subject: [PATCH] fix(rust): say who is online is withheld, never that nobody is Module-Rust now withholds who is online below an operator-chosen audience - staff by default (org lead, 2026-09-22: nothing names who is online by default). The Online route answers an empty list with hidden, count and audience; the feed answers presenceHidden and presenceAudience. Before this the app would have rendered the withheld list as "the island is empty" on a full server. Now: * RustOnlineDto and RustEventListDto carry the flags; the repository and view model keep the whole answer instead of its rows. * the Online tab says "2 players online" and who can see the names; the feed says, once above the rows, that joins, deaths and chat are not shown. * an older module without the flags decodes as visible, as before. Walked on an emulator against a core with the module installed: the withheld panel and the feed note as a signed-in player at the staff default, and the names appearing on the next poll after the fleet was widened to signed-in - which also proves the app's bearer session reaches the module's viewer check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .../runicgateway/app/data/api/dto/RustDto.kt | 24 ++++++- .../app/data/repository/RustRepository.kt | 19 ++++-- .../app/ui/rust/RustServerScreen.kt | 66 ++++++++++++++----- .../app/ui/rust/RustServerViewModel.kt | 10 +-- app/src/main/res/values/strings.xml | 11 ++++ .../app/data/api/dto/RustPresenceDtoTest.kt | 61 +++++++++++++++++ .../app/ui/rust/RustServerViewModelTest.kt | 30 +++++++++ 7 files changed, 193 insertions(+), 28 deletions(-) create mode 100644 app/src/test/java/com/runicgateway/app/data/api/dto/RustPresenceDtoTest.kt diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt index 4e5a690..2bcb27b 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt @@ -70,10 +70,20 @@ data class RustServerDto( 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 data class RustEventListDto( val events: List = emptyList(), + val presenceHidden: Boolean = false, + val presenceAudience: String? = null, ) /** @@ -171,10 +181,20 @@ data class RustWipeDto( 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 data class RustOnlineDto( val players: List = emptyList(), + val hidden: Boolean = false, + val count: Int = 0, + val audience: String? = null, ) /** diff --git a/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt index 0d2b348..a051bb8 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt @@ -7,9 +7,9 @@ import com.runicgateway.app.core.result.ApiResult import com.runicgateway.app.core.result.map import com.runicgateway.app.core.result.safeApiCall import com.runicgateway.app.data.api.RustApi -import com.runicgateway.app.data.api.dto.RustEventDto +import com.runicgateway.app.data.api.dto.RustEventListDto 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.RustWipeDto import javax.inject.Inject @@ -50,14 +50,14 @@ class RustRepository @Inject constructor( kinds: List = emptyList(), wipe: String? = null, limit: Int? = null, - ): ApiResult> = safeApiCall { + ): ApiResult = safeApiCall { api.getEvents( id = id, kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","), wipe = wipe?.takeIf { it.isNotBlank() }, limit = limit, ) - }.map { it.events } + } /** The leaderboard: per wipe when [wipe] is given, all-time otherwise. */ suspend fun leaderboard( @@ -78,7 +78,12 @@ class RustRepository @Inject constructor( suspend fun wipes(id: String): ApiResult> = safeApiCall { api.getWipes(id) }.map { it.wipes } - /** The presence board. Rows survive an unreachable server, by design. */ - suspend fun online(id: String): ApiResult> = - safeApiCall { api.getOnline(id) }.map { it.players } + /** + * The presence board. Rows survive an unreachable server, by design. + * + * Answered whole rather than as its rows: `hidden` and `count` are what let + * the screen tell "withheld from you" from "nobody is on". + */ + suspend fun online(id: String): ApiResult = + safeApiCall { api.getOnline(id) } } diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt index 7b6c2f5..b50845f 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -33,8 +34,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.runicgateway.app.R import com.runicgateway.app.data.api.dto.RustEventDto +import com.runicgateway.app.data.api.dto.RustEventListDto import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto -import com.runicgateway.app.data.api.dto.RustPresenceDto +import com.runicgateway.app.data.api.dto.RustOnlineDto import com.runicgateway.app.data.api.dto.RustServerDto import com.runicgateway.app.data.api.dto.RustWipeDto import com.runicgateway.app.ui.ErrorKind @@ -252,7 +254,7 @@ private fun WipeFilter( @Composable private fun FeedPanel( - feed: Polled>, + feed: Polled, filterId: String, onFilter: (String) -> Unit, onRetry: () -> Unit, @@ -275,17 +277,36 @@ private fun FeedPanel( when (val s = feed.state) { is UiState.Loading -> LoadingView() is UiState.Error -> ErrorView(s.kind, onRetry = onRetry) - is UiState.Success -> if (s.data.isEmpty()) { - EmptyView(stringResource(R.string.rust_feed_empty)) - } else { - LazyColumn( - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (feed.refreshFailed) { - item { RefreshFailedLine() } + is UiState.Success -> { + // Said once, above the rows, so a thin feed reads as withheld + // rather than as a quiet server (org lead: nothing names who is + // online by default). + if (s.data.presenceHidden) { + Text( + text = stringResource( + if (s.data.presenceAudience == "signed_in") { + R.string.rust_feed_presence_hidden_signin + } else { + R.string.rust_feed_presence_hidden_staff + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + if (s.data.events.isEmpty()) { + EmptyView(stringResource(R.string.rust_feed_empty)) + } else { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (feed.refreshFailed) { + item { RefreshFailedLine() } + } + items(s.data.events, key = { it.id }) { FeedRow(it) } } - items(s.data, key = { it.id }) { FeedRow(it) } } } } @@ -444,14 +465,29 @@ private fun LeaderboardPanel( @Composable private fun OnlinePanel( - online: Polled>, + online: Polled, serverOnline: Boolean, onRetry: () -> Unit, ) { when (val s = online.state) { is UiState.Loading -> LoadingView() is UiState.Error -> ErrorView(s.kind, onRetry = onRetry) - is UiState.Success -> if (s.data.isEmpty()) { + // Withheld is not empty. Below the operator's audience the server sends + // the count and no names, and an empty list rendered as "nobody is on" + // would be a false statement about a full server. + is UiState.Success -> if (s.data.hidden) { + val count = s.data.count + EmptyView( + pluralStringResource(R.plurals.rust_online_hidden_count, count, count) + "\n" + + stringResource( + when (s.data.audience) { + "signed_in" -> R.string.rust_online_hidden_signin + "public" -> R.string.rust_online_hidden_public + else -> R.string.rust_online_hidden_staff + }, + ), + ) + } else if (s.data.players.isEmpty()) { EmptyView( stringResource( if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline, @@ -479,7 +515,7 @@ private fun OnlinePanel( if (online.refreshFailed) { item { RefreshFailedLine() } } - items(s.data, key = { it.steamId }) { player -> + items(s.data.players, key = { it.steamId }) { player -> ShardCard(Modifier.fillMaxWidth()) { Row( Modifier.fillMaxWidth().padding(16.dp), diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt index 2c1500b..48ae83a 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt @@ -6,9 +6,9 @@ package com.runicgateway.app.ui.rust import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.runicgateway.app.data.api.dto.RustEventDto +import com.runicgateway.app.data.api.dto.RustEventListDto 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.RustWipeDto import com.runicgateway.app.data.repository.RustRepository @@ -52,8 +52,10 @@ data class RustServerUi( val sort: String = RustSort.KILLS, /** The wipe every panel is filtered to. **Null is all time**, not "unknown". */ val selectedWipe: String? = null, - val feed: Polled> = Polled(), - val online: Polled> = Polled(), + /** The whole answer, not its rows: `presenceHidden` is part of what it says. */ + val feed: Polled = Polled(), + /** Likewise — `hidden` and `count` are what tell "withheld" from "nobody". */ + val online: Polled = Polled(), val leaderboard: UiState> = UiState.Loading, val wipes: UiState> = UiState.Loading, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7e91b39..793228c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -593,6 +593,17 @@ Nothing has happened on this server yet — or not during the wipe you are looking at. Nobody has scored here yet. This server has not reported a wipe yet. + + + %1$d player online + %1$d players online + + Only this site’s staff can see who they are. + Sign in to see who they are. + This site is not showing who they are right now. + Joins, deaths and chat are not shown. Only this site’s staff can see what players did. + Joins, deaths and chat are not shown. Sign in to see what players did. The server is up and the island is empty. Somebody has to be first. Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is. On the server right now. diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/RustPresenceDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/RustPresenceDtoTest.kt new file mode 100644 index 0000000..915d075 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/RustPresenceDtoTest.kt @@ -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( + """{"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( + """{"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("""{"players":[]}""") + assertFalse(dto.hidden) + assertNull(dto.audience) + } + + @Test fun aWithheldFeedSaysSo() { + val dto = json.decodeFromString( + """{"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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustServerViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustServerViewModelTest.kt index efec390..03116f6 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/RustServerViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustServerViewModelTest.kt @@ -4,6 +4,7 @@ package com.runicgateway.app.ui.rust 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.RustLeaderboardRowDto import com.runicgateway.app.data.api.dto.RustOnlineDto @@ -100,6 +101,35 @@ class RustServerViewModelTest { 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 fun `a poll on a still panel asks for nothing but the server line`() { api.wipes = RustWipeListDto(listOf(RustWipeDto(wipeId = "w1"))) -- 2.49.1