Compare commits
26 Commits
v0.3.1
...
7acbe54f46
| Author | SHA1 | Date | |
|---|---|---|---|
| 7acbe54f46 | |||
| c7c49a9d6b | |||
| 1530c83fbc | |||
| c65913c62a | |||
| 17e9451494 | |||
| b0117acac1 | |||
| 5eaf5d22c6 | |||
| 12b2172731 | |||
| 4f85021be2 | |||
| 06b6b015c2 | |||
| aacef35def | |||
| 833e51de69 | |||
| 4fe7a7e2a3 | |||
| b10dd444b3 | |||
| f3da6ea618 | |||
| ae170670d9 | |||
| 7fc497a1a4 | |||
| 4e3bb914ff | |||
| efe14d3828 | |||
| 43215b49a0 | |||
| a6446b04d8 | |||
| 9c52a3dafa | |||
| f0a3b6c03e | |||
| 3aeb295342 | |||
| 03d4ef6fad | |||
| a1fa4901ef |
54
.gitea/scripts/gen_tree.py
Normal file
54
.gitea/scripts/gen_tree.py
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
|
||||
|
||||
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
|
||||
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
|
||||
|
||||
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
|
||||
|
||||
Deterministic ordering: directories before files, each group sorted
|
||||
case-insensitively with the raw name as a tiebreak. Output uses the classic
|
||||
`tree(1)` box-drawing style so the result is stable across runs and platforms.
|
||||
"""
|
||||
import sys
|
||||
|
||||
|
||||
def build(paths):
|
||||
root = {}
|
||||
for p in paths:
|
||||
p = p.strip().replace("\\", "/")
|
||||
if not p:
|
||||
continue
|
||||
node = root
|
||||
for part in p.split("/"):
|
||||
node = node.setdefault(part, {})
|
||||
return root
|
||||
|
||||
|
||||
def render(node, prefix, lines):
|
||||
entries = list(node.items())
|
||||
# directories (non-empty children dict) before files, then case-insensitive name
|
||||
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
|
||||
for i, (name, child) in enumerate(entries):
|
||||
last = i == len(entries) - 1
|
||||
branch = "└── " if last else "├── "
|
||||
suffix = "/" if child else ""
|
||||
lines.append(f"{prefix}{branch}{name}{suffix}")
|
||||
if child:
|
||||
render(child, prefix + (" " if last else "│ "), lines)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
|
||||
except AttributeError:
|
||||
pass
|
||||
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
|
||||
tree = build(sys.stdin.read().splitlines())
|
||||
lines = [f"{root_label}/"]
|
||||
render(tree, "", lines)
|
||||
sys.stdout.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -18,11 +18,12 @@
|
||||
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
|
||||
# dashboard when you want to.
|
||||
#
|
||||
# Scope: this analyses the Kotlin source directly (the Sonar scanner reads
|
||||
# sonar-project.properties). It does NOT run a Gradle build, so no Android SDK /
|
||||
# JDK install is needed — the Kotlin analyzer is source-based. See the "Optional
|
||||
# enrichment" note in sonar-project.properties for wiring in Android Lint /
|
||||
# coverage reports later.
|
||||
# Scope: the Sonar scanner reads sonar-project.properties and analyses the Kotlin
|
||||
# source directly. Before the scan we run the JVM unit tests + JaCoCo so SonarQube
|
||||
# receives real coverage (sonar.coverage.jacoco.xmlReportPaths) — otherwise it
|
||||
# reports 0% and the coverage gate fails despite the test suite existing. That
|
||||
# Gradle step needs JDK 17 + the Android SDK (same toolchain as pr-checks.yml);
|
||||
# the runner container is bare, so base tools are apt-installed first.
|
||||
|
||||
name: SonarQube
|
||||
|
||||
@@ -40,6 +41,16 @@ jobs:
|
||||
analysis:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# The bare runner container lacks git/curl/unzip (checkout + sdkmanager need
|
||||
# them) and we install JDK 17 from the Ubuntu archive rather than
|
||||
# actions/setup-java (this runner can't reach api.adoptium.net). Mirrors
|
||||
# pr-checks.yml — see its header note.
|
||||
- name: Install base tools + JDK 17
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y git curl unzip openjdk-17-jdk-headless
|
||||
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check out (full history for accurate new-code + blame)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -47,6 +58,31 @@ jobs:
|
||||
# compute "new code". A shallow clone degrades both.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
set +o pipefail
|
||||
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
# Produce the JaCoCo XML the scan reports as coverage. Scoped to the debug
|
||||
# variant (matches enableUnitTestCoverage) to keep peak memory down.
|
||||
- name: Unit tests + JaCoCo coverage
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew --no-daemon testDebugUnitTest jacocoTestReport
|
||||
|
||||
- name: Run SonarQube scan
|
||||
uses: sonarsource/sonarqube-scan-action@v4
|
||||
env:
|
||||
|
||||
111
.gitea/workflows/sync-project-tree.yml
Normal file
111
.gitea/workflows/sync-project-tree.yml
Normal file
@@ -0,0 +1,111 @@
|
||||
name: sync-project-tree
|
||||
|
||||
# Keeps this repo's file-layout snapshot (docs/android/PROJECT_TREE.md in the
|
||||
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
|
||||
# tree from tracked files and, if it changed, opens (or force-updates) a pull
|
||||
# request against the docs repo. It never writes to the docs repo's `main`
|
||||
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
|
||||
# other workflows use (the token needs repo read/write on RunicGateway/docs).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: sync-project-tree
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
DOCS_REPO: RunicGateway/docs
|
||||
SELF_REPO: RunicGateway/Android-app
|
||||
DOCS_PATH: android/PROJECT_TREE.md
|
||||
TREE_TITLE: Android App
|
||||
ROOT_LABEL: android-app
|
||||
PR_BRANCH: chore/sync-android-tree
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out this repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Ensure python3 is available
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
|
||||
|
||||
- name: Render PROJECT_TREE.md from tracked files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p _sync
|
||||
{
|
||||
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
|
||||
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
|
||||
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
|
||||
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
|
||||
printf '> by hand — changes will be overwritten by the next sync.\n\n'
|
||||
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
|
||||
printf 'git-ignored paths are excluded).\n\n'
|
||||
printf '```text\n'
|
||||
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
|
||||
printf '```\n'
|
||||
} > _sync/PROJECT_TREE.md
|
||||
echo "----- generated ${DOCS_PATH} -----"
|
||||
cat _sync/PROJECT_TREE.md
|
||||
|
||||
- name: Open or update the docs PR if the tree changed
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Secrets can carry a trailing CR/LF depending on how they were pasted;
|
||||
# strip line breaks before they land in a URL or Authorization header.
|
||||
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
|
||||
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
|
||||
|
||||
git clone --depth 1 "${REMOTE}" docs_repo
|
||||
cd docs_repo
|
||||
git config user.name "runic-docs-bot"
|
||||
git config user.email "ci@whitlocktech.com"
|
||||
|
||||
mkdir -p "$(dirname "${DOCS_PATH}")"
|
||||
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
|
||||
git add "${DOCS_PATH}"
|
||||
if git diff --cached --quiet; then
|
||||
echo "PROJECT_TREE.md already up to date — nothing to sync."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
|
||||
git checkout -B "${PR_BRANCH}"
|
||||
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
|
||||
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
|
||||
|
||||
# Open a PR only if one isn't already open for this branch (a force-push
|
||||
# to an existing open PR's head updates it in place).
|
||||
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
|
||||
"${API}/pulls?state=open&limit=50" \
|
||||
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
|
||||
if [ "${OPEN}" = "0" ]; then
|
||||
curl -sSf -X POST "${API}/pulls" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg head "${PR_BRANCH}" \
|
||||
--arg base "main" \
|
||||
--arg title "docs(tree): sync ${DOCS_PATH}" \
|
||||
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
|
||||
'{head: $head, base: $base, title: $title, body: $body}')" \
|
||||
>/dev/null
|
||||
echo "Opened a new docs PR for ${PR_BRANCH}."
|
||||
else
|
||||
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
|
||||
fi
|
||||
@@ -10,6 +10,11 @@ plugins {
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
jacoco
|
||||
}
|
||||
|
||||
jacoco {
|
||||
toolVersion = "0.8.12"
|
||||
}
|
||||
|
||||
// Release signing material (PLAN.md §12) is never committed. It is read from, in
|
||||
@@ -78,6 +83,11 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// Produce a JaCoCo .exec from JVM unit tests so SonarQube receives real
|
||||
// coverage (§12.1). Debug-only: the scan analyses the debug variant.
|
||||
enableUnitTestCoverage = true
|
||||
}
|
||||
release {
|
||||
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
|
||||
// release APK). Keep rules live in proguard-rules.pro.
|
||||
@@ -170,3 +180,35 @@ dependencies {
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
}
|
||||
|
||||
// JaCoCo XML coverage from the JVM unit tests, consumed by SonarQube (§12.1). Generated,
|
||||
// DI (Hilt), and Compose-scaffold classes are excluded so they don't dilute the number;
|
||||
// pure-@Composable UI is excluded on the Sonar side (sonar.coverage.exclusions) because
|
||||
// JVM unit tests can't execute composable bodies without Robolectric.
|
||||
tasks.register<JacocoReport>("jacocoTestReport") {
|
||||
dependsOn("testDebugUnitTest")
|
||||
group = "verification"
|
||||
description = "Generates JaCoCo XML/HTML coverage for the debug unit tests."
|
||||
|
||||
reports {
|
||||
xml.required.set(true)
|
||||
html.required.set(true)
|
||||
}
|
||||
|
||||
val coverageExcludes = listOf(
|
||||
"**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*",
|
||||
"**/*_Hilt*.*", "**/Hilt_*.*", "**/*_Factory*.*", "**/*_MembersInjector*.*",
|
||||
"**/*_Impl*.*", "**/di/**", "**/*Module.*", "**/*Module$*.*",
|
||||
"**/*ComposableSingletons*.*", "**/ComposableSingletons$*.*",
|
||||
)
|
||||
val buildDirFile = layout.buildDirectory.get().asFile
|
||||
classDirectories.setFrom(
|
||||
fileTree("$buildDirFile/tmp/kotlin-classes/debug") { exclude(coverageExcludes) },
|
||||
)
|
||||
sourceDirectories.setFrom(files("src/main/java", "src/main/kotlin"))
|
||||
executionData.setFrom(
|
||||
fileTree(buildDirFile) {
|
||||
include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
|
||||
import com.runicgateway.app.core.auth.sso.SsoAuthManager
|
||||
import com.runicgateway.app.core.push.PushNotifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.ui.AppViewModel
|
||||
import com.runicgateway.app.ui.AppViewModel.AppState
|
||||
@@ -30,8 +31,8 @@ import com.runicgateway.app.ui.LocalAssetResolver
|
||||
import com.runicgateway.app.ui.RunicApp
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.connect.ConnectScreen
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
import com.runicgateway.app.ui.theme.RunicGatewayTheme
|
||||
import com.runicgateway.app.ui.theme.parseBrandColor
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -39,8 +40,8 @@ import javax.inject.Inject
|
||||
/**
|
||||
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
|
||||
* connect screen until a shard site is configured (§3), then the main app.
|
||||
* The Material theme is seeded from the per-shard brand accent, and asset-path
|
||||
* resolution is provided to the whole tree.
|
||||
* The Material theme is resolved from the shard's published appearance (M12),
|
||||
* and asset-path resolution is provided to the whole tree.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -69,9 +70,21 @@ class MainActivity : ComponentActivity() {
|
||||
val appViewModel: AppViewModel = hiltViewModel()
|
||||
val state by appViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val accent = (state as? AppState.Ready)?.brand?.let { parseBrandColor(it.accent) }
|
||||
// The whole theme, not just the accent (THEMING_AND_NAV.md §5.1): the
|
||||
// resolved token map is applied field by field over the shipped palette,
|
||||
// so NONE — before the site is connected, or when settings can't be
|
||||
// read — is the app exactly as it shipped.
|
||||
val appearance = (state as? AppState.Ready)?.appearance ?: SiteAppearance.NONE
|
||||
|
||||
RunicGatewayTheme(accent = accent) {
|
||||
// The admin's theme and nav can change while the app is backgrounded
|
||||
// (THEMING_AND_NAV.md §5.5). Re-read them on resume, beside the session
|
||||
// re-validation RunicApp already does. Best-effort and silent.
|
||||
LifecycleResumeEffect(Unit) {
|
||||
appViewModel.refreshAppearance()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
RunicGatewayTheme(appearance = appearance) {
|
||||
CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -83,7 +96,7 @@ class MainActivity : ComponentActivity() {
|
||||
ConnectScreen(onConnected = appViewModel::onConnected)
|
||||
is AppState.Ready ->
|
||||
RunicApp(
|
||||
brand = s.brand,
|
||||
brand = s.appearance.brand,
|
||||
onChangeServer = appViewModel::changeServer,
|
||||
deepLinkStream = pendingStream,
|
||||
onDeepLinkConsumed = { pendingStream = null },
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
import android.os.Build
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Supplies a friendly label for this device, sent as `device_name` at login so a
|
||||
* trusted-device / active-session row is recognizable in the account lists
|
||||
* (TRUSTED_DEVICES_MFA.md). Behind an interface so the auth repository stays free of
|
||||
* `android.os.Build` and unit-testable on the JVM.
|
||||
*/
|
||||
fun interface DeviceNameProvider {
|
||||
/** A human label like "Google Pixel 8", or null if nothing meaningful is available. */
|
||||
fun deviceName(): String?
|
||||
}
|
||||
|
||||
/** Production impl: manufacturer + model from [Build] (e.g. "Samsung SM-S918B"). */
|
||||
@Singleton
|
||||
class BuildDeviceNameProvider @Inject constructor() : DeviceNameProvider {
|
||||
override fun deviceName(): String? {
|
||||
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
|
||||
val model = Build.MODEL?.trim().orEmpty()
|
||||
val label = when {
|
||||
model.isEmpty() -> manufacturer
|
||||
manufacturer.isEmpty() || model.startsWith(manufacturer, ignoreCase = true) -> model
|
||||
else -> "$manufacturer $model"
|
||||
}.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
return label.take(100).ifBlank { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* [TrustTokenStore] backed by its **own** EncryptedSharedPreferences file
|
||||
* (Tink/AES-256-GCM), distinct from the session store so it is never wiped by
|
||||
* [SessionManager.onSignedOut] — the trust token must outlive a logout to do its
|
||||
* job (TRUSTED_DEVICES_MFA.md). The token is stored alongside the username it was
|
||||
* minted for so [tokenFor] only returns it for a matching login.
|
||||
*
|
||||
* The prefs handle is lazy so a device that never trusts pays the keystore cost
|
||||
* only if a token is actually stored or read.
|
||||
*/
|
||||
@Singleton
|
||||
class EncryptedTrustTokenStore @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : TrustTokenStore {
|
||||
|
||||
private val prefs: SharedPreferences by lazy {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
override fun tokenFor(username: String): String? {
|
||||
val token = prefs.getString(KEY_TOKEN, null) ?: return null
|
||||
val owner = prefs.getString(KEY_USERNAME, null) ?: return null
|
||||
// Case-insensitive: usernames are matched case-insensitively server-side.
|
||||
return if (owner.equals(username, ignoreCase = true)) token else null
|
||||
}
|
||||
|
||||
override fun save(username: String, token: String) {
|
||||
prefs.edit()
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putString(KEY_USERNAME, username)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "runic_trust"
|
||||
const val KEY_TOKEN = "trust_token"
|
||||
const val KEY_USERNAME = "trust_username"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
/**
|
||||
* At-rest home for the opaque trusted-device token (TRUSTED_DEVICES_MFA.md). It is
|
||||
* the native analogue of the web `rg_trust` cookie: a device that holds a valid
|
||||
* token skips the TOTP step on its next login (never the password).
|
||||
*
|
||||
* Deliberately **separate** from [TokenStore] and untouched by session teardown —
|
||||
* the token must **survive logout and a dead-refresh sign-out**, because it is only
|
||||
* ever consulted at a *fresh* login (exactly the moment after the session is gone).
|
||||
* Clearing it there would make the feature a no-op. It is scoped to the username it
|
||||
* was minted for so it is never replayed for a different account on a shared device,
|
||||
* and is cleared only by an explicit untrust, a Settings → Server switch, or a
|
||||
* server-side revocation (password change/reset, TOTP disable) that renders it dead.
|
||||
*
|
||||
* Tokens are sensitive, so the production impl uses EncryptedSharedPreferences —
|
||||
* never plain prefs or logs. Kept behind an interface for an in-memory test fake.
|
||||
*/
|
||||
interface TrustTokenStore {
|
||||
/** The stored trust token for [username], or null if this device isn't trusted for them. */
|
||||
fun tokenFor(username: String): String?
|
||||
|
||||
/** Persist [token] as the trust token for [username] (overwrites any prior one). */
|
||||
fun save(username: String, token: String)
|
||||
|
||||
/** Drop the trust token — untrust-all and the Settings → Server hard reset. */
|
||||
fun clear()
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package com.runicgateway.app.core.auth.sso
|
||||
|
||||
import com.runicgateway.app.BuildConfig
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
||||
@@ -49,6 +50,7 @@ class SsoAuthManager @Inject constructor(
|
||||
private val sessionManager: SessionManager,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val pendingStore: PendingSsoStore,
|
||||
private val trustTokenStore: TrustTokenStore,
|
||||
) {
|
||||
|
||||
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
|
||||
@@ -193,6 +195,14 @@ class SsoAuthManager @Inject constructor(
|
||||
_outcome.value = Outcome.Failed(Failure.SERVER)
|
||||
return
|
||||
}
|
||||
// The user ticked "trust this device" on the TOTP form inside the Custom
|
||||
// Tab. That tab's cookie already covers future SSO sign-ins; persisting
|
||||
// the token the exchange handed back is what lets a native PASSWORD login
|
||||
// on this device skip the code too (TRUSTED_DEVICES_MFA.md). Scoped to the
|
||||
// username exactly like the password path, so it is never replayed for a
|
||||
// different account on a shared device. Saved BEFORE onSignedIn so a
|
||||
// process death mid-callback can't lose it.
|
||||
body.trustToken?.let { trustTokenStore.save(body.user.username, it) }
|
||||
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
||||
_outcome.value = Outcome.Success
|
||||
return
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* The live shard SSE feed as a cold flow of lifecycle + frame events (PLAN.md §6.2,
|
||||
* §7). Extracted as an interface so consumers (e.g. [com.runicgateway.app.data.repository.ShardRepository])
|
||||
* depend on the capability, not the OkHttp-backed [ShardStreamClient] — the boards
|
||||
* can then be unit-tested against a fake stream instead of a real network connection.
|
||||
*/
|
||||
interface ShardStream {
|
||||
fun events(): Flow<ShardStreamEvent>
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class ShardStreamClient @Inject constructor(
|
||||
baseClient: OkHttpClient,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val json: Json,
|
||||
) {
|
||||
) : ShardStream {
|
||||
// SSE is a long-lived, mostly-idle connection (keepalive comments every ~25s),
|
||||
// so the read timeout must be disabled or the idle stream would be killed.
|
||||
private val sseClient: OkHttpClient = baseClient.newBuilder()
|
||||
@@ -56,7 +56,7 @@ class ShardStreamClient @Inject constructor(
|
||||
* drive a live/offline indicator; [ShardStreamEvent.Frame] carries a decoded
|
||||
* `{ kind, … }` payload the boards merge in place.
|
||||
*/
|
||||
fun events(): Flow<ShardStreamEvent> = channelFlow {
|
||||
override fun events(): Flow<ShardStreamEvent> = channelFlow {
|
||||
var backoffMs = INITIAL_BACKOFF_MS
|
||||
while (isActive) {
|
||||
val url = baseUrlHolder.current?.resolve(STREAM_PATH)
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.POST
|
||||
|
||||
@@ -27,9 +28,15 @@ import retrofit2.http.POST
|
||||
interface AuthApi {
|
||||
|
||||
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
|
||||
// [trustToken] rides the `X-Trust-Token` header (TRUSTED_DEVICES_MFA.md): a valid
|
||||
// token bound to this user lets the server skip the TOTP step. Retrofit omits the
|
||||
// header entirely when it is null, so an untrusted device sends nothing.
|
||||
@Headers("X-Runic-No-Session: 1")
|
||||
@POST("api/v1/auth/mobile/login")
|
||||
suspend fun login(@Body body: MobileLoginRequest): Response<MobileTokenResponse>
|
||||
suspend fun login(
|
||||
@Body body: MobileLoginRequest,
|
||||
@Header("X-Trust-Token") trustToken: String? = null,
|
||||
): Response<MobileTokenResponse>
|
||||
|
||||
@POST("api/v1/auth/mobile/logout")
|
||||
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>
|
||||
|
||||
@@ -7,11 +7,21 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
|
||||
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
|
||||
import com.runicgateway.app.data.api.dto.RevokedCountDto
|
||||
import com.runicgateway.app.data.api.dto.RevokedFlagDto
|
||||
import com.runicgateway.app.data.api.dto.TotpCodeRequest
|
||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.HTTP
|
||||
import retrofit2.http.PATCH
|
||||
@@ -52,4 +62,28 @@ interface MeApi {
|
||||
// path template explicit alongside the provider argument.
|
||||
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
|
||||
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit
|
||||
|
||||
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) — devices allowed to skip TOTP ──
|
||||
|
||||
@GET("api/v1/auth/me/trusted-devices")
|
||||
suspend fun trustedDevices(): List<TrustedDeviceDto>
|
||||
|
||||
// Raw [Response] so the caller can read the `409 { error, devices }` cap body,
|
||||
// which a thrown HttpException would discard.
|
||||
@POST("api/v1/auth/me/trusted-devices")
|
||||
suspend fun trustThisDevice(@Body body: TrustDeviceRequest): Response<TrustDeviceResultDto>
|
||||
|
||||
@DELETE("api/v1/auth/me/trusted-devices/{id}")
|
||||
suspend fun revokeTrustedDevice(@Path("id") id: Long): RevokedFlagDto
|
||||
|
||||
@DELETE("api/v1/auth/me/trusted-devices")
|
||||
suspend fun revokeAllTrustedDevices(): RevokedCountDto
|
||||
|
||||
// ── Recovery (backup) codes ──────────────────────────────────────────────
|
||||
|
||||
@GET("api/v1/auth/me/account/recovery-codes/status")
|
||||
suspend fun recoveryCodesStatus(): RecoveryStatusDto
|
||||
|
||||
@POST("api/v1/auth/me/account/recovery-codes/generate")
|
||||
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
|
||||
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
|
||||
import com.runicgateway.app.data.api.dto.AtlasMetaDto
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.data.api.dto.ContactRequest
|
||||
import com.runicgateway.app.data.api.dto.ContactResponse
|
||||
@@ -12,11 +15,17 @@ import com.runicgateway.app.data.api.dto.GovernorDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.data.api.dto.MarketMetaDto
|
||||
import com.runicgateway.app.data.api.dto.MarketPageDto
|
||||
import com.runicgateway.app.data.api.dto.MarketVendorDto
|
||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.PageDto
|
||||
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||
import com.runicgateway.app.data.api.dto.PostDto
|
||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.RulesetDto
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
||||
@@ -93,6 +102,14 @@ interface PublicApi {
|
||||
suspend fun postContact(@Body body: ContactRequest): ContactResponse
|
||||
|
||||
// ── Public shard widgets (§6.2) ──────────────────────────────────────
|
||||
/**
|
||||
* Which shard features this caller may reach, so the menu hides entries instead
|
||||
* of rendering links that 404/403 (§5, M11). Answered per-viewer: an anonymous
|
||||
* call and a signed-in one can differ.
|
||||
*/
|
||||
@GET("api/v1/public/shard/features")
|
||||
suspend fun getShardFeatures(): ShardFeaturesDto
|
||||
|
||||
@GET("api/v1/public/shard/status")
|
||||
suspend fun getShardStatus(): ShardStatusDto
|
||||
|
||||
@@ -128,4 +145,65 @@ interface PublicApi {
|
||||
|
||||
@GET("api/v1/public/shard/houses")
|
||||
suspend fun getShardHouses(): List<HouseDto>
|
||||
|
||||
// ── Protocol 3.0 shard content (§9 M11) ──────────────────────────────
|
||||
//
|
||||
// Each of these sits behind the website's `requireFeature` gate: a 404 means the
|
||||
// shard doesn't publish it and a 403 means this viewer is below its audience rung,
|
||||
// which `toShardUiState()` folds into one "not available here" state.
|
||||
|
||||
/** The shard's configured ruleset. A `null` body means "not published yet". */
|
||||
@GET("api/v1/public/shard/ruleset")
|
||||
suspend fun getShardRuleset(): RulesetDto?
|
||||
|
||||
/** Every points/loyalty leaderboard the shard publishes. */
|
||||
@GET("api/v1/public/shard/points")
|
||||
suspend fun getShardPoints(): List<PointsBoardDto>
|
||||
|
||||
@GET("api/v1/public/shard/points/{system}")
|
||||
suspend fun getShardPointsBoard(@Path("system") system: String): PointsBoardDto
|
||||
|
||||
/**
|
||||
* Search the player-vendor index. **Rate-limited** — the first genuinely expensive
|
||||
* public endpoint on the site, so handle `429` (`ErrorKind.RATE_LIMITED`).
|
||||
*/
|
||||
@GET("api/v1/public/shard/market")
|
||||
suspend fun getShardMarket(
|
||||
@Query("q") query: String? = null,
|
||||
@Query("minPrice") minPrice: Long? = null,
|
||||
@Query("maxPrice") maxPrice: Long? = null,
|
||||
@Query("map") map: String? = null,
|
||||
@Query("region") region: String? = null,
|
||||
@Query("sort") sort: String? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("offset") offset: Int? = null,
|
||||
): MarketPageDto
|
||||
|
||||
/** Index size, staleness, and which facets/regions actually hold vendors. */
|
||||
@GET("api/v1/public/shard/market/meta")
|
||||
suspend fun getShardMarketMeta(): MarketMetaDto
|
||||
|
||||
@GET("api/v1/public/shard/market/vendors/{serial}")
|
||||
suspend fun getShardMarketVendor(
|
||||
@Path("serial") serial: String,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("offset") offset: Int? = null,
|
||||
): MarketVendorDto
|
||||
|
||||
// The atlas lives under /public/atlas, NOT /public/shard: it is static shard
|
||||
// content parsed from the server's data files, so it stays readable while the
|
||||
// shard is down — but it IS site-mode gated, unlike the shard routes.
|
||||
@GET("api/v1/public/atlas/creatures")
|
||||
suspend fun getAtlasCreatures(
|
||||
@Query("q") query: String? = null,
|
||||
@Query("facet") facet: String? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("offset") offset: Int? = null,
|
||||
): AtlasCreaturePageDto
|
||||
|
||||
@GET("api/v1/public/atlas/creatures/{slug}")
|
||||
suspend fun getAtlasCreature(@Path("slug") slug: String): AtlasCreatureDto
|
||||
|
||||
@GET("api/v1/public/atlas/meta")
|
||||
suspend fun getAtlasMeta(): AtlasMetaDto
|
||||
}
|
||||
|
||||
@@ -55,9 +55,78 @@ data class TotpSetupDto(
|
||||
@Serializable
|
||||
data class TotpCodeRequest(val code: String)
|
||||
|
||||
/** Result of enabling/disabling 2FA. */
|
||||
/**
|
||||
* Result of enabling/disabling 2FA. Enabling also returns the freshly generated
|
||||
* single-use [recoveryCodes] **once** (null on disable and for older backends) — the
|
||||
* app shows them for the user to save and never persists them.
|
||||
*/
|
||||
@Serializable
|
||||
data class TotpStateDto(val totp_enabled: Boolean = false)
|
||||
data class TotpStateDto(
|
||||
val totp_enabled: Boolean = false,
|
||||
val recoveryCodes: List<String>? = null,
|
||||
)
|
||||
|
||||
// ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ───────────────
|
||||
|
||||
/**
|
||||
* An active trusted device (`GET /auth/me/trusted-devices`): a browser/app allowed
|
||||
* to skip the TOTP step at login. Never carries the token. Timestamps are ISO-8601
|
||||
* strings shown as-is (advisory display).
|
||||
*/
|
||||
@Serializable
|
||||
data class TrustedDeviceDto(
|
||||
val id: Long = 0,
|
||||
val platform: String? = null,
|
||||
val deviceName: String? = null,
|
||||
val userAgent: String? = null,
|
||||
val createdAt: String? = null,
|
||||
val lastUsedAt: String? = null,
|
||||
val expiresAt: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /auth/me/trusted-devices` body — an optional friendly label. */
|
||||
@Serializable
|
||||
data class TrustDeviceRequest(val deviceName: String? = null)
|
||||
|
||||
/**
|
||||
* `POST /auth/me/trusted-devices` success (native): the opaque [trustToken] to store
|
||||
* and replay via `X-Trust-Token`. Web receives the token as a cookie and no body token.
|
||||
*/
|
||||
@Serializable
|
||||
data class TrustDeviceResultDto(
|
||||
val trusted: Boolean = false,
|
||||
val trustToken: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `409 { error: "trusted_device_limit", devices }` from a trust attempt at the cap —
|
||||
* the app lists [devices] and asks the user to revoke one, then retry.
|
||||
*/
|
||||
@Serializable
|
||||
data class TrustedDeviceLimitDto(
|
||||
val error: String? = null,
|
||||
val devices: List<TrustedDeviceDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** `DELETE /auth/me/trusted-devices/:id` — idempotent single-revoke result. */
|
||||
@Serializable
|
||||
data class RevokedFlagDto(val revoked: Boolean = false)
|
||||
|
||||
/** `DELETE /auth/me/trusted-devices` — count of devices untrusted ("untrust all"). */
|
||||
@Serializable
|
||||
data class RevokedCountDto(val revoked: Int = 0)
|
||||
|
||||
/** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */
|
||||
@Serializable
|
||||
data class RecoveryStatusDto(val remaining: Int = 0)
|
||||
|
||||
/** `POST /auth/me/account/recovery-codes/generate` body — password step-up. */
|
||||
@Serializable
|
||||
data class RecoveryGenerateRequest(val currentPassword: String? = null)
|
||||
|
||||
/** A fresh single-use recovery-code batch, returned **once** (generate + totp enable). */
|
||||
@Serializable
|
||||
data class RecoveryCodesDto(val recoveryCodes: List<String> = emptyList())
|
||||
|
||||
/** A linked external identity (`GET /auth/me/account/identities`). */
|
||||
@Serializable
|
||||
|
||||
@@ -12,12 +12,23 @@ import kotlinx.serialization.Serializable
|
||||
* safe (§8, recorded for M1).
|
||||
*/
|
||||
|
||||
/** `POST /auth/mobile/login` body. [code] is only sent on the 2FA retry. */
|
||||
/**
|
||||
* `POST /auth/mobile/login` body (trusted-devices contract, TRUSTED_DEVICES_MFA.md).
|
||||
* [code] is only sent on the 2FA retry; [recoveryCode] is its single-use fallback
|
||||
* (sent instead of [code]). [trustDevice] asks the server to remember this device so
|
||||
* future logins skip the second factor — on success the response carries a
|
||||
* [MobileTokenResponse.trustToken] the app stores and replays via `X-Trust-Token`.
|
||||
* [device_name] labels the resulting trusted-device / session row (snake_case to
|
||||
* match the backend field exactly).
|
||||
*/
|
||||
@Serializable
|
||||
data class MobileLoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val code: String? = null,
|
||||
val recoveryCode: String? = null,
|
||||
val trustDevice: Boolean? = null,
|
||||
val device_name: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /auth/mobile/refresh` body. */
|
||||
@@ -34,6 +45,11 @@ data class MobileLogoutRequest(
|
||||
/**
|
||||
* Success payload from login and refresh: the token pair, the access lifetime
|
||||
* (a zeit/ms duration string, e.g. "15m"), and the safe (secret-stripped) user.
|
||||
*
|
||||
* Login additionally carries the trusted-device outcome when `trustDevice` was set:
|
||||
* [trustToken] is the opaque token to persist + replay (present only when the trust
|
||||
* was accepted), or [trustLimitReached] + [devices] when the per-user cap blocked it
|
||||
* (the login itself still succeeded). Refresh never sets these.
|
||||
*/
|
||||
@Serializable
|
||||
data class MobileTokenResponse(
|
||||
@@ -41,6 +57,9 @@ data class MobileTokenResponse(
|
||||
val refreshToken: String,
|
||||
val expiresIn: String? = null,
|
||||
val user: SafeUserDto,
|
||||
val trustToken: String? = null,
|
||||
val trustLimitReached: Boolean = false,
|
||||
val devices: List<TrustedDeviceDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */
|
||||
|
||||
@@ -60,8 +60,15 @@ data class NotificationStreamsDto(
|
||||
* `GET/PUT /auth/me/notifications/subscriptions` — the user's opted-in stream ids.
|
||||
* PUT replaces the full set; unknown ids are dropped server-side and the stored set
|
||||
* echoed back.
|
||||
*
|
||||
* [streams] intentionally has NO default: this DTO doubles as the PUT body, and the
|
||||
* backend validator requires the `streams` field (`body('streams').isArray()`).
|
||||
* kotlinx omits a property equal to its default (encodeDefaults=false), so a default
|
||||
* of `emptyList()` would drop the field when the user clears their LAST subscription,
|
||||
* sending `{}` → 400 "Validation failed" (the "can't turn off the last one" bug). With
|
||||
* no default the empty list always serializes as `{"streams":[]}`. Do not re-add a default.
|
||||
*/
|
||||
@Serializable
|
||||
data class NotificationSubscriptionsDto(
|
||||
val streams: List<String> = emptyList(),
|
||||
val streams: List<String>,
|
||||
)
|
||||
|
||||
@@ -83,8 +83,47 @@ data class CharProfileDto(
|
||||
val titles: TitlesDto? = null,
|
||||
val guild: GuildRefDto? = null,
|
||||
val governorOf: List<String> = emptyList(),
|
||||
/**
|
||||
* Loyalty / points standings (Protocol 3.0 §7.3). Empty for a character that has
|
||||
* earned nothing anywhere — the shard omits systems the character has no entry in
|
||||
* — and empty on a shard whose plugin predates 3.0.
|
||||
*
|
||||
* Served **ungated**: a character's own standings are self-service data on
|
||||
* `/player/shard/char/:serial` and do not depend on the public `leaderboards`
|
||||
* feature being visible. Don't re-gate them app-side.
|
||||
*/
|
||||
val points: List<CharPointsDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One point system a character holds a score in (Protocol 3.0 §7.3).
|
||||
*
|
||||
* Three shapes here are counter-intuitive, and all three are what a REAL shard sends
|
||||
* (`docs/link/v3.md` §7.5 — a fake shard emits whatever the spec says it should):
|
||||
*
|
||||
* - **[maxPoints] `0` means UNCAPPED, and is the common case**, not an edge case.
|
||||
* ServUO's idiom for an uncapped system is `double.MaxValue`, which the plugin
|
||||
* normalises to `0` because the C# cast is unchecked and yielded `long.MinValue`.
|
||||
* Nothing may divide by it, and a full-width progress bar for an uncapped score
|
||||
* would imply a completion that doesn't exist.
|
||||
* - **[nameString] is usually `null`.** Most systems name themselves with a cliloc
|
||||
* rather than a literal, so humanising [system] (`QueensLoyalty` → "Queens
|
||||
* Loyalty") is the PRIMARY display path, not a defensive fallback.
|
||||
* - **[rank] is absent unless the shard runs `Bridge.cfg PointsProfileRank=true`.**
|
||||
* Absent and "unranked" are different answers, so it renders only when sent.
|
||||
*/
|
||||
@Serializable
|
||||
data class CharPointsDto(
|
||||
val system: String? = null,
|
||||
val nameString: String? = null,
|
||||
val points: Long? = null,
|
||||
val maxPoints: Long? = null,
|
||||
val rank: Int? = null,
|
||||
) {
|
||||
/** The cap, or null when the system is uncapped (see [maxPoints]). */
|
||||
val cap: Long? get() = maxPoints?.takeIf { it > 0 }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CharStatsDto(
|
||||
val str: Int? = null,
|
||||
@@ -134,17 +173,45 @@ data class EquipmentDto(
|
||||
val itemId: Int? = null,
|
||||
val hue: Int? = null,
|
||||
val mods: JsonObject? = null,
|
||||
)
|
||||
/**
|
||||
* A player-given name — set for the minority of items someone has renamed, null
|
||||
* for almost everything else. The shard sends the plain `Item.Name` field; it
|
||||
* never builds a display name (that call is a packet builder, not a field read).
|
||||
*/
|
||||
val name: String? = null,
|
||||
/**
|
||||
* The item's type name, resolved from its cliloc id **by the website** against
|
||||
* its own table (`docs/website/CLILOCS.md`). Null on a shard that has no cliloc
|
||||
* table configured, which is fully supported — the sheet then falls back to the
|
||||
* layer, exactly as it did before the table existed.
|
||||
*/
|
||||
val clilocName: String? = null,
|
||||
) {
|
||||
/**
|
||||
* What to call this item.
|
||||
*
|
||||
* A player-given [name] outranks the resolved type name — "Bob's lucky axe" must
|
||||
* not be relabelled "hatchet" — and the server applies the same precedence, so
|
||||
* this only re-states it for an item that arrived with both.
|
||||
*/
|
||||
val label: String? get() = name ?: clilocName ?: layer
|
||||
}
|
||||
|
||||
/**
|
||||
* Display titles (Protocol 2.0). `selected` is the index into `reward` currently
|
||||
* shown (-1 if none); `reward` entries may be a cliloc number-as-string or a
|
||||
* literal — numeric ones are skipped without a cliloc table (as the website does).
|
||||
* Display titles (Protocol 2.0). `selected` is the index into [reward] currently
|
||||
* shown (-1 if none); [reward] entries may be a cliloc number-as-string or a literal.
|
||||
*
|
||||
* [rewardResolved] is the website's **parallel array** with the numeric entries turned
|
||||
* into words against its cliloc table — same length and order as [reward], with a null
|
||||
* where an id resolved to nothing. It is absent entirely when no entry was numeric or
|
||||
* the shard has no cliloc table, so read it positionally and tolerate it being short.
|
||||
* See `displayTitles` in the character sheet.
|
||||
*/
|
||||
@Serializable
|
||||
data class TitlesDto(
|
||||
val selected: Int? = null,
|
||||
val reward: List<String> = emptyList(),
|
||||
val rewardResolved: List<String?> = emptyList(),
|
||||
val fameKarma: String? = null,
|
||||
val skill: String? = null,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* DTOs for the public site/identity endpoints. Shapes mirror the backend
|
||||
@@ -80,4 +81,28 @@ data class SettingsDto(
|
||||
val brand: BrandDto = BrandDto(),
|
||||
/** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
|
||||
val push: PushConfigDto = PushConfigDto(),
|
||||
/**
|
||||
* The admin's **resolved** theme tokens — the CSS custom properties the site
|
||||
* paints, already layered `:root ← preset ← custom` by the server
|
||||
* (THEMING_AND_NAV.md §3). Absent when no `theme_visual` row exists, which
|
||||
* means "the shipped defaults" and is the untouched-instance path.
|
||||
*
|
||||
* Held as a raw [JsonElement] rather than a `Map<String, String>` on
|
||||
* purpose: a single unexpected value must not fail the decode of the whole
|
||||
* settings payload and take `brand` and `push` down with it. It is coerced
|
||||
* field-by-field by `SiteAppearance.from`.
|
||||
*
|
||||
* The raw `theme_visual` / `brand_assets` rows ride along in this same
|
||||
* response and are deliberately **not** modeled — they are inputs, and
|
||||
* re-deriving a palette from them would be a second `resolveThemeTokens` in
|
||||
* Kotlin, guaranteed to drift (§3).
|
||||
*/
|
||||
val theme: JsonElement? = null,
|
||||
/**
|
||||
* The public nav overrides, as the raw JSON **string** stored in
|
||||
* `settings.value` (TEXT) — so it is parsed a second time, exactly as the web
|
||||
* client's `parseJsonSetting` does. Absent when the admin never edited the
|
||||
* nav.
|
||||
*/
|
||||
@SerialName("nav_public") val navPublic: String? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* DTOs for the four shard-content surfaces Protocol 3.0 added (PLAN.md §9 M11):
|
||||
* the ruleset, the points leaderboards, the player-vendor marketplace, and the spawn
|
||||
* atlas. Shapes mirror the website's `public/shard.controller.js` + `public/atlas.
|
||||
* controller.js` responses; see `docs/link/v3.md` §5–§8.
|
||||
*
|
||||
* Every field is nullable-with-a-default, which is load-bearing rather than merely
|
||||
* defensive here: an admin can gate individual fields away per audience rung
|
||||
* (`ownerName`, `location`, a board's `name`), so a response legitimately arrives
|
||||
* with them missing and must still decode.
|
||||
*/
|
||||
|
||||
// ── Ruleset (§5) ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `GET /public/shard/ruleset` — what this shard's world is configured to do.
|
||||
*
|
||||
* Every block is optional and omitted when its system is off, so a null block means
|
||||
* "not applicable here", not "unknown". A `null` BODY (rather than an empty object)
|
||||
* means the shard has never published a ruleset — distinct from the feature being
|
||||
* switched off, which is a 404.
|
||||
*/
|
||||
@Serializable
|
||||
data class RulesetDto(
|
||||
val shard: String? = null,
|
||||
val expansion: String? = null,
|
||||
/**
|
||||
* The public connect address, published only when the operator set one. It is
|
||||
* also the ruleset's one admin-configurable field, so it can be present for a
|
||||
* signed-in viewer and absent for an anonymous one.
|
||||
*/
|
||||
val connect: String? = null,
|
||||
/** A flat bag of on/off flags — `cityLoyalty`, `vvv`, `siege`, `chat`, … */
|
||||
val systems: Map<String, Boolean> = emptyMap(),
|
||||
val caps: RulesetCapsDto? = null,
|
||||
val accounts: RulesetAccountsDto? = null,
|
||||
val housing: RulesetHousingDto? = null,
|
||||
val vetRewards: RulesetVetRewardsDto? = null,
|
||||
val vendors: RulesetVendorsDto? = null,
|
||||
val vvv: RulesetVvvDto? = null,
|
||||
val store: RulesetStoreDto? = null,
|
||||
val schedule: RulesetScheduleDto? = null,
|
||||
val updatedAt: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Skill and stat caps.
|
||||
*
|
||||
* **[skill] and [totalSkill] are in TENTHS** — 1000 is 100.0 — the way ServUO stores
|
||||
* them, and the raw number is actively misleading rather than merely unhelpful (a
|
||||
* "1000 skill cap" reads as a shard with ten times the usual limit). Use [skillCap]
|
||||
* and [totalSkillCap]. The stat caps below them are plain values.
|
||||
*/
|
||||
@Serializable
|
||||
data class RulesetCapsDto(
|
||||
val skill: Int? = null,
|
||||
val totalSkill: Int? = null,
|
||||
val stat: Int? = null,
|
||||
val str: Int? = null,
|
||||
val dex: Int? = null,
|
||||
val int: Int? = null,
|
||||
val strMax: Int? = null,
|
||||
val dexMax: Int? = null,
|
||||
val intMax: Int? = null,
|
||||
) {
|
||||
val skillCap: Double? get() = skill?.let { it / 10.0 }
|
||||
val totalSkillCap: Double? get() = totalSkill?.let { it / 10.0 }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RulesetAccountsDto(
|
||||
val perIp: Int? = null,
|
||||
val charSlots: Int? = null,
|
||||
val autoCreate: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RulesetHousingDto(val accountHouseLimit: Int? = null)
|
||||
|
||||
@Serializable
|
||||
data class RulesetVetRewardsDto(
|
||||
val enabled: Boolean? = null,
|
||||
val rewardIntervalDays: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RulesetVendorsDto(
|
||||
val restockDelayMinutes: Int? = null,
|
||||
val maxSell: Int? = null,
|
||||
val economyStockAmount: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RulesetVvvDto(
|
||||
val enabled: Boolean? = null,
|
||||
val startSilver: Int? = null,
|
||||
val enhancedRules: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RulesetStoreDto(
|
||||
val enabled: Boolean? = null,
|
||||
val currencyName: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RulesetScheduleDto(
|
||||
val autoSaveFrequencyMinutes: Int? = null,
|
||||
val autoRestartEnabled: Boolean? = null,
|
||||
val autoRestartHour: Int? = null,
|
||||
val autoRestartMinute: Int? = null,
|
||||
)
|
||||
|
||||
// ── Leaderboards (§7) ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One point system's board (`GET /public/shard/points`, `/points/:system`).
|
||||
*
|
||||
* [maxPoints] `0` means **uncapped** and is the common case, and [nameString] is
|
||||
* usually null because most systems name themselves with a cliloc — the same two
|
||||
* traps as [CharPointsDto], documented in full there.
|
||||
*
|
||||
* [players] counts players actually *holding* points, not the entry count: ten of the
|
||||
* shard's systems auto-add a zero-point row for every character ever created, so the
|
||||
* raw count would report the whole census as one system's participants.
|
||||
*/
|
||||
@Serializable
|
||||
data class PointsBoardDto(
|
||||
val system: String? = null,
|
||||
val nameString: String? = null,
|
||||
val nameNumber: Int? = null,
|
||||
val maxPoints: Long? = null,
|
||||
val players: Int? = null,
|
||||
val showOnGump: Boolean = true,
|
||||
val top: List<PointsEntryDto> = emptyList(),
|
||||
val t: Long? = null,
|
||||
val updatedAt: String? = null,
|
||||
) {
|
||||
/** The cap, or null when the system is uncapped. */
|
||||
val cap: Long? get() = maxPoints?.takeIf { it > 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* A ranked character on a board. [name] is admin-configurable (the `leaderboards`
|
||||
* feature's one field rule), so a shard can publish standings without naming who
|
||||
* holds them — a rank with no name is a valid row, not a broken one.
|
||||
*/
|
||||
@Serializable
|
||||
data class PointsEntryDto(
|
||||
val rank: Int? = null,
|
||||
val serial: String? = null,
|
||||
val name: String? = null,
|
||||
val points: Long? = null,
|
||||
)
|
||||
|
||||
// ── Marketplace (§8) ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Where a shop stands. **Nested, not flattened**, on the wire and in the read model
|
||||
* alike, so that ONE admin rule hides the facet, the coordinates, the region and the
|
||||
* house together — five flat keys would be five rules that drift apart (`v3.md` §8.8).
|
||||
* A null location means an admin gated it away; render that as an answer, not a blank.
|
||||
*/
|
||||
@Serializable
|
||||
data class MarketLocationDto(
|
||||
val map: String? = null,
|
||||
val x: Int? = null,
|
||||
val y: Int? = null,
|
||||
val z: Int? = null,
|
||||
val region: String? = null,
|
||||
val house: String? = null,
|
||||
)
|
||||
|
||||
/** The shop a listing belongs to, as embedded in a search result. */
|
||||
@Serializable
|
||||
data class MarketVendorRefDto(
|
||||
val serial: String? = null,
|
||||
val shopName: String? = null,
|
||||
val ownerName: String? = null,
|
||||
val location: MarketLocationDto? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One item for sale. [displayName] is resolved server-side against the site's cliloc
|
||||
* table, preferring a player-set [name]; a shard with no cliloc table configured sends
|
||||
* neither and the item renders by id.
|
||||
*
|
||||
* [child] marks an item priced by an enclosing container rather than itself, exactly
|
||||
* as the in-game Vendor Search reports it.
|
||||
*/
|
||||
@Serializable
|
||||
data class MarketListingDto(
|
||||
val serial: String? = null,
|
||||
val itemId: Int? = null,
|
||||
val hue: Int? = null,
|
||||
val amount: Int? = null,
|
||||
val price: Long? = null,
|
||||
val name: String? = null,
|
||||
val cliloc: Int? = null,
|
||||
val displayName: String? = null,
|
||||
val child: Boolean = false,
|
||||
val vendor: MarketVendorRefDto? = null,
|
||||
) {
|
||||
/** What to call this item; null when the shard publishes no name for it. */
|
||||
val label: String? get() = name ?: displayName
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of search results (`GET /public/shard/market`).
|
||||
*
|
||||
* Returns **listings, not vendors**: "who sells a vanquishing kryss and for how much"
|
||||
* is the question, and a vendor-shaped result would make every caller flatten the
|
||||
* shops back out.
|
||||
*
|
||||
* [staleAt] is the oldest vendor timestamp in the index and **must be surfaced**. The
|
||||
* shard sweeps vendors round-robin, so a listing can legitimately be a full cycle old;
|
||||
* a page implying live prices sends someone to an item that sold twenty minutes ago.
|
||||
*/
|
||||
@Serializable
|
||||
data class MarketPageDto(
|
||||
val listings: List<MarketListingDto> = emptyList(),
|
||||
val total: Int = 0,
|
||||
val limit: Int? = null,
|
||||
val offset: Int? = null,
|
||||
val vendors: Int? = null,
|
||||
val staleAt: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One shop and its stock (`GET /public/shard/market/vendors/:serial`).
|
||||
*
|
||||
* [truncated] means the shard publishes only the first `MarketMaxListings` of a larger
|
||||
* inventory — [count] is what is published, [total] what the shop holds. Saying so is
|
||||
* the point of this screen: a search result list cannot express it.
|
||||
*/
|
||||
@Serializable
|
||||
data class MarketVendorDto(
|
||||
val serial: String? = null,
|
||||
val shopName: String? = null,
|
||||
val ownerSerial: String? = null,
|
||||
val ownerName: String? = null,
|
||||
val location: MarketLocationDto? = null,
|
||||
val count: Int? = null,
|
||||
val total: Int? = null,
|
||||
val truncated: Boolean = false,
|
||||
val updatedAt: String? = null,
|
||||
val items: List<MarketListingDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** Index size, staleness and the filter options that actually hold vendors. */
|
||||
@Serializable
|
||||
data class MarketMetaDto(
|
||||
val vendors: Int = 0,
|
||||
val items: Int = 0,
|
||||
val staleAt: String? = null,
|
||||
val freshAt: String? = null,
|
||||
val maps: List<String> = emptyList(),
|
||||
val regions: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
// ── Spawn atlas (§6) ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A creature in the bestiary. Served from `/public/atlas`, **not** `/public/shard`:
|
||||
* the atlas is static shard *content* parsed from the server's own data files, not
|
||||
* live shard *state*, so it does not go offline with the sidecar — but unlike the
|
||||
* shard routes it IS site-mode gated, like posts and the wiki.
|
||||
*
|
||||
* [points] is a **count** of spawners; [spawners] is the list, and only the
|
||||
* single-creature route sends it. The two names are one letter apart in meaning and
|
||||
* were deliberately separated (`v3.md` §6.3) — do not reuse one for the other.
|
||||
*/
|
||||
@Serializable
|
||||
data class AtlasCreatureDto(
|
||||
val slug: String? = null,
|
||||
val name: String? = null,
|
||||
/** How many can be alive at once, summed across every spawner. */
|
||||
val total: Int? = null,
|
||||
/** How many spawners mention this creature. */
|
||||
val points: Int? = null,
|
||||
/** Spawner count per facet. */
|
||||
val facets: Map<String, Int> = emptyMap(),
|
||||
/**
|
||||
* Where it appears, aggregated per named place — the detail route only, and the
|
||||
* answer the whole screen exists to give. **Objects, not strings:** the server
|
||||
* sends `{facet, label, spawners, maxAlive}`, and typing this `List<String>`
|
||||
* made the detail route fail to decode entirely.
|
||||
*/
|
||||
val places: List<AtlasPlaceDto> = emptyList(),
|
||||
/**
|
||||
* Operator-supplied sprite file name under `/uploads/atlas/`, or null — which is
|
||||
* the normal state, since no artwork ships. Neither client renders it yet; the
|
||||
* field is carried so a decode never depends on that staying true.
|
||||
*/
|
||||
val art: String? = null,
|
||||
val spawners: List<AtlasSpawnerDto> = emptyList(),
|
||||
val spawnersTruncated: Boolean = false,
|
||||
/** Creatures sharing its spawners — the detail route only. */
|
||||
val alsoHere: List<AtlasCreatureDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One named place a creature spawns in, already aggregated across its spawners.
|
||||
*
|
||||
* [label] is the server's point-in-rect resolution of raw coordinates ("Shrines",
|
||||
* "Isamu-Jima", "Yew"), falling back to the nearest landmark and finally
|
||||
* "Wilderness" — turning a list of coordinates into an answer.
|
||||
*/
|
||||
@Serializable
|
||||
data class AtlasPlaceDto(
|
||||
val facet: String? = null,
|
||||
val label: String? = null,
|
||||
/** Spawners in this place. */
|
||||
val spawners: Int? = null,
|
||||
/** How many can be alive at once here, summed across those spawners. */
|
||||
val maxAlive: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One spawn point.
|
||||
*
|
||||
* **[minDelay] / [maxDelay] are SECONDS**, normalised by the server's parser.
|
||||
* XmlSpawner writes them in minutes *except* when a delay doesn't divide into whole
|
||||
* minutes, flagging that per record — so the raw file has `5` meaning five minutes on
|
||||
* one spawner and five seconds on the next, both plausible. The API and this client
|
||||
* carry seconds throughout.
|
||||
*/
|
||||
@Serializable
|
||||
data class AtlasSpawnerDto(
|
||||
val id: Long? = null,
|
||||
val facet: String? = null,
|
||||
val name: String? = null,
|
||||
val x: Int? = null,
|
||||
val y: Int? = null,
|
||||
val maxCount: Int? = null,
|
||||
val minDelay: Int? = null,
|
||||
val maxDelay: Int? = null,
|
||||
val region: String? = null,
|
||||
val landmark: String? = null,
|
||||
/** The server's own "Despise, Felucca" style placement label. */
|
||||
val label: String? = null,
|
||||
)
|
||||
|
||||
/** A page of creature search results (`GET /public/atlas/creatures`). */
|
||||
@Serializable
|
||||
data class AtlasCreaturePageDto(
|
||||
val creatures: List<AtlasCreatureDto> = emptyList(),
|
||||
val total: Int = 0,
|
||||
val limit: Int? = null,
|
||||
val offset: Int? = null,
|
||||
)
|
||||
|
||||
/** When the atlas was last derived from the shard's data files, and what it holds. */
|
||||
@Serializable
|
||||
data class AtlasMetaDto(
|
||||
val importedAt: String? = null,
|
||||
val generatedAt: String? = null,
|
||||
val counts: Map<String, Int> = emptyMap(),
|
||||
val facets: List<String> = emptyList(),
|
||||
)
|
||||
@@ -15,11 +15,36 @@ import kotlinx.serialization.json.JsonObject
|
||||
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Which shard surfaces this caller may reach (`GET /public/shard/features`), plus
|
||||
* the audience rung they resolved to.
|
||||
*
|
||||
* Every shard-derived feature is admin-configurable — it can be switched off or
|
||||
* raised to a higher rung — so the menu cannot be a static list (PLAN.md §5, M11).
|
||||
* [level] is the SERVER's answer on the `anonymous → logged_in → player → staff →
|
||||
* admin` ladder and is authoritative: don't re-derive a rung from the session role,
|
||||
* since `player` means *a linked game account* and staff always satisfy it.
|
||||
*
|
||||
* The response reports only what the caller can see, so the list itself never
|
||||
* discloses a feature they're gated out of.
|
||||
*/
|
||||
@Serializable
|
||||
data class ShardFeaturesDto(
|
||||
val level: String? = null,
|
||||
val features: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* A game actor (player/leader/governor) as embedded in board payloads. Per the wire
|
||||
* spec (`docs/link/INTEGRATION.md` §1), in-game [serial]s are opaque hex-string keys
|
||||
* (e.g. `"0x1A2B"`), never numbers, and [webId] is the linked site-user id as a
|
||||
* string (e.g. `"9931"`) — both are decoded as strings, not parsed.
|
||||
* (e.g. `"0x1A2B"`), never numbers.
|
||||
*
|
||||
* [acct] and [webId] are **locked to the admin rung** by the visibility framework
|
||||
* (`docs/link/v3.md` §3.4 rule 1) — a game account name and a linked site-user id are
|
||||
* not in-game-visible the way a character name is, so they are stripped from every
|
||||
* response below `admin` and no admin setting can loosen that. The fields stay
|
||||
* declared because an admin session does receive them; nothing below one should
|
||||
* expect a value.
|
||||
*/
|
||||
@Serializable
|
||||
data class ActorDto(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.appearance
|
||||
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Parse a JSON-valued settings row, client side — the second stage of decoding
|
||||
* `nav_public` (THEMING_AND_NAV.md §3).
|
||||
*
|
||||
* The Kotlin counterpart to the web client's `lib/settingsJson.js`, and
|
||||
* deliberately the same three lines of judgement: `settings.value` is TEXT, so
|
||||
* the row arrives as a **string inside** the already-decoded settings object,
|
||||
* and a malformed or wrong-shaped one must read as **absent** — the surface
|
||||
* falls back to the coded default — never as an error and never as a
|
||||
* half-applied object.
|
||||
*/
|
||||
private val settingsJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* @param raw the raw stored value, as it arrived in the settings payload
|
||||
* @return the parsed object, or null when absent/malformed
|
||||
*/
|
||||
fun parseJsonSetting(raw: String?): JsonObject? {
|
||||
if (raw.isNullOrEmpty()) return null
|
||||
val parsed = try {
|
||||
settingsJson.parseToJsonElement(raw)
|
||||
} catch (_: SerializationException) {
|
||||
return null
|
||||
}
|
||||
// Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to
|
||||
// every consumer of these keys as a syntax error is.
|
||||
return parsed as? JsonObject
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.appearance
|
||||
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Everything the app renders itself with that the shard's admin controls
|
||||
* (THEMING_AND_NAV.md, M12): the brand block, the resolved theme tokens, and the
|
||||
* public navigation overrides. One value, held once in [com.runicgateway.app.ui.AppViewModel],
|
||||
* so the theme and the drawer can never disagree about which shard they are showing.
|
||||
*
|
||||
* **[NONE] is the shipped app.** An instance with no settings rows, a backend
|
||||
* that predates the feature, and a settings call that failed outright are all the
|
||||
* same state here, and all three must render exactly as the app did before this
|
||||
* milestone existed (§2). That is why nothing on this class is nullable except
|
||||
* [brand], which was already nullable and whose absence already meant "use the
|
||||
* bundled strings".
|
||||
*/
|
||||
data class SiteAppearance(
|
||||
/** The per-shard branding block; null when settings couldn't be loaded. */
|
||||
val brand: BrandDto? = null,
|
||||
/**
|
||||
* The resolved CSS custom properties, keyed by token (`"--accent"` → `"#7f99bd"`).
|
||||
* Empty means "the shipped defaults" — the server never emits an empty map,
|
||||
* but absent and empty are the same thing to the app and it must not depend
|
||||
* on that.
|
||||
*/
|
||||
val theme: Map<String, String> = emptyMap(),
|
||||
/**
|
||||
* The parsed `nav_public` row, or null when the admin never edited the nav.
|
||||
* Kept as the raw object here; reading `items` / `sections` / `links` out of
|
||||
* it is the job of the phases that render them.
|
||||
*/
|
||||
val navPublic: JsonObject? = null,
|
||||
) {
|
||||
companion object {
|
||||
/** The shipped app: no brand, no overrides. Also what a failed load means. */
|
||||
val NONE = SiteAppearance()
|
||||
|
||||
/**
|
||||
* Build the appearance from a `GET /public/settings` body. Forgiving
|
||||
* field by field (§2): a bad `--accent` must not discard a good `--bg`
|
||||
* beside it, and a malformed `nav_public` must not cost the theme.
|
||||
*/
|
||||
fun from(settings: SettingsDto?): SiteAppearance {
|
||||
if (settings == null) return NONE
|
||||
return SiteAppearance(
|
||||
brand = settings.brand,
|
||||
theme = themeTokens(settings.theme as? JsonObject),
|
||||
navPublic = parseJsonSetting(settings.navPublic),
|
||||
)
|
||||
}
|
||||
|
||||
// Every themable token is a string server-side (validated on write, and
|
||||
// resolveThemeTokens only ever copies a validated value). Anything else
|
||||
// is dropped rather than coerced, so an unexpected value costs exactly
|
||||
// its own token and the rest of the palette still applies.
|
||||
private fun themeTokens(raw: JsonObject?): Map<String, String> {
|
||||
if (raw.isNullOrEmpty()) return emptyMap()
|
||||
return buildMap {
|
||||
for ((token, value) in raw) {
|
||||
val text = (value as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
if (!text.isNullOrBlank()) put(token, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,19 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
|
||||
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
|
||||
import com.runicgateway.app.data.api.dto.TotpCodeRequest
|
||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceLimitDto
|
||||
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -26,6 +35,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class AccountRepository @Inject constructor(
|
||||
private val api: MeApi,
|
||||
private val json: Json,
|
||||
) {
|
||||
suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() }
|
||||
|
||||
@@ -48,4 +58,63 @@ class AccountRepository @Inject constructor(
|
||||
|
||||
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
|
||||
safeApiCall { api.unlinkIdentity(provider) }
|
||||
|
||||
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) ───────────────────────────
|
||||
|
||||
suspend fun trustedDevices(): ApiResult<List<TrustedDeviceDto>> =
|
||||
safeApiCall { api.trustedDevices() }
|
||||
|
||||
/** The distinct outcomes of trusting the current device — the cap is a first-class case. */
|
||||
sealed interface TrustOutcome {
|
||||
/** Trusted; [trustToken] is the opaque token to persist (native). */
|
||||
data class Trusted(val trustToken: String?) : TrustOutcome
|
||||
|
||||
/** At the per-user cap — [devices] must be pruned before retrying. */
|
||||
data class LimitReached(val devices: List<TrustedDeviceDto>) : TrustOutcome
|
||||
data object NetworkError : TrustOutcome
|
||||
data object ServerError : TrustOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust the current device. Reads the raw response so the `409 { error, devices }`
|
||||
* cap body survives (a thrown [retrofit2.HttpException] would discard it).
|
||||
*/
|
||||
suspend fun trustThisDevice(deviceName: String? = null): TrustOutcome {
|
||||
val response = try {
|
||||
api.trustThisDevice(TrustDeviceRequest(deviceName))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IOException) {
|
||||
return TrustOutcome.NetworkError
|
||||
} catch (_: Exception) {
|
||||
return TrustOutcome.ServerError
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
return TrustOutcome.Trusted(response.body()?.trustToken)
|
||||
}
|
||||
if (response.code() == 409) {
|
||||
val devices = runCatching {
|
||||
val raw = response.errorBody()?.string()
|
||||
if (raw.isNullOrBlank()) emptyList()
|
||||
else json.decodeFromString<TrustedDeviceLimitDto>(raw).devices
|
||||
}.getOrDefault(emptyList())
|
||||
return TrustOutcome.LimitReached(devices)
|
||||
}
|
||||
return TrustOutcome.ServerError
|
||||
}
|
||||
|
||||
suspend fun revokeTrustedDevice(id: Long): ApiResult<Boolean> =
|
||||
safeApiCall { api.revokeTrustedDevice(id).revoked }
|
||||
|
||||
suspend fun revokeAllTrustedDevices(): ApiResult<Int> =
|
||||
safeApiCall { api.revokeAllTrustedDevices().revoked }
|
||||
|
||||
// ── Recovery (backup) codes ────────────────────────────────────────────
|
||||
|
||||
suspend fun recoveryCodesStatus(): ApiResult<RecoveryStatusDto> =
|
||||
safeApiCall { api.recoveryCodesStatus() }
|
||||
|
||||
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
|
||||
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
|
||||
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.auth.DeviceNameProvider
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.push.PushManager
|
||||
import com.runicgateway.app.data.api.AuthApi
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
@@ -12,6 +14,7 @@ import com.runicgateway.app.data.api.dto.MobileLogoutRequest
|
||||
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
||||
import com.runicgateway.app.data.api.dto.TotpRequiredError
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -32,6 +35,8 @@ class AuthRepository @Inject constructor(
|
||||
private val ssoApi: SsoApi,
|
||||
private val sessionManager: SessionManager,
|
||||
private val pushManager: PushManager,
|
||||
private val trustTokenStore: TrustTokenStore,
|
||||
private val deviceNameProvider: DeviceNameProvider,
|
||||
private val json: Json,
|
||||
) {
|
||||
|
||||
@@ -73,7 +78,15 @@ class AuthRepository @Inject constructor(
|
||||
|
||||
/** Outcome of a login attempt (§4.1). */
|
||||
sealed interface LoginResult {
|
||||
data object Success : LoginResult
|
||||
/**
|
||||
* Signed in. [trustLimitReached] is true when "trust this device" was asked
|
||||
* for but the per-user cap blocked it (the login still succeeded, but no trust
|
||||
* token was issued); [devices] then lists the trusted devices to manage.
|
||||
*/
|
||||
data class Success(
|
||||
val trustLimitReached: Boolean = false,
|
||||
val devices: List<TrustedDeviceDto> = emptyList(),
|
||||
) : LoginResult
|
||||
|
||||
/** The account has 2FA on — reveal the code field and resubmit with a code. */
|
||||
data object TotpRequired : LoginResult
|
||||
@@ -89,9 +102,32 @@ class AuthRepository @Inject constructor(
|
||||
data object NetworkError : LoginResult
|
||||
}
|
||||
|
||||
suspend fun login(username: String, password: String, code: String? = null): LoginResult {
|
||||
/**
|
||||
* Native login (TRUSTED_DEVICES_MFA.md). A stored trust token bound to [username]
|
||||
* rides the `X-Trust-Token` header so a trusted device skips the TOTP step. A
|
||||
* second factor is either a [code] (TOTP) or a single-use [recoveryCode]. With
|
||||
* [trustDevice], the server may return a fresh trust token to persist for next time.
|
||||
*/
|
||||
suspend fun login(
|
||||
username: String,
|
||||
password: String,
|
||||
code: String? = null,
|
||||
recoveryCode: String? = null,
|
||||
trustDevice: Boolean = false,
|
||||
): LoginResult {
|
||||
val storedTrustToken = trustTokenStore.tokenFor(username)
|
||||
val response: Response<MobileTokenResponse> = try {
|
||||
authApi.login(MobileLoginRequest(username = username, password = password, code = code))
|
||||
authApi.login(
|
||||
MobileLoginRequest(
|
||||
username = username,
|
||||
password = password,
|
||||
code = code,
|
||||
recoveryCode = recoveryCode,
|
||||
trustDevice = trustDevice.takeIf { it },
|
||||
device_name = if (trustDevice) deviceNameProvider.deviceName() else null,
|
||||
),
|
||||
trustToken = storedTrustToken,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IOException) {
|
||||
@@ -100,8 +136,14 @@ class AuthRepository @Inject constructor(
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body() ?: return LoginResult.ServerError
|
||||
// Persist a freshly minted trust token (scoped to this account) so the next
|
||||
// login skips the second factor — it deliberately outlives logout.
|
||||
body.trustToken?.let { trustTokenStore.save(username, it) }
|
||||
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
||||
return LoginResult.Success
|
||||
return LoginResult.Success(
|
||||
trustLimitReached = body.trustLimitReached,
|
||||
devices = body.devices,
|
||||
)
|
||||
}
|
||||
|
||||
return when (response.code()) {
|
||||
@@ -111,6 +153,20 @@ class AuthRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a trust token minted by the self-service "trust this device" action
|
||||
* (Account → Trusted Devices), scoped to [username] exactly like the login path.
|
||||
*/
|
||||
fun saveTrustToken(username: String, token: String) = trustTokenStore.save(username, token)
|
||||
|
||||
/**
|
||||
* Drop the locally stored trust token so this device stops skipping the TOTP step
|
||||
* (used after "untrust all" and on a Settings → Server switch). Server-side
|
||||
* revocation makes any surviving token inert anyway — the next login just prompts
|
||||
* for the code — so this is a client-side cleanliness step, never load-bearing.
|
||||
*/
|
||||
fun clearTrustToken() = trustTokenStore.clear()
|
||||
|
||||
/**
|
||||
* Revoke this session (or, with [allDevices], every session) and clear local
|
||||
* tokens (§4.3). Best-effort: the local session is torn down even if the
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.net.ServerUrl
|
||||
import com.runicgateway.app.core.prefs.ServerPreferences
|
||||
@@ -26,6 +27,8 @@ class ConnectionRepository @Inject constructor(
|
||||
private val prefs: ServerPreferences,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val sessionManager: SessionManager,
|
||||
private val trustTokenStore: TrustTokenStore,
|
||||
private val shardFeaturesRepository: ShardFeaturesRepository,
|
||||
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
||||
private val config: com.runicgateway.app.core.AppConfig,
|
||||
) {
|
||||
@@ -106,6 +109,13 @@ class ConnectionRepository @Inject constructor(
|
||||
}
|
||||
pushManager.setNtfyUrl(null)
|
||||
sessionManager.onSignedOut()
|
||||
// The trust token is bound to the old host — drop it so we don't replay it
|
||||
// against a different shard (it survives a plain logout, but not a host switch).
|
||||
trustTokenStore.clear()
|
||||
// Shard visibility is the OLD host's answer. Sign-out alone would not clear it:
|
||||
// a switch between two signed-out hosts changes no session, so nothing else
|
||||
// invalidates the cache and the new shard would inherit the old one's menu.
|
||||
shardFeaturesRepository.invalidate()
|
||||
prefs.clear()
|
||||
baseUrlHolder.set(null)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Which shard surfaces the current viewer may reach, from
|
||||
* `GET /public/shard/features` (PLAN.md §5, §9 M11).
|
||||
*
|
||||
* Every shard-derived feature is admin-configurable — it can be switched off, or its
|
||||
* audience raised above the caller's rung — so shard navigation can no longer be a
|
||||
* static list gated on the session role alone. [level] is the server's own answer on
|
||||
* the `anonymous → logged_in → player → staff → admin` ladder; the app does not
|
||||
* re-derive it.
|
||||
*
|
||||
* **This is presentation only.** The gate is server-side: a disabled feature `404`s
|
||||
* and an out-of-rung one `403`s whether or not the entry was rendered. That is why an
|
||||
* unknown answer deliberately **fails open** — see [ShardFeatures] and [canSee].
|
||||
*/
|
||||
@Singleton
|
||||
class ShardFeaturesRepository @Inject constructor(
|
||||
private val api: PublicApi,
|
||||
) {
|
||||
private val _features = MutableStateFlow<ShardFeatures?>(null)
|
||||
|
||||
/** The current answer, or `null` while it is unknown (in flight, or the lookup failed). */
|
||||
val features: StateFlow<ShardFeatures?> = _features.asStateFlow()
|
||||
|
||||
// Serializes concurrent refreshes: the shell refreshes on every session change,
|
||||
// and two overlapping loads would race to publish.
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* Re-resolve the visible set. Called on every session change (sign-in, sign-out,
|
||||
* a role revalidation that actually changed the user), because the answer is
|
||||
* per-viewer.
|
||||
*
|
||||
* A failed lookup clears the cache rather than keeping a stale one: falling back
|
||||
* to "show everything" is the safe direction here, since the server still gates
|
||||
* every call.
|
||||
*/
|
||||
suspend fun refresh() = mutex.withLock {
|
||||
_features.value = when (val result = safeApiCall { api.getShardFeatures() }) {
|
||||
is ApiResult.Ok -> ShardFeatures(
|
||||
level = result.data.level,
|
||||
visible = result.data.features.toSet(),
|
||||
)
|
||||
// Includes the 404 an older, pre-Protocol-3.0 website returns for this
|
||||
// route — that site has no visibility framework, so "unknown" is exactly
|
||||
// the right answer and the menu behaves as it did before M11.
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached answer. Called on a Settings → Server switch: the features
|
||||
* belong to the host that reported them, and a switch between two signed-out
|
||||
* hosts changes no session, so nothing else would invalidate them.
|
||||
*/
|
||||
fun invalidate() {
|
||||
_features.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved visibility answer for one viewer: the rung the server placed them on
|
||||
* and the shard features they may reach.
|
||||
*/
|
||||
data class ShardFeatures(
|
||||
val level: String?,
|
||||
val visible: Set<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* True when [feature] may be shown — **or when the answer isn't known yet**.
|
||||
*
|
||||
* The fail-open default is deliberate and matches the web client (`lib/useShardFeatures.js`):
|
||||
* the server gates every call regardless, so the cost of guessing wrong is a link that
|
||||
* briefly `403`s, while the cost of guessing the other way is a navigation drawer that
|
||||
* flickers its entries in on every cold start.
|
||||
*/
|
||||
fun canSee(features: ShardFeatures?, feature: String): Boolean =
|
||||
features == null || feature in features.visible
|
||||
|
||||
/** Feature names as the website's `shardVisibility.js` `FEATURES` map spells them. */
|
||||
object ShardFeature {
|
||||
const val STATUS = "status"
|
||||
const val ACTIVITY = "activity"
|
||||
const val CHAMPS = "champs"
|
||||
const val GUILDS = "guilds"
|
||||
const val GOVERNORS = "governors"
|
||||
const val HOUSES = "houses"
|
||||
const val PRESENCE = "presence"
|
||||
|
||||
// Added by Protocol 3.0.
|
||||
const val RULESET = "ruleset"
|
||||
const val ATLAS = "atlas"
|
||||
const val LEADERBOARDS = "leaderboards"
|
||||
const val MARKET = "market"
|
||||
}
|
||||
@@ -3,11 +3,13 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.net.ShardStreamClient
|
||||
import com.runicgateway.app.core.net.ShardStream
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
|
||||
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.data.api.dto.EconomySampleDto
|
||||
import com.runicgateway.app.data.api.dto.FeedEventDto
|
||||
@@ -15,8 +17,13 @@ import com.runicgateway.app.data.api.dto.GovernorDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.data.api.dto.MarketMetaDto
|
||||
import com.runicgateway.app.data.api.dto.MarketPageDto
|
||||
import com.runicgateway.app.data.api.dto.MarketVendorDto
|
||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.RulesetDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.serialization.KSerializer
|
||||
@@ -35,7 +42,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class ShardRepository @Inject constructor(
|
||||
private val api: PublicApi,
|
||||
private val stream: ShardStreamClient,
|
||||
private val stream: ShardStream,
|
||||
private val json: Json,
|
||||
) {
|
||||
// ── Snapshots ────────────────────────────────────────────────────────
|
||||
@@ -62,6 +69,59 @@ class ShardRepository @Inject constructor(
|
||||
|
||||
suspend fun houses(): ApiResult<List<HouseDto>> = safeApiCall { api.getShardHouses() }
|
||||
|
||||
// ── Protocol 3.0 shard content (§9 M11) ──────────────────────────────
|
||||
//
|
||||
// All four sit behind `requireFeature`, so a 404/403 here is "this shard doesn't
|
||||
// publish it" rather than a fault — see `toShardUiState()`.
|
||||
|
||||
/** The shard ruleset, or `Ok(null)` when the shard has never published one. */
|
||||
suspend fun ruleset(): ApiResult<RulesetDto?> = safeApiCall { api.getShardRuleset() }
|
||||
|
||||
suspend fun pointsBoards(): ApiResult<List<PointsBoardDto>> = safeApiCall { api.getShardPoints() }
|
||||
|
||||
suspend fun pointsBoard(system: String): ApiResult<PointsBoardDto> =
|
||||
safeApiCall { api.getShardPointsBoard(system) }
|
||||
|
||||
suspend fun market(
|
||||
query: String? = null,
|
||||
map: String? = null,
|
||||
region: String? = null,
|
||||
sort: String = SORT_PRICE_ASC,
|
||||
limit: Int = MARKET_PAGE,
|
||||
offset: Int = 0,
|
||||
): ApiResult<MarketPageDto> = safeApiCall {
|
||||
api.getShardMarket(
|
||||
query = query?.takeIf { it.isNotBlank() },
|
||||
map = map?.takeIf { it.isNotBlank() },
|
||||
region = region?.takeIf { it.isNotBlank() },
|
||||
sort = sort,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun marketMeta(): ApiResult<MarketMetaDto> = safeApiCall { api.getShardMarketMeta() }
|
||||
|
||||
suspend fun marketVendor(serial: String): ApiResult<MarketVendorDto> =
|
||||
safeApiCall { api.getShardMarketVendor(serial) }
|
||||
|
||||
suspend fun atlasCreatures(
|
||||
query: String? = null,
|
||||
facet: String? = null,
|
||||
limit: Int = ATLAS_PAGE,
|
||||
offset: Int = 0,
|
||||
): ApiResult<AtlasCreaturePageDto> = safeApiCall {
|
||||
api.getAtlasCreatures(
|
||||
query = query?.takeIf { it.isNotBlank() },
|
||||
facet = facet?.takeIf { it.isNotBlank() },
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun atlasCreature(slug: String): ApiResult<AtlasCreatureDto> =
|
||||
safeApiCall { api.getAtlasCreature(slug) }
|
||||
|
||||
// ── Live stream ──────────────────────────────────────────────────────
|
||||
/** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */
|
||||
fun liveEvents(): Flow<ShardStreamEvent> = stream.events()
|
||||
@@ -73,9 +133,27 @@ class ShardRepository @Inject constructor(
|
||||
fun governorFrame(obj: JsonObject): GovernorDto? = decode(obj, GovernorDto.serializer())
|
||||
fun presenceFrame(obj: JsonObject): PresenceDto? = decode(obj, PresenceDto.serializer())
|
||||
|
||||
// Protocol 3.0 frames. `world.ruleset` and `points.board` ride the public stream by
|
||||
// default; `vendor.listing` does NOT — the market feature ships with its SSE fan-out
|
||||
// disabled (a live firehose of vendor inventories would be the site's biggest
|
||||
// bandwidth consumer), so the market screen is a plain paginated read and must never
|
||||
// wait on a frame.
|
||||
fun rulesetFrame(obj: JsonObject): RulesetDto? = decode(obj, RulesetDto.serializer())
|
||||
fun pointsBoardFrame(obj: JsonObject): PointsBoardDto? = decode(obj, PointsBoardDto.serializer())
|
||||
|
||||
private fun <T> decode(obj: JsonObject, serializer: KSerializer<T>): T? = try {
|
||||
json.decodeFromJsonElement(serializer, obj)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SORT_PRICE_ASC = "price_asc"
|
||||
const val SORT_PRICE_DESC = "price_desc"
|
||||
const val SORT_RECENT = "recent"
|
||||
|
||||
/** The server caps `limit` at 100; stay well under it on a phone. */
|
||||
const val MARKET_PAGE = 50
|
||||
const val ATLAS_PAGE = 50
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ 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.HostSelectionInterceptor
|
||||
import com.runicgateway.app.core.net.ShardStream
|
||||
import com.runicgateway.app.core.net.ShardStreamClient
|
||||
import com.runicgateway.app.core.net.TokenAuthenticator
|
||||
import com.runicgateway.app.core.net.UserAgentInterceptor
|
||||
import com.runicgateway.app.data.api.AuthApi
|
||||
@@ -94,6 +96,12 @@ object NetworkModule {
|
||||
@Singleton
|
||||
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
|
||||
|
||||
/** Expose the live SSE feed as the [ShardStream] capability so repositories depend
|
||||
* on the interface (unit-testable against a fake), not the OkHttp-backed client. */
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideShardStream(client: ShardStreamClient): ShardStream = client
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
*/
|
||||
package com.runicgateway.app.di
|
||||
|
||||
import com.runicgateway.app.core.auth.BuildDeviceNameProvider
|
||||
import com.runicgateway.app.core.auth.DeviceNameProvider
|
||||
import com.runicgateway.app.core.auth.EncryptedTokenStore
|
||||
import com.runicgateway.app.core.auth.EncryptedTrustTokenStore
|
||||
import com.runicgateway.app.core.auth.TokenStore
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
|
||||
import com.runicgateway.app.core.auth.sso.PendingSsoStore
|
||||
import dagger.Binds
|
||||
@@ -25,4 +29,13 @@ abstract class StorageModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindPendingSsoStore(impl: EncryptedPendingSsoStore): PendingSsoStore
|
||||
|
||||
/** The trusted-device token store — its own encrypted file, outlives session teardown. */
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTrustTokenStore(impl: EncryptedTrustTokenStore): TrustTokenStore
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.push.PushManager
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
import com.runicgateway.app.data.repository.ConnectionRepository
|
||||
import com.runicgateway.app.data.repository.SettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -20,8 +20,8 @@ import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Top-level app gate (PLAN.md §3): decides whether the first-run connect screen
|
||||
* or the main UI shows, and holds the per-shard branding the theme is seeded
|
||||
* from. Activity-scoped so the whole app observes one state.
|
||||
* or the main UI shows, and holds the per-shard [SiteAppearance] the theme and
|
||||
* the drawer are built from. Activity-scoped so the whole app observes one state.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AppViewModel @Inject constructor(
|
||||
@@ -38,8 +38,11 @@ class AppViewModel @Inject constructor(
|
||||
/** No shard site configured yet — show the connect screen. */
|
||||
data object NeedsConnection : AppState
|
||||
|
||||
/** A site is configured; [brand] is null if branding couldn't be loaded (still usable). */
|
||||
data class Ready(val brand: BrandDto?) : AppState
|
||||
/**
|
||||
* A site is configured. [appearance] is [SiteAppearance.NONE] when settings
|
||||
* couldn't be loaded — the shipped app, still fully usable (§2).
|
||||
*/
|
||||
data class Ready(val appearance: SiteAppearance) : AppState
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<AppState>(AppState.Loading)
|
||||
@@ -48,7 +51,7 @@ class AppViewModel @Inject constructor(
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_state.value = if (connectionRepository.restore()) {
|
||||
AppState.Ready(loadBrand())
|
||||
AppState.Ready(loadAppearance())
|
||||
} else {
|
||||
AppState.NeedsConnection
|
||||
}
|
||||
@@ -57,7 +60,30 @@ class AppViewModel @Inject constructor(
|
||||
|
||||
/** Called by the connect screen once a site has been validated + saved. */
|
||||
fun onConnected() {
|
||||
viewModelScope.launch { _state.value = AppState.Ready(loadBrand()) }
|
||||
viewModelScope.launch { _state.value = AppState.Ready(loadAppearance()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the appearance while the app is already running — on resume, beside
|
||||
* the session's own re-validation (§5.5). An admin who re-skins the site from
|
||||
* a laptop and picks the phone up should see it.
|
||||
*
|
||||
* Best-effort, and silent either way: a failed refresh **keeps the last good
|
||||
* appearance** rather than dropping back to the shipped one, so a moment of
|
||||
* no connectivity does not repaint a themed shard. There is no loading state
|
||||
* and no error surface. Ignored unless a site is configured.
|
||||
*/
|
||||
fun refreshAppearance() {
|
||||
if (_state.value !is AppState.Ready) return
|
||||
viewModelScope.launch {
|
||||
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
|
||||
pushManager.setNtfyUrl(settings.push.ntfyUrl)
|
||||
// changeServer() may have raced us back to the connect screen while the
|
||||
// call was in flight; don't resurrect Ready on top of it.
|
||||
if (_state.value is AppState.Ready) {
|
||||
_state.value = AppState.Ready(SiteAppearance.from(settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings → Server switch: hard reset back to the connect screen (§3). */
|
||||
@@ -69,14 +95,14 @@ class AppViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Load public settings for branding and feed the shard's push relay URL into the
|
||||
* [PushManager] (§11) — its arrival is what lets push re-register after a restart
|
||||
* or sign-in. Returns the brand block (null if settings couldn't be loaded).
|
||||
* Load public settings for the appearance and feed the shard's push relay URL into
|
||||
* the [PushManager] (§11) — its arrival is what lets push re-register after a restart
|
||||
* or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded.
|
||||
*/
|
||||
private suspend fun loadBrand(): BrandDto? {
|
||||
private suspend fun loadAppearance(): SiteAppearance {
|
||||
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
|
||||
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
|
||||
return settings?.brand
|
||||
return SiteAppearance.from(settings)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,6 +51,8 @@ import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.ui.auth.AccountScreen
|
||||
import com.runicgateway.app.ui.auth.LoginScreen
|
||||
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
||||
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
|
||||
import com.runicgateway.app.ui.auth.roleLabelRes
|
||||
import com.runicgateway.app.ui.contact.ContactScreen
|
||||
import com.runicgateway.app.ui.home.HomeScreen
|
||||
@@ -70,10 +72,16 @@ import com.runicgateway.app.ui.player.CharactersScreen
|
||||
import com.runicgateway.app.ui.player.MyHousesScreen
|
||||
import com.runicgateway.app.ui.player.VendorsScreen
|
||||
import com.runicgateway.app.ui.session.SessionViewModel
|
||||
import com.runicgateway.app.ui.shard.AtlasCreatureScreen
|
||||
import com.runicgateway.app.ui.shard.AtlasScreen
|
||||
import com.runicgateway.app.ui.shard.ChampsScreen
|
||||
import com.runicgateway.app.ui.shard.GovernorsScreen
|
||||
import com.runicgateway.app.ui.shard.GuildsScreen
|
||||
import com.runicgateway.app.ui.shard.HousesScreen
|
||||
import com.runicgateway.app.ui.shard.LeaderboardsScreen
|
||||
import com.runicgateway.app.ui.shard.MarketScreen
|
||||
import com.runicgateway.app.ui.shard.MarketVendorScreen
|
||||
import com.runicgateway.app.ui.shard.RulesScreen
|
||||
import com.runicgateway.app.ui.shard.ShardBoard
|
||||
import com.runicgateway.app.ui.shard.ShardScreen
|
||||
import com.runicgateway.app.ui.wiki.WikiPageScreen
|
||||
@@ -83,6 +91,9 @@ import kotlinx.coroutines.launch
|
||||
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
||||
private val TOP_LEVEL_ROUTES = setOf(
|
||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
||||
// Protocol 3.0 content screens are drawer destinations, so the drawer gesture works
|
||||
// on them too (M11).
|
||||
Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS,
|
||||
Routes.NOTIFICATIONS,
|
||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||
@@ -110,6 +121,8 @@ fun RunicApp(
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val session by sessionViewModel.session.collectAsStateWithLifecycle()
|
||||
// What this shard publishes, independently of who the caller is (§5, M11).
|
||||
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
|
||||
|
||||
// Re-validate the cached role each time the app returns to the foreground (§4.3).
|
||||
LifecycleResumeEffect(Unit) {
|
||||
@@ -130,7 +143,7 @@ fun RunicApp(
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||
val entries = visibleEntries(APP_MENU, session)
|
||||
val entries = visibleEntries(APP_MENU, session, shardFeatures)
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -301,6 +314,30 @@ private fun RunicNavHost(
|
||||
composable(Routes.SHARD_GUILDS) { GuildsScreen() }
|
||||
composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() }
|
||||
composable(Routes.SHARD_HOUSES) { HousesScreen() }
|
||||
|
||||
// Protocol 3.0 shard content (M11). Each screen self-reports "not published
|
||||
// here" from its own 404/403, so a deep link to a gated feature still lands on
|
||||
// an honest answer even though the menu hides the entry.
|
||||
composable(Routes.SHARD_RULES) { RulesScreen() }
|
||||
composable(Routes.SHARD_LEADERBOARDS) { LeaderboardsScreen(brand = brand) }
|
||||
composable(Routes.SHARD_MARKET) {
|
||||
MarketScreen(onOpenVendor = { serial -> navController.navigate(Routes.marketVendor(serial)) })
|
||||
}
|
||||
composable(
|
||||
route = Routes.SHARD_MARKET_VENDOR,
|
||||
arguments = listOf(navArgument(Routes.Args.SERIAL) { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
MarketVendorScreen(serial = entry.arguments?.getString(Routes.Args.SERIAL).orEmpty())
|
||||
}
|
||||
composable(Routes.ATLAS) {
|
||||
AtlasScreen(onOpenCreature = { slug -> navController.navigate(Routes.atlasCreature(slug)) })
|
||||
}
|
||||
composable(
|
||||
route = Routes.ATLAS_CREATURE,
|
||||
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty())
|
||||
}
|
||||
composable(Routes.WIKI) {
|
||||
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
||||
}
|
||||
@@ -339,12 +376,27 @@ private fun RunicNavHost(
|
||||
roleLabel = stringResource(roleLabelRes(s.user.role)),
|
||||
onSignOut = onSignOut,
|
||||
onSignOutEverywhere = onSignOutEverywhere,
|
||||
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
|
||||
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
|
||||
)
|
||||
Session.SignedOut -> LaunchedEffect(Unit) {
|
||||
navController.navigateTopLevel(Routes.HOME)
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(Routes.ACCOUNT_TRUSTED_DEVICES) {
|
||||
// Signed-in only; a drop (sign-out/demotion) sends the user home (§4.3).
|
||||
when (session) {
|
||||
is Session.SignedIn -> TrustedDevicesScreen()
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
composable(Routes.ACCOUNT_RECOVERY_CODES) {
|
||||
when (session) {
|
||||
is Session.SignedIn -> RecoveryCodesScreen()
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
composable(Routes.NOTIFICATIONS) {
|
||||
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
||||
// leaving stale settings up. The backend gates every call regardless (§5).
|
||||
|
||||
@@ -34,6 +34,13 @@ enum class ErrorKind {
|
||||
/** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */
|
||||
SHARD_OFFLINE,
|
||||
|
||||
/**
|
||||
* This shard doesn't publish the surface, or doesn't publish it to this viewer
|
||||
* (M11). Distinct from [NOT_FOUND] and [SHARD_OFFLINE]: the site is up, the shard
|
||||
* may well be up, and retrying changes nothing — an admin decides this.
|
||||
*/
|
||||
FEATURE_UNAVAILABLE,
|
||||
|
||||
/** Any other non-2xx server response. */
|
||||
SERVER,
|
||||
}
|
||||
@@ -52,3 +59,27 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
|
||||
httpStatus = status,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [toUiState] for a **shard-derived** read, where `404` carries a second meaning.
|
||||
*
|
||||
* The website's `requireFeature` gate answers `404` when a feature is switched off —
|
||||
* deliberately, so the response doesn't disclose that the surface exists — and `403`
|
||||
* when it's on but the caller is below its audience rung (`docs/link/v3.md` §3.6).
|
||||
* On these routes a `404` therefore almost never means "no such thing"; it means this
|
||||
* shard doesn't publish it. Rendering "couldn't be found" with a retry button would
|
||||
* invite the user to retry something an admin controls.
|
||||
*
|
||||
* Kept as a separate mapper rather than folded into [toUiState] because both statuses
|
||||
* mean something else off the shard surface: `404` is a genuinely missing item (a
|
||||
* deleted post, an unknown wiki slug) and `403` is an ownership or role refusal on a
|
||||
* 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) {
|
||||
UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status)
|
||||
} else {
|
||||
toUiState()
|
||||
}
|
||||
else -> toUiState()
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -30,7 +29,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -47,6 +45,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
@@ -140,7 +139,7 @@ private fun PostsTab(
|
||||
}
|
||||
}
|
||||
items(state.data, key = { it.id }) { post ->
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(post.title, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
@@ -191,7 +190,7 @@ private fun WikiTab(
|
||||
}
|
||||
}
|
||||
items(state.data, key = { it.id }) { cat ->
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(cat.title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -38,6 +37,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* The support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close,
|
||||
@@ -84,7 +84,6 @@ fun AdminSupportScreen(
|
||||
|
||||
replyTo?.let { page ->
|
||||
RespondDialog(
|
||||
page = page,
|
||||
onDismiss = { replyTo = null },
|
||||
onSend = { message, close ->
|
||||
viewModel.respond(page.pageId, message, close)
|
||||
@@ -101,7 +100,7 @@ private fun SupportPageCard(
|
||||
onReply: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
val who = page.sender?.name ?: page.sender?.account ?: page.pageId
|
||||
Text(
|
||||
@@ -122,7 +121,6 @@ private fun SupportPageCard(
|
||||
|
||||
@Composable
|
||||
private fun RespondDialog(
|
||||
page: SupportPageDto,
|
||||
onDismiss: () -> Unit,
|
||||
onSend: (message: String, close: Boolean) -> Unit,
|
||||
) {
|
||||
|
||||
@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -51,6 +50,7 @@ import com.runicgateway.app.ui.auth.AccountViewModel.Section
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
@@ -65,6 +65,8 @@ fun AccountScreen(
|
||||
roleLabel: String,
|
||||
onSignOut: () -> Unit,
|
||||
onSignOutEverywhere: () -> Unit,
|
||||
onOpenTrustedDevices: () -> Unit,
|
||||
onOpenRecoveryCodes: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -78,10 +80,15 @@ fun AccountScreen(
|
||||
) {
|
||||
IdentityCard(username = username, roleLabel = roleLabel)
|
||||
|
||||
// One-time recovery codes surfaced right after enabling 2FA — save them now.
|
||||
state.recoveryCodesOnce?.let { codes ->
|
||||
RecoveryCodesShowOnceCard(codes, onDismiss = viewModel::dismissRecoveryCodes)
|
||||
}
|
||||
|
||||
when (val account = state.account) {
|
||||
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
|
||||
is UiState.Error -> ErrorView(account.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
|
||||
is UiState.Success -> AccountSections(account.data, state, viewModel)
|
||||
is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||
@@ -100,7 +107,7 @@ fun AccountScreen(
|
||||
|
||||
@Composable
|
||||
private fun IdentityCard(username: String, roleLabel: String) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
Text(text = username, style = MaterialTheme.typography.titleLarge)
|
||||
StatusPill(
|
||||
@@ -117,16 +124,37 @@ private fun AccountSections(
|
||||
account: PlayerAccountDto,
|
||||
state: AccountViewModel.State,
|
||||
viewModel: AccountViewModel,
|
||||
onOpenTrustedDevices: () -> Unit,
|
||||
onOpenRecoveryCodes: () -> Unit,
|
||||
) {
|
||||
UsernameSection(account, state, viewModel)
|
||||
PasswordSection(account, state, viewModel)
|
||||
TwoFactorSection(account, state, viewModel)
|
||||
SecuritySection(onOpenTrustedDevices, onOpenRecoveryCodes)
|
||||
IdentitiesSection(state, viewModel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Links to the dedicated trusted-device and recovery-code screens
|
||||
* (TRUSTED_DEVICES_MFA.md). Kept simple — the management UX lives on those screens.
|
||||
*/
|
||||
@Composable
|
||||
private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCodes: () -> Unit) {
|
||||
SectionCard(R.string.account_security_title) {
|
||||
OutlinedButton(
|
||||
onClick = onOpenTrustedDevices,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.account_security_trusted_devices)) }
|
||||
OutlinedButton(
|
||||
onClick = onOpenRecoveryCodes,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) { Text(stringResource(R.string.account_security_recovery_codes)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||
content()
|
||||
|
||||
@@ -49,6 +49,8 @@ class AccountViewModel @Inject constructor(
|
||||
val busy: Boolean = false,
|
||||
/** The pending TOTP enrollment (QR shown) between setup and enable. */
|
||||
val totpSetup: TotpSetupDto? = null,
|
||||
/** The single-use recovery codes returned once when 2FA was just enabled. */
|
||||
val recoveryCodesOnce: List<String>? = null,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
@@ -122,9 +124,12 @@ class AccountViewModel @Inject constructor(
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.totpEnable(code.trim())) {
|
||||
when (val result = accountRepository.totpEnable(code.trim())) {
|
||||
is ApiResult.Ok -> {
|
||||
_state.update { it.copy(totpSetup = null) }
|
||||
// 2FA enable returns the fresh recovery-code batch once — surface it.
|
||||
_state.update {
|
||||
it.copy(totpSetup = null, recoveryCodesOnce = result.data.recoveryCodes?.takeIf(List<String>::isNotEmpty))
|
||||
}
|
||||
finish(Section.TOTP, true, R.string.account_totp_enabled)
|
||||
reloadAccount()
|
||||
}
|
||||
@@ -134,6 +139,9 @@ class AccountViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the one-time recovery-code batch shown after enabling 2FA. */
|
||||
fun dismissRecoveryCodes() = _state.update { it.copy(recoveryCodesOnce = null) }
|
||||
|
||||
fun disableTotp(code: String) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
|
||||
@@ -5,6 +5,7 @@ package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -16,6 +17,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -123,22 +125,73 @@ fun LoginScreen(
|
||||
)
|
||||
|
||||
if (state.totpRequired) {
|
||||
OutlinedTextField(
|
||||
value = state.code,
|
||||
onValueChange = viewModel::onCodeChange,
|
||||
singleLine = true,
|
||||
if (state.useRecoveryCode) {
|
||||
OutlinedTextField(
|
||||
value = state.recoveryCode,
|
||||
onValueChange = viewModel::onRecoveryCodeChange,
|
||||
singleLine = true,
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_recovery_code)) },
|
||||
supportingText = { Text(stringResource(R.string.login_recovery_hint)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = state.code,
|
||||
onValueChange = viewModel::onCodeChange,
|
||||
singleLine = true,
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_totp_code)) },
|
||||
supportingText = { Text(stringResource(R.string.login_totp_hint)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.NumberPassword,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Toggle between authenticator code and a single-use recovery code.
|
||||
TextButton(
|
||||
onClick = { viewModel.onUseRecoveryCodeChange(!state.useRecoveryCode) },
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_totp_code)) },
|
||||
supportingText = { Text(stringResource(R.string.login_totp_hint)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.NumberPassword,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (state.useRecoveryCode) R.string.login_use_totp_instead
|
||||
else R.string.login_use_recovery_instead,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// "Trust this device" → skip the 2FA step on future logins here.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
.padding(top = 4.dp),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = state.trustDevice,
|
||||
onCheckedChange = viewModel::onTrustDeviceChange,
|
||||
enabled = !state.submitting,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.login_trust_device),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.error?.let { err ->
|
||||
|
||||
@@ -39,8 +39,14 @@ class LoginViewModel @Inject constructor(
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val code: String = "",
|
||||
/** A single-use recovery code, entered instead of [code] when [useRecoveryCode]. */
|
||||
val recoveryCode: String = "",
|
||||
/** True once the account is known to have 2FA on — reveal the code field. */
|
||||
val totpRequired: Boolean = false,
|
||||
/** "Enter a recovery code instead" — swap the TOTP field for the recovery field. */
|
||||
val useRecoveryCode: Boolean = false,
|
||||
/** "Trust this device" — skip the 2FA step on future logins (TRUSTED_DEVICES_MFA.md). */
|
||||
val trustDevice: Boolean = false,
|
||||
val submitting: Boolean = false,
|
||||
val error: LoginError? = null,
|
||||
val signedIn: Boolean = false,
|
||||
@@ -83,6 +89,16 @@ class LoginViewModel @Inject constructor(
|
||||
fun onCodeChange(value: String) =
|
||||
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }
|
||||
|
||||
/** Recovery codes are alphanumeric; keep it permissive, just trim length + noise. */
|
||||
fun onRecoveryCodeChange(value: String) =
|
||||
_state.update { it.copy(recoveryCode = value.filterNot(Char::isWhitespace).take(32), error = null) }
|
||||
|
||||
fun onTrustDeviceChange(value: Boolean) = _state.update { it.copy(trustDevice = value) }
|
||||
|
||||
/** Toggle between the TOTP field and the recovery-code field on the 2FA step. */
|
||||
fun onUseRecoveryCodeChange(value: Boolean) =
|
||||
_state.update { it.copy(useRecoveryCode = value, error = null) }
|
||||
|
||||
val registerUrl: String? get() = websiteUrls.register()
|
||||
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
|
||||
|
||||
@@ -137,45 +153,68 @@ class LoginViewModel @Inject constructor(
|
||||
fun submit() {
|
||||
val s = _state.value
|
||||
if (s.submitting) return
|
||||
if (s.username.isBlank() || s.password.isBlank()) {
|
||||
_state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) }
|
||||
return
|
||||
}
|
||||
// If 2FA is being requested, a code must accompany the resubmit.
|
||||
if (s.totpRequired && s.code.isBlank()) {
|
||||
_state.update { it.copy(error = LoginError.BAD_CODE) }
|
||||
val validationError = validateForSubmit(s)
|
||||
if (validationError != null) {
|
||||
_state.update { it.copy(error = validationError) }
|
||||
return
|
||||
}
|
||||
|
||||
_state.update { it.copy(submitting = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
val code = s.code.trim().takeIf { it.isNotBlank() }
|
||||
when (authRepository.login(s.username.trim(), s.password, code)) {
|
||||
LoginResult.Success ->
|
||||
_state.update { it.copy(submitting = false, signedIn = true) }
|
||||
|
||||
LoginResult.TotpRequired ->
|
||||
// Reveal the code field; a wrong code re-lands here as BAD_CODE.
|
||||
_state.update {
|
||||
it.copy(
|
||||
submitting = false,
|
||||
totpRequired = true,
|
||||
error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null,
|
||||
)
|
||||
}
|
||||
|
||||
LoginResult.InvalidCredentials ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) }
|
||||
|
||||
LoginResult.RateLimited ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) }
|
||||
|
||||
LoginResult.ServerError ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.SERVER) }
|
||||
|
||||
LoginResult.NetworkError ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
|
||||
}
|
||||
// Only one second factor is sent; the recovery toggle picks which.
|
||||
val code = s.code.trim().takeIf { it.isNotBlank() && !s.useRecoveryCode }
|
||||
val recoveryCode = s.recoveryCode.trim().takeIf { it.isNotBlank() && s.useRecoveryCode }
|
||||
val result = authRepository.login(
|
||||
username = s.username.trim(),
|
||||
password = s.password,
|
||||
code = code,
|
||||
recoveryCode = recoveryCode,
|
||||
trustDevice = s.trustDevice,
|
||||
)
|
||||
applyLoginResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-flight form checks for [submit]; returns the error to surface, or null if ready to send. */
|
||||
private fun validateForSubmit(s: UiState): LoginError? {
|
||||
if (s.username.isBlank() || s.password.isBlank()) return LoginError.INVALID_CREDENTIALS
|
||||
// If 2FA is being requested, the chosen second factor must accompany the resubmit.
|
||||
if (s.totpRequired) {
|
||||
val factor = if (s.useRecoveryCode) s.recoveryCode else s.code
|
||||
if (factor.isBlank()) return LoginError.BAD_CODE
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Folds a [LoginResult] back into the UI state (clears [UiState.submitting] on every path). */
|
||||
private fun applyLoginResult(result: LoginResult) = when (result) {
|
||||
is LoginResult.Success ->
|
||||
// The trusted-device cap (result.trustLimitReached) is an edge case:
|
||||
// login succeeded but the device wasn't remembered. It's surfaced +
|
||||
// managed on the Trusted Devices screen rather than blocking sign-in.
|
||||
_state.update { it.copy(submitting = false, signedIn = true) }
|
||||
|
||||
LoginResult.TotpRequired ->
|
||||
// Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
|
||||
_state.update {
|
||||
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
|
||||
it.copy(
|
||||
submitting = false,
|
||||
totpRequired = true,
|
||||
error = if (hadFactor) LoginError.BAD_CODE else null,
|
||||
)
|
||||
}
|
||||
|
||||
LoginResult.InvalidCredentials ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) }
|
||||
|
||||
LoginResult.RateLimited ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) }
|
||||
|
||||
LoginResult.ServerError ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.SERVER) }
|
||||
|
||||
LoginResult.NetworkError ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): shows the remaining count and a
|
||||
* password-stepped regenerate that reveals a fresh single-use batch **once**. The
|
||||
* codes are shown only in memory — copy or share them before leaving; they are never
|
||||
* stored on the device.
|
||||
*/
|
||||
@Composable
|
||||
fun RecoveryCodesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RecoveryCodesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var currentPassword by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.recovery_codes_title), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
stringResource(R.string.recovery_codes_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
val remainingText = when (val r = state.remaining) {
|
||||
is UiState.Success -> stringResource(R.string.recovery_codes_remaining, r.data)
|
||||
is UiState.Error -> stringResource(R.string.recovery_codes_remaining_unknown)
|
||||
UiState.Loading -> stringResource(R.string.recovery_codes_remaining_loading)
|
||||
}
|
||||
Text(remainingText, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 16.dp))
|
||||
|
||||
state.freshCodes?.let { codes ->
|
||||
RecoveryCodesShowOnceCard(codes, onDismiss = { viewModel.dismissFreshCodes(); currentPassword = "" })
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = currentPassword,
|
||||
onValueChange = { currentPassword = it },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.account_password_current)) },
|
||||
supportingText = { Text(stringResource(R.string.recovery_codes_password_hint)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 20.dp),
|
||||
)
|
||||
|
||||
state.error?.let { err ->
|
||||
Text(
|
||||
text = stringResource(err),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.regenerate(currentPassword) },
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
) { Text(stringResource(R.string.recovery_codes_regenerate)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A show-once display of a freshly generated recovery-code batch, with copy/share and
|
||||
* a dismiss. Shared by this screen and the "2FA just enabled" surface on AccountScreen.
|
||||
*/
|
||||
@Composable
|
||||
fun RecoveryCodesShowOnceCard(codes: List<String>, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val joined = remember(codes) { codes.joinToString("\n") }
|
||||
|
||||
ShardCard(Modifier.fillMaxWidth().padding(top = 16.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.recovery_codes_new_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.recovery_codes_new_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
codes.forEach { code ->
|
||||
Text(
|
||||
code,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { clipboard.setText(AnnotatedString(joined)) },
|
||||
) { Text(stringResource(R.string.recovery_codes_copy)) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, joined)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(send, null))
|
||||
},
|
||||
) { Text(stringResource(R.string.recovery_codes_share)) }
|
||||
Button(onClick = onDismiss) { Text(stringResource(R.string.recovery_codes_done)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.repository.AccountRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): the remaining-count
|
||||
* status and a password-stepped regenerate that surfaces a fresh single-use batch
|
||||
* **once** (never persisted). The freshly generated codes live only in memory until
|
||||
* the user leaves the screen or dismisses them.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RecoveryCodesViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
data class State(
|
||||
/** Remaining unused codes (the status endpoint). */
|
||||
val remaining: UiState<Int> = UiState.Loading,
|
||||
/** A just-generated batch to show once, or null. Cleared on dismiss/leave. */
|
||||
val freshCodes: List<String>? = null,
|
||||
val busy: Boolean = false,
|
||||
@param:StringRes val error: Int? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(remaining = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Regenerate the codes; [currentPassword] is required for accounts that have one. */
|
||||
fun regenerate(currentPassword: String?) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, error = null, freshCodes = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.generateRecoveryCodes(currentPassword?.takeIf { it.isNotBlank() })) {
|
||||
is ApiResult.Ok -> {
|
||||
_state.update { it.copy(busy = false, freshCodes = result.data.recoveryCodes) }
|
||||
// Refresh the remaining count to reflect the new batch.
|
||||
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
|
||||
}
|
||||
is ApiResult.HttpError ->
|
||||
_state.update { it.copy(busy = false, error = R.string.recovery_codes_error) }
|
||||
is ApiResult.NetworkError ->
|
||||
_state.update { it.copy(busy = false, error = R.string.error_network) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the shown-once batch from memory (user saved them / navigated away). */
|
||||
fun dismissFreshCodes() = _state.update { it.copy(freshCodes = null) }
|
||||
}
|
||||
|
||||
/** Map an [UiState] success value (local helper mirroring ApiResult.map). */
|
||||
private inline fun <T, R> UiState<T>.map(transform: (T) -> R): UiState<R> = when (this) {
|
||||
is UiState.Success -> UiState.Success(transform(data))
|
||||
is UiState.Loading -> UiState.Loading
|
||||
is UiState.Error -> this
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* Account → Trusted Devices (TRUSTED_DEVICES_MFA.md): the devices allowed to skip
|
||||
* the TOTP step at login. Trust the current device, revoke one, or untrust all. The
|
||||
* server re-checks ownership on every call; this screen just renders the outcomes.
|
||||
*/
|
||||
@Composable
|
||||
fun TrustedDevicesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TrustedDevicesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.trusted_devices_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.trusted_devices_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
state.feedback?.let { fb ->
|
||||
Text(
|
||||
text = stringResource(fb.messageRes),
|
||||
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
when (val devices = state.devices) {
|
||||
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
|
||||
is UiState.Error -> ErrorView(devices.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
|
||||
is UiState.Success -> {
|
||||
if (devices.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.trusted_devices_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
} else {
|
||||
devices.data.forEach { device ->
|
||||
TrustedDeviceRow(device, state.busy, onRevoke = { viewModel.revoke(device.id) })
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||
|
||||
Button(
|
||||
onClick = viewModel::trustThisDevice,
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(stringResource(R.string.trusted_devices_trust_this)) }
|
||||
|
||||
if (devices.data.isNotEmpty()) {
|
||||
OutlinedButton(
|
||||
onClick = viewModel::revokeAll,
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) { Text(stringResource(R.string.trusted_devices_untrust_all)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrustedDeviceRow(device: TrustedDeviceDto, busy: Boolean, onRevoke: () -> Unit) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = device.deviceName?.takeIf { it.isNotBlank() }
|
||||
?: device.platform?.replaceFirstChar { it.uppercase() }
|
||||
?: stringResource(R.string.trusted_devices_unknown),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
device.lastUsedAt?.let {
|
||||
Text(
|
||||
stringResource(R.string.trusted_devices_last_used, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onRevoke, enabled = !busy) {
|
||||
Text(stringResource(R.string.trusted_devices_revoke))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.DeviceNameProvider
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.data.repository.AccountRepository
|
||||
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
|
||||
import com.runicgateway.app.data.repository.AuthRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives the Trusted Devices screen (TRUSTED_DEVICES_MFA.md): list the devices
|
||||
* allowed to skip the TOTP step, trust the current one (persisting the returned
|
||||
* token via [AuthRepository]), revoke one, and untrust all. The trust action folds
|
||||
* the `409` cap into a first-class [Feedback] telling the user to revoke one first.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class TrustedDevicesViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val sessionManager: com.runicgateway.app.core.auth.SessionManager,
|
||||
private val deviceNameProvider: DeviceNameProvider,
|
||||
) : ViewModel() {
|
||||
|
||||
/** A one-shot result banner shown above the list. */
|
||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||
|
||||
data class State(
|
||||
val devices: UiState<List<TrustedDeviceDto>> = UiState.Loading,
|
||||
val busy: Boolean = false,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(devices = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust the current device; persist the returned token so future logins skip 2FA. */
|
||||
fun trustThisDevice() {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val outcome = accountRepository.trustThisDevice(deviceNameProvider.deviceName())) {
|
||||
is TrustOutcome.Trusted -> {
|
||||
// Bind the fresh token to the signed-in username (mirrors the login path).
|
||||
val username = sessionManager.state.value.let {
|
||||
(it as? com.runicgateway.app.core.auth.Session.SignedIn)?.user?.username
|
||||
}
|
||||
if (outcome.trustToken != null && username != null) {
|
||||
authRepository.saveTrustToken(username, outcome.trustToken)
|
||||
}
|
||||
finish(true, R.string.trusted_devices_trusted)
|
||||
reload()
|
||||
}
|
||||
is TrustOutcome.LimitReached -> finish(false, R.string.trusted_devices_limit)
|
||||
TrustOutcome.NetworkError -> finish(false, R.string.error_network)
|
||||
TrustOutcome.ServerError -> finish(false, R.string.trusted_devices_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun revoke(id: Long) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.revokeTrustedDevice(id)) {
|
||||
is ApiResult.Ok -> {
|
||||
finish(true, R.string.trusted_devices_revoked)
|
||||
reload()
|
||||
}
|
||||
else -> finish(false, R.string.trusted_devices_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun revokeAll() {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.revokeAllTrustedDevices()) {
|
||||
is ApiResult.Ok -> {
|
||||
// Every device is untrusted now, including this one — drop the local token.
|
||||
authRepository.clearTrustToken()
|
||||
finish(true, R.string.trusted_devices_revoked_all)
|
||||
reload()
|
||||
}
|
||||
else -> finish(false, R.string.trusted_devices_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
private suspend fun reload() {
|
||||
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
|
||||
}
|
||||
|
||||
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
|
||||
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
|
||||
}
|
||||
@@ -36,6 +36,10 @@ fun LoadingView(modifier: Modifier = Modifier) {
|
||||
/**
|
||||
* Whole-screen error state with a friendly, kind-specific message and a Retry
|
||||
* button (§7). Copy is resolved from string resources so it stays localizable.
|
||||
*
|
||||
* [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).
|
||||
*/
|
||||
@Composable
|
||||
fun ErrorView(
|
||||
@@ -53,15 +57,20 @@ fun ErrorView(
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.padding(top = 16.dp).width(160.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.action_retry))
|
||||
if (isRetryable(kind)) {
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.padding(top = 16.dp).width(160.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.action_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */
|
||||
fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE
|
||||
|
||||
/** Centered informational message for an empty list (§7). */
|
||||
@Composable
|
||||
fun EmptyView(message: String, modifier: Modifier = Modifier) {
|
||||
@@ -83,5 +92,6 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
|
||||
ErrorKind.NOT_FOUND -> R.string.error_not_found
|
||||
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.SERVER -> R.string.error_server
|
||||
}
|
||||
|
||||
@@ -14,24 +14,22 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.runicgateway.app.ui.theme.ShardCardBottom
|
||||
import com.runicgateway.app.ui.theme.ShardCardTop
|
||||
import com.runicgateway.app.ui.theme.LocalShardPalette
|
||||
import com.runicgateway.app.ui.theme.LocalShardStructure
|
||||
import com.runicgateway.app.ui.theme.ShardDanger
|
||||
import com.runicgateway.app.ui.theme.ShardDangerBg
|
||||
import com.runicgateway.app.ui.theme.ShardElevated
|
||||
import com.runicgateway.app.ui.theme.ShardFaint
|
||||
import com.runicgateway.app.ui.theme.ShardOutline
|
||||
import com.runicgateway.app.ui.theme.ShardPillBg
|
||||
import com.runicgateway.app.ui.theme.ShardPillFg
|
||||
import com.runicgateway.app.ui.theme.ShardSuccess
|
||||
import com.runicgateway.app.ui.theme.ShardSuccessBg
|
||||
import com.runicgateway.app.ui.theme.ShardSuccessDot
|
||||
@@ -43,6 +41,14 @@ import com.runicgateway.app.ui.theme.ShardWarningBg
|
||||
* (docs/android/PLAN.md §M5): the recurring pill, section-label, feature-card,
|
||||
* and stat-bar motifs the mockup repeats across screens. Pure presentation —
|
||||
* no state, no data dependencies — so any screen can adopt them.
|
||||
*
|
||||
* This is the app's **only** file that reaches past `MaterialTheme` for a
|
||||
* themable value, so it is the one place M12 had to migrate: the surface, line
|
||||
* and accent tokens come from [LocalShardPalette] and the pill shape and card
|
||||
* depth from [LocalShardStructure], both following the shard's theme
|
||||
* (THEMING_AND_NAV.md §5.1, §5.2, §5.4). The success/warning/danger constants
|
||||
* stay imported directly — those are semantic and never themed, mirroring the
|
||||
* server's `FIXED_TOKENS`.
|
||||
*/
|
||||
|
||||
/** Semantic tone for a [StatusPill] / [OnlineDot]. */
|
||||
@@ -50,21 +56,26 @@ enum class PillTone { Success, Warning, Danger, Neutral, Info }
|
||||
|
||||
private data class PillColors(val fg: Color, val bg: Color)
|
||||
|
||||
@Composable
|
||||
private fun toneColors(tone: PillTone): PillColors = when (tone) {
|
||||
PillTone.Success -> PillColors(ShardSuccess, ShardSuccessBg)
|
||||
PillTone.Warning -> PillColors(ShardWarning, ShardWarningBg)
|
||||
PillTone.Danger -> PillColors(ShardDanger, ShardDangerBg)
|
||||
PillTone.Neutral, PillTone.Info -> PillColors(ShardPillFg, ShardPillBg)
|
||||
PillTone.Neutral, PillTone.Info ->
|
||||
LocalShardPalette.current.let { PillColors(it.pillFg, it.pillBg) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A small uppercase status chip — "Live", "Up", "Enabled", "IDOC", a role — with a
|
||||
* rounded filled background tinted by [tone]. Mirrors the mockup's pill badges.
|
||||
*
|
||||
* The one place `--radius-pill` lands: the app's other two [CircleShape] uses are
|
||||
* 8dp status dots, and a dot stays a dot however square the shard makes its site.
|
||||
*/
|
||||
@Composable
|
||||
fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) {
|
||||
val c = toneColors(tone)
|
||||
Surface(color = c.bg, shape = CircleShape, modifier = modifier) {
|
||||
Surface(color = c.bg, shape = LocalShardStructure.current.pill, modifier = modifier) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -81,7 +92,7 @@ fun OnlineDot(tone: PillTone, modifier: Modifier = Modifier) {
|
||||
PillTone.Success -> ShardSuccessDot
|
||||
PillTone.Warning -> ShardWarning
|
||||
PillTone.Danger -> ShardDanger
|
||||
PillTone.Neutral, PillTone.Info -> ShardFaint
|
||||
PillTone.Neutral, PillTone.Info -> LocalShardPalette.current.faint
|
||||
}
|
||||
Box(modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
}
|
||||
@@ -95,7 +106,7 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = ShardFaint,
|
||||
color = LocalShardPalette.current.faint,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -104,6 +115,11 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
* The elevated "feature" card: a vertical blue gradient with a hairline outline and
|
||||
* soft shadow, used for the home status card, the shard-online banner, and the
|
||||
* vendor card. [content] is laid out in a padded [Column].
|
||||
*
|
||||
* The radius is `MaterialTheme.shapes.medium` rather than the literal 12dp it was
|
||||
* built with — the same value, now following `--radius-card`'s ratio (§5.2). The
|
||||
* shadow this doc always claimed is finally drawn, at the depth `--shadow-card`
|
||||
* resolves to (§5.4).
|
||||
*/
|
||||
@Composable
|
||||
fun FeatureCard(
|
||||
@@ -111,17 +127,40 @@ fun FeatureCard(
|
||||
contentPadding: Int = 18,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val palette = LocalShardPalette.current
|
||||
val shape = MaterialTheme.shapes.medium
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Brush.verticalGradient(listOf(ShardCardTop, ShardCardBottom)))
|
||||
.border(1.dp, ShardOutline, RoundedCornerShape(12.dp)),
|
||||
.shadow(LocalShardStructure.current.cardElevation, shape)
|
||||
.clip(shape)
|
||||
.background(Brush.verticalGradient(listOf(palette.cardTop, palette.cardBottom)))
|
||||
.border(1.dp, palette.outline, shape),
|
||||
) {
|
||||
Column(Modifier.padding(contentPadding.dp), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Material [Card] at the shard's resolved depth — the app's standard card, and
|
||||
* the reason every screen's `Card(` became a `ShardCard(`.
|
||||
*
|
||||
* `Card` takes its elevation as a **default argument**, not from the theme, so
|
||||
* unlike the color scheme and the shape scale there is no way to make
|
||||
* `--shadow-card` reach ~24 call sites without a wrapper. Passing
|
||||
* [CardDefaults.cardElevation] at each site instead would have put the same line
|
||||
* in eighteen files and let one drift. A `Card(` outside this file is therefore a
|
||||
* card the shard cannot theme, which makes the invariant greppable.
|
||||
*/
|
||||
@Composable
|
||||
fun ShardCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = LocalShardStructure.current.cardElevation),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A slim rounded meter (vitals / skills). [fraction] is clamped to 0..1; the fill is
|
||||
* the slate accent over a bordered dark track.
|
||||
@@ -129,13 +168,14 @@ fun FeatureCard(
|
||||
@Composable
|
||||
fun StatBar(fraction: Float, modifier: Modifier = Modifier) {
|
||||
val pct = fraction.coerceIn(0f, 1f)
|
||||
val palette = LocalShardPalette.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(ShardElevated)
|
||||
.border(1.dp, ShardOutline, RoundedCornerShape(3.dp)),
|
||||
.background(palette.elevated)
|
||||
.border(1.dp, palette.outline, RoundedCornerShape(3.dp)),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
|
||||
@@ -6,6 +6,9 @@ package com.runicgateway.app.ui.navigation
|
||||
import androidx.annotation.StringRes
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.data.repository.ShardFeature
|
||||
import com.runicgateway.app.data.repository.ShardFeatures
|
||||
import com.runicgateway.app.data.repository.canSee
|
||||
|
||||
/**
|
||||
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
|
||||
@@ -21,7 +24,12 @@ enum class MenuAccess {
|
||||
/** Visible to any signed-in account (§5, "My Account"). */
|
||||
SIGNED_IN,
|
||||
|
||||
/** Visible only to a player — the linked game-data groups (§6.3). */
|
||||
/**
|
||||
* The linked game-data groups (§6.3). Visible to any player **or** staff:
|
||||
* staff are a superset of players (all player abilities plus their staff
|
||||
* tools), and the backend's player self-service surface is role-agnostic, so
|
||||
* a signed-in admin/editor/moderator sees + uses their own characters too.
|
||||
*/
|
||||
PLAYER,
|
||||
|
||||
/** Visible to any staff role (admin/editor/moderator) — the M10 staff surface (§1). */
|
||||
@@ -35,6 +43,15 @@ data class MenuEntry(
|
||||
val route: String,
|
||||
@param:StringRes val labelRes: Int,
|
||||
val access: MenuAccess = MenuAccess.PUBLIC,
|
||||
/**
|
||||
* For a shard-derived surface, the visibility feature it belongs to (M11).
|
||||
*
|
||||
* Session role is not the only gate on these: an admin can switch a feature off
|
||||
* or raise its audience above the caller's rung, so the entry is filtered by
|
||||
* `GET /public/shard/features` as well as by [access]. `null` means the entry
|
||||
* isn't shard-derived and only [access] applies.
|
||||
*/
|
||||
val feature: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -46,7 +63,13 @@ val APP_MENU: List<MenuEntry> = listOf(
|
||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||
MenuEntry(Routes.SHARD, R.string.menu_shard),
|
||||
MenuEntry(Routes.SHARD, R.string.menu_shard, feature = ShardFeature.STATUS),
|
||||
// Protocol 3.0 shard content (M11). Each hides when the shard doesn't publish it,
|
||||
// which for a brand-new install is every one of them until the plugin has swept.
|
||||
MenuEntry(Routes.SHARD_RULES, R.string.menu_rules, feature = ShardFeature.RULESET),
|
||||
MenuEntry(Routes.ATLAS, R.string.menu_atlas, feature = ShardFeature.ATLAS),
|
||||
MenuEntry(Routes.SHARD_LEADERBOARDS, R.string.menu_leaderboards, feature = ShardFeature.LEADERBOARDS),
|
||||
MenuEntry(Routes.SHARD_MARKET, R.string.menu_market, feature = ShardFeature.MARKET),
|
||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
||||
@@ -62,16 +85,28 @@ val APP_MENU: List<MenuEntry> = listOf(
|
||||
)
|
||||
|
||||
/**
|
||||
* The entries the given [session] may see. Pure + side-effect-free so the access
|
||||
* gating is unit-tested without Compose.
|
||||
* The entries the given [session] may see, given the shard [features] it may reach.
|
||||
* Pure + side-effect-free so the gating is unit-tested without Compose.
|
||||
*
|
||||
* Two independent filters, and both must pass:
|
||||
*
|
||||
* - [MenuEntry.access] against the session — who the caller is.
|
||||
* - [MenuEntry.feature] against the shard's live visibility config — what this shard
|
||||
* publishes at all (M11). `null` [features] means the answer isn't known yet and
|
||||
* every shard entry shows; see [canSee] for why that direction is deliberate.
|
||||
*/
|
||||
fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry> =
|
||||
fun visibleEntries(
|
||||
entries: List<MenuEntry>,
|
||||
session: Session,
|
||||
features: ShardFeatures? = null,
|
||||
): List<MenuEntry> =
|
||||
entries.filter { entry ->
|
||||
when (entry.access) {
|
||||
val allowedByRole = when (entry.access) {
|
||||
MenuAccess.PUBLIC -> true
|
||||
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
||||
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
|
||||
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
|
||||
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
||||
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
||||
}
|
||||
allowedByRole && (entry.feature == null || canSee(features, entry.feature))
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ object Routes {
|
||||
const val LOGIN = "login"
|
||||
const val ACCOUNT = "account"
|
||||
|
||||
/** MFA management, reached from Account (TRUSTED_DEVICES_MFA.md). Signed-in only. */
|
||||
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
|
||||
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
|
||||
|
||||
/** Opt-in push notification settings (§11, signed-in). */
|
||||
const val NOTIFICATIONS = "notifications"
|
||||
|
||||
@@ -30,6 +34,18 @@ object Routes {
|
||||
const val SHARD_GOVERNORS = "shard/governors"
|
||||
const val SHARD_HOUSES = "shard/houses"
|
||||
|
||||
/**
|
||||
* Protocol 3.0 shard content (M11), each gated by its own visibility feature. The
|
||||
* atlas is not under `shard/` on the wire (`/public/atlas`) because it is static
|
||||
* content rather than live state, but it is a peer of these in the app's nav.
|
||||
*/
|
||||
const val SHARD_RULES = "shard/rules"
|
||||
const val SHARD_LEADERBOARDS = "shard/leaderboards"
|
||||
const val SHARD_MARKET = "shard/market"
|
||||
const val SHARD_MARKET_VENDOR = "shard/market/{serial}"
|
||||
const val ATLAS = "atlas"
|
||||
const val ATLAS_CREATURE = "atlas/{slug}"
|
||||
|
||||
/** Player game-data groups (§6.3, player-only). Distinct from the public shard boards. */
|
||||
const val PLAYER_CHARACTERS = "player/characters"
|
||||
const val PLAYER_VENDORS = "player/vendors"
|
||||
@@ -68,6 +84,12 @@ object Routes {
|
||||
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
||||
fun playerChar(serial: String) = "player/char/$serial"
|
||||
|
||||
/** One player vendor's shop, by in-game (hex) serial. */
|
||||
fun marketVendor(serial: String) = "shard/market/$serial"
|
||||
|
||||
/** One creature's atlas page, by slug. */
|
||||
fun atlasCreature(slug: String) = "atlas/$slug"
|
||||
|
||||
/**
|
||||
* The in-app destination a tapped push notification deep-links to (§11, M7
|
||||
* Part 2 work item 7). Maps a stream id to the screen that shows its content;
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
@@ -29,6 +28,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/** News hub with category tabs and a post list (PLAN.md §6.1). */
|
||||
@Composable
|
||||
@@ -82,7 +82,7 @@ private fun PostList(
|
||||
|
||||
@Composable
|
||||
private fun PostRow(post: PostDto, onClick: () -> Unit) {
|
||||
Card(
|
||||
ShardCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -26,6 +25,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.CharPointsDto
|
||||
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||
import com.runicgateway.app.data.api.dto.CharStatsDto
|
||||
import com.runicgateway.app.data.api.dto.EquipmentDto
|
||||
@@ -36,6 +36,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatBar
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
@@ -71,6 +72,7 @@ private fun CharacterSheet(char: CharProfileDto, modifier: Modifier = Modifier)
|
||||
char.stats?.let { AttributesBlock(it) }
|
||||
char.stats?.resist?.let { ResistancesBlock(it) }
|
||||
SkillsBlock(char.skills)
|
||||
PointsBlock(displayPoints(char))
|
||||
EquipmentBlock(char.equipment)
|
||||
}
|
||||
}
|
||||
@@ -215,6 +217,47 @@ private fun SkillsBlock(skills: List<SkillDto>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loyalty & points standings (Protocol 3.0 §7.3). Renders nothing at all for a
|
||||
* character that has earned nothing anywhere, which is a normal state.
|
||||
*
|
||||
* Only a system with a real cap gets a meter: an uncapped score
|
||||
* ([CharPointsDto.maxPoints] `0`, the common case on a real shard) has nothing to be
|
||||
* a fraction of, and a full-width bar would imply a completion that doesn't exist.
|
||||
*/
|
||||
@Composable
|
||||
private fun PointsBlock(points: List<CharPointsDto>) {
|
||||
if (points.isEmpty()) return
|
||||
SheetCard(R.string.player_char_points) {
|
||||
points.forEach { entry ->
|
||||
val cap = entry.cap
|
||||
val score = entry.points ?: 0L
|
||||
Column(Modifier.padding(vertical = 5.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
// `rank` is absent unless the shard opts in; absent and
|
||||
// "unranked" are different, so the suffix only appears when sent.
|
||||
entry.rank?.let { stringResource(R.string.player_char_points_ranked, pointsLabel(entry), it) }
|
||||
?: pointsLabel(entry),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
cap?.let { stringResource(R.string.player_char_points_of, score, it) } ?: score.toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
if (cap != null) StatBar((score.toDouble() / cap).coerceIn(0.0, 1.0).toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
||||
@@ -223,7 +266,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
||||
equipment.forEach { item ->
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Text(
|
||||
item.layer ?: stringResource(R.string.player_char_item),
|
||||
item.label ?: stringResource(R.string.player_char_item),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
val meta = listOfNotNull(
|
||||
@@ -246,7 +289,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
||||
|
||||
@Composable
|
||||
private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||
content()
|
||||
@@ -266,23 +309,58 @@ internal fun formatSkill(value: Double): String =
|
||||
/**
|
||||
* The human-readable title chips for a [TitlesDto] (parity with the website's
|
||||
* `CharacterSheet.jsx#displayTitles`): fame/karma, skill title, and the selected
|
||||
* reward title — but only if it is a literal string, not a bare cliloc number
|
||||
* (the app ships no cliloc table). De-duplicated, blanks dropped.
|
||||
* reward title.
|
||||
*
|
||||
* Reward entries arrive as either a literal or a cliloc number in string form. The
|
||||
* server now resolves the numeric ones into `rewardResolved`, a **parallel** array
|
||||
* (see `docs/website/CLILOCS.md`), so the mapping below is index-preserving: an entry
|
||||
* that didn't resolve becomes null and is skipped, but must not shift the `selected`
|
||||
* index onto its neighbour. A number with no resolution is still skipped rather than
|
||||
* rendered as a raw id, which is also the whole behavior on a shard that configures
|
||||
* no cliloc table.
|
||||
*
|
||||
* Falling back to the first title that resolved (rather than showing nothing) matters
|
||||
* when the *selected* one is the unresolved entry. De-duplicated, blanks dropped.
|
||||
*/
|
||||
internal fun displayTitles(titles: TitlesDto?): List<String> {
|
||||
if (titles == null) return emptyList()
|
||||
val out = mutableListOf<String>()
|
||||
titles.fameKarma?.let { out.add(it) }
|
||||
titles.skill?.let { out.add(it) }
|
||||
val reward = titles.reward
|
||||
val sel = titles.selected ?: -1
|
||||
val candidate = when {
|
||||
sel in reward.indices -> reward[sel]
|
||||
else -> reward.firstOrNull { it.isNotBlank() && !it.all(Char::isDigit) }
|
||||
val reward = titles.reward.mapIndexed { i, raw ->
|
||||
titles.rewardResolved.getOrNull(i)
|
||||
?: raw.takeUnless { it.isBlank() || it.all(Char::isDigit) }
|
||||
}
|
||||
if (candidate != null && candidate.isNotBlank() && !candidate.all(Char::isDigit)) out.add(candidate)
|
||||
val candidate = reward.getOrNull(titles.selected ?: -1) ?: reward.firstNotNullOfOrNull { it }
|
||||
if (!candidate.isNullOrBlank()) out.add(candidate)
|
||||
return out.filter { it.isNotBlank() }.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* A point system's display name: the shard's own [CharPointsDto.nameString] when it
|
||||
* has one, else the humanised `PointsType` key.
|
||||
*
|
||||
* The fallback is the PRIMARY path, not a defensive nicety — most systems name
|
||||
* themselves with a cliloc, so `nameString` comes back null for four of five boards
|
||||
* on a real shard (`docs/link/v3.md` §7.5). Parity with the website's
|
||||
* `humanisePoints`.
|
||||
*/
|
||||
internal fun pointsLabel(entry: CharPointsDto): String {
|
||||
entry.nameString?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
val key = entry.system.orEmpty()
|
||||
return key
|
||||
.replace(Regex("([a-z0-9])([A-Z])"), "$1 $2")
|
||||
.replaceFirstChar { it.uppercaseChar() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The points block, best standing first, dropping systems the character has no score
|
||||
* in. Guarded for an older shard plugin that sends no `points` block at all.
|
||||
*/
|
||||
internal fun displayPoints(char: CharProfileDto): List<CharPointsDto> =
|
||||
char.points
|
||||
.filter { (it.points ?: 0L) > 0L }
|
||||
.sortedByDescending { it.points ?: 0L }
|
||||
|
||||
private fun jsonText(element: kotlinx.serialization.json.JsonElement): String =
|
||||
runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() }
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -39,6 +38,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
@@ -90,7 +90,7 @@ fun CharactersScreen(
|
||||
@Composable
|
||||
private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||
var code by rememberSaveable { mutableStateOf("") }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_link_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
@@ -121,7 +121,7 @@ private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersView
|
||||
private fun CreateAccountCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||
var account by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_create_title), style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(
|
||||
@@ -204,7 +204,7 @@ private fun RosterError(kind: ErrorKind, onRetry: () -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) {
|
||||
Card(
|
||||
ShardCard(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -28,6 +27,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* The player's own houses with home/decay status (PLAN.md §6.3), text-only. An
|
||||
@@ -60,7 +60,7 @@ fun MyHousesScreen(
|
||||
|
||||
@Composable
|
||||
private fun HouseCard(house: PlayerHouseDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -31,6 +30,7 @@ import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@@ -79,7 +79,7 @@ fun VendorsScreen(
|
||||
|
||||
@Composable
|
||||
private fun SalesCard(sales: UiState<List<VendorSaleDto>>) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_sales_title), style = MaterialTheme.typography.titleMedium)
|
||||
when (sales) {
|
||||
@@ -185,7 +185,7 @@ private fun VendorError(kind: ErrorKind, onRetry: () -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun VendorCard(vendor: VendorDto) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
vendor.shopName ?: stringResource(R.string.player_vendor_fallback),
|
||||
|
||||
@@ -8,6 +8,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.data.repository.AuthRepository
|
||||
import com.runicgateway.app.data.repository.ShardFeatures
|
||||
import com.runicgateway.app.data.repository.ShardFeaturesRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -23,10 +25,28 @@ import javax.inject.Inject
|
||||
class SessionViewModel @Inject constructor(
|
||||
sessionManager: SessionManager,
|
||||
private val authRepository: AuthRepository,
|
||||
shardFeaturesRepository: ShardFeaturesRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val session: StateFlow<Session> = sessionManager.state
|
||||
|
||||
/**
|
||||
* Which shard features this viewer may reach (M11). Held here beside [session]
|
||||
* because it answers the same question for the same consumer: what the shared
|
||||
* menu reveals. Role and feature config are independent gates — see
|
||||
* [com.runicgateway.app.ui.navigation.visibleEntries].
|
||||
*/
|
||||
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
|
||||
|
||||
init {
|
||||
// The answer is per-viewer, so it is re-resolved on every session change.
|
||||
// A StateFlow conflates equal values, so a resume revalidation that returns
|
||||
// the same user does not refetch — only a real sign-in/out/role change does.
|
||||
viewModelScope.launch {
|
||||
session.collect { shardFeaturesRepository.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-validate the cached role against the backend on app resume. */
|
||||
fun revalidate() {
|
||||
viewModelScope.launch { authRepository.revalidate() }
|
||||
|
||||
301
app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt
Normal file
301
app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt
Normal file
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
|
||||
import com.runicgateway.app.data.api.dto.AtlasPlaceDto
|
||||
import com.runicgateway.app.data.api.dto.AtlasSpawnerDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* The spawn atlas / bestiary (PLAN.md §9 M11): "where do I find X".
|
||||
*
|
||||
* The whole point of the feature is the placement transform the server does — a spawn
|
||||
* at 5411,1234 becomes *"Despise, Felucca"* — so a row leads with where a creature is
|
||||
* found, not with coordinates.
|
||||
*/
|
||||
@Composable
|
||||
fun AtlasScreen(
|
||||
onOpenCreature: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AtlasViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier.fillMaxSize()) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = viewModel::onQueryChange,
|
||||
label = { Text(stringResource(R.string.atlas_search_label)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { viewModel.search() }),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
|
||||
is UiState.Success -> {
|
||||
if (s.data.creatures.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.atlas_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
|
||||
) {
|
||||
items(s.data.creatures, key = { it.slug.orEmpty() }) { creature ->
|
||||
CreatureCard(creature, onOpenCreature)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreatureCard(creature: AtlasCreatureDto, onOpenCreature: (String) -> Unit) {
|
||||
val slug = creature.slug
|
||||
ShardCard(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (slug != null) Modifier.clickable { onOpenCreature(slug) } else Modifier),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = creature.name ?: slug.orEmpty(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// `points` is a COUNT of spawners on this route; `spawners` is the list,
|
||||
// and only the detail route sends it.
|
||||
creature.points?.let {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.atlas_spawner_count, it, it),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
facetSummary(creature)?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One creature: every spawner, where it stands, and what shares its spawns. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun AtlasCreatureScreen(
|
||||
slug: String,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AtlasCreatureViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(slug) { viewModel.load(slug) }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::retry, modifier = modifier)
|
||||
is UiState.Success -> {
|
||||
val creature = s.data
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
|
||||
) {
|
||||
item {
|
||||
Column {
|
||||
Text(
|
||||
creature.name ?: creature.slug.orEmpty(),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
creature.total?.let {
|
||||
Text(
|
||||
stringResource(R.string.atlas_total_alive, it),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (creature.facets.isNotEmpty()) {
|
||||
FlowRow(
|
||||
Modifier.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
creature.facets.entries.sortedBy { it.key }.forEach { (facet, count) ->
|
||||
StatusPill(
|
||||
text = stringResource(R.string.atlas_facet_count, facet, count),
|
||||
tone = PillTone.Neutral,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The aggregate comes first: "where is it" is the question, and the
|
||||
// individual coordinates below are the follow-up. Same ordering as web.
|
||||
if (creature.places.isNotEmpty()) {
|
||||
item { SectionLabel(stringResource(R.string.atlas_section_places)) }
|
||||
items(
|
||||
creature.places,
|
||||
key = { "${it.facet.orEmpty()}:${it.label.orEmpty()}" },
|
||||
) { place ->
|
||||
PlaceRow(place)
|
||||
}
|
||||
}
|
||||
if (creature.spawners.isNotEmpty()) {
|
||||
item { SectionLabel(stringResource(R.string.atlas_section_spawners)) }
|
||||
items(creature.spawners, key = { it.id ?: it.hashCode().toLong() }) { spawner ->
|
||||
SpawnerRow(spawner)
|
||||
}
|
||||
if (creature.spawnersTruncated) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.atlas_spawners_truncated),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (creature.alsoHere.isNotEmpty()) {
|
||||
item { SectionLabel(stringResource(R.string.atlas_section_also_here)) }
|
||||
item {
|
||||
Text(
|
||||
creature.alsoHere.mapNotNull { it.name ?: it.slug }.joinToString(", "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceRow(place: AtlasPlaceDto) {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
text = placeLabel(place),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
val meta = listOfNotNull(
|
||||
place.facet,
|
||||
place.spawners?.let { pluralStringResource(R.plurals.atlas_spawner_count, it, it) },
|
||||
place.maxAlive?.let { stringResource(R.string.atlas_place_max_alive, it) },
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpawnerRow(spawner: AtlasSpawnerDto) {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
text = spawnerPlace(spawner),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
val meta = listOfNotNull(
|
||||
spawner.maxCount?.let { stringResource(R.string.atlas_max_count, it) },
|
||||
// Seconds, normalised server-side — the raw XmlSpawner values are minutes
|
||||
// OR seconds per record.
|
||||
formatRespawn(spawner.minDelay, spawner.maxDelay)
|
||||
?.let { stringResource(R.string.atlas_respawn, it) },
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Where a spawner stands, preferring the server's own placement label — the
|
||||
* point-in-rect transform is what turns a coordinate into "Despise, Felucca" and is
|
||||
* the reason this feature exists. Falls back through region, landmark, and finally the
|
||||
* raw coordinates, which is honest rather than useless for the ~17% of spawns that
|
||||
* resolve to no named place.
|
||||
*/
|
||||
internal fun spawnerPlace(spawner: AtlasSpawnerDto): String {
|
||||
spawner.label?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
val place = spawner.region ?: spawner.landmark
|
||||
val facet = spawner.facet
|
||||
return when {
|
||||
place != null && facet != null -> "$place, $facet"
|
||||
place != null -> place
|
||||
spawner.x != null && spawner.y != null ->
|
||||
listOfNotNull(facet, "${spawner.x}, ${spawner.y}").joinToString(" ")
|
||||
else -> facet.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of an aggregated place. [AtlasPlaceDto.label] is already the server's
|
||||
* resolved answer and falls back to "Wilderness" there, so the only case left here is
|
||||
* a place that carried no label at all — then the facet is better than nothing.
|
||||
*/
|
||||
internal fun placeLabel(place: AtlasPlaceDto): String =
|
||||
place.label?.takeIf { it.isNotBlank() } ?: place.facet.orEmpty()
|
||||
|
||||
/**
|
||||
* A creature's facets as one line, most spawners first — "where is it *mostly*" is the
|
||||
* question a search result answers.
|
||||
*/
|
||||
internal fun facetSummary(creature: AtlasCreatureDto): String? {
|
||||
if (creature.facets.isEmpty()) return null
|
||||
return creature.facets.entries
|
||||
.sortedByDescending { it.value }
|
||||
.joinToString(", ") { it.key }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
|
||||
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toShardUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The spawn atlas / bestiary (PLAN.md §9 M11, `docs/link/v3.md` §6): where each
|
||||
* creature spawns, derived server-side from the shard's own data files.
|
||||
*
|
||||
* Static shard **content**, not live state — it does not go offline with the sidecar,
|
||||
* and it lives under `/public/atlas`, not `/public/shard`. Unlike the shard routes it
|
||||
* IS site-mode gated, so a site in maintenance withholds it independently.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AtlasViewModel @Inject constructor(
|
||||
private val repository: ShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<UiState<AtlasCreaturePageDto>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<AtlasCreaturePageDto>> = _state.asStateFlow()
|
||||
|
||||
private val _query = MutableStateFlow("")
|
||||
val query: StateFlow<String> = _query.asStateFlow()
|
||||
|
||||
private val _facet = MutableStateFlow<String?>(null)
|
||||
val facet: StateFlow<String?> = _facet.asStateFlow()
|
||||
|
||||
/**
|
||||
* The facets this shard actually has. Discovered from the atlas itself — a shard
|
||||
* may add, replace or rename facets when its maps change, so nothing here may name
|
||||
* one (`v3.md` §6.1 R2).
|
||||
*/
|
||||
private val _facets = MutableStateFlow<List<String>>(emptyList())
|
||||
val facets: StateFlow<List<String>> = _facets.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun onQueryChange(value: String) {
|
||||
_query.value = value
|
||||
}
|
||||
|
||||
fun onFacetChange(value: String?) {
|
||||
if (value == _facet.value) return
|
||||
_facet.value = value
|
||||
search()
|
||||
}
|
||||
|
||||
fun search() = load()
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
val page = repository.atlasCreatures(query = _query.value, facet = _facet.value)
|
||||
if (page is ApiResult.Ok && _facets.value.isEmpty()) {
|
||||
// Only the first successful page needs to establish the filter options;
|
||||
// a filtered page would otherwise narrow them to its own results.
|
||||
_facets.value = page.data.creatures
|
||||
.flatMap { it.facets.keys }
|
||||
.distinct()
|
||||
.sorted()
|
||||
}
|
||||
_state.value = page.toShardUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One creature's detail page: every spawner, and what else shares them. */
|
||||
@HiltViewModel
|
||||
class AtlasCreatureViewModel @Inject constructor(
|
||||
private val repository: ShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<UiState<AtlasCreatureDto>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<AtlasCreatureDto>> = _state.asStateFlow()
|
||||
|
||||
private var slug: String? = null
|
||||
|
||||
fun load(slug: String) {
|
||||
this.slug = slug
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
_state.value = repository.atlasCreature(slug).toShardUiState()
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
slug?.let { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A respawn delay as text. **The API carries SECONDS** — XmlSpawner stores minutes
|
||||
* except when a delay doesn't divide into whole minutes, and the server's parser
|
||||
* normalises the two spellings so a `5` is never ambiguous here (`v3.md` §6.3).
|
||||
*
|
||||
* Pure, so the unit conversion is unit-tested rather than eyeballed on a page.
|
||||
*/
|
||||
internal fun formatRespawn(minSeconds: Int?, maxSeconds: Int?): String? {
|
||||
val lo = minSeconds ?: maxSeconds ?: return null
|
||||
val hi = maxSeconds ?: minSeconds ?: return null
|
||||
return if (lo == hi) humaniseSeconds(lo) else "${humaniseSeconds(lo)}–${humaniseSeconds(hi)}"
|
||||
}
|
||||
|
||||
private fun humaniseSeconds(seconds: Int): String = when {
|
||||
seconds < 60 -> "${seconds}s"
|
||||
seconds % 60 == 0 -> "${seconds / 60}m"
|
||||
else -> "${seconds / 60}m ${seconds % 60}s"
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */
|
||||
@@ -44,7 +44,7 @@ fun ChampsScreen(
|
||||
|
||||
@Composable
|
||||
private fun ChampCard(champ: ChampDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import com.runicgateway.app.ui.toShardUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -49,7 +49,7 @@ class ChampsViewModel @Inject constructor(
|
||||
board.seed(result.data)
|
||||
publish()
|
||||
}
|
||||
else -> _state.value = result.toUiState()
|
||||
else -> _state.value = result.toShardUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -33,6 +32,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/** The town-governor board (PLAN.md §6.2), live via `city.update`, with per-city history. */
|
||||
@Composable
|
||||
@@ -84,7 +84,7 @@ private fun CityCard(
|
||||
onExpand: () -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
Column(
|
||||
Modifier
|
||||
|
||||
@@ -11,7 +11,7 @@ import com.runicgateway.app.data.api.dto.GovernorDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import com.runicgateway.app.ui.toShardUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -55,7 +55,7 @@ class GovernorsViewModel @Inject constructor(
|
||||
board.seed(result.data)
|
||||
publish()
|
||||
}
|
||||
else -> _state.value = result.toUiState()
|
||||
else -> _state.value = result.toShardUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -20,6 +19,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/** The guild board (PLAN.md §6.2), live via SSE deltas. */
|
||||
@Composable
|
||||
@@ -42,7 +42,7 @@ fun GuildsScreen(
|
||||
|
||||
@Composable
|
||||
private fun GuildCard(guild: GuildDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import com.runicgateway.app.ui.toShardUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -50,7 +50,7 @@ class GuildsViewModel @Inject constructor(
|
||||
board.seed(result.data)
|
||||
publish()
|
||||
}
|
||||
else -> _state.value = result.toUiState()
|
||||
else -> _state.value = result.toShardUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */
|
||||
@@ -44,7 +44,7 @@ fun HousesScreen(
|
||||
|
||||
@Composable
|
||||
private fun HouseCard(house: HouseDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import com.runicgateway.app.ui.toShardUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -50,7 +50,7 @@ class HousesViewModel @Inject constructor(
|
||||
board.seed(result.data)
|
||||
publish()
|
||||
}
|
||||
else -> _state.value = result.toUiState()
|
||||
else -> _state.value = result.toShardUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||
import com.runicgateway.app.data.api.dto.PointsEntryDto
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* The points/loyalty leaderboards (PLAN.md §9 M11), one card per system, live via
|
||||
* `points.board` frames.
|
||||
*/
|
||||
@Composable
|
||||
fun LeaderboardsScreen(
|
||||
brand: BrandDto? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: LeaderboardsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||
|
||||
LiveBoardScreen(
|
||||
emptyMessage = stringResource(R.string.leaderboards_empty),
|
||||
state = state,
|
||||
connected = connected,
|
||||
onRetry = viewModel::load,
|
||||
key = { it.system.orEmpty() },
|
||||
modifier = modifier,
|
||||
) { board -> BoardCard(board, placeholderName(brand)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoardCard(board: PointsBoardDto, placeholderName: String) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = boardLabel(board),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
board.players?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.leaderboards_players, it),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
// A cap is worth stating only when there is one; most systems on a real
|
||||
// shard are uncapped (maxPoints 0), and "/ 0" would be nonsense.
|
||||
board.cap?.let {
|
||||
SectionLabel(stringResource(R.string.leaderboards_cap, it))
|
||||
}
|
||||
|
||||
if (board.top.isEmpty()) {
|
||||
// A board nobody has scored on still gets a row, so the page reads as a
|
||||
// set of standings waiting to be filled rather than a stack of blanks.
|
||||
// It is deliberately NOT shaped like an entry — no rank, no score, the
|
||||
// instance's own name — because a placeholder that looked like a real
|
||||
// standing would be a fabricated one. The first real entry replaces it.
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 3.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = placeholderName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.leaderboards_no_score),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.leaderboards_board_empty),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
} else {
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
board.top.forEach { entry -> EntryRow(entry) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntryRow(entry: PointsEntryDto) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 3.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.leaderboards_rank_name,
|
||||
entry.rank ?: 0,
|
||||
// The character name is admin-configurable — a shard can publish
|
||||
// standings without naming who holds them, so a nameless rank is a
|
||||
// valid row rather than a broken one.
|
||||
entry.name ?: stringResource(R.string.leaderboards_hidden_name),
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = (entry.points ?: 0L).toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A board's display name: the shard's own literal when it has one, else the humanised
|
||||
* `PointsType` key. The fallback is the PRIMARY path — four of five boards on a real
|
||||
* shard name themselves with a cliloc and send `nameString: null`.
|
||||
*/
|
||||
internal fun boardLabel(board: PointsBoardDto): String {
|
||||
board.nameString?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
return board.system.orEmpty()
|
||||
.replace(Regex("([a-z0-9])([A-Z])"), "$1 $2")
|
||||
.replaceFirstChar { it.uppercaseChar() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The name to stand in for an empty board: this instance's, falling back to the app
|
||||
* name — the same resolution the app bar uses, so a shard that publishes no branding
|
||||
* still reads as *something* rather than as a blank row.
|
||||
*
|
||||
* Pure and separate so the fallback order is testable; [BrandDto.name] can be present
|
||||
* but blank, which is a shard that set the key and left it empty.
|
||||
*/
|
||||
@Composable
|
||||
internal fun placeholderName(brand: BrandDto?): String =
|
||||
brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name)
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toShardUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The points/loyalty leaderboards (PLAN.md §9 M11, `docs/link/v3.md` §7): one board
|
||||
* per point currency the shard publishes, each with its top ranks.
|
||||
*
|
||||
* Served from the website's own store, so the page renders while the shard is down —
|
||||
* which matters more here than for live state: these are standings accumulated over
|
||||
* months, and blanking them during a restart would look like data loss.
|
||||
*
|
||||
* Kept live by `points.board` frames, one per system, merged in place by [LiveBoard].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class LeaderboardsViewModel @Inject constructor(
|
||||
private val repository: ShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val board = LiveBoard<PointsBoardDto> { it.system.orEmpty() }
|
||||
|
||||
private val _state = MutableStateFlow<UiState<List<PointsBoardDto>>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<List<PointsBoardDto>>> = _state.asStateFlow()
|
||||
|
||||
private val _connected = MutableStateFlow(false)
|
||||
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
collectLive()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.pointsBoards()) {
|
||||
is ApiResult.Ok -> {
|
||||
board.seed(result.data)
|
||||
publish()
|
||||
}
|
||||
else -> _state.value = result.toShardUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectLive() {
|
||||
viewModelScope.launch {
|
||||
repository.liveEvents().collect { event ->
|
||||
when (event) {
|
||||
is ShardStreamEvent.Open -> _connected.value = true
|
||||
is ShardStreamEvent.Closed -> _connected.value = false
|
||||
is ShardStreamEvent.Frame -> applyFrame(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
||||
// There is deliberately no `points.remove` on the wire: the system set is fixed
|
||||
// for a given shard build, the same argument `city.update` makes.
|
||||
if (frame.kind != "points.board") return
|
||||
repository.pointsBoardFrame(frame.data)?.let { board.upsert(it) }
|
||||
if (_state.value is UiState.Success) publish()
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
_state.value = UiState.Success(orderBoards(board.values()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Board display order: most-contested first, then by name, so the boards people
|
||||
* actually compete on lead. Pure, so the ordering is unit-tested.
|
||||
*
|
||||
* Boards the shard flags as not player-facing (`showOnGump = false`) are dropped —
|
||||
* that is the shard's own "is this for players?" signal and the plugin already filters
|
||||
* on it, so this only guards a shard configured to publish extras.
|
||||
*/
|
||||
internal fun orderBoards(boards: Collection<PointsBoardDto>): List<PointsBoardDto> =
|
||||
boards
|
||||
.filter { it.showOnGump }
|
||||
.sortedWith(
|
||||
compareByDescending<PointsBoardDto> { it.players ?: 0 }
|
||||
.thenBy { (it.nameString ?: it.system).orEmpty().lowercase() },
|
||||
)
|
||||
260
app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt
Normal file
260
app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt
Normal file
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.MarketListingDto
|
||||
import com.runicgateway.app.data.api.dto.MarketLocationDto
|
||||
import com.runicgateway.app.data.api.dto.MarketVendorDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* The shard-wide marketplace (PLAN.md §9 M11): search every player vendor's stock.
|
||||
*
|
||||
* The staleness line under the search box is required, not decoration — see
|
||||
* [MarketViewModel]. Results are listings, so a row names both the item and the shop
|
||||
* that sells it, and tapping it opens that shop.
|
||||
*/
|
||||
@Composable
|
||||
fun MarketScreen(
|
||||
onOpenVendor: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MarketViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val meta by viewModel.meta.collectAsStateWithLifecycle()
|
||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier.fillMaxSize()) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = viewModel::onQueryChange,
|
||||
label = { Text(stringResource(R.string.market_search_label)) },
|
||||
singleLine = true,
|
||||
// Searched on submit rather than per keystroke: this is the site's first
|
||||
// rate-limited public endpoint.
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { viewModel.search() }),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
meta?.staleAt?.let {
|
||||
SectionLabel(
|
||||
text = stringResource(R.string.market_staleness),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
|
||||
is UiState.Success -> {
|
||||
if (s.data.listings.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.market_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
|
||||
) {
|
||||
items(s.data.listings, key = { it.serial ?: it.hashCode().toString() }) { listing ->
|
||||
ListingCard(listing, onOpenVendor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListingCard(listing: MarketListingDto, onOpenVendor: (String) -> Unit) {
|
||||
val vendorSerial = listing.vendor?.serial
|
||||
ShardCard(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (vendorSerial != null) Modifier.clickable { onOpenVendor(vendorSerial) } else Modifier),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = listingTitle(listing)
|
||||
?: stringResource(R.string.market_unnamed_item, listing.itemId ?: 0),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.market_price, listing.price ?: 0L),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
val shop = listing.vendor?.shopName ?: listing.vendor?.ownerName
|
||||
if (shop != null) {
|
||||
Text(
|
||||
text = shop,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
locationLine(listing.vendor?.location)?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One shop and its stock. The only surface that can answer the two questions a result
|
||||
* list can't: how much of a truncated shop is published, and where a shop is when the
|
||||
* shard doesn't say.
|
||||
*/
|
||||
@Composable
|
||||
fun MarketVendorScreen(
|
||||
serial: String,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MarketVendorViewModel = hiltViewModel(),
|
||||
) {
|
||||
androidx.compose.runtime.LaunchedEffect(serial) { viewModel.load(serial) }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::retry, modifier = modifier)
|
||||
is UiState.Success -> VendorContent(s.data, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VendorContent(vendor: MarketVendorDto, modifier: Modifier = Modifier) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
|
||||
) {
|
||||
item {
|
||||
Column {
|
||||
Text(
|
||||
vendor.shopName ?: stringResource(R.string.market_unnamed_shop),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
vendor.ownerName?.let {
|
||||
Text(
|
||||
stringResource(R.string.market_owner, it),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
// A gated location is a real answer, not a blank: the shard has
|
||||
// this shop, it just doesn't publish where it stands.
|
||||
text = locationLine(vendor.location) ?: stringResource(R.string.market_location_hidden),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (vendor.truncated) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.market_truncated,
|
||||
vendor.count ?: vendor.items.size,
|
||||
vendor.total ?: 0,
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (vendor.items.isEmpty()) {
|
||||
item { Text(stringResource(R.string.market_shop_empty), style = MaterialTheme.typography.bodyMedium) }
|
||||
} else {
|
||||
items(vendor.items, key = { it.serial ?: it.hashCode().toString() }) { item ->
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = listingTitle(item) ?: stringResource(R.string.market_unnamed_item, item.itemId ?: 0),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.market_price, item.price ?: 0L),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* What to call a listing: a player-set name, else the server-resolved cliloc name,
|
||||
* else null so the caller can fall back to the item id. A shard with no cliloc table
|
||||
* configured legitimately publishes neither.
|
||||
*
|
||||
* A stack shows its count, since "12 × ingot" and "ingot" at the same price are very
|
||||
* different offers.
|
||||
*/
|
||||
internal fun listingTitle(listing: MarketListingDto): String? {
|
||||
val base = listing.label ?: return null
|
||||
val amount = listing.amount ?: 1
|
||||
return if (amount > 1) "$amount × $base" else base
|
||||
}
|
||||
|
||||
/**
|
||||
* A shop's whereabouts as one line, or null when the shard publishes no location —
|
||||
* which happens both because an admin gated the field and because the nesting means
|
||||
* the WHOLE block goes at once, never a half-populated one.
|
||||
*/
|
||||
internal fun locationLine(location: MarketLocationDto?): String? {
|
||||
if (location == null) return null
|
||||
val place = location.house ?: location.region
|
||||
val facet = location.map
|
||||
return when {
|
||||
place != null && facet != null -> "$place, $facet"
|
||||
place != null -> place
|
||||
facet != null -> facet
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.MarketMetaDto
|
||||
import com.runicgateway.app.data.api.dto.MarketPageDto
|
||||
import com.runicgateway.app.data.api.dto.MarketVendorDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toShardUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The shard-wide player-vendor marketplace (PLAN.md §9 M11, `docs/link/v3.md` §8):
|
||||
* search every shop's stock at once.
|
||||
*
|
||||
* **Not live, on purpose.** The `market` feature ships with its SSE fan-out disabled —
|
||||
* a firehose of full vendor inventories would be the site's biggest bandwidth consumer
|
||||
* and no screen needs it live — so this is a plain paginated read. It is also the
|
||||
* first genuinely **rate-limited** public endpoint, which is why the query is applied
|
||||
* on submit rather than on every keystroke.
|
||||
*
|
||||
* The staleness stamp from [meta] is not decoration: the shard sweeps vendors
|
||||
* round-robin, so a listing can legitimately be a full cycle behind, and a screen that
|
||||
* implied live prices would send someone to an item that sold twenty minutes ago.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class MarketViewModel @Inject constructor(
|
||||
private val repository: ShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<UiState<MarketPageDto>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<MarketPageDto>> = _state.asStateFlow()
|
||||
|
||||
private val _meta = MutableStateFlow<MarketMetaDto?>(null)
|
||||
val meta: StateFlow<MarketMetaDto?> = _meta.asStateFlow()
|
||||
|
||||
private val _query = MutableStateFlow("")
|
||||
val query: StateFlow<String> = _query.asStateFlow()
|
||||
|
||||
private val _sort = MutableStateFlow(ShardRepository.SORT_PRICE_ASC)
|
||||
val sort: StateFlow<String> = _sort.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun onQueryChange(value: String) {
|
||||
// Bounded to what the server accepts, so an over-long query is trimmed here
|
||||
// rather than bounced as a 400.
|
||||
_query.value = value.take(MAX_QUERY)
|
||||
}
|
||||
|
||||
fun onSortChange(value: String) {
|
||||
if (value == _sort.value) return
|
||||
_sort.value = value
|
||||
search()
|
||||
}
|
||||
|
||||
/** Run the current query. Called on submit, not per keystroke — this endpoint is rate-limited. */
|
||||
fun search() = load()
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
// Meta is secondary: the staleness banner and filter options are worth
|
||||
// having, but a failure there must not blank the results.
|
||||
_meta.value = (repository.marketMeta() as? ApiResult.Ok)?.data
|
||||
_state.value = repository.market(
|
||||
query = _query.value,
|
||||
sort = _sort.value,
|
||||
).toShardUiState()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The server rejects a longer `q`. */
|
||||
const val MAX_QUERY = 60
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One shop and its stock. The only surface that can render the two states a result
|
||||
* list cannot: a [MarketVendorDto.truncated] shop, and a location an admin has gated
|
||||
* away — which is a real answer ("the shard doesn't publish where this is") rather
|
||||
* than an empty coordinate.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class MarketVendorViewModel @Inject constructor(
|
||||
private val repository: ShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<UiState<MarketVendorDto>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<MarketVendorDto>> = _state.asStateFlow()
|
||||
|
||||
private var serial: String? = null
|
||||
|
||||
fun load(serial: String) {
|
||||
this.serial = serial
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
_state.value = repository.marketVendor(serial).toShardUiState()
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
serial?.let { load(it) }
|
||||
}
|
||||
}
|
||||
207
app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt
Normal file
207
app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt
Normal file
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.RulesetCapsDto
|
||||
import com.runicgateway.app.data.api.dto.RulesetDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* The shard ruleset (PLAN.md §9 M11): what this world is configured to do.
|
||||
*
|
||||
* Every block renders only when the shard published it — an omitted block means the
|
||||
* system is off, not that the value is unknown, so an empty section would assert
|
||||
* something false.
|
||||
*/
|
||||
@Composable
|
||||
fun RulesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RulesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> {
|
||||
val ruleset = s.data
|
||||
// A null body is a successful read of a shard that has never published its
|
||||
// ruleset — distinct from the feature being switched off, which is an error
|
||||
// state above.
|
||||
if (ruleset == null) {
|
||||
EmptyView(stringResource(R.string.rules_unpublished), modifier)
|
||||
} else {
|
||||
RulesetContent(ruleset, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RulesetContent(ruleset: RulesetDto, modifier: Modifier = Modifier) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
|
||||
) {
|
||||
item {
|
||||
RuleCard(stringResource(R.string.rules_section_shard)) {
|
||||
RuleRow(stringResource(R.string.rules_name), ruleset.shard)
|
||||
RuleRow(stringResource(R.string.rules_expansion), ruleset.expansion)
|
||||
// `connect` is the ruleset's one admin-configurable field: an operator
|
||||
// who published an address may still want it behind a login, so its
|
||||
// absence here is a setting, not a missing value.
|
||||
RuleRow(stringResource(R.string.rules_connect), ruleset.connect)
|
||||
}
|
||||
}
|
||||
if (ruleset.systems.isNotEmpty()) {
|
||||
item { SystemsCard(ruleset.systems) }
|
||||
}
|
||||
ruleset.caps?.let { caps -> item { CapsCard(caps) } }
|
||||
item {
|
||||
val accounts = ruleset.accounts
|
||||
val housing = ruleset.housing
|
||||
if (accounts != null || housing != null) {
|
||||
RuleCard(stringResource(R.string.rules_section_accounts)) {
|
||||
RuleRow(stringResource(R.string.rules_char_slots), accounts?.charSlots?.toString())
|
||||
RuleRow(stringResource(R.string.rules_per_ip), accounts?.perIp?.toString())
|
||||
RuleRow(
|
||||
stringResource(R.string.rules_house_limit),
|
||||
housing?.accountHouseLimit?.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ruleset.vendors?.let { vendors ->
|
||||
item {
|
||||
RuleCard(stringResource(R.string.rules_section_vendors)) {
|
||||
RuleRow(
|
||||
stringResource(R.string.rules_restock_delay),
|
||||
vendors.restockDelayMinutes?.let { stringResource(R.string.rules_minutes, it) },
|
||||
)
|
||||
RuleRow(stringResource(R.string.rules_max_sell), vendors.maxSell?.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
ruleset.schedule?.let { schedule ->
|
||||
item {
|
||||
RuleCard(stringResource(R.string.rules_section_schedule)) {
|
||||
RuleRow(
|
||||
stringResource(R.string.rules_autosave),
|
||||
schedule.autoSaveFrequencyMinutes?.let { stringResource(R.string.rules_minutes, it) },
|
||||
)
|
||||
RuleRow(
|
||||
stringResource(R.string.rules_autorestart),
|
||||
formatRestart(
|
||||
schedule.autoRestartEnabled,
|
||||
schedule.autoRestartHour,
|
||||
schedule.autoRestartMinute,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SystemsCard(systems: Map<String, Boolean>) {
|
||||
RuleCard(stringResource(R.string.rules_section_systems)) {
|
||||
FlowRow(
|
||||
Modifier.padding(top = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
// Sorted so the list is stable across reloads; the wire order is a config
|
||||
// read order and carries no meaning.
|
||||
systems.entries.sortedBy { it.key }.forEach { (key, on) ->
|
||||
StatusPill(
|
||||
text = humaniseSystem(key),
|
||||
tone = if (on) PillTone.Success else PillTone.Neutral,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CapsCard(caps: RulesetCapsDto) {
|
||||
RuleCard(stringResource(R.string.rules_section_caps)) {
|
||||
// Skill caps arrive in TENTHS (1000 = 100.0). Showing the raw number would read
|
||||
// as a shard with ten times the usual limit.
|
||||
RuleRow(stringResource(R.string.rules_skill_cap), caps.skillCap?.let { formatSkillCap(it) })
|
||||
RuleRow(stringResource(R.string.rules_total_skill_cap), caps.totalSkillCap?.let { formatSkillCap(it) })
|
||||
RuleRow(stringResource(R.string.rules_stat_cap), caps.stat?.toString())
|
||||
RuleRow(stringResource(R.string.rules_str_cap), caps.str?.toString())
|
||||
RuleRow(stringResource(R.string.rules_dex_cap), caps.dex?.toString())
|
||||
RuleRow(stringResource(R.string.rules_int_cap), caps.int?.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RuleCard(title: String, content: @Composable () -> Unit) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One label/value line. Renders nothing when the shard published no value. */
|
||||
@Composable
|
||||
private fun RuleRow(label: String, value: String?) {
|
||||
if (value.isNullOrBlank()) return
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
|
||||
|
||||
/** `cityLoyalty` → "City loyalty". The systems block is a flat bag of config keys. */
|
||||
internal fun humaniseSystem(key: String): String = key
|
||||
.replace(Regex("([a-z0-9])([A-Z])"), "$1 $2")
|
||||
.replaceFirstChar { it.uppercaseChar() }
|
||||
|
||||
/** A skill cap already converted out of tenths: drop the ".0" on whole values. */
|
||||
internal fun formatSkillCap(value: Double): String =
|
||||
if (value % 1.0 == 0.0) value.toInt().toString() else "%.1f".format(value)
|
||||
|
||||
/** The auto-restart schedule, or null when the shard doesn't run one. */
|
||||
internal fun formatRestart(enabled: Boolean?, hour: Int?, minute: Int?): String? {
|
||||
if (enabled != true) return null
|
||||
if (hour == null) return null
|
||||
return "%02d:%02d".format(hour, minute ?: 0)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.data.api.dto.RulesetDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toShardUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The shard ruleset (PLAN.md §9 M11, `docs/link/v3.md` §5): what this world is
|
||||
* configured to do — systems on/off, caps, account and housing limits, the champion
|
||||
* and Felucca tables, the save/restart schedule.
|
||||
*
|
||||
* Two states the screen must tell apart, which is why the success type is nullable:
|
||||
* a `null` body means the shard has **never published** a ruleset (the plugin is old,
|
||||
* or `RulesetEnabled=false`), while the feature being switched off is a 404 folded
|
||||
* into `ErrorKind.FEATURE_UNAVAILABLE`.
|
||||
*
|
||||
* Kept live by the `world.ruleset` frame, which the shard re-emits on every sidecar
|
||||
* reconnect — so a shard that restarts with edited config updates the open screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RulesViewModel @Inject constructor(
|
||||
private val repository: ShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<UiState<RulesetDto?>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<RulesetDto?>> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
collectLive()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
_state.value = repository.ruleset().toShardUiState()
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectLive() {
|
||||
viewModelScope.launch {
|
||||
repository.liveEvents().collect { event ->
|
||||
if (event !is ShardStreamEvent.Frame || event.kind != "world.ruleset") return@collect
|
||||
// The frame IS the whole ruleset — replace rather than merge. A frame the
|
||||
// app can't decode is skipped, leaving the loaded copy in place.
|
||||
repository.rulesetFrame(event.data)?.let { _state.value = UiState.Success(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -32,12 +31,34 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.FeatureCard
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.data.repository.ShardFeature
|
||||
import com.runicgateway.app.data.repository.ShardFeatures
|
||||
import com.runicgateway.app.data.repository.canSee
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** Board destinations reachable from the hub. */
|
||||
enum class ShardBoard { CHAMPS, GUILDS, GOVERNORS, HOUSES }
|
||||
/**
|
||||
* Board destinations reachable from the hub, each tagged with the visibility feature
|
||||
* that governs it (M11). An admin can switch any of these off or raise its audience,
|
||||
* so the hub's board list is filtered the same way the drawer is — a tile whose
|
||||
* feature the caller can't see would only lead to a `404`/`403`.
|
||||
*/
|
||||
enum class ShardBoard(val feature: String) {
|
||||
CHAMPS(ShardFeature.CHAMPS),
|
||||
GUILDS(ShardFeature.GUILDS),
|
||||
GOVERNORS(ShardFeature.GOVERNORS),
|
||||
HOUSES(ShardFeature.HOUSES),
|
||||
}
|
||||
|
||||
/**
|
||||
* The boards this viewer may reach. Pure + side-effect-free so the gating is
|
||||
* unit-tested without Compose, exactly like `visibleEntries` for the drawer. An
|
||||
* unknown answer shows every board — the server gates regardless (see [canSee]).
|
||||
*/
|
||||
fun visibleBoards(features: ShardFeatures?): List<ShardBoard> =
|
||||
ShardBoard.entries.filter { canSee(features, it.feature) }
|
||||
|
||||
/**
|
||||
* The Shard hub (PLAN.md §6.2): live connection status, online count + latest
|
||||
@@ -54,6 +75,7 @@ fun ShardScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val feed by viewModel.feed.collectAsStateWithLifecycle()
|
||||
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||
val features by viewModel.shardFeatures.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
@@ -62,6 +84,7 @@ fun ShardScreen(
|
||||
hub = s.data,
|
||||
feed = feed,
|
||||
connected = connected,
|
||||
features = features,
|
||||
onOpenBoard = onOpenBoard,
|
||||
modifier = modifier,
|
||||
)
|
||||
@@ -73,6 +96,7 @@ private fun HubContent(
|
||||
hub: ShardHub,
|
||||
feed: List<FeedLine>,
|
||||
connected: Boolean,
|
||||
features: ShardFeatures?,
|
||||
onOpenBoard: (ShardBoard) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -82,7 +106,7 @@ private fun HubContent(
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
|
||||
) {
|
||||
item { StatusCard(hub.status, hub.presence?.count) }
|
||||
item { BoardsCard(onOpenBoard) }
|
||||
item { BoardsCard(features, onOpenBoard) }
|
||||
|
||||
if (hub.online.isNotEmpty()) {
|
||||
item { SectionHeader(stringResource(R.string.shard_section_staff)) }
|
||||
@@ -159,14 +183,17 @@ private fun StatusCard(status: ShardStatusDto, presenceCount: Int?) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoardsCard(onOpenBoard: (ShardBoard) -> Unit) {
|
||||
val boards = listOf(
|
||||
ShardBoard.CHAMPS to R.string.shard_nav_champs,
|
||||
ShardBoard.GUILDS to R.string.shard_nav_guilds,
|
||||
ShardBoard.GOVERNORS to R.string.shard_nav_governors,
|
||||
ShardBoard.HOUSES to R.string.shard_nav_houses,
|
||||
)
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
private fun BoardsCard(features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Unit) {
|
||||
val boards = visibleBoards(features).map { board ->
|
||||
board to when (board) {
|
||||
ShardBoard.CHAMPS -> R.string.shard_nav_champs
|
||||
ShardBoard.GUILDS -> R.string.shard_nav_guilds
|
||||
ShardBoard.GOVERNORS -> R.string.shard_nav_governors
|
||||
ShardBoard.HOUSES -> R.string.shard_nav_houses
|
||||
}
|
||||
}
|
||||
if (boards.isEmpty()) return
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
boards.forEachIndexed { index, (board, labelRes) ->
|
||||
Text(
|
||||
|
||||
@@ -10,9 +10,11 @@ import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.data.repository.ShardFeatures
|
||||
import com.runicgateway.app.data.repository.ShardFeaturesRepository
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import com.runicgateway.app.ui.toShardUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -39,11 +41,18 @@ data class ShardHub(
|
||||
@HiltViewModel
|
||||
class ShardViewModel @Inject constructor(
|
||||
private val repository: ShardRepository,
|
||||
shardFeaturesRepository: ShardFeaturesRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<UiState<ShardHub>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<ShardHub>> = _state.asStateFlow()
|
||||
|
||||
/**
|
||||
* Which boards to offer (M11). Read-only here — the app shell refreshes this on
|
||||
* every session change, and the hub only filters its tiles with it.
|
||||
*/
|
||||
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
|
||||
|
||||
private val _feed = MutableStateFlow<List<FeedLine>>(emptyList())
|
||||
val feed: StateFlow<List<FeedLine>> = _feed.asStateFlow()
|
||||
|
||||
@@ -69,8 +78,8 @@ class ShardViewModel @Inject constructor(
|
||||
seedFeed()
|
||||
}
|
||||
// Both error variants are ApiResult<Nothing>, so their UiState is Nothing-typed.
|
||||
is ApiResult.HttpError -> _state.value = status.toUiState()
|
||||
is ApiResult.NetworkError -> _state.value = status.toUiState()
|
||||
is ApiResult.HttpError -> _state.value = status.toShardUiState()
|
||||
is ApiResult.NetworkError -> _state.value = status.toShardUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ import androidx.compose.ui.graphics.Color
|
||||
* without the leading `#`) into a Compose [Color]. Returns null for anything
|
||||
* unparseable so the theme falls back to its default scheme (PLAN.md §3, §5).
|
||||
* Pure logic — covered by JVM unit tests.
|
||||
*
|
||||
* Also the parser for every color token in the shard's resolved theme map
|
||||
* ([ShardPalette.resolve], M12): the server validates those as `#RGB` or
|
||||
* `#RRGGBB` on write, and a null here is what makes a token that slipped
|
||||
* through anyway cost only itself.
|
||||
*/
|
||||
fun parseBrandColor(hex: String?): Color? {
|
||||
if (hex.isNullOrBlank()) return null
|
||||
|
||||
126
app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt
Normal file
126
app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* The shard's resolved color palette — the fifteen themable tokens of
|
||||
* `GET /public/settings`' `theme` map, parsed into Compose colors
|
||||
* (THEMING_AND_NAV.md §5.1).
|
||||
*
|
||||
* **The default value of every field is the shipped constant from
|
||||
* [ui/theme/Color.kt], and that is not an approximation.** The app's M5 palette
|
||||
* *is* the website's `runic-gateway` preset, value for value, because both were
|
||||
* drawn from the same `theme.css`. So [Shipped] renders exactly as the app did
|
||||
* before this milestone, and an instance with no `theme_visual` row resolves
|
||||
* back to it token by token (§2, AC-1).
|
||||
*
|
||||
* The palette has two consumers and one resolution: ten of the fifteen tokens
|
||||
* have a Material role and are fed into the [androidx.compose.material3.ColorScheme]
|
||||
* by [shardColorScheme]; the other five have none, and reach the screens that
|
||||
* need them through [LocalShardPalette].
|
||||
*/
|
||||
@Immutable
|
||||
data class ShardPalette(
|
||||
/** `--bg-deep` — the page behind everything. */
|
||||
val page: Color = ShardPage,
|
||||
/** `--bg` — the screen background. */
|
||||
val surface: Color = ShardSurface,
|
||||
/** `--panel-flat` — top bar, inputs, drawer, list tracks. */
|
||||
val elevated: Color = ShardElevated,
|
||||
/** `--panel-a` — feature-card gradient, top. No Material role. */
|
||||
val cardTop: Color = ShardCardTop,
|
||||
/** `--panel-b` — feature-card gradient, bottom. No Material role. */
|
||||
val cardBottom: Color = ShardCardBottom,
|
||||
/** `--line` — borders and input outlines. */
|
||||
val outline: Color = ShardOutline,
|
||||
/** `--line-soft` — hairline row dividers. */
|
||||
val divider: Color = ShardDivider,
|
||||
/** `--ink` — the brightest headings. No Material role. */
|
||||
val heading: Color = ShardHeading,
|
||||
/** `--head` — heading on a surface. No Material role. */
|
||||
val headingDim: Color = ShardHeadingDim,
|
||||
/** `--text` — body copy. */
|
||||
val body: Color = ShardBody,
|
||||
/** `--muted` — secondary text. */
|
||||
val muted: Color = ShardMuted,
|
||||
/** `--dim` — meta and faint labels. No Material role. */
|
||||
val faint: Color = ShardFaint,
|
||||
/** `--accent` — links and secondary highlights. */
|
||||
val accent: Color = ShardAccent,
|
||||
/** `--accent-bright` — the filled CTA surface. */
|
||||
val cta: Color = ShardCta,
|
||||
/** `--blue` — the neutral/info pill background. */
|
||||
val pillBg: Color = ShardPillBg,
|
||||
) {
|
||||
/**
|
||||
* Text drawn on the [cta] fill. **Derived, never themed** — it tracks
|
||||
* `--bg-deep`, exactly as the server refuses to freeze `--panel-grad` as a
|
||||
* literal (§5.1). A value expressed in terms of another token must follow
|
||||
* it, or a future light preset inherits a dark one and looks broken.
|
||||
*/
|
||||
val onCta: Color get() = page
|
||||
|
||||
/**
|
||||
* The neutral/info pill's foreground. Also derived: `ShardPillFg` and
|
||||
* `ShardCta` are the same `--accent-bright` value, so the pill's text
|
||||
* follows the CTA fill rather than being a sixteenth token the contract
|
||||
* does not have.
|
||||
*/
|
||||
val pillFg: Color get() = cta
|
||||
|
||||
companion object {
|
||||
/** The shipped app: the M5 palette, i.e. the `runic-gateway` preset. */
|
||||
val Shipped = ShardPalette()
|
||||
|
||||
/**
|
||||
* Resolve a `theme` token map into a palette, **field by field** (§2).
|
||||
* A token that is missing, blank or unparseable falls back to its
|
||||
* shipped value on its own; a bad `--accent` must never discard a good
|
||||
* `--bg` beside it (AC-2).
|
||||
*
|
||||
* [brandAccent] is the pre-feature branding path and must keep working:
|
||||
* an instance with a `BRAND_ACCENT_COLOR` but no `theme_visual` row
|
||||
* still tints its links and highlights. It seeds `--accent` only — the
|
||||
* server resolves `brand.accent` as `theme['--accent'] || env`, so the
|
||||
* token always wins where both exist.
|
||||
*/
|
||||
fun resolve(theme: Map<String, String>, brandAccent: Color? = null): ShardPalette {
|
||||
if (theme.isEmpty() && brandAccent == null) return Shipped
|
||||
fun token(name: String, shipped: Color): Color =
|
||||
parseBrandColor(theme[name]) ?: shipped
|
||||
return ShardPalette(
|
||||
page = token("--bg-deep", ShardPage),
|
||||
surface = token("--bg", ShardSurface),
|
||||
elevated = token("--panel-flat", ShardElevated),
|
||||
cardTop = token("--panel-a", ShardCardTop),
|
||||
cardBottom = token("--panel-b", ShardCardBottom),
|
||||
outline = token("--line", ShardOutline),
|
||||
divider = token("--line-soft", ShardDivider),
|
||||
heading = token("--ink", ShardHeading),
|
||||
headingDim = token("--head", ShardHeadingDim),
|
||||
body = token("--text", ShardBody),
|
||||
muted = token("--muted", ShardMuted),
|
||||
faint = token("--dim", ShardFaint),
|
||||
accent = token("--accent", brandAccent ?: ShardAccent),
|
||||
cta = token("--accent-bright", ShardCta),
|
||||
pillBg = token("--blue", ShardPillBg),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The live palette, for the five tokens with no Material role and for the
|
||||
* components that draw the card gradient. Everything that *can* go through
|
||||
* `MaterialTheme.colorScheme` still should — this is the escape hatch, not the
|
||||
* front door.
|
||||
*
|
||||
* Defaulted to [ShardPalette.Shipped] so previews and any composable outside
|
||||
* [RunicGatewayTheme] still draw the shipped palette rather than crashing.
|
||||
*/
|
||||
val LocalShardPalette = staticCompositionLocalOf { ShardPalette.Shipped }
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The shard's resolved corner radii and card depth — the `structure` half of the
|
||||
* admin's Appearance page (THEMING_AND_NAV.md §5.2, §5.4), the counterpart to
|
||||
* [ShardPalette].
|
||||
*
|
||||
* **Radii are applied as a ratio, never as a literal.** The app's [Shapes] came
|
||||
* from the M5 mockup and the website's from `theme.css`; the two scales genuinely
|
||||
* differ (`--radius-card` 10px against `medium` 12dp). Copying the web value in
|
||||
* would restyle an untouched app the day this milestone shipped, so each field is
|
||||
* scaled by `resolved ÷ runic-gateway baseline` instead. A shard on the shipped
|
||||
* theme, or one that explicitly picks `runic-gateway`, gives ratio 1.0 on every
|
||||
* field and is a provable no-op (§2, AC-1).
|
||||
*
|
||||
* Card depth is the one thing here that is **not** a no-op — see [ShippedCardElevation].
|
||||
*/
|
||||
@Immutable
|
||||
data class ShardStructure(
|
||||
/** The Material shape scale, ratio-scaled off the app's own shipped dp values. */
|
||||
val shapes: Shapes = ShippedShapes,
|
||||
/**
|
||||
* `--radius-pill`. Not part of [shapes]: the app draws its chips with
|
||||
* [CircleShape], which is a percentage and so has no dp for a ratio to scale.
|
||||
* Resolved as a literal instead — the only rule available — see [pillShape].
|
||||
*/
|
||||
val pill: Shape = CircleShape,
|
||||
/** `--shadow-card`, mapped onto Material elevation (§5.4). */
|
||||
val cardElevation: Dp = ShippedCardElevation,
|
||||
) {
|
||||
companion object {
|
||||
/** The shipped app: the M5 shape scale and the `runic-gateway` card depth. */
|
||||
val Shipped = ShardStructure()
|
||||
|
||||
/**
|
||||
* Resolve a `theme` token map into a structure, **field by field** (§2):
|
||||
* a `--radius-panel` the server never validated must not cost the
|
||||
* `--radius-card` beside it, exactly as in [ShardPalette.resolve].
|
||||
*/
|
||||
fun resolve(theme: Map<String, String>): ShardStructure {
|
||||
if (theme.isEmpty()) return Shipped
|
||||
val input = ratio(theme["--radius-input"], BaseInputPx)
|
||||
val card = ratio(theme["--radius-card"], BaseCardPx)
|
||||
val panel = ratio(theme["--radius-panel"], BasePanelPx)
|
||||
return ShardStructure(
|
||||
shapes = Shapes(
|
||||
extraSmall = corner(ShippedExtraSmallDp, input),
|
||||
small = corner(ShippedSmallDp, input),
|
||||
medium = corner(ShippedMediumDp, card),
|
||||
// extraLarge has no web counterpart and follows the panel
|
||||
// ratio, since it is the panel family.
|
||||
large = corner(ShippedLargeDp, panel),
|
||||
extraLarge = corner(ShippedExtraLargeDp, panel),
|
||||
),
|
||||
pill = pillShape(theme["--radius-pill"]),
|
||||
cardElevation = elevation(theme["--shadow-card"]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The live structure, for the two things Material's theme cannot carry: the pill
|
||||
* shape, and a card elevation ([androidx.compose.material3.Card] takes its
|
||||
* elevation as a default argument, not from a composition local). The shape
|
||||
* scale itself reaches screens through `MaterialTheme.shapes` and needs nothing
|
||||
* here.
|
||||
*/
|
||||
val LocalShardStructure = staticCompositionLocalOf { ShardStructure.Shipped }
|
||||
|
||||
// ── the shipped scale ──────────────────────────────────────────────────────
|
||||
//
|
||||
// The app's own dp values, which the ratios scale. Kept here rather than in
|
||||
// Theme.kt so the resolution and the thing it resolves back to sit together.
|
||||
|
||||
private const val ShippedExtraSmallDp = 8
|
||||
private const val ShippedSmallDp = 8
|
||||
private const val ShippedMediumDp = 12
|
||||
private const val ShippedLargeDp = 16
|
||||
private const val ShippedExtraLargeDp = 24
|
||||
|
||||
/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
|
||||
internal val ShippedShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(ShippedExtraSmallDp.dp),
|
||||
small = RoundedCornerShape(ShippedSmallDp.dp),
|
||||
medium = RoundedCornerShape(ShippedMediumDp.dp),
|
||||
large = RoundedCornerShape(ShippedLargeDp.dp),
|
||||
extraLarge = RoundedCornerShape(ShippedExtraLargeDp.dp),
|
||||
)
|
||||
|
||||
/**
|
||||
* The depth an unthemed instance draws its cards at.
|
||||
*
|
||||
* **This is the one field of this milestone that is deliberately not a no-op.**
|
||||
* The app has been flat since M5 — Material's filled `Card` is `Level0` and
|
||||
* `FeatureCard` never had the shadow its own docs claimed — while the
|
||||
* `runic-gateway` preset's `--shadow-card` is the "Default" option. §5.4 is
|
||||
* applied as written rather than rebased on the app's flat baseline, so every
|
||||
* card gains this depth and the admin's four-step control reads the same on the
|
||||
* phone as on the web. Approved by the org lead as an amendment to §2.
|
||||
*/
|
||||
private val ShippedCardElevation = 4.dp
|
||||
|
||||
// ── the runic-gateway baselines ───────────────────────────────────────────
|
||||
//
|
||||
// The preset the app's own scale corresponds to (server/src/config/themePresets.js).
|
||||
// A resolved value is meaningful only against these: the ratio, not the number,
|
||||
// is what crosses from the web scale to the app's.
|
||||
|
||||
private const val BaseInputPx = 8f
|
||||
private const val BaseCardPx = 10f
|
||||
private const val BasePanelPx = 12f
|
||||
private const val BasePillPx = 999f
|
||||
|
||||
/**
|
||||
* Below half the pill baseline the chip stops reading as a pill and becomes a
|
||||
* rounded rectangle, so an admin who squares the site off squares off the app's
|
||||
* chips too. Fantasy's 4px and Modern's 8px both land here; `runic-gateway`'s
|
||||
* 999px does not.
|
||||
*/
|
||||
private const val PillCircleFloorPx = BasePillPx / 2f
|
||||
|
||||
// A radius as the server writes it: an integer count of px, 0..999, always with
|
||||
// the unit (`isRadius` in utils/themeResolve.js). Anything else is not a value
|
||||
// this app can scale, and falls back to the shipped dp on its own.
|
||||
private val RadiusPx = Regex("""^\s*(\d{1,3})px\s*$""")
|
||||
|
||||
// The blur of a CSS box-shadow: `0 14px 34px rgba(...)`. The x offset carries no
|
||||
// unit, so the blur is the second px length.
|
||||
private val ShadowLengthPx = Regex("""(\d+(?:\.\d+)?)px""")
|
||||
|
||||
/**
|
||||
* `--shadow-card` mapped to elevation, by **nearest blur** rather than by exact
|
||||
* string. §5.4 specified a string match against the server's `SHADOW_OPTIONS`,
|
||||
* but the Fantasy preset publishes `0 16px 38px rgba(0, 0, 0, 0.45)` — a value
|
||||
* `SHADOW_OPTIONS` does not contain, because a preset's own tokens never pass
|
||||
* through that dropdown. An exact match would have missed the one preset whose
|
||||
* point is a heavier shadow. Matching the blur puts any future preset on the
|
||||
* nearest step instead of silently on the default.
|
||||
*/
|
||||
private val ShadowSteps = listOf(20f to 2.dp, 34f to 4.dp, 44f to 8.dp)
|
||||
|
||||
private fun parseRadiusPx(raw: String?): Float? =
|
||||
raw?.let { RadiusPx.find(it) }?.groupValues?.get(1)?.toFloatOrNull()
|
||||
|
||||
private fun ratio(raw: String?, baselinePx: Float): Float =
|
||||
parseRadiusPx(raw)?.let { it / baselinePx } ?: 1f
|
||||
|
||||
/** Scale one shipped dp by its ratio, rounded to whole dp and clamped at 0. */
|
||||
private fun corner(shippedDp: Int, ratio: Float) =
|
||||
RoundedCornerShape((shippedDp * ratio).roundToInt().coerceAtLeast(0).dp)
|
||||
|
||||
private fun pillShape(raw: String?): Shape {
|
||||
val px = parseRadiusPx(raw) ?: return CircleShape
|
||||
return if (px >= PillCircleFloorPx) CircleShape else RoundedCornerShape(px.roundToInt().dp)
|
||||
}
|
||||
|
||||
private fun elevation(raw: String?): Dp {
|
||||
val value = raw?.trim() ?: return ShippedCardElevation
|
||||
if (value.equals("none", ignoreCase = true)) return 0.dp
|
||||
val blur = ShadowLengthPx.findAll(value).drop(1).firstOrNull()
|
||||
?.groupValues?.get(1)?.toFloatOrNull()
|
||||
?: return ShippedCardElevation
|
||||
return ShadowSteps.minByOrNull { abs(it.first - blur) }?.second ?: ShippedCardElevation
|
||||
}
|
||||
@@ -3,78 +3,88 @@
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
|
||||
/**
|
||||
* The shard-website color scheme (M5 design pass). The app is **dark-only** — the
|
||||
* design is a single deep blue-black theme, so there is no light variant and the
|
||||
* system light/dark setting is intentionally ignored. Material roles are mapped
|
||||
* onto the palette in [ui/theme/Color.kt] so the ~20 token-based screens take on
|
||||
* the theme without per-screen color work.
|
||||
* Maps a resolved [ShardPalette] onto the Material roles (THEMING_AND_NAV.md
|
||||
* §5.1). The app is **dark-only** — the design is a single deep blue-black
|
||||
* theme, so there is no light variant and the system light/dark setting is
|
||||
* intentionally ignored; every v1 preset on the website is dark too.
|
||||
*
|
||||
* Ten of the palette's fifteen tokens land here, which is why the ~20
|
||||
* token-based screens take on a shard's theme with no per-screen color work.
|
||||
* Pure, so the no-op proof (AC-1) can assert on it directly.
|
||||
*/
|
||||
private val ShardColorScheme = darkColorScheme(
|
||||
primary = ShardCta, // filled CTA buttons
|
||||
onPrimary = ShardOnCta,
|
||||
secondary = ShardAccent, // links / secondary highlights
|
||||
onSecondary = ShardOnCta,
|
||||
tertiary = ShardAccent,
|
||||
onTertiary = ShardOnCta,
|
||||
background = ShardPage,
|
||||
onBackground = ShardBody,
|
||||
surface = ShardSurface,
|
||||
onSurface = ShardBody,
|
||||
surfaceVariant = ShardElevated,
|
||||
onSurfaceVariant = ShardMuted,
|
||||
surfaceContainer = ShardElevated,
|
||||
surfaceContainerHigh = ShardElevated,
|
||||
surfaceContainerLow = ShardSurface,
|
||||
outline = ShardOutline,
|
||||
outlineVariant = ShardDivider,
|
||||
secondaryContainer = ShardPillBg, // neutral chips / selected drawer item
|
||||
onSecondaryContainer = ShardPillFg,
|
||||
internal fun shardColorScheme(palette: ShardPalette): ColorScheme = darkColorScheme(
|
||||
primary = palette.cta, // filled CTA buttons
|
||||
onPrimary = palette.onCta,
|
||||
secondary = palette.accent, // links / secondary highlights
|
||||
onSecondary = palette.onCta,
|
||||
tertiary = palette.accent,
|
||||
onTertiary = palette.onCta,
|
||||
background = palette.page,
|
||||
onBackground = palette.body,
|
||||
surface = palette.surface,
|
||||
onSurface = palette.body,
|
||||
surfaceVariant = palette.elevated,
|
||||
onSurfaceVariant = palette.muted,
|
||||
surfaceContainer = palette.elevated,
|
||||
surfaceContainerHigh = palette.elevated,
|
||||
surfaceContainerLow = palette.surface,
|
||||
outline = palette.outline,
|
||||
outlineVariant = palette.divider,
|
||||
secondaryContainer = palette.pillBg, // neutral chips / selected drawer item
|
||||
onSecondaryContainer = palette.pillFg,
|
||||
// Semantic, never themed — mirrors the server's FIXED_TOKENS (§4).
|
||||
error = ShardDanger,
|
||||
onError = ShardOnCta,
|
||||
onError = palette.onCta,
|
||||
errorContainer = ShardDangerBg,
|
||||
onErrorContainer = ShardDanger,
|
||||
)
|
||||
|
||||
/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
|
||||
private val ShardShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(8.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp),
|
||||
extraLarge = RoundedCornerShape(24.dp),
|
||||
)
|
||||
|
||||
/**
|
||||
* App theme. The color scheme is the fixed shard-website dark palette; when a shard
|
||||
* publishes a brand accent (PLAN.md §3), it seeds the [MaterialTheme]'s primary and
|
||||
* secondary roles so buttons and highlights carry that shard's color while the rest
|
||||
* of the deep blue-black system stays intact. With no accent, the slate default is
|
||||
* used.
|
||||
* App theme, themed by the shard (M12). [appearance] carries the resolved token
|
||||
* map the admin's Appearance page publishes; it is applied field by field over
|
||||
* the shipped palette and shape scale, so [SiteAppearance.NONE] — no settings
|
||||
* rows, a backend that predates the feature, or a settings call that failed —
|
||||
* renders as the app did before this milestone (§2), the one exception being the
|
||||
* card depth [ShardStructure] documents.
|
||||
*
|
||||
* The palette reaches screens two ways: through [MaterialTheme]'s color scheme
|
||||
* for the ten tokens with a Material role, and through [LocalShardPalette] for
|
||||
* the five without one. The radii split the same way — [MaterialTheme]'s shape
|
||||
* scale for everything Material draws, [LocalShardStructure] for the pill and
|
||||
* the card depth, which it cannot carry.
|
||||
*/
|
||||
@Composable
|
||||
fun RunicGatewayTheme(
|
||||
accent: Color? = null,
|
||||
appearance: SiteAppearance = SiteAppearance.NONE,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = if (accent != null) {
|
||||
ShardColorScheme.copy(primary = accent, secondary = accent, tertiary = accent)
|
||||
} else {
|
||||
ShardColorScheme
|
||||
val palette = remember(appearance) {
|
||||
ShardPalette.resolve(
|
||||
theme = appearance.theme,
|
||||
brandAccent = parseBrandColor(appearance.brand?.accent),
|
||||
)
|
||||
}
|
||||
val colorScheme = remember(palette) { shardColorScheme(palette) }
|
||||
val structure = remember(appearance) { ShardStructure.resolve(appearance.theme) }
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
shapes = ShardShapes,
|
||||
content = content,
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalShardPalette provides palette,
|
||||
LocalShardStructure provides structure,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
shapes = structure.shapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -30,6 +29,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/** Wiki index: search field + page list (PLAN.md §6.1). */
|
||||
@Composable
|
||||
@@ -72,7 +72,7 @@ fun WikiScreen(
|
||||
private fun WikiList(pages: List<WikiSummaryDto>, onOpenPage: (String) -> Unit) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
|
||||
items(pages, key = { it.id }) { page ->
|
||||
Card(
|
||||
ShardCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<string name="error_not_found">This content couldn\'t be found.</string>
|
||||
<string name="error_rate_limited">Too many requests. Please try again in a moment.</string>
|
||||
<string name="error_shard_offline">The shard is offline right now.</string>
|
||||
<string name="error_feature_unavailable">This shard doesn\'t publish this here.</string>
|
||||
<string name="error_server">Something went wrong on the server. Please try again.</string>
|
||||
|
||||
<!-- ── First-run connect (§3) ──────────────────────────────────────── -->
|
||||
@@ -40,6 +41,10 @@
|
||||
<string name="menu_news">News</string>
|
||||
<string name="menu_wiki">Wiki</string>
|
||||
<string name="menu_shard">Shard</string>
|
||||
<string name="menu_rules">Rules</string>
|
||||
<string name="menu_atlas">Atlas</string>
|
||||
<string name="menu_leaderboards">Leaderboards</string>
|
||||
<string name="menu_market">Market</string>
|
||||
<string name="menu_about">About</string>
|
||||
<string name="menu_contact">Contact</string>
|
||||
<string name="menu_account">My account</string>
|
||||
@@ -201,6 +206,49 @@
|
||||
<string name="account_identity_unlinked">Account unlinked.</string>
|
||||
<string name="account_identity_error">Couldn\'t unlink that account.</string>
|
||||
|
||||
<!-- ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ─────── -->
|
||||
<!-- Login 2FA step -->
|
||||
<string name="login_recovery_code">Recovery code</string>
|
||||
<string name="login_recovery_hint">Enter one of your single-use backup codes.</string>
|
||||
<string name="login_use_recovery_instead">Use a recovery code instead</string>
|
||||
<string name="login_use_totp_instead">Use your authenticator code instead</string>
|
||||
<string name="login_trust_device">Trust this device (skip codes for 30 days)</string>
|
||||
|
||||
<!-- Account: security section -->
|
||||
<string name="account_security_title">Security</string>
|
||||
<string name="account_security_trusted_devices">Trusted devices</string>
|
||||
<string name="account_security_recovery_codes">Recovery codes</string>
|
||||
|
||||
<!-- Trusted devices screen -->
|
||||
<string name="trusted_devices_title">Trusted devices</string>
|
||||
<string name="trusted_devices_subtitle">These devices can skip the authentication code at sign-in for 30 days.</string>
|
||||
<string name="trusted_devices_empty">No trusted devices yet.</string>
|
||||
<string name="trusted_devices_unknown">Unknown device</string>
|
||||
<string name="trusted_devices_last_used">Last used %1$s</string>
|
||||
<string name="trusted_devices_revoke">Revoke</string>
|
||||
<string name="trusted_devices_trust_this">Trust this device</string>
|
||||
<string name="trusted_devices_untrust_all">Untrust all devices</string>
|
||||
<string name="trusted_devices_trusted">This device is now trusted.</string>
|
||||
<string name="trusted_devices_revoked">Device revoked.</string>
|
||||
<string name="trusted_devices_revoked_all">All devices untrusted.</string>
|
||||
<string name="trusted_devices_limit">You\'ve reached the trusted-device limit. Revoke one, then try again.</string>
|
||||
<string name="trusted_devices_error">Something went wrong. Please try again.</string>
|
||||
|
||||
<!-- Recovery codes screen -->
|
||||
<string name="recovery_codes_title">Recovery codes</string>
|
||||
<string name="recovery_codes_subtitle">Single-use backup codes let you sign in if you lose your authenticator.</string>
|
||||
<string name="recovery_codes_remaining">%1$d codes remaining</string>
|
||||
<string name="recovery_codes_remaining_loading">Checking remaining codes…</string>
|
||||
<string name="recovery_codes_remaining_unknown">Couldn\'t load the remaining count.</string>
|
||||
<string name="recovery_codes_password_hint">Enter your current password to generate a new set.</string>
|
||||
<string name="recovery_codes_regenerate">Generate new codes</string>
|
||||
<string name="recovery_codes_error">Couldn\'t generate codes. Check your password and that two-factor is on.</string>
|
||||
<string name="recovery_codes_new_title">Your new recovery codes</string>
|
||||
<string name="recovery_codes_new_hint">Save these now — they\'re shown only once and each works a single time.</string>
|
||||
<string name="recovery_codes_copy">Copy</string>
|
||||
<string name="recovery_codes_share">Share</string>
|
||||
<string name="recovery_codes_done">Done</string>
|
||||
|
||||
<!-- ── Player: game-account linking (§6.3) ─────────────────────────── -->
|
||||
<string name="player_link_title">Link your game account</string>
|
||||
<string name="player_link_hint">In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses.</string>
|
||||
@@ -247,6 +295,11 @@
|
||||
<string name="player_char_pois">Poison</string>
|
||||
<string name="player_char_energy">Energy</string>
|
||||
<string name="player_char_skills">Skills</string>
|
||||
<string name="player_char_points">Loyalty & Points</string>
|
||||
<!-- A point system's name followed by the character's rank on that board, e.g. "Queens Loyalty · #3". -->
|
||||
<string name="player_char_points_ranked">%1$s · #%2$d</string>
|
||||
<!-- A score against its cap. Only shown for capped systems; an uncapped score shows the number alone. -->
|
||||
<string name="player_char_points_of">%1$d / %2$d</string>
|
||||
<string name="player_char_equipment">Equipment</string>
|
||||
<string name="player_char_item">Item</string>
|
||||
<string name="player_char_item_id">id %1$d</string>
|
||||
@@ -333,6 +386,85 @@
|
||||
<string name="guilds_leader">Led by %1$s</string>
|
||||
<string name="guilds_alliance">Alliance: %1$s</string>
|
||||
|
||||
<!-- ── Rules / ruleset (Protocol 3.0 §5, M11) ──────────────────────── -->
|
||||
<!-- A successful read of a shard that has never published its ruleset — NOT the same
|
||||
as the feature being switched off, which renders as an error state. -->
|
||||
<string name="rules_unpublished">This shard hasn\'t published its ruleset yet.</string>
|
||||
<string name="rules_section_shard">Shard</string>
|
||||
<string name="rules_section_systems">Systems</string>
|
||||
<string name="rules_section_caps">Skill & stat caps</string>
|
||||
<string name="rules_section_accounts">Accounts & housing</string>
|
||||
<string name="rules_section_vendors">Vendors</string>
|
||||
<string name="rules_section_schedule">Saves & restarts</string>
|
||||
<string name="rules_name">Name</string>
|
||||
<string name="rules_expansion">Expansion</string>
|
||||
<string name="rules_connect">Connect</string>
|
||||
<string name="rules_skill_cap">Individual skill cap</string>
|
||||
<string name="rules_total_skill_cap">Total skill cap</string>
|
||||
<string name="rules_stat_cap">Total stat cap</string>
|
||||
<string name="rules_str_cap">Strength cap</string>
|
||||
<string name="rules_dex_cap">Dexterity cap</string>
|
||||
<string name="rules_int_cap">Intelligence cap</string>
|
||||
<string name="rules_char_slots">Character slots</string>
|
||||
<string name="rules_per_ip">Accounts per IP</string>
|
||||
<string name="rules_house_limit">Houses per account</string>
|
||||
<string name="rules_restock_delay">Vendor restock delay</string>
|
||||
<string name="rules_max_sell">Max sell quantity</string>
|
||||
<string name="rules_autosave">Auto-save every</string>
|
||||
<string name="rules_autorestart">Auto-restart at</string>
|
||||
<string name="rules_minutes">%1$d min</string>
|
||||
|
||||
<!-- ── Leaderboards (Protocol 3.0 §7, M11) ─────────────────────────── -->
|
||||
<string name="leaderboards_empty">This shard isn\'t publishing any leaderboards yet.</string>
|
||||
<string name="leaderboards_board_empty">Nobody has scored here yet.</string>
|
||||
<!-- Where a score would sit on the placeholder row of an unscored board. An em
|
||||
dash, not "0" — nobody has scored zero, nobody has scored at all. -->
|
||||
<string name="leaderboards_no_score">—</string>
|
||||
<string name="leaderboards_players">%1$d players</string>
|
||||
<!-- Only shown for capped systems; most systems on a real shard are uncapped. -->
|
||||
<string name="leaderboards_cap">Cap: %1$d</string>
|
||||
<string name="leaderboards_rank_name">#%1$d %2$s</string>
|
||||
<!-- A shard may publish standings without naming who holds them (the board's one
|
||||
admin-configurable field). -->
|
||||
<string name="leaderboards_hidden_name">Someone</string>
|
||||
|
||||
<!-- ── Market (Protocol 3.0 §8, M11) ───────────────────────────────── -->
|
||||
<string name="market_search_label">Search every shop</string>
|
||||
<string name="market_empty">No listings match that search.</string>
|
||||
<string name="market_shop_empty">This shop has nothing for sale.</string>
|
||||
<!-- Required, not decoration: the shard sweeps vendors round-robin, so a price can
|
||||
legitimately be a full cycle old. -->
|
||||
<string name="market_staleness">Prices are refreshed in rotation and may be out of date.</string>
|
||||
<string name="market_price">%1$d gp</string>
|
||||
<string name="market_unnamed_item">Item %1$d</string>
|
||||
<string name="market_unnamed_shop">A shop</string>
|
||||
<string name="market_owner">Kept by %1$s</string>
|
||||
<!-- A gated location is a real answer: the shop exists, the shard just doesn't say
|
||||
where it stands. -->
|
||||
<string name="market_location_hidden">This shard doesn\'t publish shop locations.</string>
|
||||
<string name="market_truncated">Showing %1$d of %2$d — this shop holds more than the shard publishes.</string>
|
||||
|
||||
<!-- ── Spawn atlas (Protocol 3.0 §6, M11) ──────────────────────────── -->
|
||||
<string name="atlas_search_label">Search creatures</string>
|
||||
<string name="atlas_empty">No creatures match that search.</string>
|
||||
<!-- A place can legitimately hold a single spawner, and the aggregate list is full
|
||||
of them — "1 spawners" on every other row is worth a plural for. -->
|
||||
<plurals name="atlas_spawner_count">
|
||||
<item quantity="one">%1$d spawner</item>
|
||||
<item quantity="other">%1$d spawners</item>
|
||||
</plurals>
|
||||
<string name="atlas_total_alive">Up to %1$d alive at once</string>
|
||||
<string name="atlas_facet_count">%1$s (%2$d)</string>
|
||||
<!-- The aggregate: "where is it", as opposed to the raw coordinates below it. -->
|
||||
<string name="atlas_section_places">Where it spawns</string>
|
||||
<string name="atlas_place_max_alive">up to %1$d at once</string>
|
||||
<string name="atlas_section_spawners">Spawn points</string>
|
||||
<string name="atlas_section_also_here">Also spawns here</string>
|
||||
<string name="atlas_spawners_truncated">More spawn points than shown.</string>
|
||||
<string name="atlas_max_count">Up to %1$d</string>
|
||||
<!-- The delay is already in seconds; the server normalizes XmlSpawner's mixed units. -->
|
||||
<string name="atlas_respawn">Respawn %1$s</string>
|
||||
|
||||
<!-- ── Governors (§6.2) ────────────────────────────────────────────── -->
|
||||
<string name="governors_empty">No governors — this shard may not run the City Loyalty system.</string>
|
||||
<string name="governor_current">Governed by %1$s</string>
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.StoredSession
|
||||
import com.runicgateway.app.core.auth.TokenStore
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
||||
@@ -45,6 +46,17 @@ class SsoAuthManagerTest {
|
||||
override fun clear() { pending = null }
|
||||
}
|
||||
|
||||
/** In-memory stand-in for the encrypted trust-token store, scoped by username
|
||||
* the same way the production impl is. */
|
||||
private class FakeTrustTokenStore : TrustTokenStore {
|
||||
var owner: String? = null
|
||||
var token: String? = null
|
||||
override fun tokenFor(username: String): String? =
|
||||
if (owner.equals(username, ignoreCase = true)) token else null
|
||||
override fun save(username: String, token: String) { owner = username; this.token = token }
|
||||
override fun clear() { owner = null; token = null }
|
||||
}
|
||||
|
||||
/** Records the exchange it was called with and returns a scripted response. */
|
||||
private class FakeSsoApi(
|
||||
private val exchangeResult: () -> Response<MobileTokenResponse>,
|
||||
@@ -76,10 +88,11 @@ class SsoAuthManagerTest {
|
||||
session: SessionManager,
|
||||
base: String? = "https://shard.example.com/",
|
||||
store: PendingSsoStore = FakePendingSsoStore(),
|
||||
trust: TrustTokenStore = FakeTrustTokenStore(),
|
||||
): SsoAuthManager {
|
||||
val holder = BaseUrlHolder()
|
||||
if (base != null) holder.set(base.toHttpUrl())
|
||||
return SsoAuthManager(api, session, holder, store)
|
||||
return SsoAuthManager(api, session, holder, store, trust)
|
||||
}
|
||||
|
||||
/** Build a start URL and pull the generated `state` back out of it. */
|
||||
@@ -120,6 +133,38 @@ class SsoAuthManagerTest {
|
||||
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
|
||||
}
|
||||
|
||||
// Trusted devices over SSO (TRUSTED_DEVICES_MFA.md). Ticking "trust this device"
|
||||
// on the TOTP form inside the Custom Tab trusts that browser via cookie; the
|
||||
// exchange additionally hands the APP its own token so a native password login
|
||||
// on this device skips the code too. Before this, SSO ignored trust entirely.
|
||||
@Test fun `a trustToken on the exchange response is persisted for the signed-in user`() = runTest {
|
||||
val api = FakeSsoApi { Response.success(tokenPair().copy(trustToken = "opaque-trust")) }
|
||||
val session = SessionManager(FakeTokenStore())
|
||||
val trust = FakeTrustTokenStore()
|
||||
val mgr = managerWith(api, session, trust = trust)
|
||||
|
||||
val state = startAndState(mgr)
|
||||
mgr.complete(state = state, code = "auth-code-1", error = null)
|
||||
|
||||
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
|
||||
assertEquals("opaque-trust", trust.tokenFor("alice"))
|
||||
// Scoped to the account that minted it — never replayed for someone else.
|
||||
assertNull(trust.tokenFor("mallory"))
|
||||
}
|
||||
|
||||
@Test fun `no trustToken on the response leaves the store untouched`() = runTest {
|
||||
val api = FakeSsoApi { Response.success(tokenPair()) }
|
||||
val session = SessionManager(FakeTokenStore())
|
||||
val trust = FakeTrustTokenStore()
|
||||
val mgr = managerWith(api, session, trust = trust)
|
||||
|
||||
val state = startAndState(mgr)
|
||||
mgr.complete(state = state, code = "auth-code-1", error = null)
|
||||
|
||||
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
|
||||
assertNull(trust.tokenFor("alice"))
|
||||
}
|
||||
|
||||
@Test fun `state mismatch fails without exchanging`() = runTest {
|
||||
val api = FakeSsoApi { Response.success(tokenPair()) }
|
||||
val session = SessionManager(FakeTokenStore())
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.result
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The [ApiResult] helpers: [map] transforms an [ApiResult.Ok] and passes the two
|
||||
* failure variants through unchanged; [isShardUnavailable] is the 503 "shard down"
|
||||
* signal the player screens render as offline.
|
||||
*/
|
||||
class ApiResultExtrasTest {
|
||||
|
||||
@Test fun mapTransformsOkBody() {
|
||||
val mapped = ApiResult.Ok(listOf(1, 2, 3)).map { it.size }
|
||||
assertEquals(ApiResult.Ok(3), mapped)
|
||||
}
|
||||
|
||||
@Test fun mapPassesFailuresThroughUnchanged() {
|
||||
val http: ApiResult<Int> = ApiResult.HttpError(500, "boom")
|
||||
assertSame(http, http.map { it + 1 })
|
||||
|
||||
val cause = RuntimeException("offline")
|
||||
val network: ApiResult<Int> = ApiResult.NetworkError(cause)
|
||||
val out = network.map { it + 1 }
|
||||
assertTrue(out is ApiResult.NetworkError)
|
||||
assertSame(cause, (out as ApiResult.NetworkError).cause)
|
||||
}
|
||||
|
||||
@Test fun isShardUnavailableOnlyForHttp503() {
|
||||
assertTrue(ApiResult.HttpError(503).isShardUnavailable())
|
||||
assertFalse(ApiResult.HttpError(500).isShardUnavailable())
|
||||
assertFalse(ApiResult.Ok(Unit).isShardUnavailable())
|
||||
assertFalse(ApiResult.NetworkError(RuntimeException()).isShardUnavailable())
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,66 @@ class AccountDtoTest {
|
||||
assertTrue(json.decodeFromString<TotpStateDto>("""{"totp_enabled":true}""").totp_enabled)
|
||||
}
|
||||
|
||||
@Test fun totpEnableCarriesOneTimeRecoveryCodes() {
|
||||
// Enabling 2FA now returns the fresh single-use batch once (TRUSTED_DEVICES_MFA.md).
|
||||
val dto = json.decodeFromString<TotpStateDto>(
|
||||
"""{"totp_enabled":true,"recoveryCodes":["aaaa-1111","bbbb-2222"]}""",
|
||||
)
|
||||
assertTrue(dto.totp_enabled)
|
||||
assertEquals(listOf("aaaa-1111", "bbbb-2222"), dto.recoveryCodes)
|
||||
}
|
||||
|
||||
@Test fun totpStateDisableHasNoRecoveryCodes() {
|
||||
// Disable (and older backends) omit the field — must decode to null, not crash.
|
||||
val dto = json.decodeFromString<TotpStateDto>("""{"totp_enabled":false}""")
|
||||
assertFalse(dto.totp_enabled)
|
||||
assertEquals(null, dto.recoveryCodes)
|
||||
}
|
||||
|
||||
@Test fun trustedDeviceDecodes() {
|
||||
val dto = json.decodeFromString<TrustedDeviceDto>(
|
||||
"""{"id":5,"platform":"mobile","deviceName":"Pixel 8","userAgent":"RunicGatewayApp/1.0",
|
||||
"createdAt":"2026-07-20T10:00:00Z","lastUsedAt":"2026-07-22T09:00:00Z",
|
||||
"expiresAt":"2026-08-19T10:00:00Z"}""",
|
||||
)
|
||||
assertEquals(5L, dto.id)
|
||||
assertEquals("mobile", dto.platform)
|
||||
assertEquals("Pixel 8", dto.deviceName)
|
||||
assertEquals("2026-07-22T09:00:00Z", dto.lastUsedAt)
|
||||
}
|
||||
|
||||
@Test fun trustDeviceResultCarriesNativeToken() {
|
||||
val dto = json.decodeFromString<TrustDeviceResultDto>(
|
||||
"""{"trusted":true,"trustToken":"opaque-token-abc"}""",
|
||||
)
|
||||
assertTrue(dto.trusted)
|
||||
assertEquals("opaque-token-abc", dto.trustToken)
|
||||
}
|
||||
|
||||
@Test fun trustedDeviceLimitDecodesDevices() {
|
||||
val dto = json.decodeFromString<TrustedDeviceLimitDto>(
|
||||
"""{"error":"trusted_device_limit","devices":[
|
||||
{"id":1,"platform":"web","deviceName":"Firefox"},
|
||||
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}""",
|
||||
)
|
||||
assertEquals("trusted_device_limit", dto.error)
|
||||
assertEquals(2, dto.devices.size)
|
||||
assertEquals(2L, dto.devices[1].id)
|
||||
}
|
||||
|
||||
@Test fun recoveryStatusAndCodesDecode() {
|
||||
assertEquals(7, json.decodeFromString<RecoveryStatusDto>("""{"remaining":7}""").remaining)
|
||||
val codes = json.decodeFromString<RecoveryCodesDto>(
|
||||
"""{"recoveryCodes":["c1","c2","c3"]}""",
|
||||
)
|
||||
assertEquals(3, codes.recoveryCodes.size)
|
||||
}
|
||||
|
||||
@Test fun revokedResultsDecode() {
|
||||
assertTrue(json.decodeFromString<RevokedFlagDto>("""{"revoked":true}""").revoked)
|
||||
assertEquals(4, json.decodeFromString<RevokedCountDto>("""{"revoked":4}""").revoked)
|
||||
}
|
||||
|
||||
@Test fun linkedIdentityDecodes() {
|
||||
val dto = json.decodeFromString<LinkedIdentityDto>(
|
||||
"""{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode/encode tests for the staff-operations DTOs (`/admin/…`, PLAN.md §6.4).
|
||||
* Covers the snake_case `@SerialName` mappings, the `AdminPostDto.isPublished`
|
||||
* tinyint bridge, nested dashboard shapes, and the request bodies the app encodes.
|
||||
*/
|
||||
class AdminDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun dashboardDecodesNestedCountsAndActivity() {
|
||||
val dto = json.decodeFromString<AdminDashboardDto>(
|
||||
"""{
|
||||
"site_mode":"maintenance",
|
||||
"last_change":{"at":"2026-07-20T10:00:00Z","by":"admin"},
|
||||
"counts":{"posts":{"news":4,"newsletter":1},"users":37},
|
||||
"recent_activity":[
|
||||
{"id":9,"username":"mod","action":"post.publish",
|
||||
"detail":{"postId":12},"created_at":"2026-07-22T09:00:00Z"}
|
||||
]
|
||||
}""",
|
||||
)
|
||||
assertEquals("maintenance", dto.siteMode)
|
||||
assertEquals("admin", dto.lastChange.by)
|
||||
assertEquals(4, dto.counts.posts["news"])
|
||||
assertEquals(37, dto.counts.users)
|
||||
assertEquals(1, dto.recentActivity.size)
|
||||
assertEquals("post.publish", dto.recentActivity[0].action)
|
||||
// `detail` is provider-shaped JSON kept as a raw element.
|
||||
assertEquals(12, dto.recentActivity[0].detail!!.jsonObject["postId"]!!.jsonPrimitive.int)
|
||||
}
|
||||
|
||||
@Test fun dashboardDefaultsWhenKeysAbsent() {
|
||||
val dto = json.decodeFromString<AdminDashboardDto>("{}")
|
||||
assertEquals("live", dto.siteMode)
|
||||
assertTrue(dto.counts.posts.isEmpty())
|
||||
assertTrue(dto.recentActivity.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun adminPostBridgesPublishedTinyintToBoolean() {
|
||||
val published = json.decodeFromString<AdminPostDto>(
|
||||
"""{"id":1,"category":"news","title":"Hi","published":1,"published_at":"2026-07-21T00:00:00Z"}""",
|
||||
)
|
||||
assertTrue(published.isPublished)
|
||||
assertEquals("2026-07-21T00:00:00Z", published.publishedAt)
|
||||
|
||||
val draft = json.decodeFromString<AdminPostDto>("""{"id":2,"title":"Draft","published":0}""")
|
||||
assertFalse(draft.isPublished)
|
||||
}
|
||||
|
||||
@Test fun adminWikiCategoryAndTagDecodeCounts() {
|
||||
val cat = json.decodeFromString<AdminWikiCategoryDto>(
|
||||
"""{"id":3,"slug":"lore","title":"Lore","sort_order":2,"page_count":5,"published_count":4}""",
|
||||
)
|
||||
assertEquals(2, cat.sortOrder)
|
||||
assertEquals(5, cat.pageCount)
|
||||
assertEquals(4, cat.publishedCount)
|
||||
|
||||
val tag = json.decodeFromString<AdminWikiTagDto>("""{"id":8,"slug":"pvp","label":"PvP","published_count":11}""")
|
||||
assertEquals("PvP", tag.label)
|
||||
assertEquals(11, tag.publishedCount)
|
||||
}
|
||||
|
||||
@Test fun supportPageDecodesSenderActor() {
|
||||
val dto = json.decodeFromString<SupportPageDto>(
|
||||
"""{"pageId":"0x1A2B","type":"other","message":"stuck",
|
||||
"handled":false,"sender":{"name":"Gwen","account":"gwen01"}}""",
|
||||
)
|
||||
assertEquals("0x1A2B", dto.pageId)
|
||||
assertEquals("Gwen", dto.sender?.name)
|
||||
assertEquals("gwen01", dto.sender?.account)
|
||||
assertEquals(false, dto.handled)
|
||||
}
|
||||
|
||||
@Test fun supportPageToleratesMissingSender() {
|
||||
val dto = json.decodeFromString<SupportPageDto>("""{"pageId":"0x01"}""")
|
||||
assertNull(dto.sender)
|
||||
assertNull(dto.type)
|
||||
}
|
||||
|
||||
@Test fun siteModeStateDecodesAudit() {
|
||||
val dto = json.decodeFromString<SiteModeStateDto>(
|
||||
"""{"site_mode":"maintenance","changed_at":"2026-07-22T08:00:00Z","changed_by":"admin"}""",
|
||||
)
|
||||
assertEquals("maintenance", dto.siteMode)
|
||||
assertEquals("admin", dto.changedBy)
|
||||
}
|
||||
|
||||
@Test fun requestBodiesEncodeWithSnakeCaseKeys() {
|
||||
assertTrue(json.encodeToString(SiteModeRequest("maintenance")).contains("\"mode\":\"maintenance\""))
|
||||
assertTrue(json.encodeToString(PublishRequest(true)).contains("\"published\":true"))
|
||||
assertTrue(json.encodeToString(UnbanRequest("gwen01")).contains("\"account\":\"gwen01\""))
|
||||
assertTrue(json.encodeToString(BroadcastRequest("hello", hue = 33)).contains("\"hue\":33"))
|
||||
assertTrue(json.encodeToString(PageRespondRequest("done", close = true)).contains("\"close\":true"))
|
||||
assertTrue(json.encodeToString(WikiCategoryRequest(slug = "lore", title = "Lore", sortOrder = 1))
|
||||
.contains("\"sort_order\":1"))
|
||||
|
||||
val post = json.encodeToString(PostCreateRequest(category = "news", title = "T", imageUrl = "/img.png"))
|
||||
assertTrue(post.contains("\"image_url\":\"/img.png\""))
|
||||
assertTrue(post.contains("\"category\":\"news\""))
|
||||
|
||||
val ban = json.encodeToString(BanRequest(account = "x", durationSec = 3600, reason = "afk"))
|
||||
assertTrue(ban.contains("\"durationSec\":3600"))
|
||||
|
||||
val kick = json.encodeToString(KickRequest(serial = "0xFF"))
|
||||
assertTrue(kick.contains("\"serial\":\"0xFF\""))
|
||||
}
|
||||
}
|
||||
@@ -68,4 +68,35 @@ class AuthDtoTest {
|
||||
assertNull(dto.expiresIn)
|
||||
assertEquals("admin", dto.user.role)
|
||||
}
|
||||
|
||||
@Test fun loginCarriesTrustTokenWhenDeviceTrusted() {
|
||||
// trustDevice accepted → an opaque token to persist + replay (TRUSTED_DEVICES_MFA.md).
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
|
||||
"trustToken":"opaque-abc"}""",
|
||||
)
|
||||
assertEquals("opaque-abc", dto.trustToken)
|
||||
assertFalse(dto.trustLimitReached)
|
||||
}
|
||||
|
||||
@Test fun loginSignalsTrustLimitWithDevices() {
|
||||
// At the cap: login still succeeds, but no token; the device list is returned.
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
|
||||
"trustLimitReached":true,"devices":[{"id":1,"platform":"mobile","deviceName":"Old"}]}""",
|
||||
)
|
||||
assertNull(dto.trustToken)
|
||||
assertTrue(dto.trustLimitReached)
|
||||
assertEquals(1, dto.devices.size)
|
||||
}
|
||||
|
||||
@Test fun loginWithoutTrustFieldsDefaultsCleanly() {
|
||||
// A normal (no-trust) login omits every trust field — must not crash or mis-flag.
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":4,"username":"d","role":"player"}}""",
|
||||
)
|
||||
assertNull(dto.trustToken)
|
||||
assertFalse(dto.trustLimitReached)
|
||||
assertTrue(dto.devices.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Encode/decode tests for the mobile bearer-auth request bodies (`/auth/mobile/…`)
|
||||
* and the token pair — the snake_case `device_name`, the omit-nulls behaviour, and
|
||||
* the trusted-device outcome fields on the login response.
|
||||
*/
|
||||
class AuthRequestDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun loginRequestEncodesSnakeCaseDeviceNameAndOmitsNulls() {
|
||||
val body = json.encodeToString(
|
||||
MobileLoginRequest(username = "gwen", password = "pw", trustDevice = true, device_name = "Pixel 8"),
|
||||
)
|
||||
assertTrue(body.contains("\"username\":\"gwen\""))
|
||||
assertTrue(body.contains("\"device_name\":\"Pixel 8\""))
|
||||
assertTrue(body.contains("\"trustDevice\":true"))
|
||||
assertFalse(body.contains("\"code\"")) // null omitted (explicitNulls = false)
|
||||
}
|
||||
|
||||
@Test fun loginRequestCarriesSecondFactorOnRetry() {
|
||||
val withCode = json.encodeToString(MobileLoginRequest("u", "p", code = "123456"))
|
||||
assertTrue(withCode.contains("\"code\":\"123456\""))
|
||||
val withRecovery = json.encodeToString(MobileLoginRequest("u", "p", recoveryCode = "aaaa-1111"))
|
||||
assertTrue(withRecovery.contains("\"recoveryCode\":\"aaaa-1111\""))
|
||||
}
|
||||
|
||||
@Test fun refreshAndLogoutBodiesEncode() {
|
||||
assertTrue(json.encodeToString(MobileRefreshRequest("rt")).contains("\"refreshToken\":\"rt\""))
|
||||
assertTrue(json.encodeToString(MobileLogoutRequest(all = true)).contains("\"all\":true"))
|
||||
}
|
||||
|
||||
@Test fun tokenResponseDecodesTrustOutcome() {
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","expiresIn":"15m",
|
||||
"user":{"id":1,"username":"gwen","role":"player"},
|
||||
"trustToken":"opaque"}""",
|
||||
)
|
||||
assertEquals("a", dto.accessToken)
|
||||
assertEquals("opaque", dto.trustToken)
|
||||
assertFalse(dto.trustLimitReached)
|
||||
assertEquals("gwen", dto.user.username)
|
||||
}
|
||||
|
||||
@Test fun tokenResponseDecodesTrustLimitReached() {
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":1,"username":"g","role":"player"},
|
||||
"trustLimitReached":true,"devices":[{"id":1,"platform":"web","deviceName":"FF"}]}""",
|
||||
)
|
||||
assertTrue(dto.trustLimitReached)
|
||||
assertEquals(1, dto.devices.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode tests for the news post + CMS page + contact DTOs (`/public/posts…`,
|
||||
* `/public/pages/:slug`, `/public/contact`). One [PostDto] shape serves both the
|
||||
* list (no body) and detail (with body); a [PageDto] keeps block props as raw JSON.
|
||||
*/
|
||||
class ContentDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun postDetailDecodesBodyAndImage() {
|
||||
val dto = json.decodeFromString<PostDto>(
|
||||
"""{"id":10,"category":"news","title":"Update","slug":"update",
|
||||
"excerpt":"e","body":"<p>full</p>","image_url":"/i.png",
|
||||
"published_at":"2026-07-21T00:00:00Z","created_at":"2026-07-20T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals(10L, dto.id)
|
||||
assertEquals("<p>full</p>", dto.body)
|
||||
assertEquals("/i.png", dto.imageUrl)
|
||||
assertEquals("2026-07-21T00:00:00Z", dto.publishedAt)
|
||||
}
|
||||
|
||||
@Test fun postListRowToleratesMissingBody() {
|
||||
val dto = json.decodeFromString<PostDto>("""{"id":11,"category":"newsletter","title":"N"}""")
|
||||
assertNull(dto.body)
|
||||
assertNull(dto.imageUrl)
|
||||
}
|
||||
|
||||
@Test fun pageDecodesBlocksWithRawProps() {
|
||||
val dto = json.decodeFromString<PageDto>(
|
||||
"""{
|
||||
"id":3,"slug":"about","title":"About","status":"published",
|
||||
"blocks":[
|
||||
{"type":"heading","props":{"text":"Welcome","level":1},"visible":true},
|
||||
{"type":"divider","props":{}}
|
||||
],
|
||||
"publishedAt":"2026-07-01T00:00:00Z"
|
||||
}""",
|
||||
)
|
||||
assertEquals("about", dto.slug)
|
||||
assertEquals(2, dto.blocks.size)
|
||||
assertEquals("heading", dto.blocks[0].type)
|
||||
// props stay a raw JSON object the renderer reads by key.
|
||||
assertEquals("Welcome", dto.blocks[0].props["text"]!!.jsonPrimitive.content)
|
||||
assertTrue(dto.blocks[1].visible) // default true when absent
|
||||
}
|
||||
|
||||
@Test fun contactResponseSentAndFallbackVariants() {
|
||||
val sent = json.decodeFromString<ContactResponse>("""{"sent":true}""")
|
||||
assertTrue(sent.sent)
|
||||
assertNull(sent.fallback)
|
||||
|
||||
val fallback = json.decodeFromString<ContactResponse>(
|
||||
"""{"sent":false,"fallback":"mailto","email":"a@b.c"}""",
|
||||
)
|
||||
assertEquals("mailto", fallback.fallback)
|
||||
assertEquals("a@b.c", fallback.email)
|
||||
}
|
||||
|
||||
@Test fun contactRequestEncodesAllFields() {
|
||||
val body = json.encodeToString(ContactRequest(name = "Gwen", email = "g@x.c", message = "hi"))
|
||||
assertTrue(body.contains("\"name\":\"Gwen\""))
|
||||
assertTrue(body.contains("\"email\":\"g@x.c\""))
|
||||
assertTrue(body.contains("\"message\":\"hi\""))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -58,6 +59,15 @@ class NotificationsDtoTest {
|
||||
assertEquals(listOf("news.post", "champ.start"), dto.streams)
|
||||
}
|
||||
|
||||
@Test fun emptySubscriptionsStillSerializeStreamsField() {
|
||||
// Regression: clearing the LAST subscription sends an empty set. The backend
|
||||
// validator requires `streams`, so it must be present as `[]`, not omitted.
|
||||
// Uses the production Json config (no encodeDefaults) to prove the field is
|
||||
// always emitted because the DTO field has no default.
|
||||
val body = json.encodeToString(NotificationSubscriptionsDto(emptyList()))
|
||||
assertEquals("""{"streams":[]}""", body)
|
||||
}
|
||||
|
||||
@Test fun settingsPushBlockDecodes() {
|
||||
val dto = json.decodeFromString<SettingsDto>(
|
||||
"""{"site_title":"Shard","brand":{"name":"Shard"},"push":{"ntfyUrl":"https://ntfy.shard.tld"}}""",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode/encode tests for the player self-service game-data DTOs
|
||||
* (`/player/shard/…`): account linking, roster, the full character sheet, vendors,
|
||||
* sales, and own-houses. Only the fields the text-only v1 renders are asserted.
|
||||
*/
|
||||
class PlayerGameDataDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun linkRequestAndResultRoundTrip() {
|
||||
assertTrue(json.encodeToString(ShardLinkRequest("ABC123")).contains("\"code\":\"ABC123\""))
|
||||
val result = json.decodeFromString<ShardLinkResultDto>("""{"linked":true,"account":"acct1"}""")
|
||||
assertTrue(result.linked)
|
||||
assertEquals("acct1", result.account)
|
||||
assertTrue(json.encodeToString(CreateGameAccountRequest("acct1", "pw")).contains("\"account\":\"acct1\""))
|
||||
}
|
||||
|
||||
@Test fun linkedAccountDecodes() {
|
||||
val dto = json.decodeFromString<ShardLinkDto>(
|
||||
"""{"account":"acct1","userId":42,"charName":"Gwen","linkedAt":"2026-07-20T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals("acct1", dto.account)
|
||||
assertEquals(42L, dto.userId)
|
||||
}
|
||||
|
||||
@Test fun rosterDecodesCharacters() {
|
||||
val dto = json.decodeFromString<RosterDto>(
|
||||
"""{"acct":"acct1","chars":[
|
||||
{"slot":0,"serial":"0x24C","name":"Gwen","body":401,"online":true},
|
||||
{"slot":1,"serial":"0x24D","name":"Alt","online":false}]}""",
|
||||
)
|
||||
assertEquals(2, dto.chars.size)
|
||||
assertTrue(dto.chars[0].online)
|
||||
assertEquals("0x24C", dto.chars[0].serial)
|
||||
}
|
||||
|
||||
@Test fun charSheetDecodesStatsSkillsEquipmentTitlesGuild() {
|
||||
val dto = json.decodeFromString<CharProfileDto>(
|
||||
"""{
|
||||
"serial":"0x24C","name":"Gwen","title":"the Brave","online":true,"acct":"acct1",
|
||||
"stats":{"str":100,"dex":90,"int":80,"hits":95,"hitsMax":100,
|
||||
"resist":{"phys":70,"fire":50,"cold":45,"pois":40,"energy":35}},
|
||||
"skills":[{"n":"Swords","base":100.0,"value":110.0,"cap":120.0}],
|
||||
"equipment":[{"serial":"0x9","layer":"OneHanded","itemId":5044,"hue":0}],
|
||||
"titles":{"selected":0,"reward":["1049643"],"fameKarma":"Glorious"},
|
||||
"guild":{"name":"Knights","abbr":"KoT"},
|
||||
"governorOf":["Britain"]
|
||||
}""",
|
||||
)
|
||||
assertEquals("Gwen", dto.name)
|
||||
assertEquals(100, dto.stats!!.str)
|
||||
assertEquals(70, dto.stats!!.resist!!.phys)
|
||||
assertEquals(110.0, dto.skills.first().value!!, 0.0)
|
||||
assertEquals("OneHanded", dto.equipment.first().layer)
|
||||
assertEquals("Glorious", dto.titles!!.fameKarma)
|
||||
assertEquals("Knights", dto.guild!!.name)
|
||||
assertEquals(listOf("Britain"), dto.governorOf)
|
||||
}
|
||||
|
||||
@Test fun charSheetToleratesMinimalPayload() {
|
||||
val dto = json.decodeFromString<CharProfileDto>("""{"serial":"0x1","name":"Bare"}""")
|
||||
assertEquals(null, dto.stats)
|
||||
assertTrue(dto.skills.isEmpty())
|
||||
assertTrue(dto.equipment.isEmpty())
|
||||
assertFalse(dto.online)
|
||||
}
|
||||
|
||||
@Test fun vendorSnapshotAndListingsDecode() {
|
||||
val dto = json.decodeFromString<VendorSnapshotDto>(
|
||||
"""{"acct":"acct1","vendors":[
|
||||
{"serial":"0x9","shopName":"Wares","holdGold":5000,"map":"Felucca","x":1,"y":2,
|
||||
"listings":[{"serial":"0xA","itemId":3862,"amount":5,"price":250,"forSale":true}]}]}""",
|
||||
)
|
||||
val vendor = dto.vendors.first()
|
||||
assertEquals("Wares", vendor.shopName)
|
||||
assertEquals(5000L, vendor.holdGold)
|
||||
val listing = vendor.listings.first()
|
||||
assertEquals(250L, listing.price)
|
||||
assertTrue(listing.forSale)
|
||||
}
|
||||
|
||||
@Test fun vendorSaleDecodes() {
|
||||
val dto = json.decodeFromString<VendorSaleDto>(
|
||||
"""{"t":1700000000000,"itemType":"katana","amount":1,"price":1000,"commission":50,"ownerAcct":"acct1"}""",
|
||||
)
|
||||
assertEquals(1000L, dto.price)
|
||||
assertEquals(50, dto.commission)
|
||||
}
|
||||
|
||||
@Test fun playerHouseDecodesDecayFields() {
|
||||
val dto = json.decodeFromString<PlayerHouseDto>(
|
||||
"""{"serial":"0x40001","stage":"LikeNew","region":"Britain","name":"Keep",
|
||||
"isIdoc":false,"builtOn":"2026-01-01T00:00:00Z","lastRefreshed":"2026-07-22T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals("LikeNew", dto.stage)
|
||||
assertEquals("Keep", dto.name)
|
||||
assertFalse(dto.isIdoc)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -99,4 +100,57 @@ class PlayerShardDtoTest {
|
||||
assertTrue(dto.linked)
|
||||
assertEquals("whitlocktech", dto.account)
|
||||
}
|
||||
|
||||
// ── Protocol 3.0 additions to char.profile ───────────────────────────
|
||||
|
||||
@Test fun charProfileDecodesThePointsBlock() {
|
||||
// Shaped like a real shard's reply: an uncapped board (maxPoints 0), a
|
||||
// cliloc-named board (nameString null), and no `rank` unless opted in.
|
||||
val dto = json.decodeFromString<CharProfileDto>(
|
||||
"""{"serial":"0x24C","name":"Darrow",
|
||||
"points":[{"system":"QueensLoyalty","nameString":"Queen's Loyalty",
|
||||
"points":29500,"maxPoints":30000,"rank":3},
|
||||
{"system":"VoidPool","nameString":null,"points":180,"maxPoints":0}]}""",
|
||||
)
|
||||
assertEquals(2, dto.points.size)
|
||||
val queens = dto.points[0]
|
||||
assertEquals("Queen's Loyalty", queens.nameString)
|
||||
assertEquals(29500L, queens.points)
|
||||
assertEquals(30000L, queens.cap)
|
||||
assertEquals(3, queens.rank)
|
||||
|
||||
val voidPool = dto.points[1]
|
||||
assertNull("maxPoints 0 means uncapped, not a zero cap", voidPool.cap)
|
||||
assertNull("rank is absent unless the shard opts in", voidPool.rank)
|
||||
assertNull(voidPool.nameString)
|
||||
}
|
||||
|
||||
@Test fun charProfileWithoutAPointsBlockDecodesToEmpty() {
|
||||
// A shard plugin that predates Protocol 3.0 sends no `points` key at all.
|
||||
val dto = json.decodeFromString<CharProfileDto>("""{"serial":"0x24C","name":"Darrow"}""")
|
||||
assertEquals(emptyList<CharPointsDto>(), dto.points)
|
||||
}
|
||||
|
||||
@Test fun equipmentDecodesTheServerResolvedClilocName() {
|
||||
val dto = json.decodeFromString<CharProfileDto>(
|
||||
"""{"serial":"0x24C",
|
||||
"equipment":[{"serial":"0x40","layer":"OneHanded","itemId":5040,"cliloc":1023721,
|
||||
"clilocName":"hatchet"},
|
||||
{"serial":"0x41","layer":"Shirt","name":"Bob's lucky shirt",
|
||||
"clilocName":"fancy shirt"}]}""",
|
||||
)
|
||||
assertEquals("hatchet", dto.equipment[0].label)
|
||||
assertEquals("Bob's lucky shirt", dto.equipment[1].label)
|
||||
}
|
||||
|
||||
@Test fun titlesDecodeTheParallelResolvedArrayIncludingItsNulls() {
|
||||
// rewardResolved carries a null where the cliloc table had nothing; the array
|
||||
// must stay positionally aligned with `reward`.
|
||||
val dto = json.decodeFromString<TitlesDto>(
|
||||
"""{"selected":1,"reward":["1049565","1049566"],
|
||||
"rewardResolved":[null,"Knight of Trinsic"]}""",
|
||||
)
|
||||
assertEquals(listOf("1049565", "1049566"), dto.reward)
|
||||
assertEquals(listOf(null, "Knight of Trinsic"), dto.rewardResolved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode tests for the public site/identity DTOs (`/public/status`,
|
||||
* `/public/settings`). Covers the `StatusDto.isMaintenance` derivation, nested
|
||||
* branding/registration/push blocks, and the additive-field tolerance.
|
||||
*/
|
||||
class PublicDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun statusDecodesVersionAndMaintenanceFlag() {
|
||||
val dto = json.decodeFromString<StatusDto>(
|
||||
"""{"mode":"MAINTENANCE","status_message":"back soon",
|
||||
"version":{"service":"web","api":"v1","server":"1.2.3"}}""",
|
||||
)
|
||||
assertTrue(dto.isMaintenance) // case-insensitive
|
||||
assertEquals("back soon", dto.statusMessage)
|
||||
assertEquals("1.2.3", dto.version.server)
|
||||
}
|
||||
|
||||
@Test fun liveStatusIsNotMaintenance() {
|
||||
assertFalse(json.decodeFromString<StatusDto>("""{"mode":"live"}""").isMaintenance)
|
||||
}
|
||||
|
||||
@Test fun statusDefaultsWhenEmpty() {
|
||||
val dto = json.decodeFromString<StatusDto>("{}")
|
||||
assertEquals("live", dto.mode)
|
||||
assertFalse(dto.isMaintenance)
|
||||
assertEquals("", dto.version.api)
|
||||
}
|
||||
|
||||
@Test fun settingsDecodesBrandRegistrationAndPush() {
|
||||
val dto = json.decodeFromString<SettingsDto>(
|
||||
"""{
|
||||
"site_title":"UOMysticmoon","status_message":"welcome",
|
||||
"registration":{"password":true,"sso":false},
|
||||
"gameAccountSignup":true,
|
||||
"brand":{"name":"UOMysticmoon","shortName":"UOM","accent":"#7f99bd",
|
||||
"logo":"/logo.png","hero":"/hero.png","contactEmail":"a@b.c","url":"https://x"},
|
||||
"push":{"ntfyUrl":"https://ntfy.example.com"}
|
||||
}""",
|
||||
)
|
||||
assertEquals("UOMysticmoon", dto.siteTitle)
|
||||
assertTrue(dto.registration.password)
|
||||
assertFalse(dto.registration.sso)
|
||||
assertTrue(dto.gameAccountSignup)
|
||||
assertEquals("#7f99bd", dto.brand.accent)
|
||||
assertEquals("/logo.png", dto.brand.logo)
|
||||
assertEquals("https://ntfy.example.com", dto.push.ntfyUrl)
|
||||
}
|
||||
|
||||
@Test fun settingsDefaultsOnOlderBackend() {
|
||||
// A backend that predates push/branding: nested blocks fall back to defaults.
|
||||
val dto = json.decodeFromString<SettingsDto>("""{"site_title":"Bare"}""")
|
||||
assertFalse(dto.registration.password)
|
||||
assertEquals("", dto.brand.name)
|
||||
assertEquals(null, dto.push.ntfyUrl)
|
||||
// …and one that predates admin theming: both M12 fields are simply absent
|
||||
// (THEMING_AND_NAV.md §2 — absence means the shipped defaults).
|
||||
assertEquals(null, dto.theme)
|
||||
assertEquals(null, dto.navPublic)
|
||||
}
|
||||
|
||||
@Test fun settingsDecodesTheThemeAndNavRows() {
|
||||
val dto = json.decodeFromString<SettingsDto>(
|
||||
"""{
|
||||
"theme":{"--accent":"#c8a45c","--radius-card":"3px"},
|
||||
"nav_public":"{\"/wiki\":{\"label\":\"Codex\"}}",
|
||||
"theme_visual":"{\"preset\":\"fantasy\"}"
|
||||
}""",
|
||||
)
|
||||
assertEquals("#c8a45c", (dto.theme as JsonObject)["--accent"]?.jsonPrimitive?.content)
|
||||
// nav_public stays a raw string here: settings.value is TEXT, so it is
|
||||
// parsed a second time by SiteAppearance.
|
||||
assertEquals("""{"/wiki":{"label":"Codex"}}""", dto.navPublic)
|
||||
}
|
||||
|
||||
@Test fun anUnexpectedThemeKindStillDecodesTheRest() {
|
||||
// `theme` is a raw JsonElement precisely so a value we did not expect
|
||||
// cannot fail the decode and take brand/push with it.
|
||||
val dto = json.decodeFromString<SettingsDto>(
|
||||
"""{"theme":"nonsense","brand":{"name":"UOMysticmoon"}}""",
|
||||
)
|
||||
assertEquals("UOMysticmoon", dto.brand.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode tests for the public shard board DTOs (`/public/shard/…`), covering the
|
||||
* computed helpers ([ActorDto.label], [ShardStatusDto.isOnline]) and the
|
||||
* permissive board payloads (champ/guild/governor/house/presence).
|
||||
*/
|
||||
class ShardBoardDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun actorLabelPrefersNameThenAcctThenFallback() {
|
||||
assertEquals("Gwen", json.decodeFromString<ActorDto>("""{"name":"Gwen","acct":"g01"}""").label)
|
||||
assertEquals("g01", json.decodeFromString<ActorDto>("""{"acct":"g01"}""").label)
|
||||
assertEquals("Someone", json.decodeFromString<ActorDto>("{}").label)
|
||||
}
|
||||
|
||||
@Test fun shardStatusIsOnlineOnlyWhenEnabledAndPluginConnected() {
|
||||
val online = json.decodeFromString<ShardStatusDto>(
|
||||
"""{"enabled":true,"pluginConnected":true,"onlineCount":42,
|
||||
"economy":{"accounts":10,"gold":123456.0,"t":1000}}""",
|
||||
)
|
||||
assertTrue(online.isOnline)
|
||||
assertEquals(42, online.onlineCount)
|
||||
assertEquals(123456.0, online.economy!!.gold!!, 0.0)
|
||||
|
||||
assertFalse(json.decodeFromString<ShardStatusDto>("""{"enabled":true,"pluginConnected":false}""").isOnline)
|
||||
assertFalse(json.decodeFromString<ShardStatusDto>("{}").isOnline)
|
||||
}
|
||||
|
||||
@Test fun champDecodesBossAndProgressFields() {
|
||||
val dto = json.decodeFromString<ChampDto>(
|
||||
"""{"serial":"0x1","category":"champion","name":"Rikktor","active":true,
|
||||
"bossUp":true,"boss":"Rikktor","level":3,"maxLevel":16,"kills":10,"maxKills":100,
|
||||
"hits":5000,"hitsMax":9000,"map":"Felucca","x":1,"y":2,"z":0}""",
|
||||
)
|
||||
assertTrue(dto.active)
|
||||
assertTrue(dto.bossUp)
|
||||
assertEquals(16, dto.maxLevel)
|
||||
assertEquals(5000L, dto.hits)
|
||||
}
|
||||
|
||||
@Test fun guildDecodesLeaderActor() {
|
||||
val dto = json.decodeFromString<GuildDto>(
|
||||
"""{"id":7,"name":"Knights","abbr":"KoT","members":12,"online":3,
|
||||
"leader":{"name":"Arthur","webId":"9931"}}""",
|
||||
)
|
||||
assertEquals("Knights", dto.name)
|
||||
assertEquals("Arthur", dto.leader!!.label)
|
||||
assertEquals("9931", dto.leader!!.webId)
|
||||
}
|
||||
|
||||
@Test fun governorAndTermDecode() {
|
||||
val gov = json.decodeFromString<GovernorDto>(
|
||||
"""{"city":"Britain","governor":{"name":"Dawn"},"electionPhase":"campaign"}""",
|
||||
)
|
||||
assertEquals("Britain", gov.city)
|
||||
assertEquals("Dawn", gov.governor!!.label)
|
||||
|
||||
val term = json.decodeFromString<GovernorTermDto>(
|
||||
"""{"city":"Britain","governor":{"name":"Dawn"},"startedAt":1000,"endedAt":2000,"votes":50}""",
|
||||
)
|
||||
assertEquals(50, term.votes)
|
||||
assertEquals(2000L, term.endedAt)
|
||||
}
|
||||
|
||||
@Test fun houseAndPresenceAndStaffDecode() {
|
||||
val house = json.decodeFromString<HouseDto>(
|
||||
"""{"serial":"0x40","name":"Tower","region":"Britain","isIdoc":true,"x":5,"y":6}""",
|
||||
)
|
||||
assertTrue(house.isIdoc)
|
||||
assertEquals("Tower", house.name)
|
||||
|
||||
val presence = json.decodeFromString<PresenceDto>(
|
||||
"""{"count":30,"byFacet":{"Felucca":10,"Trammel":20},"byRegion":{"Britain":5}}""",
|
||||
)
|
||||
assertEquals(30, presence.count)
|
||||
assertEquals(10, presence.byFacet["Felucca"])
|
||||
|
||||
val staff = json.decodeFromString<OnlineStaffDto>("""{"serial":"0x2","name":"GM Bob","map":"Felucca","x":1,"y":2,"z":0}""")
|
||||
assertEquals("GM Bob", staff.name)
|
||||
}
|
||||
|
||||
@Test fun feedEventDecodesPayloadObject() {
|
||||
val ev = json.decodeFromString<FeedEventDto>(
|
||||
"""{"id":9,"kind":"champ.spawn","t":1234,"payload":{"name":"Rikktor"},"createdAt":"2026-07-22T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals("champ.spawn", ev.kind)
|
||||
assertTrue(ev.payload!!.containsKey("name"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode tests for the four Protocol 3.0 content DTOs, against payloads captured from a
|
||||
* **live** server rather than hand-written to match the Kotlin types.
|
||||
*
|
||||
* These exist because the fakes in `data/api/fake/` construct DTOs directly, so no test
|
||||
* in the suite ever fed one real JSON — and `AtlasCreatureDto.places` shipped typed
|
||||
* `List<String>` while the server sends objects. That decodes to an exception, the
|
||||
* screen renders "something went wrong on the server", and 336 green tests say nothing.
|
||||
* Nullable-with-defaults protects against a *missing* field, never a *wrong type*.
|
||||
*/
|
||||
class ShardContentDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
// ── Spawn atlas (§6) ─────────────────────────────────────────────────
|
||||
|
||||
/** Trimmed from `GET /api/v1/public/atlas/creatures/seaserpent` on a real shard. */
|
||||
private val seaSerpent = """
|
||||
{"slug":"seaserpent","name":"SeaSerpent","total":6048,"points":477,
|
||||
"facets":{"Felucca":237,"Trammel":240},"art":"seaserpent.png",
|
||||
"places":[{"facet":"Felucca","label":"Wilderness","spawners":91,"maxAlive":1194},
|
||||
{"facet":"Trammel","label":"Britain","spawners":12,"maxAlive":96}],
|
||||
"spawners":[{"id":1617,"facet":"Felucca","name":"SeaLife#68","x":1691,"y":1623,
|
||||
"width":350,"height":350,"range":175,"maxCount":15,"minDelay":300,
|
||||
"maxDelay":600,"todStart":0,"todEnd":0,"todMode":0,
|
||||
"region":"Britain","landmark":null,"label":"Britain"}],
|
||||
"spawnersTruncated":true,
|
||||
"alsoHere":[{"slug":"waterelemental","name":"WaterElemental","shared":242}]}
|
||||
""".trimIndent()
|
||||
|
||||
@Test fun atlasCreatureDetailDecodesTheRealPayload() {
|
||||
val creature = json.decodeFromString<AtlasCreatureDto>(seaSerpent)
|
||||
|
||||
assertEquals("seaserpent", creature.slug)
|
||||
assertEquals(6048, creature.total)
|
||||
assertEquals(477, creature.points)
|
||||
assertEquals(240, creature.facets["Trammel"])
|
||||
assertTrue(creature.spawnersTruncated)
|
||||
assertEquals("seaserpent.png", creature.art)
|
||||
}
|
||||
|
||||
@Test fun atlasPlacesAreObjectsNotStrings() {
|
||||
// The regression. `places` is the aggregate the screen exists to show, and it
|
||||
// arrives as {facet,label,spawners,maxAlive} — never as a bare place name.
|
||||
val places = json.decodeFromString<AtlasCreatureDto>(seaSerpent).places
|
||||
|
||||
assertEquals(2, places.size)
|
||||
assertEquals("Wilderness", places[0].label)
|
||||
assertEquals("Felucca", places[0].facet)
|
||||
assertEquals(91, places[0].spawners)
|
||||
assertEquals(1194, places[0].maxAlive)
|
||||
}
|
||||
|
||||
@Test fun atlasCreatureSurvivesAProjectedOrEmptyPayload() {
|
||||
// The search route sends no `places`/`spawners`/`art`, and the visibility
|
||||
// framework can drop any field from any of them.
|
||||
val lean = json.decodeFromString<AtlasCreatureDto>("""{"slug":"orc"}""")
|
||||
assertEquals("orc", lean.slug)
|
||||
assertTrue(lean.places.isEmpty())
|
||||
assertTrue(lean.spawners.isEmpty())
|
||||
assertNull(lean.art)
|
||||
|
||||
val bare = json.decodeFromString<AtlasCreatureDto>("""{"places":[{}]}""")
|
||||
assertNull(bare.places[0].label)
|
||||
assertNull(bare.places[0].spawners)
|
||||
}
|
||||
|
||||
// ── Market (§8) ──────────────────────────────────────────────────────
|
||||
|
||||
@Test fun marketListingDecodesWithItsNestedVendorAndLocation() {
|
||||
// `location` nests on the wire so one visibility rule covers map/x/y/region/house.
|
||||
val listing = json.decodeFromString<MarketListingDto>(
|
||||
"""{"serial":"0x40014A57","itemId":3937,"hue":1878,"amount":1,"price":115,
|
||||
"name":null,"cliloc":1023937,"displayName":"longsword","child":false,
|
||||
"vendor":{"serial":"0x2CB","shopName":"Seed Shop 225","ownerSerial":"0x201",
|
||||
"ownerName":"Seed004A",
|
||||
"location":{"map":"Felucca","x":1562,"y":1604,"z":0,
|
||||
"region":"Britain","house":"Seed House 4"}}}""",
|
||||
)
|
||||
|
||||
assertEquals("longsword", listing.displayName)
|
||||
assertEquals(115L, listing.price)
|
||||
assertEquals("Seed Shop 225", listing.vendor?.shopName)
|
||||
assertEquals("Felucca", listing.vendor?.location?.map)
|
||||
}
|
||||
|
||||
@Test fun marketListingSurvivesTheFieldsAVisitorMayNotSee() {
|
||||
// Below the `staff` rung the server omits ownerName/ownerSerial, and below
|
||||
// `player` the whole nested location. Neither may break the decode.
|
||||
val projected = json.decodeFromString<MarketListingDto>(
|
||||
"""{"serial":"0x40014A57","price":115,"displayName":"longsword",
|
||||
"vendor":{"serial":"0x2CB","shopName":"Seed Shop 225"}}""",
|
||||
)
|
||||
|
||||
assertEquals("Seed Shop 225", projected.vendor?.shopName)
|
||||
assertNull(projected.vendor?.ownerName)
|
||||
assertNull(projected.vendor?.location)
|
||||
}
|
||||
|
||||
// ── Points boards (§7) ───────────────────────────────────────────────
|
||||
|
||||
@Test fun pointsBoardDecodesAnEmptyBoardAndItsCap() {
|
||||
// A shard with nothing scored yet is the common case, not an error, and
|
||||
// maxPoints 0 is the "uncapped" sentinel rather than a cap of zero.
|
||||
val board = json.decodeFromString<PointsBoardDto>(
|
||||
"""{"kind":"points.board","system":"QueensLoyalty","nameNumber":1095163,
|
||||
"nameString":null,"players":0,"maxPoints":15000,"showOnGump":true,
|
||||
"top":[],"t":1785556444154,"updatedAt":"2026-08-01T03:54:04.000Z"}""",
|
||||
)
|
||||
|
||||
assertEquals("QueensLoyalty", board.system)
|
||||
assertEquals(15000L, board.maxPoints)
|
||||
assertNull(board.nameString)
|
||||
assertTrue(board.top.isEmpty())
|
||||
}
|
||||
|
||||
// ── Ruleset (§5) ─────────────────────────────────────────────────────
|
||||
|
||||
@Test fun rulesetDecodesTheNestedSectionsAndTolerantlySkipsUnknownOnes() {
|
||||
// The frame is built from an allowlist that grows with the shard's config; a
|
||||
// key this client has never heard of must not break the rules page.
|
||||
val ruleset = json.decodeFromString<RulesetDto>(
|
||||
"""{"kind":"world.ruleset","shard":"My Shard","expansion":"EJ",
|
||||
"caps":{"skill":1000,"totalSkill":7000,"stat":225,"str":125},
|
||||
"systems":{"factions":false,"vvv":true,"siege":false},
|
||||
"accounts":{"charSlots":7,"perIp":3},
|
||||
"somethingAddedLater":{"nested":true}}""",
|
||||
)
|
||||
|
||||
assertEquals("My Shard", ruleset.shard)
|
||||
assertEquals("EJ", ruleset.expansion)
|
||||
// Caps arrive in tenths; the DTO's computed property is what the screen shows.
|
||||
assertEquals(7000, ruleset.caps?.totalSkill)
|
||||
assertEquals(700.0, ruleset.caps?.totalSkillCap!!, 0.0)
|
||||
assertEquals(false, ruleset.systems["factions"])
|
||||
assertEquals(true, ruleset.systems["vvv"])
|
||||
}
|
||||
}
|
||||
@@ -111,4 +111,30 @@ class ShardDtoTest {
|
||||
assertEquals("bob", ActorDto(acct = "bob").label)
|
||||
assertEquals("Someone", ActorDto().label)
|
||||
}
|
||||
|
||||
@Test fun actorArrivesWithoutAcctOrWebIdBelowTheAdminRung() {
|
||||
// Those two fields are locked to `admin` by the visibility framework and are
|
||||
// stripped from every response below it — the app must decode their absence,
|
||||
// not depend on them (docs/link/v3.md §3.4 rule 1).
|
||||
val dto = json.decodeFromString<ActorDto>("""{"serial":"0x24C","name":"Darrow","player":true}""")
|
||||
assertEquals("Darrow", dto.label)
|
||||
assertNull(dto.acct)
|
||||
assertNull(dto.webId)
|
||||
}
|
||||
|
||||
@Test fun shardFeaturesDecodesTheRungAndVisibleSet() {
|
||||
val dto = json.decodeFromString<ShardFeaturesDto>(
|
||||
"""{"level":"player","features":["status","champs","guilds","market"]}""",
|
||||
)
|
||||
assertEquals("player", dto.level)
|
||||
assertTrue(dto.features.contains("market"))
|
||||
assertEquals(4, dto.features.size)
|
||||
}
|
||||
|
||||
@Test fun shardFeaturesDecodesAnEmptySet() {
|
||||
// A fully-gated shard: every feature switched off for this viewer. Distinct
|
||||
// from the lookup failing, which the repository represents as null.
|
||||
val dto = json.decodeFromString<ShardFeaturesDto>("""{"level":"anonymous","features":[]}""")
|
||||
assertEquals(emptyList<String>(), dto.features)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode/encode tests for the Mobile SSO bridge DTOs (PLAN.md §4.2). Provider
|
||||
* discovery is public (never secrets); the exchange body uses snake_case
|
||||
* `code_verifier` to match the backend.
|
||||
*/
|
||||
class SsoDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun providerDecodesWithOptionalFields() {
|
||||
val dto = json.decodeFromString<SsoProviderDto>(
|
||||
"""{"id":"discord","name":"Discord","icon":"discord","loginUrl":"/auth/discord","priority":2}""",
|
||||
)
|
||||
assertEquals("discord", dto.id)
|
||||
assertEquals("Discord", dto.name)
|
||||
assertEquals(2, dto.priority)
|
||||
}
|
||||
|
||||
@Test fun providerToleratesMissingOptionals() {
|
||||
val dto = json.decodeFromString<SsoProviderDto>("""{"id":"oidc","name":"Corp SSO"}""")
|
||||
assertNull(dto.icon)
|
||||
assertNull(dto.priority)
|
||||
}
|
||||
|
||||
@Test fun exchangeRequestEncodesSnakeCaseVerifier() {
|
||||
val body = json.encodeToString(MobileSsoExchangeRequest(code = "abc123", codeVerifier = "v-e-r-i-f-i-e-r"))
|
||||
assertTrue(body.contains("\"code\":\"abc123\""))
|
||||
assertTrue(body.contains("\"code_verifier\":\"v-e-r-i-f-i-e-r\""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode tests for the wiki DTOs (`/public/wiki*`). Summary rows omit the body;
|
||||
* the detail page carries tags, backlinks, and unresolved ("red") link targets.
|
||||
*/
|
||||
class WikiDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun summaryRowDecodesWithCategory() {
|
||||
val dto = json.decodeFromString<WikiSummaryDto>(
|
||||
"""{"id":5,"slug":"pvp","title":"PvP","excerpt":"combat",
|
||||
"category_slug":"systems","category_title":"Systems","updated_at":"2026-07-20T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals(5L, dto.id)
|
||||
assertEquals("systems", dto.categorySlug)
|
||||
assertEquals("Systems", dto.categoryTitle)
|
||||
assertEquals("combat", dto.excerpt)
|
||||
}
|
||||
|
||||
@Test fun pageDecodesTagsBacklinksAndMissingLinks() {
|
||||
val dto = json.decodeFromString<WikiPageDto>(
|
||||
"""{
|
||||
"id":9,"slug":"housing","title":"Housing","body":"<p>text</p>",
|
||||
"category_slug":"systems","category_title":"Systems",
|
||||
"tags":[{"slug":"idoc","label":"IDOC"}],
|
||||
"backlinks":[{"slug":"pvp","title":"PvP"}],
|
||||
"missing_links":["nonexistent-page"]
|
||||
}""",
|
||||
)
|
||||
assertEquals("<p>text</p>", dto.body)
|
||||
assertEquals(1, dto.tags.size)
|
||||
assertEquals("IDOC", dto.tags[0].label)
|
||||
assertEquals("pvp", dto.backlinks[0].slug)
|
||||
assertEquals(listOf("nonexistent-page"), dto.missingLinks)
|
||||
}
|
||||
|
||||
@Test fun pageDefaultsCollectionsWhenAbsent() {
|
||||
val dto = json.decodeFromString<WikiPageDto>("""{"id":1,"slug":"x","title":"X"}""")
|
||||
assertTrue(dto.tags.isEmpty())
|
||||
assertTrue(dto.backlinks.isEmpty())
|
||||
assertTrue(dto.missingLinks.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun categoryAndTagDecodePublishedCounts() {
|
||||
val cat = json.decodeFromString<WikiCategoryDto>(
|
||||
"""{"id":2,"slug":"systems","title":"Systems","description":"d","published_count":12}""",
|
||||
)
|
||||
assertEquals(12L, cat.publishedCount)
|
||||
val tag = json.decodeFromString<WikiTagDto>("""{"id":4,"slug":"idoc","label":"IDOC","published_count":3}""")
|
||||
assertEquals(3L, tag.publishedCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.AdminApi
|
||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||
import com.runicgateway.app.data.api.dto.BanRequest
|
||||
import com.runicgateway.app.data.api.dto.BroadcastRequest
|
||||
import com.runicgateway.app.data.api.dto.KickRequest
|
||||
import com.runicgateway.app.data.api.dto.PageRespondRequest
|
||||
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||
import com.runicgateway.app.data.api.dto.PublishRequest
|
||||
import com.runicgateway.app.data.api.dto.SiteModeRequest
|
||||
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
||||
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||
import com.runicgateway.app.data.api.dto.UnbanRequest
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
||||
import com.runicgateway.app.util.okUnit
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* A configurable fake of [AdminApi] for the staff-ops repository/ViewModel tests.
|
||||
* Read endpoints return their `var`; write endpoints returning `Response<Unit>`
|
||||
* return [unitResponse] (default 200) so a test can drive the 200 / 403 / 503 copy
|
||||
* branches. Set [error] to throw from every call (network / decode failure paths).
|
||||
*/
|
||||
class FakeAdminApi : AdminApi {
|
||||
|
||||
var error: Throwable? = null
|
||||
|
||||
var dashboard: AdminDashboardDto = AdminDashboardDto()
|
||||
var siteMode: SiteModeStateDto = SiteModeStateDto()
|
||||
var posts: List<AdminPostDto> = emptyList()
|
||||
var createdPost: AdminPostDto = AdminPostDto(id = 0)
|
||||
var publishedPost: AdminPostDto = AdminPostDto(id = 0)
|
||||
var wikiCategories: List<AdminWikiCategoryDto> = emptyList()
|
||||
var createdCategory: AdminWikiCategoryDto = AdminWikiCategoryDto(id = 0)
|
||||
var wikiTags: List<AdminWikiTagDto> = emptyList()
|
||||
var supportPages: List<SupportPageDto> = emptyList()
|
||||
|
||||
/** Response returned by the bodyless write endpoints (kick/ban/delete/respond/…). */
|
||||
var unitResponse: Response<Unit> = okUnit()
|
||||
|
||||
/** Bodies seen by write calls, so a test can assert what was sent. */
|
||||
var lastPostCreate: PostCreateRequest? = null
|
||||
var lastBan: BanRequest? = null
|
||||
var lastRespond: Pair<String, PageRespondRequest>? = null
|
||||
|
||||
private fun <T> reply(value: T): T {
|
||||
error?.let { throw it }
|
||||
return value
|
||||
}
|
||||
|
||||
override suspend fun dashboard(): AdminDashboardDto = reply(dashboard)
|
||||
override suspend fun setSiteMode(body: SiteModeRequest): SiteModeStateDto = reply(siteMode)
|
||||
|
||||
override suspend fun posts(): List<AdminPostDto> = reply(posts)
|
||||
override suspend fun createPost(body: PostCreateRequest): AdminPostDto {
|
||||
lastPostCreate = body
|
||||
return reply(createdPost)
|
||||
}
|
||||
override suspend fun publishPost(id: Long, body: PublishRequest): AdminPostDto = reply(publishedPost)
|
||||
override suspend fun deletePost(id: Long): Response<Unit> = reply(unitResponse)
|
||||
|
||||
override suspend fun wikiCategories(): List<AdminWikiCategoryDto> = reply(wikiCategories)
|
||||
override suspend fun createWikiCategory(body: WikiCategoryRequest): AdminWikiCategoryDto = reply(createdCategory)
|
||||
override suspend fun deleteWikiCategory(id: Long): Response<Unit> = reply(unitResponse)
|
||||
override suspend fun wikiTags(): List<AdminWikiTagDto> = reply(wikiTags)
|
||||
|
||||
override suspend fun kick(body: KickRequest): Response<Unit> = reply(unitResponse)
|
||||
override suspend fun ban(body: BanRequest): Response<Unit> {
|
||||
lastBan = body
|
||||
return reply(unitResponse)
|
||||
}
|
||||
override suspend fun unban(body: UnbanRequest): Response<Unit> = reply(unitResponse)
|
||||
override suspend fun broadcast(body: BroadcastRequest): Response<Unit> = reply(unitResponse)
|
||||
|
||||
override suspend fun supportPages(): List<SupportPageDto> = reply(supportPages)
|
||||
override suspend fun respondPage(id: String, body: PageRespondRequest): Response<Unit> {
|
||||
lastRespond = id to body
|
||||
return reply(unitResponse)
|
||||
}
|
||||
override suspend fun closePage(id: String): Response<Unit> = reply(unitResponse)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.PlayerShardApi
|
||||
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||
import com.runicgateway.app.data.api.dto.CreateGameAccountRequest
|
||||
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||
import com.runicgateway.app.data.api.dto.RosterDto
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkDto
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkRequest
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
|
||||
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
|
||||
|
||||
/**
|
||||
* A configurable fake of [PlayerShardApi] for the player self-service repository /
|
||||
* ViewModel tests. Set the relevant `var`; set [error] to throw from every call
|
||||
* (drives the `503 shard offline` / `403 not-linked` / network paths).
|
||||
*/
|
||||
class FakePlayerShardApi : PlayerShardApi {
|
||||
|
||||
var error: Throwable? = null
|
||||
|
||||
var linkResult: ShardLinkResultDto = ShardLinkResultDto()
|
||||
var accounts: List<ShardLinkDto> = emptyList()
|
||||
var roster: RosterDto = RosterDto()
|
||||
var char: CharProfileDto = CharProfileDto()
|
||||
var vendors: VendorSnapshotDto = VendorSnapshotDto()
|
||||
var sales: List<VendorSaleDto> = emptyList()
|
||||
var houses: List<PlayerHouseDto> = emptyList()
|
||||
|
||||
private fun <T> reply(value: T): T {
|
||||
error?.let { throw it }
|
||||
return value
|
||||
}
|
||||
|
||||
override suspend fun link(body: ShardLinkRequest): ShardLinkResultDto = reply(linkResult)
|
||||
override suspend fun createAccount(body: CreateGameAccountRequest): ShardLinkResultDto = reply(linkResult)
|
||||
override suspend fun accounts(): List<ShardLinkDto> = reply(accounts)
|
||||
override suspend fun roster(account: String): RosterDto = reply(roster)
|
||||
override suspend fun char(serial: String): CharProfileDto = reply(char)
|
||||
override suspend fun vendors(account: String): VendorSnapshotDto = reply(vendors)
|
||||
override suspend fun sales(): List<VendorSaleDto> = reply(sales)
|
||||
override suspend fun houses(): List<PlayerHouseDto> = reply(houses)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.data.api.dto.ContactRequest
|
||||
import com.runicgateway.app.data.api.dto.ContactResponse
|
||||
import com.runicgateway.app.data.api.dto.EconomySampleDto
|
||||
import com.runicgateway.app.data.api.dto.FeedEventDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.PageDto
|
||||
import com.runicgateway.app.data.api.dto.PostDto
|
||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
|
||||
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
|
||||
import com.runicgateway.app.data.api.dto.AtlasMetaDto
|
||||
import com.runicgateway.app.data.api.dto.MarketMetaDto
|
||||
import com.runicgateway.app.data.api.dto.MarketPageDto
|
||||
import com.runicgateway.app.data.api.dto.MarketVendorDto
|
||||
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||
import com.runicgateway.app.data.api.dto.RulesetDto
|
||||
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.WikiPageDto
|
||||
import com.runicgateway.app.data.api.dto.WikiSummaryDto
|
||||
import com.runicgateway.app.data.api.dto.WikiTagDto
|
||||
|
||||
/**
|
||||
* A configurable fake of [PublicApi] for repository/ViewModel tests. Set the
|
||||
* relevant `var` to the body a call should return; set [error] to make every call
|
||||
* throw (drives the `ApiResult.HttpError` / `NetworkError` paths). Defaults are
|
||||
* empty/neutral so a call an assertion doesn't care about never crashes.
|
||||
*/
|
||||
class FakePublicApi : PublicApi {
|
||||
|
||||
/** When non-null, every call throws this (use `httpError(code)` or an IOException). */
|
||||
var error: Throwable? = null
|
||||
|
||||
var status: StatusDto = StatusDto()
|
||||
var settings: SettingsDto = SettingsDto()
|
||||
var posts: List<PostDto> = emptyList()
|
||||
var post: PostDto = PostDto(id = 0)
|
||||
var page: PageDto = PageDto(id = 0)
|
||||
var wikiPages: List<WikiSummaryDto> = emptyList()
|
||||
var wikiCategories: List<WikiCategoryDto> = emptyList()
|
||||
var wikiTags: List<WikiTagDto> = emptyList()
|
||||
var wikiPage: WikiPageDto = WikiPageDto(id = 0)
|
||||
var contactResponse: ContactResponse = ContactResponse(sent = true)
|
||||
var shardStatus: ShardStatusDto = ShardStatusDto()
|
||||
var shardFeed: List<FeedEventDto> = emptyList()
|
||||
var shardEconomy: List<EconomySampleDto> = emptyList()
|
||||
var shardOnline: List<OnlineStaffDto> = emptyList()
|
||||
var shardPresence: PresenceDto = PresenceDto()
|
||||
var champs: List<ChampDto> = emptyList()
|
||||
var guilds: List<GuildDto> = emptyList()
|
||||
var governors: List<GovernorDto> = emptyList()
|
||||
var governorHistory: List<GovernorTermDto> = emptyList()
|
||||
var houses: List<HouseDto> = emptyList()
|
||||
var shardFeatures: ShardFeaturesDto = ShardFeaturesDto()
|
||||
|
||||
// Protocol 3.0 content (M11). `ruleset` is nullable on the wire: null means the
|
||||
// shard has never published one, which is a success, not a failure.
|
||||
var ruleset: RulesetDto? = null
|
||||
var pointsBoards: List<PointsBoardDto> = emptyList()
|
||||
var pointsBoard: PointsBoardDto = PointsBoardDto()
|
||||
var market: MarketPageDto = MarketPageDto()
|
||||
var marketMeta: MarketMetaDto = MarketMetaDto()
|
||||
var marketVendor: MarketVendorDto = MarketVendorDto()
|
||||
var atlasCreatures: AtlasCreaturePageDto = AtlasCreaturePageDto()
|
||||
var atlasCreature: AtlasCreatureDto = AtlasCreatureDto()
|
||||
var atlasMeta: AtlasMetaDto = AtlasMetaDto()
|
||||
|
||||
/** Last market query seen, so a test can assert blanks were dropped. */
|
||||
var lastMarketQuery: String? = null
|
||||
|
||||
/** Last atlas facet filter seen. */
|
||||
var lastAtlasFacet: String? = null
|
||||
|
||||
/** Last contact request body seen (so a test can assert it was trimmed/forwarded). */
|
||||
var lastContact: ContactRequest? = null
|
||||
|
||||
private fun <T> reply(value: T): T {
|
||||
error?.let { throw it }
|
||||
return value
|
||||
}
|
||||
|
||||
override suspend fun probeStatus(absoluteStatusUrl: String): StatusDto = reply(status)
|
||||
override suspend fun getStatus(): StatusDto = reply(status)
|
||||
override suspend fun getSettings(): SettingsDto = reply(settings)
|
||||
|
||||
override suspend fun getPosts(category: String): List<PostDto> = reply(posts)
|
||||
override suspend fun getPost(category: String, idOrSlug: String): PostDto = reply(post)
|
||||
override suspend fun getPage(slug: String): PageDto = reply(page)
|
||||
|
||||
override suspend fun getWikiPages(query: String?, category: String?, tag: String?): List<WikiSummaryDto> =
|
||||
reply(wikiPages)
|
||||
override suspend fun getWikiCategories(): List<WikiCategoryDto> = reply(wikiCategories)
|
||||
override suspend fun getWikiTags(): List<WikiTagDto> = reply(wikiTags)
|
||||
override suspend fun getWikiPage(slug: String): WikiPageDto = reply(wikiPage)
|
||||
|
||||
override suspend fun postContact(body: ContactRequest): ContactResponse {
|
||||
lastContact = body
|
||||
return reply(contactResponse)
|
||||
}
|
||||
|
||||
override suspend fun getShardFeatures(): ShardFeaturesDto = reply(shardFeatures)
|
||||
override suspend fun getShardRuleset(): RulesetDto? = reply(ruleset)
|
||||
override suspend fun getShardPoints(): List<PointsBoardDto> = reply(pointsBoards)
|
||||
override suspend fun getShardPointsBoard(system: String): PointsBoardDto = reply(pointsBoard)
|
||||
|
||||
override suspend fun getShardMarket(
|
||||
query: String?,
|
||||
minPrice: Long?,
|
||||
maxPrice: Long?,
|
||||
map: String?,
|
||||
region: String?,
|
||||
sort: String?,
|
||||
limit: Int?,
|
||||
offset: Int?,
|
||||
): MarketPageDto {
|
||||
lastMarketQuery = query
|
||||
return reply(market)
|
||||
}
|
||||
|
||||
override suspend fun getShardMarketMeta(): MarketMetaDto = reply(marketMeta)
|
||||
override suspend fun getShardMarketVendor(serial: String, limit: Int?, offset: Int?): MarketVendorDto =
|
||||
reply(marketVendor)
|
||||
|
||||
override suspend fun getAtlasCreatures(
|
||||
query: String?,
|
||||
facet: String?,
|
||||
limit: Int?,
|
||||
offset: Int?,
|
||||
): AtlasCreaturePageDto {
|
||||
lastAtlasFacet = facet
|
||||
return reply(atlasCreatures)
|
||||
}
|
||||
|
||||
override suspend fun getAtlasCreature(slug: String): AtlasCreatureDto = reply(atlasCreature)
|
||||
override suspend fun getAtlasMeta(): AtlasMetaDto = reply(atlasMeta)
|
||||
override suspend fun getShardStatus(): ShardStatusDto = reply(shardStatus)
|
||||
override suspend fun getShardFeed(kind: String?, limit: Int?): List<FeedEventDto> = reply(shardFeed)
|
||||
override suspend fun getShardEconomy(limit: Int?): List<EconomySampleDto> = reply(shardEconomy)
|
||||
override suspend fun getShardOnline(): List<OnlineStaffDto> = reply(shardOnline)
|
||||
override suspend fun getShardPresence(): PresenceDto = reply(shardPresence)
|
||||
override suspend fun getShardChamps(): List<ChampDto> = reply(champs)
|
||||
override suspend fun getShardGuilds(): List<GuildDto> = reply(guilds)
|
||||
override suspend fun getShardGovernors(): List<GovernorDto> = reply(governors)
|
||||
override suspend fun getShardGovernorHistory(city: String, limit: Int?): List<GovernorTermDto> =
|
||||
reply(governorHistory)
|
||||
override suspend fun getShardHouses(): List<HouseDto> = reply(houses)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.core.net.ShardStream
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
/**
|
||||
* A finite fake of the live [ShardStream] for board-ViewModel tests: it emits the
|
||||
* given [events] once and completes, so the ViewModel's `collectLive()` finishes
|
||||
* immediately (no perpetual reconnect loop) and any live-frame handling it triggers
|
||||
* is exercised deterministically.
|
||||
*/
|
||||
class FakeShardStream(private val events: List<ShardStreamEvent> = emptyList()) : ShardStream {
|
||||
override fun events(): Flow<ShardStreamEvent> = flowOf(*events.toTypedArray())
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.appearance
|
||||
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The second-stage parse of a JSON-valued settings row (THEMING_AND_NAV.md §3).
|
||||
* The rule under test is the one the web client's `parseJsonSetting` states:
|
||||
* anything that is not a plain object reads as **absent**, never as an error.
|
||||
*/
|
||||
class SettingsJsonTest {
|
||||
|
||||
@Test fun parsesAPlainObject() {
|
||||
val parsed = parseJsonSetting("""{"/wiki":{"label":"Codex","order":0}}""")
|
||||
assertEquals(1, parsed!!.size)
|
||||
assertEquals(setOf("/wiki"), parsed.keys)
|
||||
}
|
||||
|
||||
@Test fun parsesTheWrappedPublicShape() {
|
||||
val parsed = parseJsonSetting(
|
||||
"""{"items":{"/":{"hidden":true}},"sections":[{"id":"s1","label":"Play"}],"links":[]}""",
|
||||
)
|
||||
assertEquals(setOf("items", "sections", "links"), parsed!!.keys)
|
||||
}
|
||||
|
||||
@Test fun absentValuesReadAsNull() {
|
||||
assertNull(parseJsonSetting(null))
|
||||
assertNull(parseJsonSetting(""))
|
||||
}
|
||||
|
||||
@Test fun malformedJsonReadsAsNull() {
|
||||
assertNull(parseJsonSetting("{"))
|
||||
assertNull(parseJsonSetting("""{"a":}"""))
|
||||
assertNull(parseJsonSetting("not json at all"))
|
||||
}
|
||||
|
||||
@Test fun nonObjectJsonReadsAsNull() {
|
||||
// A stored `null`, number, string or array is as unusable to every
|
||||
// consumer of these keys as a syntax error is.
|
||||
assertNull(parseJsonSetting("null"))
|
||||
assertNull(parseJsonSetting("4"))
|
||||
assertNull(parseJsonSetting("\"x\""))
|
||||
assertNull(parseJsonSetting("[]"))
|
||||
}
|
||||
|
||||
@Test fun unusualKeysAndValuesSurviveVerbatim() {
|
||||
// The parse stage validates the *kind*, not the shape — a nonsense entry
|
||||
// is dropped later, by the phase that reads it.
|
||||
val parsed = parseJsonSetting("""{"/site/news":{"order":"first"},"nonsense":7}""")
|
||||
assertEquals(JsonPrimitive(7), parsed!!["nonsense"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.appearance
|
||||
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [SiteAppearance.from] — the coercion between the settings payload and what the
|
||||
* theme and the drawer read (THEMING_AND_NAV.md §2, §3).
|
||||
*
|
||||
* The claims that matter here are the two the milestone rests on: an untouched
|
||||
* instance resolves to *nothing* (so the shipped app renders), and a bad token
|
||||
* costs exactly its own token.
|
||||
*/
|
||||
class SiteAppearanceTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
private fun appearanceOf(body: String) =
|
||||
SiteAppearance.from(json.decodeFromString<SettingsDto>(body))
|
||||
|
||||
@Test fun untouchedInstanceResolvesToNoOverrides() {
|
||||
// No theme_visual row, no nav_public row: the shipped app, exactly (§2).
|
||||
val appearance = appearanceOf("""{"brand":{"name":"UOMysticmoon"}}""")
|
||||
assertTrue(appearance.theme.isEmpty())
|
||||
assertNull(appearance.navPublic)
|
||||
assertEquals("UOMysticmoon", appearance.brand?.name)
|
||||
}
|
||||
|
||||
@Test fun failedSettingsCallIsTheSameAsNoOverrides() {
|
||||
assertSame(SiteAppearance.NONE, SiteAppearance.from(null))
|
||||
assertNull(SiteAppearance.NONE.brand)
|
||||
assertTrue(SiteAppearance.NONE.theme.isEmpty())
|
||||
assertNull(SiteAppearance.NONE.navPublic)
|
||||
}
|
||||
|
||||
@Test fun resolvedThemeTokensAreReadAsAMap() {
|
||||
val appearance = appearanceOf(
|
||||
"""{"theme":{"--accent":"#c8a45c","--bg":"#1a1410","--radius-card":"10px",
|
||||
"--shadow-card":"none","--sans":"Inter, sans-serif"}}""",
|
||||
)
|
||||
assertEquals("#c8a45c", appearance.theme["--accent"])
|
||||
assertEquals("#1a1410", appearance.theme["--bg"])
|
||||
assertEquals("10px", appearance.theme["--radius-card"])
|
||||
assertEquals("none", appearance.theme["--shadow-card"])
|
||||
assertEquals("Inter, sans-serif", appearance.theme["--sans"])
|
||||
}
|
||||
|
||||
@Test fun anEmptyThemeMapIsTheSameAsAbsent() {
|
||||
// The server returns null rather than {} — the app must not depend on that.
|
||||
assertTrue(appearanceOf("""{"theme":{}}""").theme.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun aBadTokenCostsOnlyItself() {
|
||||
// AC-2 in miniature at the decode boundary: a non-string or blank value is
|
||||
// dropped field-by-field, and its neighbours still apply.
|
||||
val appearance = appearanceOf(
|
||||
"""{"theme":{"--accent":"#c8a45c","--bg":7,"--line":null,"--ink":" "}}""",
|
||||
)
|
||||
assertEquals(mapOf("--accent" to "#c8a45c"), appearance.theme)
|
||||
}
|
||||
|
||||
@Test fun aThemeOfTheWrongKindDoesNotCostTheBrand() {
|
||||
// The whole reason `theme` is modeled as a raw JsonElement: one unexpected
|
||||
// value must not fail the decode and take brand and push down with it.
|
||||
val appearance = appearanceOf(
|
||||
"""{"theme":"not an object","brand":{"name":"UOMysticmoon","accent":"#7f99bd"},
|
||||
"push":{"ntfyUrl":"https://ntfy.example.com"}}""",
|
||||
)
|
||||
assertTrue(appearance.theme.isEmpty())
|
||||
assertEquals("#7f99bd", appearance.brand?.accent)
|
||||
}
|
||||
|
||||
@Test fun navPublicIsParsedASecondTime() {
|
||||
// It arrives as a JSON string inside a JSON object, because settings.value
|
||||
// is TEXT.
|
||||
val appearance = appearanceOf("""{"nav_public":"{\"/wiki\":{\"label\":\"Codex\"}}"}""")
|
||||
assertEquals(setOf("/wiki"), appearance.navPublic?.keys)
|
||||
}
|
||||
|
||||
@Test fun aMalformedNavPublicDoesNotCostTheTheme() {
|
||||
val appearance = appearanceOf("""{"nav_public":"{oops","theme":{"--accent":"#c8a45c"}}""")
|
||||
assertNull(appearance.navPublic)
|
||||
assertEquals("#c8a45c", appearance.theme["--accent"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.MeApi
|
||||
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
|
||||
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
|
||||
import com.runicgateway.app.data.api.dto.RevokedCountDto
|
||||
import com.runicgateway.app.data.api.dto.RevokedFlagDto
|
||||
import com.runicgateway.app.data.api.dto.TotpCodeRequest
|
||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* [AccountRepository] trusted-device + recovery logic (TRUSTED_DEVICES_MFA.md) over a
|
||||
* fake [MeApi]. The interesting case is the `409` cap: the device list must survive
|
||||
* into a typed [TrustOutcome.LimitReached] rather than being lost as a bare error.
|
||||
*/
|
||||
class AccountTrustedDevicesTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
/** A fake MeApi; only the trusted-device/recovery methods under test are wired. */
|
||||
private open class FakeMeApi(
|
||||
var trustResponse: Response<TrustDeviceResultDto>? = null,
|
||||
var devices: List<TrustedDeviceDto> = emptyList(),
|
||||
var revokeFlag: Boolean = true,
|
||||
var revokeCount: Int = 0,
|
||||
var remaining: Int = 0,
|
||||
var generated: List<String> = emptyList(),
|
||||
) : MeApi {
|
||||
override suspend fun trustedDevices(): List<TrustedDeviceDto> = devices
|
||||
override suspend fun trustThisDevice(body: TrustDeviceRequest): Response<TrustDeviceResultDto> =
|
||||
trustResponse ?: Response.success(TrustDeviceResultDto(trusted = true, trustToken = "t"))
|
||||
override suspend fun revokeTrustedDevice(id: Long): RevokedFlagDto = RevokedFlagDto(revokeFlag)
|
||||
override suspend fun revokeAllTrustedDevices(): RevokedCountDto = RevokedCountDto(revokeCount)
|
||||
override suspend fun recoveryCodesStatus(): RecoveryStatusDto = RecoveryStatusDto(remaining)
|
||||
override suspend fun generateRecoveryCodes(body: RecoveryGenerateRequest): RecoveryCodesDto =
|
||||
RecoveryCodesDto(generated)
|
||||
|
||||
// Unused by these tests.
|
||||
override suspend fun getAccount(): PlayerAccountDto = PlayerAccountDto()
|
||||
override suspend fun changeUsername(body: ChangeUsernameRequest): UsernameResponse = UsernameResponse()
|
||||
override suspend fun changePassword(body: ChangePasswordRequest) = Unit
|
||||
override suspend fun totpSetup(): TotpSetupDto = TotpSetupDto()
|
||||
override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
|
||||
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
|
||||
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
|
||||
override suspend fun unlinkIdentity(provider: String) = Unit
|
||||
}
|
||||
|
||||
private fun repo(api: MeApi) = AccountRepository(api, json)
|
||||
|
||||
@Test fun trustThisDeviceReturnsToken() = runTest {
|
||||
val api = FakeMeApi(trustResponse = Response.success(TrustDeviceResultDto(true, "opaque-xyz")))
|
||||
val outcome = repo(api).trustThisDevice("Pixel")
|
||||
assertTrue(outcome is TrustOutcome.Trusted)
|
||||
assertEquals("opaque-xyz", (outcome as TrustOutcome.Trusted).trustToken)
|
||||
}
|
||||
|
||||
@Test fun trustThisDeviceParsesCapDevicesFrom409() = runTest {
|
||||
val body = """{"error":"trusted_device_limit","devices":[
|
||||
{"id":1,"platform":"web","deviceName":"Firefox"},
|
||||
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}"""
|
||||
.toResponseBody("application/json".toMediaTypeOrNull())
|
||||
val api = FakeMeApi(trustResponse = Response.error(409, body))
|
||||
val outcome = repo(api).trustThisDevice(null)
|
||||
assertTrue(outcome is TrustOutcome.LimitReached)
|
||||
val devices = (outcome as TrustOutcome.LimitReached).devices
|
||||
assertEquals(2, devices.size)
|
||||
assertEquals("Pixel", devices[1].deviceName)
|
||||
}
|
||||
|
||||
@Test fun trustThisDeviceOtherErrorIsServerError() = runTest {
|
||||
val body = """{"message":"boom"}""".toResponseBody("application/json".toMediaTypeOrNull())
|
||||
val api = FakeMeApi(trustResponse = Response.error(500, body))
|
||||
assertTrue(repo(api).trustThisDevice(null) is TrustOutcome.ServerError)
|
||||
}
|
||||
|
||||
@Test fun revokeMapsFlagAndCount() = runTest {
|
||||
val revoked = repo(FakeMeApi(revokeFlag = true)).revokeTrustedDevice(9)
|
||||
assertTrue(revoked is ApiResult.Ok && revoked.data)
|
||||
|
||||
val all = repo(FakeMeApi(revokeCount = 3)).revokeAllTrustedDevices()
|
||||
assertTrue(all is ApiResult.Ok && all.data == 3)
|
||||
}
|
||||
|
||||
@Test fun recoveryStatusAndGenerateMap() = runTest {
|
||||
val status = repo(FakeMeApi(remaining = 6)).recoveryCodesStatus()
|
||||
assertTrue(status is ApiResult.Ok && status.data.remaining == 6)
|
||||
|
||||
val gen = repo(FakeMeApi(generated = listOf("a", "b"))).generateRecoveryCodes("pw")
|
||||
assertTrue(gen is ApiResult.Ok)
|
||||
assertEquals(listOf("a", "b"), (gen as ApiResult.Ok).data.recoveryCodes)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user