feat(demo): explain the public demo's refusals and show its strip on Home
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m2s
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m2s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<DemoRefusal>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
val refusals: SharedFlow<DemoRefusal> = _refusals.asSharedFlow()
|
||||
|
||||
fun report(refusal: DemoRefusal) {
|
||||
_refusals.tryEmit(refusal)
|
||||
}
|
||||
}
|
||||
@@ -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<out T> {
|
||||
data class Ok<T>(val data: T) : ApiResult<T>
|
||||
data class HttpError(val status: Int, val message: String? = null) : ApiResult<Nothing>
|
||||
data class HttpError(
|
||||
val status: Int,
|
||||
val message: String? = null,
|
||||
val code: String? = null,
|
||||
) : ApiResult<Nothing>
|
||||
data class NetworkError(val cause: Throwable) : ApiResult<Nothing>
|
||||
}
|
||||
|
||||
@@ -54,12 +60,31 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = 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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<UiState<T>>`;
|
||||
@@ -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 <T> ApiResult<T>.toUiState(): UiState<T> = 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 <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
|
||||
* player or admin route, which is not an admin's visibility setting.
|
||||
*/
|
||||
fun <T> ApiResult<T>.toShardUiState(): UiState<T> = 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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
65
app/src/main/java/com/runicgateway/app/ui/demo/DemoBanner.kt
Normal file
65
app/src/main/java/com/runicgateway/app/ui/demo/DemoBanner.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
73
app/src/main/java/com/runicgateway/app/ui/demo/DemoInfo.kt
Normal file
73
app/src/main/java/com/runicgateway/app/ui/demo/DemoInfo.kt
Normal file
@@ -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<String>,
|
||||
) {
|
||||
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
|
||||
}
|
||||
@@ -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<DemoRefusal> = notices.refusals
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<StatusDto>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<StatusDto>> = _state.asStateFlow()
|
||||
|
||||
private val _demo = MutableStateFlow<DemoInfo?>(null)
|
||||
|
||||
/** The demo strip's data; null on every real shard, and while it is unknown. */
|
||||
val demo: StateFlow<DemoInfo?> = _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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,15 @@
|
||||
<string name="error_shard_offline">The game server is offline right now.</string>
|
||||
<string name="error_feature_unavailable">This site doesn\'t publish this here.</string>
|
||||
<string name="error_server">Something went wrong on the server. Please try again.</string>
|
||||
<!-- The public demo's refusal (DEMO_MODE.md §3.1): the server's own words, used when its answer carries none. -->
|
||||
<string name="error_demo_locked">This is turned off on the demo site.</string>
|
||||
|
||||
<!-- The public demo's strip on Home (DEMO_MODE.md §6). The time is said in UTC, like the web's. -->
|
||||
<string name="demo_banner_title">Demo site</string>
|
||||
<string name="demo_banner_wipe_at">" — anything you change is wiped at %1$s."</string>
|
||||
<string name="demo_banner_wipe_soon">" — anything you change is wiped every two hours."</string>
|
||||
<string name="demo_banner_logins_at">Sign-ins: %1$s — passwords at %2$s.</string>
|
||||
<string name="demo_banner_logins">Sign-ins: %1$s — passwords on the site\'s sign-in page.</string>
|
||||
|
||||
<!-- ── First-run connect (§3) ──────────────────────────────────────── -->
|
||||
<string name="connect_title">Connect to your community</string>
|
||||
|
||||
@@ -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<DemoRefusal>()
|
||||
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<DemoRefusal>()
|
||||
backgroundScope.launch { notices.refusals.collect { seen += it } }
|
||||
|
||||
call(clientAnswering(403, """{"message":"Admins only"}"""))
|
||||
call(clientAnswering(403, "<html>Forbidden</html>"))
|
||||
call(clientAnswering(500, DEMO_LOCKED_BODY))
|
||||
call(clientAnswering(200, DEMO_LOCKED_BODY))
|
||||
|
||||
assertTrue(seen.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -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("<html>403 Forbidden</html>"))
|
||||
}
|
||||
|
||||
@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<Any>(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<Any>(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())
|
||||
}
|
||||
}
|
||||
@@ -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()) })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,3 +25,14 @@ fun okUnit(): Response<Unit> = Response.success(Unit)
|
||||
|
||||
/** A non-2xx bodyless [Response] with [code]. */
|
||||
fun errorUnit(code: Int): Response<Unit> = 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<Any>(403, DEMO_LOCKED_BODY.toResponseBody(JSON)))
|
||||
|
||||
/** The `Response<Unit>` of a write the public demo refuses. */
|
||||
fun demoLockedUnit(): Response<Unit> = Response.error(403, DEMO_LOCKED_BODY.toResponseBody(JSON))
|
||||
|
||||
Reference in New Issue
Block a user