From aca358d72b5512e5d305cfc36e7c2e56afda69b4 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 26 Oct 2026 06:29:16 -0500 Subject: [PATCH] feat(demo): explain the public demo's refusals and show its strip on Home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of docs website/DEMO_MODE.md §10 (website#222). - DemoLockInterceptor on the main client peeks every 403 body. On code DEMO_LOCKED it reports the server's sentence to DemoNotices, and the shell shows it as one snackbar, like the web's toast (DM19). A newer refusal replaces the one showing. Screens added later get it without doing anything. - ApiResult.HttpError carries the body's code (read only on a 403, peeked so the body is not consumed); isDemoLocked(). ErrorKind.DEMO_LOCKED is mapped ahead of the status in toUiState and toShardUiState, and is not retryable. - Screens whose 403 copy blames the role now say the demo sentence instead: the four admin view models, Characters' account creation, and the contact form's line (the contact form is locked on the demo). - Home draws the strip from GET /public/settings' demo block: the wipe time in UTC, the published usernames, and the site's sign-in page for the passwords. The app does not list the passwords (DM20). A real shard sends no block, so it draws nothing. Checked on an emulator against website main (69998ec) with DEMO_MODE=true and seed:demo: the strip on Home; demo-player's username change refused with the snackbar under the screen's own line; demo-admin's site-mode switch refused with the demo sentence in place of "You don't have permission". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .../app/core/net/DemoLockInterceptor.kt | 39 ++++++++++ .../runicgateway/app/core/net/DemoNotices.kt | 35 +++++++++ .../runicgateway/app/core/result/ApiResult.kt | 31 +++++++- .../app/core/result/DemoLocked.kt | 54 +++++++++++++ .../app/data/api/dto/PublicDto.kt | 10 +++ .../com/runicgateway/app/di/NetworkModule.kt | 6 +- .../java/com/runicgateway/app/ui/RunicApp.kt | 19 +++++ .../java/com/runicgateway/app/ui/UiState.kt | 20 +++-- .../app/ui/admin/AdminContentViewModel.kt | 11 ++- .../app/ui/admin/AdminDashboardViewModel.kt | 7 +- .../app/ui/admin/AdminModerationViewModel.kt | 8 +- .../app/ui/admin/AdminSupportViewModel.kt | 10 ++- .../app/ui/components/StateViews.kt | 8 +- .../app/ui/contact/ContactScreen.kt | 1 + .../runicgateway/app/ui/demo/DemoBanner.kt | 65 ++++++++++++++++ .../com/runicgateway/app/ui/demo/DemoInfo.kt | 73 +++++++++++++++++ .../app/ui/demo/DemoNoticeViewModel.kt | 22 ++++++ .../runicgateway/app/ui/home/HomeScreen.kt | 16 +++- .../runicgateway/app/ui/home/HomeViewModel.kt | 24 +++++- .../app/ui/player/CharactersViewModel.kt | 18 +++-- app/src/main/res/values/strings.xml | 9 +++ .../app/core/net/DemoLockInterceptorTest.kt | 73 +++++++++++++++++ .../app/core/result/DemoLockedTest.kt | 78 +++++++++++++++++++ .../app/ui/ContentViewModelTest.kt | 32 +++++++- .../com/runicgateway/app/ui/UiStateTest.kt | 24 +++++- .../app/ui/admin/AdminContentViewModelTest.kt | 11 +++ .../ui/admin/AdminDashboardViewModelTest.kt | 11 +++ .../ui/admin/AdminModerationViewModelTest.kt | 8 ++ .../app/ui/admin/AdminSupportViewModelTest.kt | 8 ++ .../app/ui/contact/ContactViewModelTest.kt | 11 +++ .../runicgateway/app/ui/demo/DemoInfoTest.kt | 58 ++++++++++++++ .../app/ui/player/CharactersViewModelTest.kt | 8 ++ .../runicgateway/app/util/FakeApiSupport.kt | 11 +++ 33 files changed, 780 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/core/net/DemoLockInterceptor.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/net/DemoNotices.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/result/DemoLocked.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/demo/DemoBanner.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/demo/DemoInfo.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/demo/DemoNoticeViewModel.kt create mode 100644 app/src/test/java/com/runicgateway/app/core/net/DemoLockInterceptorTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/core/result/DemoLockedTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/demo/DemoInfoTest.kt diff --git a/app/src/main/java/com/runicgateway/app/core/net/DemoLockInterceptor.kt b/app/src/main/java/com/runicgateway/app/core/net/DemoLockInterceptor.kt new file mode 100644 index 0000000..a87b708 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/net/DemoLockInterceptor.kt @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.net + +import com.runicgateway.app.core.result.DEMO_LOCKED_STATUS +import com.runicgateway.app.core.result.demoRefusalOf +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Spots the public demo's refusal on any response of the main client and reports it + * to [DemoNotices] (`docs/website/DEMO_MODE.md` §3.1, DM19). Every API rides this + * client, so a screen added later is covered without doing anything. + * + * It only reads: the body is peeked, not consumed, and the response goes on to its + * caller unchanged. Only a `403` is looked at, so an ordinary answer is never + * buffered. + */ +@Singleton +class DemoLockInterceptor @Inject constructor( + private val notices: DemoNotices, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + if (response.code != DEMO_LOCKED_STATUS) return response + val body = runCatching { response.peekBody(MAX_PEEK_BYTES).string() }.getOrNull() + demoRefusalOf(body)?.let(notices::report) + return response + } + + private companion object { + /** The refusal is one short JSON object; anything longer is not one. */ + const val MAX_PEEK_BYTES = 8L * 1024 + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/net/DemoNotices.kt b/app/src/main/java/com/runicgateway/app/core/net/DemoNotices.kt new file mode 100644 index 0000000..b1a054c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/net/DemoNotices.kt @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.net + +import com.runicgateway.app.core.result.DemoRefusal +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The app's one channel for the public demo's refusals (`docs/website/DEMO_MODE.md` + * §3.1, DM19) — the counterpart of the web client's `demoNotice.js`. + * [DemoLockInterceptor] reports every `DEMO_LOCKED` answer here and the shell shows + * it as one snackbar, wherever the action was, so no screen has to learn the code. + * The screen still gets its error as usual. + * + * A refusal nobody is collecting (the app in the background) is dropped rather than + * queued: a snackbar about a tap from minutes ago would explain nothing. + */ +@Singleton +class DemoNotices @Inject constructor() { + private val _refusals = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val refusals: SharedFlow = _refusals.asSharedFlow() + + fun report(refusal: DemoRefusal) { + _refusals.tryEmit(refusal) + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt b/app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt index 6f500a8..6a2fd93 100644 --- a/app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt +++ b/app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt @@ -14,12 +14,18 @@ import java.io.IOException * an expected failure, it returns a typed variant the screen can render. * * - [Ok] — a 2xx response with a decoded body. - * - [HttpError] — the server answered with a non-2xx status. + * - [HttpError] — the server answered with a non-2xx status. [HttpError.code] is + * the error body's `code` field when it carried one (today only + * the demo's `DEMO_LOCKED` is read, see [isDemoLocked]). * - [NetworkError] — the request never got an answer (offline, DNS, TLS, timeout). */ sealed interface ApiResult { data class Ok(val data: T) : ApiResult - data class HttpError(val status: Int, val message: String? = null) : ApiResult + data class HttpError( + val status: Int, + val message: String? = null, + val code: String? = null, + ) : ApiResult data class NetworkError(val cause: Throwable) : ApiResult } @@ -54,12 +60,31 @@ suspend fun safeApiCall(block: suspend () -> T): ApiResult = try { } catch (e: CancellationException) { throw e } catch (e: HttpException) { - ApiResult.HttpError(e.code(), e.message()) + ApiResult.HttpError(e.code(), e.message(), errorCodeOf(e)) } catch (e: IOException) { ApiResult.NetworkError(e) } catch (e: SerializationException) { ApiResult.HttpError(MALFORMED_RESPONSE_STATUS, e.message) } +/** + * The error body's `code`, read only for a status that can carry one the app acts on, + * so an ordinary 404 or 500 body is never looked at. The body is peeked, not + * consumed, so whoever else holds the exception can still read it. + */ +private fun errorCodeOf(e: HttpException): String? { + if (e.code() != DEMO_LOCKED_STATUS) return null + val body = runCatching { + e.response()?.errorBody()?.source()?.let { source -> + source.request(MAX_ERROR_PEEK_BYTES) + source.peek().readUtf8(minOf(source.buffer.size, MAX_ERROR_PEEK_BYTES)) + } + }.getOrNull() + return errorCodeOf(body) +} + +/** The refusal is one short JSON object; anything longer is not one. */ +private const val MAX_ERROR_PEEK_BYTES = 8L * 1024 + /** Synthetic status for a 2xx body the app couldn't decode — an invalid upstream response. */ private const val MALFORMED_RESPONSE_STATUS = 502 diff --git a/app/src/main/java/com/runicgateway/app/core/result/DemoLocked.kt b/app/src/main/java/com/runicgateway/app/core/result/DemoLocked.kt new file mode 100644 index 0000000..7e7f87f --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/result/DemoLocked.kt @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.result + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * The public demo's refusal (`docs/website/DEMO_MODE.md` §3.1). With `DEMO_MODE` on, + * the server answers a locked write + * `403 { "message": "This is turned off on the demo site.", "code": "DEMO_LOCKED" }`. + * A module may word its own sentence (a refused console command names the command), + * so the message is carried as sent. + * + * The lock is the server's (DM10). Nothing in the app decides what is allowed; it + * only explains what was refused. + */ +const val DEMO_LOCKED_CODE = "DEMO_LOCKED" + +/** Status the server answers a demo refusal with. */ +const val DEMO_LOCKED_STATUS = 403 + +/** A refusal read off a response body. [message] is null when the body carried none. */ +data class DemoRefusal(val message: String?) + +private val lenient = Json { ignoreUnknownKeys = true } + +/** + * The `code` field of a JSON error body, or null for any body that is not a JSON + * object carrying a string `code`. Never throws: an error body is whatever the + * server or a proxy in front of it sent. + */ +fun errorCodeOf(body: String?): String? = errorObject(body)?.stringField("code") + +/** The refusal in this error body, or null when it is not one. */ +fun demoRefusalOf(body: String?): DemoRefusal? { + val obj = errorObject(body) ?: return null + if (obj.stringField("code") != DEMO_LOCKED_CODE) return null + return DemoRefusal(obj.stringField("message")?.takeIf { it.isNotBlank() }) +} + +/** True when this result is the demo's refusal. */ +fun ApiResult<*>.isDemoLocked(): Boolean = + this is ApiResult.HttpError && code == DEMO_LOCKED_CODE + +private fun errorObject(body: String?): JsonObject? { + if (body.isNullOrBlank()) return null + return runCatching { lenient.parseToJsonElement(body) as? JsonObject }.getOrNull() +} + +private fun JsonObject.stringField(name: String): String? = + (get(name) as? JsonPrimitive)?.takeIf { it.isString }?.content diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt index cdcde5c..47fc1fe 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt @@ -146,4 +146,14 @@ data class SettingsDto( * nav. */ @SerialName("nav_public") val navPublic: String? = null, + /** + * The public demo's banner data, `{ resetsAt, logins: [{ username, role, … }] }` + * (`docs/website/DEMO_MODE.md` §6). Sent only while the server runs with + * `DEMO_MODE` on, so its absence is how a real shard draws no banner. + * + * Raw for the same reason as [theme]: the banner is a nicety, and a value of an + * unexpected shape must not fail the decode of the whole payload. Read by + * `DemoInfo.from`. + */ + val demo: JsonElement? = null, ) diff --git a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt index ed3e7df..6b721c4 100644 --- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -8,6 +8,7 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact import com.runicgateway.app.BuildConfig import com.runicgateway.app.core.net.AuthInterceptor import com.runicgateway.app.core.net.BaseUrlHolder +import com.runicgateway.app.core.net.DemoLockInterceptor import com.runicgateway.app.core.net.HostSelectionInterceptor import com.runicgateway.app.core.net.ShardStream import com.runicgateway.app.core.net.ShardStreamClient @@ -58,7 +59,8 @@ object NetworkModule { /** * The main client every API and the SSE stream ride on. Order: identify (UA), - * retarget onto the configured shard host, then attach the bearer; the + * retarget onto the configured shard host, attach the bearer, then spot the + * public demo's refusal on the way back (DEMO_MODE.md §3.1); the * [TokenAuthenticator] handles silent refresh-on-401 (§4.1). Logging sits last * so it observes the final, authed request. */ @@ -68,12 +70,14 @@ object NetworkModule { hostSelectionInterceptor: HostSelectionInterceptor, userAgentInterceptor: UserAgentInterceptor, authInterceptor: AuthInterceptor, + demoLockInterceptor: DemoLockInterceptor, tokenAuthenticator: TokenAuthenticator, ): OkHttpClient { val builder = OkHttpClient.Builder() .addInterceptor(userAgentInterceptor) .addInterceptor(hostSelectionInterceptor) .addInterceptor(authInterceptor) + .addInterceptor(demoLockInterceptor) .authenticator(tokenAuthenticator) if (BuildConfig.DEBUG) { diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index b517a47..c483850 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -26,6 +26,8 @@ import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItemColors import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -33,6 +35,7 @@ import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -65,6 +68,7 @@ import com.runicgateway.app.ui.auth.TrustedDevicesScreen import com.runicgateway.app.ui.auth.roleLabelRes import com.runicgateway.app.ui.components.BrandLogo import com.runicgateway.app.ui.contact.ContactScreen +import com.runicgateway.app.ui.demo.DemoNoticeViewModel import com.runicgateway.app.ui.events.EventScreen import com.runicgateway.app.ui.events.EventSeriesScreen import com.runicgateway.app.ui.events.EventsScreen @@ -110,6 +114,7 @@ import com.runicgateway.app.ui.shard.ShardScreen import com.runicgateway.app.ui.theme.LocalShardStructure import com.runicgateway.app.ui.wiki.WikiPageScreen import com.runicgateway.app.ui.wiki.WikiScreen +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch /** Destinations that show the drawer (hamburger); others show a back arrow. */ @@ -151,6 +156,7 @@ fun RunicApp( sessionViewModel: SessionViewModel = hiltViewModel(), inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(), rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(), + demoNoticeViewModel: DemoNoticeViewModel = hiltViewModel(), ) { val brand = appearance.brand val navController = rememberNavController() @@ -192,6 +198,18 @@ fun RunicApp( // the previous account's count on the drawer. LaunchedEffect(session) { inboxBadgeViewModel.refresh() } + // The public demo's refusals (DEMO_MODE.md §3.1, DM19): one snackbar for every + // screen, the app's counterpart of the web's toast. The screen keeps its own + // failure line; this says why, in the server's words. A newer refusal replaces + // the one showing rather than queueing behind it. + val snackbarHostState = remember { SnackbarHostState() } + val demoLockedFallback = stringResource(R.string.error_demo_locked) + LaunchedEffect(Unit) { + demoNoticeViewModel.refusals.collectLatest { refusal -> + snackbarHostState.showSnackbar(refusal.message ?: demoLockedFallback) + } + } + // A tapped push notification deep-links to its stream's screen (§11, item 7). LaunchedEffect(deepLinkStream, deepLinkRef) { val stream = deepLinkStream ?: return@LaunchedEffect @@ -347,6 +365,7 @@ fun RunicApp( ) { Scaffold( modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( colors = TopAppBarDefaults.topAppBarColors( diff --git a/app/src/main/java/com/runicgateway/app/ui/UiState.kt b/app/src/main/java/com/runicgateway/app/ui/UiState.kt index c0efa1a..f5ca21c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/UiState.kt +++ b/app/src/main/java/com/runicgateway/app/ui/UiState.kt @@ -4,6 +4,7 @@ package com.runicgateway.app.ui import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.isDemoLocked /** * The load state a screen renders. View models expose a `StateFlow>`; @@ -41,6 +42,14 @@ enum class ErrorKind { */ FEATURE_UNAVAILABLE, + /** + * The public demo refused this (`403 DEMO_LOCKED`, `docs/website/DEMO_MODE.md` + * §3.1). Checked ahead of the status, because a `403` otherwise reads as "you + * may not", which blames the role of somebody signed in as an admin. Like + * [FEATURE_UNAVAILABLE], retrying cannot change the answer. + */ + DEMO_LOCKED, + /** Any other non-2xx server response. */ SERVER, } @@ -50,10 +59,11 @@ fun ApiResult.toUiState(): UiState = when (this) { is ApiResult.Ok -> UiState.Success(data) is ApiResult.NetworkError -> UiState.Error(ErrorKind.NETWORK) is ApiResult.HttpError -> UiState.Error( - kind = when (status) { - 404 -> ErrorKind.NOT_FOUND - 429 -> ErrorKind.RATE_LIMITED - 503 -> ErrorKind.SHARD_OFFLINE + kind = when { + isDemoLocked() -> ErrorKind.DEMO_LOCKED + status == 404 -> ErrorKind.NOT_FOUND + status == 429 -> ErrorKind.RATE_LIMITED + status == 503 -> ErrorKind.SHARD_OFFLINE else -> ErrorKind.SERVER }, httpStatus = status, @@ -76,7 +86,7 @@ fun ApiResult.toUiState(): UiState = when (this) { * player or admin route, which is not an admin's visibility setting. */ fun ApiResult.toShardUiState(): UiState = when (this) { - is ApiResult.HttpError -> if (status == 403 || status == 404) { + is ApiResult.HttpError -> if ((status == 403 || status == 404) && !isDemoLocked()) { UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status) } else { toUiState() diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt index dbc967c..3710083 100644 --- a/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.runicgateway.app.R import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.isDemoLocked import com.runicgateway.app.data.api.dto.AdminPostDto import com.runicgateway.app.data.api.dto.PostCreateRequest import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto @@ -133,8 +134,14 @@ class AdminContentViewModel @Inject constructor( /** Map an [ApiResult] to a [Feedback], with role/permission-aware failure copy. */ private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) { is ApiResult.Ok -> Feedback(true, okRes) - is ApiResult.HttpError -> - Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed) + is ApiResult.HttpError -> Feedback( + false, + when { + isDemoLocked() -> R.string.error_demo_locked + status == 403 -> R.string.admin_forbidden + else -> R.string.admin_action_failed + }, + ) is ApiResult.NetworkError -> Feedback(false, R.string.error_network) } } diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt index 2f19f53..c17459f 100644 --- a/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.runicgateway.app.R import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.isDemoLocked import com.runicgateway.app.data.api.dto.AdminDashboardDto import com.runicgateway.app.data.repository.AdminRepository import com.runicgateway.app.ui.UiState @@ -79,7 +80,11 @@ class AdminDashboardViewModel @Inject constructor( switching = false, feedback = Feedback( false, - if (result.status == 403) R.string.admin_forbidden else R.string.admin_action_failed, + when { + result.isDemoLocked() -> R.string.error_demo_locked + result.status == 403 -> R.string.admin_forbidden + else -> R.string.admin_action_failed + }, ), ) } diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationViewModel.kt index 4ff7180..1e01b6c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.runicgateway.app.R import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.isDemoLocked import com.runicgateway.app.data.repository.AdminRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -75,9 +76,10 @@ class AdminModerationViewModel @Inject constructor( is ApiResult.Ok -> Feedback(true, okRes) is ApiResult.HttpError -> Feedback( false, - when (r.status) { - 403 -> R.string.admin_forbidden - 503 -> R.string.admin_mod_shard_offline + when { + r.isDemoLocked() -> R.string.error_demo_locked + r.status == 403 -> R.string.admin_forbidden + r.status == 503 -> R.string.admin_mod_shard_offline else -> R.string.admin_action_failed }, ) diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportViewModel.kt index 64648ef..981529e 100644 --- a/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.runicgateway.app.R import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.isDemoLocked import com.runicgateway.app.data.api.dto.SupportPageDto import com.runicgateway.app.data.repository.AdminRepository import com.runicgateway.app.ui.UiState @@ -71,10 +72,11 @@ class AdminSupportViewModel @Inject constructor( is ApiResult.Ok -> Feedback(true, okRes) is ApiResult.HttpError -> Feedback( false, - when (r.status) { - 403 -> R.string.admin_forbidden - 404 -> R.string.admin_support_unknown_page - 503 -> R.string.admin_mod_shard_offline + when { + r.isDemoLocked() -> R.string.error_demo_locked + r.status == 403 -> R.string.admin_forbidden + r.status == 404 -> R.string.admin_support_unknown_page + r.status == 503 -> R.string.admin_mod_shard_offline else -> R.string.admin_action_failed }, ) diff --git a/app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt b/app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt index eedb479..cc12d7a 100644 --- a/app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt +++ b/app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt @@ -39,7 +39,9 @@ fun LoadingView(modifier: Modifier = Modifier) { * * [ErrorKind.FEATURE_UNAVAILABLE] renders **without** the button: an admin decides * whether the shard publishes that surface, so retrying cannot change the answer and - * offering it would read as a transient failure the user could wait out (M11). + * offering it would read as a transient failure the user could wait out (M11). The + * public demo's refusal ([ErrorKind.DEMO_LOCKED]) is the same: it is fixed until the + * site stops being a demo. */ @Composable fun ErrorView( @@ -69,7 +71,8 @@ fun ErrorView( } /** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */ -fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE +fun isRetryable(kind: ErrorKind): Boolean = + kind != ErrorKind.FEATURE_UNAVAILABLE && kind != ErrorKind.DEMO_LOCKED /** Centered informational message for an empty list (§7). */ @Composable @@ -93,5 +96,6 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) { ErrorKind.RATE_LIMITED -> R.string.error_rate_limited ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable + ErrorKind.DEMO_LOCKED -> R.string.error_demo_locked ErrorKind.SERVER -> R.string.error_server } diff --git a/app/src/main/java/com/runicgateway/app/ui/contact/ContactScreen.kt b/app/src/main/java/com/runicgateway/app/ui/contact/ContactScreen.kt index 35e085f..fc0a774 100644 --- a/app/src/main/java/com/runicgateway/app/ui/contact/ContactScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/contact/ContactScreen.kt @@ -117,5 +117,6 @@ private fun ResultMessage(result: Result) { private fun errorText(kind: ErrorKind): String = when (kind) { ErrorKind.NETWORK -> stringResource(R.string.error_network) ErrorKind.RATE_LIMITED -> stringResource(R.string.error_rate_limited) + ErrorKind.DEMO_LOCKED -> stringResource(R.string.error_demo_locked) else -> stringResource(R.string.contact_error) } diff --git a/app/src/main/java/com/runicgateway/app/ui/demo/DemoBanner.kt b/app/src/main/java/com/runicgateway/app/ui/demo/DemoBanner.kt new file mode 100644 index 0000000..1daa5e5 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/demo/DemoBanner.kt @@ -0,0 +1,65 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.demo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.runicgateway.app.R + +/** + * The public demo's strip on Home (`docs/website/DEMO_MODE.md` §6), worded like the + * web's: "**Demo site** — anything you change is wiped at 14:00 UTC. Sign-ins: + * demo-admin / demo-player — passwords at demo.runicgateway.com/account/login." + * + * Drawn only from a [DemoInfo] the server sent, so a real shard never shows it and + * the app needs no schedule of its own. [signInPage] is null when the site address + * is unknown, and the logins sentence then names the sign-in page without it. + */ +@Composable +fun DemoBanner(info: DemoInfo, signInPage: String?, modifier: Modifier = Modifier) { + val wipe = info.resetsAt + ?.let { stringResource(R.string.demo_banner_wipe_at, it) } + ?: stringResource(R.string.demo_banner_wipe_soon) + val logins = info.usernames.takeIf { it.isNotEmpty() }?.joinToString(" / ")?.let { names -> + if (signInPage != null) { + stringResource(R.string.demo_banner_logins_at, names, signInPage) + } else { + stringResource(R.string.demo_banner_logins, names) + } + } + val title = stringResource(R.string.demo_banner_title) + val accent = MaterialTheme.colorScheme.primary + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = accent, fontWeight = FontWeight.Bold)) { append(title) } + append(wipe) + logins?.let { append(" $it") } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + HorizontalDivider(thickness = 1.dp, color = accent) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/demo/DemoInfo.kt b/app/src/main/java/com/runicgateway/app/ui/demo/DemoInfo.kt new file mode 100644 index 0000000..20a27bb --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/demo/DemoInfo.kt @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.demo + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.HttpUrl +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +/** + * What the Home strip says on the public demo (`docs/website/DEMO_MODE.md` §6). + * + * @property resetsAt when the next wipe runs, already worded ("14:00 UTC"), or null + * when the server sent no readable time. + * @property usernames the published logins, in the server's order. Their passwords + * are on the site's sign-in page, not in the app (DM20). + */ +data class DemoInfo( + val resetsAt: String?, + val usernames: List, +) { + companion object { + /** + * The banner data from `GET /public/settings`' raw `demo` value, or null when + * there is none, which is every real shard. Forgiving field by field: a + * missing time or a malformed login drops that part, never the banner. + */ + fun from(demo: JsonElement?): DemoInfo? { + val obj = demo as? JsonObject ?: return null + val resetsAt = (obj["resetsAt"] as? JsonPrimitive) + ?.takeIf { it.isString } + ?.let { formatResetTime(it.content) } + val usernames = (obj["logins"] as? JsonArray).orEmpty().mapNotNull { login -> + ((login as? JsonObject)?.get("username") as? JsonPrimitive) + ?.takeIf { it.isString } + ?.content + ?.takeIf { it.isNotBlank() } + } + return DemoInfo(resetsAt, usernames) + } + } +} + +private val RESET_TIME: DateTimeFormatter = + DateTimeFormatter.ofPattern("HH:mm 'UTC'").withZone(ZoneOffset.UTC) + +/** + * "14:00 UTC" for an ISO-8601 instant, or null when it does not parse. The wipe runs + * on the hour in UTC (DM7), so the strip says UTC rather than converting, the same as + * the web's: a time in the viewer's own zone reads as a different schedule from the + * one published on `/beta`. + */ +fun formatResetTime(iso: String): String? = try { + RESET_TIME.format(Instant.parse(iso)) +} catch (_: DateTimeParseException) { + null +} + +/** + * The site's sign-in page as the strip prints it, `demo.runicgateway.com/account/login`: + * no scheme, and the port only when it is not the scheme's default. + */ +fun signInPageLabel(base: HttpUrl): String? { + val page = base.resolve("account/login") ?: return null + val port = if (page.port == HttpUrl.defaultPort(page.scheme)) "" else ":${page.port}" + return page.host + port + page.encodedPath +} diff --git a/app/src/main/java/com/runicgateway/app/ui/demo/DemoNoticeViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/demo/DemoNoticeViewModel.kt new file mode 100644 index 0000000..119e481 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/demo/DemoNoticeViewModel.kt @@ -0,0 +1,22 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.demo + +import androidx.lifecycle.ViewModel +import com.runicgateway.app.core.net.DemoNotices +import com.runicgateway.app.core.result.DemoRefusal +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharedFlow +import javax.inject.Inject + +/** + * Hands the shell the public demo's refusals (DM19), for its one snackbar. Only a + * window onto [DemoNotices]: the shell collects while it is on screen. + */ +@HiltViewModel +class DemoNoticeViewModel @Inject constructor( + notices: DemoNotices, +) : ViewModel() { + val refusals: SharedFlow = notices.refusals +} diff --git a/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt b/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt index 3fecd9e..402270d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt @@ -32,6 +32,7 @@ import com.runicgateway.app.ui.components.FeatureCard import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.OnlineDot import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.demo.DemoBanner /** * Home / status (PLAN.md §6.1): the shard's name + tagline from branding, and a @@ -44,11 +45,18 @@ fun HomeScreen( viewModel: HomeViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val demo by viewModel.demo.collectAsStateWithLifecycle() - when (val s = state) { - is UiState.Loading -> LoadingView(modifier) - is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier) - is UiState.Success -> HomeContent(brand, s.data, modifier) + Column(modifier = modifier.fillMaxSize()) { + // The public demo's strip (DEMO_MODE.md §6), above whatever the status read + // came back with, so it shows even while the status is loading or failed. + demo?.let { DemoBanner(info = it, signInPage = viewModel.signInPage) } + val content = Modifier.weight(1f) + when (val s = state) { + is UiState.Loading -> LoadingView(content) + is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = content) + is UiState.Success -> HomeContent(brand, s.data, content) + } } } diff --git a/app/src/main/java/com/runicgateway/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/home/HomeViewModel.kt index bb6f122..026518a 100644 --- a/app/src/main/java/com/runicgateway/app/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/home/HomeViewModel.kt @@ -5,9 +5,13 @@ package com.runicgateway.app.ui.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.runicgateway.app.core.net.BaseUrlHolder +import com.runicgateway.app.core.result.ApiResult import com.runicgateway.app.data.api.dto.StatusDto import com.runicgateway.app.data.repository.SettingsRepository import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.demo.DemoInfo +import com.runicgateway.app.ui.demo.signInPageLabel import com.runicgateway.app.ui.toUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -16,15 +20,27 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject -/** Loads site status (mode + version) for the Home screen (PLAN.md §6.1). */ +/** + * Loads site status (mode + version) for the Home screen (PLAN.md §6.1), and the + * public demo's banner data when the site is one (`docs/website/DEMO_MODE.md` §6). + */ @HiltViewModel class HomeViewModel @Inject constructor( private val settingsRepository: SettingsRepository, + private val baseUrlHolder: BaseUrlHolder, ) : ViewModel() { private val _state = MutableStateFlow>(UiState.Loading) val state: StateFlow> = _state.asStateFlow() + private val _demo = MutableStateFlow(null) + + /** The demo strip's data; null on every real shard, and while it is unknown. */ + val demo: StateFlow = _demo.asStateFlow() + + /** The site's sign-in page, for the strip's logins sentence; null if no site is set. */ + val signInPage: String? get() = baseUrlHolder.current?.let(::signInPageLabel) + init { load() } fun load() { @@ -32,5 +48,11 @@ class HomeViewModel @Inject constructor( viewModelScope.launch { _state.value = settingsRepository.getStatus().toUiState() } + // A separate read, and silent: the strip is a nicety, so a failed settings + // call keeps whatever was shown rather than surfacing an error of its own. + viewModelScope.launch { + val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch + _demo.value = DemoInfo.from(settings.demo) + } } } diff --git a/app/src/main/java/com/runicgateway/app/ui/player/CharactersViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/player/CharactersViewModel.kt index 04f7089..c8e8e69 100644 --- a/app/src/main/java/com/runicgateway/app/ui/player/CharactersViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/player/CharactersViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.runicgateway.app.R import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.isDemoLocked import com.runicgateway.app.core.result.map import com.runicgateway.app.data.api.dto.RosterCharDto import com.runicgateway.app.data.repository.PlayerShardRepository @@ -114,7 +115,7 @@ class CharactersViewModel @Inject constructor( load() } is ApiResult.HttpError -> _state.update { - it.copy(busy = false, feedback = Feedback(false, createErrorRes(result.status))) + it.copy(busy = false, feedback = Feedback(false, createErrorRes(result))) } is ApiResult.NetworkError -> _state.update { it.copy(busy = false, feedback = Feedback(false, R.string.error_network)) @@ -129,12 +130,15 @@ class CharactersViewModel @Inject constructor( else -> R.string.player_link_error } - private fun createErrorRes(status: Int): Int = when (status) { - 409 -> R.string.player_create_taken - 429 -> R.string.player_create_capped - 403 -> R.string.player_create_unavailable - 400 -> R.string.player_create_rejected - 503 -> R.string.error_shard_offline + // The demo's refusal comes first: its 403 would otherwise read as "signup is off + // on this shard", which is not what refused it (DEMO_MODE.md §3.1). + private fun createErrorRes(result: ApiResult.HttpError): Int = when { + result.isDemoLocked() -> R.string.error_demo_locked + result.status == 409 -> R.string.player_create_taken + result.status == 429 -> R.string.player_create_capped + result.status == 403 -> R.string.player_create_unavailable + result.status == 400 -> R.string.player_create_rejected + result.status == 503 -> R.string.error_shard_offline else -> R.string.player_create_error } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd9cafd..eeb28b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,15 @@ The game server is offline right now. This site doesn\'t publish this here. Something went wrong on the server. Please try again. + + This is turned off on the demo site. + + + Demo site + " — anything you change is wiped at %1$s." + " — anything you change is wiped every two hours." + Sign-ins: %1$s — passwords at %2$s. + Sign-ins: %1$s — passwords on the site\'s sign-in page. Connect to your community diff --git a/app/src/test/java/com/runicgateway/app/core/net/DemoLockInterceptorTest.kt b/app/src/test/java/com/runicgateway/app/core/net/DemoLockInterceptorTest.kt new file mode 100644 index 0000000..8ab33e5 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/net/DemoLockInterceptorTest.kt @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.net + +import com.runicgateway.app.core.result.DemoRefusal +import com.runicgateway.app.util.DEMO_LOCKED_BODY +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The main client's refusal spotter (DEMO_MODE.md §3.1, DM19). Runs a real OkHttp + * client whose last interceptor answers in place of the network. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DemoLockInterceptorTest { + + private val notices = DemoNotices() + + private fun clientAnswering(code: Int, body: String): OkHttpClient = + OkHttpClient.Builder() + .addInterceptor(DemoLockInterceptor(notices)) + .addInterceptor(Interceptor { chain -> + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("") + .body(body.toResponseBody("application/json".toMediaType())) + .build() + }) + .build() + + private fun call(client: OkHttpClient): String = + client.newCall(Request.Builder().url("https://demo.example/api/v1/x").build()) + .execute() + .use { it.body!!.string() } + + @Test fun aRefusalIsReportedAndTheBodyStillReachesTheCaller() = runTest(UnconfinedTestDispatcher()) { + val seen = mutableListOf() + backgroundScope.launch { notices.refusals.collect { seen += it } } + + val body = call(clientAnswering(403, DEMO_LOCKED_BODY)) + + assertEquals(listOf(DemoRefusal("This is turned off on the demo site.")), seen) + // Peeked, not consumed: Retrofit still reads the same body into its error. + assertEquals(DEMO_LOCKED_BODY, body) + } + + @Test fun anythingElseIsNotReported() = runTest(UnconfinedTestDispatcher()) { + val seen = mutableListOf() + backgroundScope.launch { notices.refusals.collect { seen += it } } + + call(clientAnswering(403, """{"message":"Admins only"}""")) + call(clientAnswering(403, "Forbidden")) + call(clientAnswering(500, DEMO_LOCKED_BODY)) + call(clientAnswering(200, DEMO_LOCKED_BODY)) + + assertTrue(seen.isEmpty()) + } +} diff --git a/app/src/test/java/com/runicgateway/app/core/result/DemoLockedTest.kt b/app/src/test/java/com/runicgateway/app/core/result/DemoLockedTest.kt new file mode 100644 index 0000000..62f2b14 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/result/DemoLockedTest.kt @@ -0,0 +1,78 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.result + +import com.runicgateway.app.util.DEMO_LOCKED_BODY +import com.runicgateway.app.util.demoLockedError +import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response + +/** Reading the public demo's refusal off an error body (DEMO_MODE.md §3.1). */ +class DemoLockedTest { + + @Test fun theServersBodyIsARefusalWithItsMessage() { + assertEquals(DemoRefusal("This is turned off on the demo site."), demoRefusalOf(DEMO_LOCKED_BODY)) + } + + @Test fun aModulesOwnSentenceIsKept() { + // A refused console command names the command (§3.4); the app must not + // swap that for the generic line. + val body = """{"message":"The command quit is turned off on the demo site.","code":"DEMO_LOCKED"}""" + assertEquals("The command quit is turned off on the demo site.", demoRefusalOf(body)!!.message) + } + + @Test fun aRefusalWithNoMessageStillCounts() { + assertEquals(DemoRefusal(null), demoRefusalOf("""{"code":"DEMO_LOCKED"}""")) + assertEquals(DemoRefusal(null), demoRefusalOf("""{"code":"DEMO_LOCKED","message":" "}""")) + } + + @Test fun anythingElseIsNotARefusal() { + assertNull(demoRefusalOf(null)) + assertNull(demoRefusalOf("")) + assertNull(demoRefusalOf("{}")) + assertNull(demoRefusalOf("""{"message":"Forbidden"}""")) + assertNull(demoRefusalOf("""{"code":"FORBIDDEN"}""")) + assertNull(demoRefusalOf("""{"code":7}""")) + assertNull(demoRefusalOf("""["DEMO_LOCKED"]""")) + assertNull(demoRefusalOf("403 Forbidden")) + } + + @Test fun safeApiCallCarriesTheCode() = runTest { + val result = safeApiCall { throw demoLockedError() } + assertEquals(DEMO_LOCKED_CODE, (result as ApiResult.HttpError).code) + assertTrue(result.isDemoLocked()) + } + + @Test fun anOrdinary403IsNotARefusal() = runTest { + val result = safeApiCall { + throw HttpException(Response.error(403, """{"message":"Admins only"}""".toResponseBody(null))) + } + assertEquals(403, (result as ApiResult.HttpError).status) + assertNull(result.code) + assertFalse(result.isDemoLocked()) + } + + @Test fun onlyA403sBodyIsRead() = runTest { + // The server answers the refusal with 403 and nothing else, so another + // status's body is never buffered, even if it happens to carry the code. + val result = safeApiCall { + throw HttpException(Response.error(500, DEMO_LOCKED_BODY.toResponseBody(null))) + } + assertNull((result as ApiResult.HttpError).code) + assertFalse(result.isDemoLocked()) + } + + @Test fun otherResultsAreNeverRefusals() { + assertFalse(ApiResult.Ok(1).isDemoLocked()) + assertFalse(ApiResult.NetworkError(java.io.IOException()).isDemoLocked()) + assertFalse(ApiResult.HttpError(403).isDemoLocked()) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt index 470f222..09badfc 100644 --- a/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt @@ -4,8 +4,10 @@ package com.runicgateway.app.ui import androidx.lifecycle.SavedStateHandle +import com.runicgateway.app.core.net.BaseUrlHolder import com.runicgateway.app.data.api.dto.PageDto import com.runicgateway.app.data.api.dto.PostDto +import com.runicgateway.app.data.api.dto.SettingsDto import com.runicgateway.app.data.api.dto.StatusDto import com.runicgateway.app.data.api.dto.WikiPageDto import com.runicgateway.app.data.api.dto.WikiSummaryDto @@ -13,6 +15,7 @@ import com.runicgateway.app.data.api.fake.FakePublicApi import com.runicgateway.app.data.repository.ContentRepository import com.runicgateway.app.data.repository.SettingsRepository import com.runicgateway.app.data.repository.WikiRepository +import com.runicgateway.app.ui.demo.DemoInfo import com.runicgateway.app.ui.home.HomeViewModel import com.runicgateway.app.ui.navigation.Routes import com.runicgateway.app.ui.news.NewsViewModel @@ -22,7 +25,10 @@ import com.runicgateway.app.ui.wiki.WikiPageViewModel import com.runicgateway.app.ui.wiki.WikiViewModel import com.runicgateway.app.util.MainDispatcherRule import com.runicgateway.app.util.httpError +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -128,13 +134,33 @@ class ContentViewModelTest { // ── Home status ───────────────────────────────────────────────────────── @Test fun homeLoadsStatus() { api.status = StatusDto(mode = "maintenance") - val vm = HomeViewModel(settings) + val vm = homeViewModel() assertTrue((vm.state.value as UiState.Success).data.isMaintenance) } @Test fun homeNetworkErrorIsUiError() { api.error = java.io.IOException("offline") - val state = HomeViewModel(settings).state.value - assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind) + val vm = homeViewModel() + assertEquals(ErrorKind.NETWORK, (vm.state.value as UiState.Error).kind) + assertNull(vm.demo.value) } + + // ── Home: the public demo's strip (DEMO_MODE.md §6) ── + @Test fun homeDrawsNoStripOnARealShard() { + assertNull(homeViewModel().demo.value) + } + + @Test fun homeReadsTheDemoBlock() { + api.settings = SettingsDto( + demo = Json.parseToJsonElement( + """{"resetsAt":"2026-10-26T14:00:00.000Z","logins":[{"username":"demo-admin","role":"admin"}]}""", + ), + ) + val vm = homeViewModel(base = "https://demo.runicgateway.com/") + assertEquals(DemoInfo("14:00 UTC", listOf("demo-admin")), vm.demo.value) + assertEquals("demo.runicgateway.com/account/login", vm.signInPage) + } + + private fun homeViewModel(base: String? = null) = + HomeViewModel(settings, BaseUrlHolder().apply { set(base?.toHttpUrl()) }) } diff --git a/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt b/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt index 17f101a..0d69008 100644 --- a/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt @@ -4,6 +4,7 @@ package com.runicgateway.app.ui import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.DEMO_LOCKED_CODE import com.runicgateway.app.ui.components.isRetryable import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -64,13 +65,30 @@ class UiStateTest { @Test fun anUnavailableFeatureIsNotRetryable() { // An admin controls this, so a retry button would read as a transient failure - // the user could wait out. - assertFalse(isRetryable(ErrorKind.FEATURE_UNAVAILABLE)) - for (kind in ErrorKind.entries.filter { it != ErrorKind.FEATURE_UNAVAILABLE }) { + // the user could wait out. The demo's refusal is fixed the same way. + val fixed = setOf(ErrorKind.FEATURE_UNAVAILABLE, ErrorKind.DEMO_LOCKED) + for (kind in fixed) assertFalse("$kind should not offer a retry", isRetryable(kind)) + for (kind in ErrorKind.entries.filter { it !in fixed }) { assertTrue("$kind should offer a retry", isRetryable(kind)) } } + // ── The public demo's refusal (DEMO_MODE.md §3.1) ── + + @Test fun theDemosRefusalIsItsOwnKind() { + val refused = ApiResult.HttpError(403, code = DEMO_LOCKED_CODE) + assertEquals(ErrorKind.DEMO_LOCKED, (refused.toUiState() as UiState.Error).kind) + assertEquals(403, (refused.toUiState() as UiState.Error).httpStatus) + // On a shard read a 403 would otherwise mean "not published to you". + assertEquals(ErrorKind.DEMO_LOCKED, (refused.toShardUiState() as UiState.Error).kind) + } + + @Test fun anotherCodeLeavesTheStatusMapping() { + val other = ApiResult.HttpError(403, code = "FORBIDDEN") + assertEquals(ErrorKind.SERVER, (other.toUiState() as UiState.Error).kind) + assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (other.toShardUiState() as UiState.Error).kind) + } + private fun kindOf(status: Int): ErrorKind = (ApiResult.HttpError(status).toUiState() as UiState.Error).kind diff --git a/app/src/test/java/com/runicgateway/app/ui/admin/AdminContentViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/admin/AdminContentViewModelTest.kt index 82d81c8..9d3a013 100644 --- a/app/src/test/java/com/runicgateway/app/ui/admin/AdminContentViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/admin/AdminContentViewModelTest.kt @@ -11,6 +11,8 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi import com.runicgateway.app.data.repository.AdminRepository import com.runicgateway.app.ui.UiState import com.runicgateway.app.util.MainDispatcherRule +import com.runicgateway.app.util.demoLockedError +import com.runicgateway.app.util.demoLockedUnit import com.runicgateway.app.util.errorUnit import com.runicgateway.app.util.httpError import org.junit.Assert.assertEquals @@ -63,6 +65,15 @@ class AdminContentViewModelTest { assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes) } + @Test fun theDemosRefusalIsNotBlamedOnTheRole() { + // Somebody signed in as demo-admin must not read "you don't have permission". + api.unitResponse = demoLockedUnit() + api.error = demoLockedError() + val vm = viewModel() + vm.togglePublish(AdminPostDto(id = 5, title = "x", published = 1)) + assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes) + } + @Test fun createCategoryRejectsBlankFields() { val vm = viewModel() vm.createCategory(slug = "", title = "", description = "", sortOrder = null) diff --git a/app/src/test/java/com/runicgateway/app/ui/admin/AdminDashboardViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/admin/AdminDashboardViewModelTest.kt index ee8a1fc..20a319c 100644 --- a/app/src/test/java/com/runicgateway/app/ui/admin/AdminDashboardViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/admin/AdminDashboardViewModelTest.kt @@ -11,6 +11,7 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi import com.runicgateway.app.data.repository.AdminRepository import com.runicgateway.app.ui.UiState import com.runicgateway.app.util.MainDispatcherRule +import com.runicgateway.app.util.demoLockedError import com.runicgateway.app.util.httpError import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -60,6 +61,16 @@ class AdminDashboardViewModelTest { assertEquals(R.string.admin_forbidden, fb.messageRes) } + @Test fun setSiteModeOnTheDemoShowsTheDemoLine() { + api.dashboard = AdminDashboardDto() + api.error = demoLockedError() + val vm = viewModel() + vm.setSiteMode("maintenance") + val fb = vm.state.value.feedback!! + assertFalse(fb.ok) + assertEquals(R.string.error_demo_locked, fb.messageRes) + } + @Test fun clearFeedbackResetsBanner() { api.error = httpError(500) val vm = viewModel() diff --git a/app/src/test/java/com/runicgateway/app/ui/admin/AdminModerationViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/admin/AdminModerationViewModelTest.kt index b73e79c..1b4f6ed 100644 --- a/app/src/test/java/com/runicgateway/app/ui/admin/AdminModerationViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/admin/AdminModerationViewModelTest.kt @@ -7,6 +7,7 @@ import com.runicgateway.app.R import com.runicgateway.app.data.api.fake.FakeAdminApi import com.runicgateway.app.data.repository.AdminRepository import com.runicgateway.app.util.MainDispatcherRule +import com.runicgateway.app.util.demoLockedUnit import com.runicgateway.app.util.errorUnit import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -48,6 +49,13 @@ class AdminModerationViewModelTest { assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes) } + @Test fun kickOnTheDemoShowsTheDemoLine() { + api.unitResponse = demoLockedUnit() + val vm = viewModel() + vm.kick(account = "x", serial = "") + assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes) + } + @Test fun broadcastRejectsBlankText() { val vm = viewModel() vm.broadcast(text = " ", hue = null) diff --git a/app/src/test/java/com/runicgateway/app/ui/admin/AdminSupportViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/admin/AdminSupportViewModelTest.kt index a4dfb86..f88b490 100644 --- a/app/src/test/java/com/runicgateway/app/ui/admin/AdminSupportViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/admin/AdminSupportViewModelTest.kt @@ -9,6 +9,7 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi import com.runicgateway.app.data.repository.AdminRepository import com.runicgateway.app.ui.UiState import com.runicgateway.app.util.MainDispatcherRule +import com.runicgateway.app.util.demoLockedUnit import com.runicgateway.app.util.errorUnit import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -52,6 +53,13 @@ class AdminSupportViewModelTest { assertEquals(R.string.admin_support_unknown_page, vm.state.value.feedback!!.messageRes) } + @Test fun closeOnTheDemoShowsTheDemoLine() { + api.unitResponse = demoLockedUnit() + val vm = viewModel() + vm.close("0x1") + assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes) + } + @Test fun closeShardOfflineMapsTo503Copy() { api.unitResponse = errorUnit(503) val vm = viewModel() diff --git a/app/src/test/java/com/runicgateway/app/ui/contact/ContactViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/contact/ContactViewModelTest.kt index ace0ca2..558dcc5 100644 --- a/app/src/test/java/com/runicgateway/app/ui/contact/ContactViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/contact/ContactViewModelTest.kt @@ -8,6 +8,7 @@ import com.runicgateway.app.data.api.fake.FakePublicApi import com.runicgateway.app.data.repository.ContactRepository import com.runicgateway.app.ui.ErrorKind import com.runicgateway.app.util.MainDispatcherRule +import com.runicgateway.app.util.demoLockedError import com.runicgateway.app.util.httpError import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -64,4 +65,14 @@ class ContactViewModelTest { assertTrue(result is ContactViewModel.Result.Failed) assertEquals(ErrorKind.SERVER, (result as ContactViewModel.Result.Failed).kind) } + + @Test fun theDemosLockedContactFormSaysSo() { + // The contact form is locked on the demo (DEMO_MODE.md §3.3). + api.error = demoLockedError() + val vm = viewModel() + vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m") + vm.send() + val result = vm.state.value.result as ContactViewModel.Result.Failed + assertEquals(ErrorKind.DEMO_LOCKED, result.kind) + } } diff --git a/app/src/test/java/com/runicgateway/app/ui/demo/DemoInfoTest.kt b/app/src/test/java/com/runicgateway/app/ui/demo/DemoInfoTest.kt new file mode 100644 index 0000000..6b5c534 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/demo/DemoInfoTest.kt @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.demo + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** The Home strip's data (DEMO_MODE.md §6). */ +class DemoInfoTest { + + private fun parse(json: String) = DemoInfo.from(Json.parseToJsonElement(json)) + + @Test fun readsTheServersShape() { + val info = parse( + """{"resetsAt":"2026-10-26T14:00:00.000Z","logins":[ + {"username":"demo-admin","password":"x","role":"admin"}, + {"username":"demo-player","password":"y","role":"player"}]}""", + ) + assertEquals(DemoInfo("14:00 UTC", listOf("demo-admin", "demo-player")), info) + } + + @Test fun noBlockMeansNoBanner() { + assertNull(DemoInfo.from(null)) + assertNull(DemoInfo.from(JsonNull)) + assertNull(parse("""["demo"]""")) + } + + @Test fun aBadPartDropsThatPartOnly() { + assertEquals( + DemoInfo(null, listOf("demo-admin")), + parse("""{"resetsAt":"soon","logins":[{"username":"demo-admin"},{"username":""},{"name":"x"},7]}"""), + ) + assertEquals(DemoInfo(null, emptyList()), parse("""{"resetsAt":42,"logins":"demo-admin"}""")) + assertEquals(DemoInfo(null, emptyList()), parse("{}")) + } + + @Test fun theTimeIsSaidInUtc() { + // Not converted to the phone's zone: it must match the schedule on /beta. + assertEquals("14:00 UTC", formatResetTime("2026-10-26T14:00:00Z")) + assertEquals("00:05 UTC", formatResetTime("2026-10-26T00:05:59.999Z")) + assertNull(formatResetTime("14:00")) + assertNull(formatResetTime("")) + } + + @Test fun theSignInPageIsPrintedWithoutTheScheme() { + assertEquals( + "demo.runicgateway.com/account/login", + signInPageLabel("https://demo.runicgateway.com/".toHttpUrl()), + ) + assertEquals("10.0.2.2:3000/account/login", signInPageLabel("http://10.0.2.2:3000/".toHttpUrl())) + assertEquals("example.com/site/account/login", signInPageLabel("https://example.com/site/".toHttpUrl())) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/player/CharactersViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/player/CharactersViewModelTest.kt index 025b75d..024ff4a 100644 --- a/app/src/test/java/com/runicgateway/app/ui/player/CharactersViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/player/CharactersViewModelTest.kt @@ -15,6 +15,7 @@ import com.runicgateway.app.data.repository.PlayerShardRepository import com.runicgateway.app.data.repository.SettingsRepository import com.runicgateway.app.ui.UiState import com.runicgateway.app.util.MainDispatcherRule +import com.runicgateway.app.util.demoLockedError import com.runicgateway.app.util.httpError import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -86,4 +87,11 @@ class CharactersViewModelTest { vm.createAccount(account = "acct1", password = "longenough") assertEquals(R.string.player_create_taken, vm.state.value.feedback!!.messageRes) } + + @Test fun createAccountOnTheDemoIsNotSignupBeingOff() { + playerApi.error = demoLockedError() + val vm = viewModel() + vm.createAccount(account = "acct1", password = "longenough") + assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes) + } } diff --git a/app/src/test/java/com/runicgateway/app/util/FakeApiSupport.kt b/app/src/test/java/com/runicgateway/app/util/FakeApiSupport.kt index ab21960..654b52c 100644 --- a/app/src/test/java/com/runicgateway/app/util/FakeApiSupport.kt +++ b/app/src/test/java/com/runicgateway/app/util/FakeApiSupport.kt @@ -25,3 +25,14 @@ fun okUnit(): Response = Response.success(Unit) /** A non-2xx bodyless [Response] with [code]. */ fun errorUnit(code: Int): Response = Response.error(code, "{}".toResponseBody(JSON)) + +/** The public demo's refusal body (DEMO_MODE.md §3.1), as the server sends it. */ +const val DEMO_LOCKED_BODY = + """{"message":"This is turned off on the demo site.","code":"DEMO_LOCKED"}""" + +/** The [HttpException] a fake API throws for a write the public demo refuses. */ +fun demoLockedError(): HttpException = + HttpException(Response.error(403, DEMO_LOCKED_BODY.toResponseBody(JSON))) + +/** The `Response` of a write the public demo refuses. */ +fun demoLockedUnit(): Response = Response.error(403, DEMO_LOCKED_BODY.toResponseBody(JSON)) -- 2.49.1