Compare commits
45 Commits
5e61ee2bbb
...
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 02fb7d47d5 | |||
| 6630d05e76 | |||
| b20abdf4ba | |||
| 5386889ef4 | |||
| d0c5bd6ddf | |||
| 6be83fe70a | |||
| 19c2f77a98 | |||
| 690441a8c3 | |||
| ac60427a3e | |||
| 1579a1982c | |||
| aca358d72b | |||
| 7a8231ba9c | |||
| 3d01871c72 | |||
| 478206e0bd | |||
| c41aff9b2e | |||
| bb9cd9008d | |||
| 3048582ce0 | |||
| 5e28989adc | |||
| ff8b66ace1 | |||
| 2c8ab390e2 | |||
| c905aee94f | |||
| 8e7a41f053 | |||
| 6a6fa5d76e | |||
| 8c6c6f918d | |||
| 531f3b2c4b | |||
| 3dddcb6525 | |||
| e6aa9526c8 | |||
| 63bdf824be | |||
| 96aae7239b | |||
| 341c13a98c | |||
| 89c317f694 | |||
| edcbc0727b | |||
| 09eb101c52 | |||
| adf9547e01 | |||
| 640b423dfa | |||
| 8e3c62d966 | |||
| f695eb6c45 | |||
| 59d955a11d | |||
| a079bd481e | |||
| 4878b74e09 | |||
| 37a828736e | |||
| 4b22ab3756 | |||
| daf483f514 | |||
| a6677d5bf9 | |||
| a6b6c92c33 |
@@ -45,8 +45,15 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# `packages: ''` is load-bearing, not tidying. The action's own default is
|
||||
# `tools` -- a package Google has REMOVED from the SDK repository -- so the
|
||||
# default makes `sdkmanager tools` exit 1 and the step fails before a line
|
||||
# of this repo is compiled. It is redundant here regardless: the next step
|
||||
# installs exactly what the build targets.
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
# Install exactly what the build targets so it never depends on AGP's
|
||||
# build-time auto-download. `yes |` accepts any license prompts; `set
|
||||
@@ -56,7 +63,7 @@ jobs:
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
set +o pipefail
|
||||
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||
yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -18,6 +18,17 @@
|
||||
# ANDROID_KEYSTORE_PASSWORD — keystore password
|
||||
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
|
||||
# ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore)
|
||||
# PLAY_SERVICE_ACCOUNT_JSON — Google Cloud service-account key (the whole JSON file)
|
||||
# with release rights on com.runicgateway.app only. Unset,
|
||||
# the Play step is skipped with a warning (PLAN.md M8).
|
||||
#
|
||||
# Google Play (PLAN.md M8): the same keystore also signs an app bundle (.aab). After
|
||||
# the Gitea release exists, the bundle is uploaded to Play's *internal* track through
|
||||
# the Play Developer API. Promotion to closed testing / production stays a click in
|
||||
# the console, so a tag never reaches testers or review on its own. Google requires
|
||||
# the app's FIRST bundle to be uploaded by hand: take it from the Gitea release
|
||||
# assets. The Play step runs last, so a Play failure fails the run but never holds
|
||||
# back the APK release.
|
||||
#
|
||||
# Runner handling matches pr-checks.yml (self-hosted `ubuntu-latest`): the container
|
||||
# lacks git/curl/unzip and can't reach api.adoptium.net, so we apt-install the base
|
||||
@@ -56,7 +67,7 @@ jobs:
|
||||
- name: Install base tools + JDK 17
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y git curl unzip jq openjdk-17-jdk-headless
|
||||
apt-get install -y git curl unzip jq openssl openjdk-17-jdk-headless
|
||||
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check out the release tag (full history for the changelog)
|
||||
@@ -103,6 +114,7 @@ jobs:
|
||||
echo
|
||||
echo "---"
|
||||
echo "Signed APK — sideload on Android 10+ (§10). The app self-configures its shard site on first run."
|
||||
echo "The .aab is the same build as a Google Play bundle; it does not install on a phone."
|
||||
} > dist/CHANGELOG.md
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
@@ -123,7 +135,7 @@ jobs:
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
set +o pipefail
|
||||
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||
yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
|
||||
|
||||
- name: Decode signing keystore
|
||||
env:
|
||||
@@ -148,7 +160,7 @@ jobs:
|
||||
sed -i -E "s/(toIntOrNull\(\) \?: )[0-9]+/\1${VERSION_CODE}/" "${GRADLE_MODULE}/build.gradle.kts"
|
||||
grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts"
|
||||
|
||||
- name: Unit tests + signed release APK
|
||||
- name: Unit tests + signed release APK + app bundle
|
||||
env:
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
@@ -156,15 +168,20 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x ./gradlew
|
||||
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease
|
||||
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest \
|
||||
:${GRADLE_MODULE}:assembleRelease :${GRADLE_MODULE}:bundleRelease
|
||||
|
||||
- name: Package APK + SHA256SUMS
|
||||
- name: Package APK + AAB + SHA256SUMS
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
|
||||
test -f "$SRC" || { echo "::error::release APK not found at $SRC"; exit 1; }
|
||||
cp "$SRC" "dist/runic-gateway-${{ steps.plan.outputs.version }}.apk"
|
||||
( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS )
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
APK="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
|
||||
AAB="${GRADLE_MODULE}/build/outputs/bundle/release/app-release.aab"
|
||||
test -f "$APK" || { echo "::error::release APK not found at $APK"; exit 1; }
|
||||
test -f "$AAB" || { echo "::error::release bundle not found at $AAB"; exit 1; }
|
||||
cp "$APK" "dist/runic-gateway-${VERSION}.apk"
|
||||
cp "$AAB" "dist/runic-gateway-${VERSION}.aab"
|
||||
( cd dist && sha256sum "runic-gateway-${VERSION}.apk" "runic-gateway-${VERSION}.aab" > SHA256SUMS )
|
||||
ls -l dist && cat dist/SHA256SUMS
|
||||
|
||||
# ── Create the Gitea release + upload assets (no push to main) ───────
|
||||
@@ -186,9 +203,94 @@ jobs:
|
||||
| jq -r '.id')"
|
||||
echo "Created release ${TAG} (id=${REL_ID})"
|
||||
|
||||
for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" SHA256SUMS; do
|
||||
for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" \
|
||||
"runic-gateway-${{ steps.plan.outputs.version }}.aab" SHA256SUMS; do
|
||||
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@dist/${f}" >/dev/null
|
||||
echo " uploaded ${f}"
|
||||
done
|
||||
|
||||
# ── Google Play: bundle → internal track (PLAN.md M8) ────────────────
|
||||
# Play Developer API v3 edit flow: open an edit, upload the bundle, put its
|
||||
# versionCode on the internal track, commit. The OAuth token comes from a
|
||||
# service-account JWT signed with openssl, so no Gradle plugin and no Google
|
||||
# SDK. The release is "completed" on the internal track, which Play only
|
||||
# accepts once the app has had a release rolled out; before that, its error
|
||||
# is "Only releases with status draft may be created on draft app", and the
|
||||
# fix is the hand upload of the first bundle, not a change here.
|
||||
- name: Upload the bundle to Google Play (internal track)
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
|
||||
echo "::warning::PLAY_SERVICE_ACCOUNT_JSON is not set; the bundle was not uploaded to Google Play."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PACKAGE="com.runicgateway.app"
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
VERSION_CODE="${{ steps.plan.outputs.versionCode }}"
|
||||
AAB="dist/runic-gateway-${{ steps.plan.outputs.version }}.aab"
|
||||
API="https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${PACKAGE}"
|
||||
UPLOAD_API="https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${PACKAGE}"
|
||||
|
||||
# The service-account key only ever touches the runner's temp dir.
|
||||
umask 077
|
||||
SA_KEY="${RUNNER_TEMP}/play-sa.pem"
|
||||
trap 'rm -f "$SA_KEY"' EXIT
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" | jq -r '.private_key' > "$SA_KEY"
|
||||
CLIENT_EMAIL="$(printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" | jq -r '.client_email')"
|
||||
|
||||
# play <curl args...>: prints the response body; on a non-2xx it prints
|
||||
# Google's error to stderr and fails, so `set -e` stops the step.
|
||||
play() {
|
||||
local out code
|
||||
out="$(mktemp)"
|
||||
code="$(curl -sS -o "$out" -w '%{http_code}' "$@")"
|
||||
if [ "${code:0:1}" != "2" ]; then
|
||||
echo "::error::Google Play API returned HTTP ${code}" >&2
|
||||
cat "$out" >&2; echo >&2
|
||||
rm -f "$out"
|
||||
return 1
|
||||
fi
|
||||
cat "$out"
|
||||
rm -f "$out"
|
||||
}
|
||||
|
||||
# Service-account JWT → OAuth access token (RFC 7523).
|
||||
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
|
||||
NOW="$(date +%s)"
|
||||
JWT_HEAD="$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | b64url)"
|
||||
JWT_CLAIMS="$(jq -cn --arg iss "$CLIENT_EMAIL" --argjson iat "$NOW" \
|
||||
'{iss:$iss, scope:"https://www.googleapis.com/auth/androidpublisher",
|
||||
aud:"https://oauth2.googleapis.com/token", iat:$iat, exp:($iat + 3600)}' | b64url)"
|
||||
JWT_SIG="$(printf '%s' "${JWT_HEAD}.${JWT_CLAIMS}" | openssl dgst -sha256 -sign "$SA_KEY" -binary | b64url)"
|
||||
ACCESS_TOKEN="$(play -X POST https://oauth2.googleapis.com/token \
|
||||
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
|
||||
--data-urlencode "assertion=${JWT_HEAD}.${JWT_CLAIMS}.${JWT_SIG}" \
|
||||
| jq -r '.access_token')"
|
||||
echo "::add-mask::${ACCESS_TOKEN}"
|
||||
AUTH="Authorization: Bearer ${ACCESS_TOKEN}"
|
||||
|
||||
EDIT_ID="$(play -X POST "${API}/edits" -H "$AUTH" \
|
||||
-H 'Content-Type: application/json' -d '{}' | jq -r '.id')"
|
||||
echo "Opened edit ${EDIT_ID}"
|
||||
|
||||
UPLOADED_CODE="$(play -X POST "${UPLOAD_API}/edits/${EDIT_ID}/bundles?uploadType=media" \
|
||||
-H "$AUTH" -H 'Content-Type: application/octet-stream' \
|
||||
--data-binary "@${AAB}" | jq -r '.versionCode')"
|
||||
if [ "$UPLOADED_CODE" != "$VERSION_CODE" ]; then
|
||||
echo "::error::Play read versionCode ${UPLOADED_CODE} from the bundle, expected ${VERSION_CODE}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Uploaded bundle (versionCode ${UPLOADED_CODE})"
|
||||
|
||||
play -X PUT "${API}/edits/${EDIT_ID}/tracks/internal" -H "$AUTH" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg name "$TAG" --arg code "$VERSION_CODE" \
|
||||
'{track:"internal", releases:[{name:$name, versionCodes:[$code], status:"completed"}]}')" \
|
||||
>/dev/null
|
||||
play -X POST "${API}/edits/${EDIT_ID}:commit" -H "$AUTH" -d '' >/dev/null
|
||||
echo "Released ${TAG} (versionCode ${VERSION_CODE}) to the internal track"
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
set +o pipefail
|
||||
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||
yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -38,13 +38,13 @@ val hasReleaseSigning = ksStoreFilePath != null && ksStorePassword != null &&
|
||||
|
||||
android {
|
||||
namespace = "com.runicgateway.app"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
// Target application id per docs/android/PLAN.md §13 (pending runicgateway.app domain).
|
||||
applicationId = "com.runicgateway.app"
|
||||
minSdk = 29
|
||||
targetSdk = 35
|
||||
targetSdk = 36
|
||||
// These committed defaults are the version source of truth (PLAN.md §10).
|
||||
// release.yml's conventional-commit engine bumps versionName here and commits
|
||||
// it on release; versionCode is derived from it (major*10000+minor*100+patch)
|
||||
@@ -181,6 +181,13 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
}
|
||||
|
||||
// StringVocabularyTest (PLAN.md M20) reads the string resources from disk. A reworded
|
||||
// string changes no class, so without this input the test task would stay UP-TO-DATE
|
||||
// and the guard would not run again.
|
||||
tasks.withType<Test>().configureEach {
|
||||
inputs.dir("src/main/res").withPathSensitivity(PathSensitivity.RELATIVE)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -8,10 +8,14 @@
|
||||
|
||||
<!-- Opt-in push notifications (M7): the runtime notification permission (API 33+)
|
||||
and a foreground service that holds the persistent ntfy connection open — the
|
||||
embedded UnifiedPush distributor, so no separate app is needed (PLAN.md §11). -->
|
||||
embedded UnifiedPush distributor, so no separate app is needed (PLAN.md §11).
|
||||
specialUse, not dataSync (PLAN.md M8, 2026-10-29): a held-open connection is not a
|
||||
transfer, and Android 15 stops dataSync after six hours a day. RECEIVE_BOOT_COMPLETED
|
||||
restarts it after a reboot or an update. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:name=".RunicGatewayApp"
|
||||
@@ -70,11 +74,28 @@
|
||||
</activity>
|
||||
|
||||
<!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11).
|
||||
dataSync foreground type; not exported — started only by PushManager. -->
|
||||
specialUse foreground type, with the reason Play reviews in the property below;
|
||||
not exported — started only by PushManager and PushBootReceiver. -->
|
||||
<service
|
||||
android:name=".core.push.PushService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Instant push notifications without Google services. When the user turns notifications on, this service holds one connection open to the push relay their community's server names, and shows a notification for each message from it. The ongoing notification shows the service is running; turning notifications off or signing out stops it." />
|
||||
</service>
|
||||
|
||||
<!-- Restarts push after a reboot or an app update, if the member had it on and was
|
||||
signed in when the phone went down (PLAN.md M8). Not exported: only the system
|
||||
delivers these broadcasts. -->
|
||||
<receiver
|
||||
android:name=".core.push.PushBootReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import com.runicgateway.app.core.result.DEMO_LOCKED_STATUS
|
||||
import com.runicgateway.app.core.result.demoRefusalOf
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Spots the public demo's refusal on any response of the main client and reports it
|
||||
* to [DemoNotices] (`docs/website/DEMO_MODE.md` §3.1, DM19). Every API rides this
|
||||
* client, so a screen added later is covered without doing anything.
|
||||
*
|
||||
* It only reads: the body is peeked, not consumed, and the response goes on to its
|
||||
* caller unchanged. Only a `403` is looked at, so an ordinary answer is never
|
||||
* buffered.
|
||||
*/
|
||||
@Singleton
|
||||
class DemoLockInterceptor @Inject constructor(
|
||||
private val notices: DemoNotices,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
if (response.code != DEMO_LOCKED_STATUS) return response
|
||||
val body = runCatching { response.peekBody(MAX_PEEK_BYTES).string() }.getOrNull()
|
||||
demoRefusalOf(body)?.let(notices::report)
|
||||
return response
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The refusal is one short JSON object; anything longer is not one. */
|
||||
const val MAX_PEEK_BYTES = 8L * 1024
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import com.runicgateway.app.core.result.DemoRefusal
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The app's one channel for the public demo's refusals (`docs/website/DEMO_MODE.md`
|
||||
* §3.1, DM19) — the counterpart of the web client's `demoNotice.js`.
|
||||
* [DemoLockInterceptor] reports every `DEMO_LOCKED` answer here and the shell shows
|
||||
* it as one snackbar, wherever the action was, so no screen has to learn the code.
|
||||
* The screen still gets its error as usual.
|
||||
*
|
||||
* A refusal nobody is collecting (the app in the background) is dropped rather than
|
||||
* queued: a snackbar about a tap from minutes ago would explain nothing.
|
||||
*/
|
||||
@Singleton
|
||||
class DemoNotices @Inject constructor() {
|
||||
private val _refusals = MutableSharedFlow<DemoRefusal>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
val refusals: SharedFlow<DemoRefusal> = _refusals.asSharedFlow()
|
||||
|
||||
fun report(refusal: DemoRefusal) {
|
||||
_refusals.tryEmit(refusal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.push
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Restarts [PushService] after a reboot or an app update (PLAN.md M8, 2026-10-29). Before
|
||||
* this, push stayed off after either until the member next opened the app.
|
||||
*
|
||||
* It starts the service straight from the saved registration rather than through
|
||||
* [PushManager], which re-registers with the backend first: at boot the network is often
|
||||
* not up yet, and a failed registration would leave push off. The registration survives a
|
||||
* reboot, the topic on the relay does not change, and the service's stream client already
|
||||
* retries with backoff until the network arrives. A sign-out clears the registration, so a
|
||||
* saved one also means the member was signed in.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class PushBootReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var prefs: PushPreferences
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action !in RESTART_ACTIONS) return
|
||||
val pending = goAsync()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
if (shouldRestart(prefs.snapshot())) {
|
||||
runCatching { PushService.start(context.applicationContext) }
|
||||
}
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
val RESTART_ACTIONS = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)
|
||||
|
||||
/** Push was on, and a registration (so a signed-in member) is still saved. */
|
||||
fun shouldRestart(snapshot: PushPreferences.Snapshot): Boolean =
|
||||
snapshot.enabled && !snapshot.topic.isNullOrBlank() && !snapshot.ntfyUrl.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.push
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The inbox id a tickle's `notification:<id>` ref names, or null.
|
||||
*
|
||||
* Prefix-exact, the same rule [Routes.forTickle] reads the ref by, and digits
|
||||
* only. The relay is untrusted, so a ref is a hint, and anything that is not
|
||||
* exactly a positive id is treated as no hint at all.
|
||||
*/
|
||||
internal fun inboxIdFromRef(ref: String?): Long? {
|
||||
if (ref == null || !ref.startsWith(Routes.INBOX_REF_PREFIX)) return null
|
||||
val digits = ref.substring(Routes.INBOX_REF_PREFIX.length)
|
||||
if (digits.isEmpty() || digits.length > MAX_ID_DIGITS || !digits.all { it in '0'..'9' }) return null
|
||||
return digits.toLongOrNull()?.takeIf { it > 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* The item from [page] that [tickle] is about, or null.
|
||||
*
|
||||
* The item must be the one the ref names **and** come from the trigger the tickle
|
||||
* names. Only the user's own inbox can be read, so a forged pair could only put
|
||||
* one of their own items on their own phone. A tickle that disagrees with the row
|
||||
* it points at is still not one to title from.
|
||||
*/
|
||||
internal fun itemForTickle(tickle: PushTickle, page: List<NotificationItemDto>): NotificationItemDto? {
|
||||
val id = inboxIdFromRef(tickle.ref) ?: return null
|
||||
return page.firstOrNull { it.id == id && it.triggerId == tickle.stream }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the inbox row a tickle points at, so the notification can say what
|
||||
* happened (`docs/modules/rust/PLAN.md` D70).
|
||||
*
|
||||
* This is the wake-and-pull contract core's `pushChannel.js` describes: the relay
|
||||
* carries `{ stream, ref }` and nothing else, and the content comes over the
|
||||
* authenticated, ownership-checked inbox API. Core has no single-item read, so
|
||||
* this reads the first page. The row was written moments ago, so it is on that
|
||||
* page, and if it is not the caller falls back to the per-stream title.
|
||||
*
|
||||
* **Every failure is null**: no ref, a signed-out app, a dead network, a timeout,
|
||||
* an item not on the page. A GET of the inbox marks nothing read, so the badge
|
||||
* is left alone.
|
||||
*/
|
||||
@Singleton
|
||||
class PushContentResolver @Inject constructor(
|
||||
private val notifications: NotificationsRepository,
|
||||
) {
|
||||
suspend fun itemFor(tickle: PushTickle): NotificationItemDto? {
|
||||
if (inboxIdFromRef(tickle.ref) == null) return null
|
||||
val page = withTimeoutOrNull(PULL_TIMEOUT_MS) {
|
||||
(notifications.inbox() as? ApiResult.Ok)?.data?.items
|
||||
} ?: return null
|
||||
return itemForTickle(tickle, page)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Long enough for a phone waking on a slow network, and short enough that a
|
||||
* notification is never held back noticeably for a title.
|
||||
*/
|
||||
const val PULL_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
|
||||
/** A `BIGINT UNSIGNED` never runs past 20 digits. */
|
||||
private const val MAX_ID_DIGITS = 20
|
||||
@@ -13,6 +13,7 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.runicgateway.app.MainActivity
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.inject.Inject
|
||||
@@ -20,10 +21,18 @@ import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Builds the notification channels and posts a notification for a received tickle
|
||||
* (PLAN.md §11, M7 Part 2 work items 2/3/7). v1 shows a **generic per-stream**
|
||||
* notification titled from the fixed [PushStreams] catalog — the content-free tickle
|
||||
* carries nothing to render, so nothing is fetched to display the notification; tapping
|
||||
* deep-links into [MainActivity] (which fetches fresh over the authenticated API).
|
||||
* (PLAN.md §11, M7 Part 2 work items 2/3/7). Tapping deep-links into [MainActivity],
|
||||
* which fetches fresh over the authenticated API.
|
||||
*
|
||||
* **The title comes from the inbox row when there is one** (`docs/modules/rust/PLAN.md`
|
||||
* D70). The tickle carries nothing to render. When its ref names an inbox row,
|
||||
* [PushContentResolver] has already pulled that row over the authenticated API, and
|
||||
* the notification says what the row says. Every other tickle keeps the M7 behaviour:
|
||||
* a **generic per-stream** title from the fixed [PushStreams] catalog. Until D70 that
|
||||
* was every tickle, and "New notification" is all a Rust raid alert ever said.
|
||||
*
|
||||
* **The lock screen shows only the generic title.** The row's text is content, and
|
||||
* a locked phone on a table is not the place for "a door was destroyed in S16".
|
||||
*/
|
||||
@Singleton
|
||||
class PushNotifier @Inject constructor(
|
||||
@@ -65,17 +74,32 @@ class PushNotifier @Inject constructor(
|
||||
.setContentIntent(deepLinkIntent(stream = null, ref = null))
|
||||
.build()
|
||||
|
||||
/** Post a notification for a tickle, deep-linking to the stream's screen on tap. */
|
||||
fun notify(tickle: PushTickle) {
|
||||
/**
|
||||
* Post a notification for a tickle, deep-linking to the stream's screen on tap.
|
||||
*
|
||||
* [item] is the inbox row the tickle points at, when it could be pulled; null
|
||||
* falls back to the per-stream title.
|
||||
*/
|
||||
fun notify(tickle: PushTickle, item: NotificationItemDto? = null) {
|
||||
if (!manager.areNotificationsEnabled()) return // POST_NOTIFICATIONS not granted
|
||||
val title = context.getString(PushStreams.titleRes(tickle.stream))
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
|
||||
.setContentTitle(title)
|
||||
val generic = context.getString(PushStreams.titleRes(tickle.stream))
|
||||
val content = notificationText(item, generic)
|
||||
val redacted = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
|
||||
.setContentTitle(generic)
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.build()
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
|
||||
.setContentTitle(content.title)
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPublicVersion(redacted)
|
||||
.setContentIntent(deepLinkIntent(tickle.stream, tickle.ref))
|
||||
.build()
|
||||
content.body?.let { body ->
|
||||
builder.setContentText(body).setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
}
|
||||
val notification = builder.build()
|
||||
try {
|
||||
manager.notify(nextId.getAndIncrement(), notification)
|
||||
} catch (_: SecurityException) {
|
||||
@@ -108,3 +132,18 @@ class PushNotifier @Inject constructor(
|
||||
const val EXTRA_REF = "com.runicgateway.app.push.REF"
|
||||
}
|
||||
}
|
||||
|
||||
/** What a posted notification says: a title always, and a body when the row had one. */
|
||||
internal data class NotificationText(val title: String, val body: String?)
|
||||
|
||||
/**
|
||||
* The text for a notification about [item], falling back to [generic] (D70).
|
||||
*
|
||||
* A row with a blank title is treated as no row: a notification with an empty
|
||||
* headline is worse than one that says only that something happened.
|
||||
*/
|
||||
internal fun notificationText(item: NotificationItemDto?, generic: String): NotificationText {
|
||||
val title = item?.title?.trim().orEmpty()
|
||||
if (title.isEmpty()) return NotificationText(generic, null)
|
||||
return NotificationText(title, item?.body?.trim()?.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -26,13 +25,20 @@ import javax.inject.Inject
|
||||
* connection to the shard's ntfy open in the background — the price of Google-free,
|
||||
* self-contained instant delivery — and posts a notification for each tickle. It runs
|
||||
* under a low-importance ongoing notification and restarts sticky; [PushManager] starts
|
||||
* and stops it as the user opts in/out or signs out.
|
||||
* and stops it as the user opts in/out or signs out, and [PushBootReceiver] starts it
|
||||
* again after a reboot or an update.
|
||||
*
|
||||
* Its foreground type is `specialUse` (PLAN.md M8, 2026-10-29). It was `dataSync`, which
|
||||
* Android 15 stops after six hours a day — and this service never stopped itself on that
|
||||
* timeout, so the app would have crashed. `specialUse` has no daily limit; the manifest
|
||||
* carries the reason Google Play reviews.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class PushService : Service() {
|
||||
|
||||
@Inject lateinit var streamClient: NtfyStreamClient
|
||||
@Inject lateinit var notifier: PushNotifier
|
||||
@Inject lateinit var content: PushContentResolver
|
||||
@Inject lateinit var prefs: PushPreferences
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
@@ -51,7 +57,7 @@ class PushService : Service() {
|
||||
|
||||
private fun startAsForeground() {
|
||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
@@ -67,8 +73,15 @@ class PushService : Service() {
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
streamClient.events(snapshot.ntfyUrl, topic).collectLatest { event ->
|
||||
if (event is NtfyStreamClient.Event.Message) notifier.notify(event.tickle)
|
||||
// Each tickle in its own job (D70). Titling one from its inbox row
|
||||
// suspends on a network read, and inside `collectLatest` the next tickle
|
||||
// would cancel it. A burst (two owners' raid alerts, then a restart) would
|
||||
// then post only the last.
|
||||
streamClient.events(snapshot.ntfyUrl, topic).collect { event ->
|
||||
if (event is NtfyStreamClient.Event.Message) {
|
||||
val tickle = event.tickle
|
||||
scope.launch { notifier.notify(tickle, content.itemFor(tickle)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,18 @@ import java.io.IOException
|
||||
* an expected failure, it returns a typed variant the screen can render.
|
||||
*
|
||||
* - [Ok] — a 2xx response with a decoded body.
|
||||
* - [HttpError] — the server answered with a non-2xx status.
|
||||
* - [HttpError] — the server answered with a non-2xx status. [HttpError.code] is
|
||||
* the error body's `code` field when it carried one (today only
|
||||
* the demo's `DEMO_LOCKED` is read, see [isDemoLocked]).
|
||||
* - [NetworkError] — the request never got an answer (offline, DNS, TLS, timeout).
|
||||
*/
|
||||
sealed interface ApiResult<out T> {
|
||||
data class Ok<T>(val data: T) : ApiResult<T>
|
||||
data class HttpError(val status: Int, val message: String? = null) : ApiResult<Nothing>
|
||||
data class HttpError(
|
||||
val status: Int,
|
||||
val message: String? = null,
|
||||
val code: String? = null,
|
||||
) : ApiResult<Nothing>
|
||||
data class NetworkError(val cause: Throwable) : ApiResult<Nothing>
|
||||
}
|
||||
|
||||
@@ -54,12 +60,31 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: HttpException) {
|
||||
ApiResult.HttpError(e.code(), e.message())
|
||||
ApiResult.HttpError(e.code(), e.message(), errorCodeOf(e))
|
||||
} catch (e: IOException) {
|
||||
ApiResult.NetworkError(e)
|
||||
} catch (e: SerializationException) {
|
||||
ApiResult.HttpError(MALFORMED_RESPONSE_STATUS, e.message)
|
||||
}
|
||||
|
||||
/**
|
||||
* The error body's `code`, read only for a status that can carry one the app acts on,
|
||||
* so an ordinary 404 or 500 body is never looked at. The body is peeked, not
|
||||
* consumed, so whoever else holds the exception can still read it.
|
||||
*/
|
||||
private fun errorCodeOf(e: HttpException): String? {
|
||||
if (e.code() != DEMO_LOCKED_STATUS) return null
|
||||
val body = runCatching {
|
||||
e.response()?.errorBody()?.source()?.let { source ->
|
||||
source.request(MAX_ERROR_PEEK_BYTES)
|
||||
source.peek().readUtf8(minOf(source.buffer.size, MAX_ERROR_PEEK_BYTES))
|
||||
}
|
||||
}.getOrNull()
|
||||
return errorCodeOf(body)
|
||||
}
|
||||
|
||||
/** The refusal is one short JSON object; anything longer is not one. */
|
||||
private const val MAX_ERROR_PEEK_BYTES = 8L * 1024
|
||||
|
||||
/** Synthetic status for a 2xx body the app couldn't decode — an invalid upstream response. */
|
||||
private const val MALFORMED_RESPONSE_STATUS = 502
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.result
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* The public demo's refusal (`docs/website/DEMO_MODE.md` §3.1). With `DEMO_MODE` on,
|
||||
* the server answers a locked write
|
||||
* `403 { "message": "This is turned off on the demo site.", "code": "DEMO_LOCKED" }`.
|
||||
* A module may word its own sentence (a refused console command names the command),
|
||||
* so the message is carried as sent.
|
||||
*
|
||||
* The lock is the server's (DM10). Nothing in the app decides what is allowed; it
|
||||
* only explains what was refused.
|
||||
*/
|
||||
const val DEMO_LOCKED_CODE = "DEMO_LOCKED"
|
||||
|
||||
/** Status the server answers a demo refusal with. */
|
||||
const val DEMO_LOCKED_STATUS = 403
|
||||
|
||||
/** A refusal read off a response body. [message] is null when the body carried none. */
|
||||
data class DemoRefusal(val message: String?)
|
||||
|
||||
private val lenient = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* The `code` field of a JSON error body, or null for any body that is not a JSON
|
||||
* object carrying a string `code`. Never throws: an error body is whatever the
|
||||
* server or a proxy in front of it sent.
|
||||
*/
|
||||
fun errorCodeOf(body: String?): String? = errorObject(body)?.stringField("code")
|
||||
|
||||
/** The refusal in this error body, or null when it is not one. */
|
||||
fun demoRefusalOf(body: String?): DemoRefusal? {
|
||||
val obj = errorObject(body) ?: return null
|
||||
if (obj.stringField("code") != DEMO_LOCKED_CODE) return null
|
||||
return DemoRefusal(obj.stringField("message")?.takeIf { it.isNotBlank() })
|
||||
}
|
||||
|
||||
/** True when this result is the demo's refusal. */
|
||||
fun ApiResult<*>.isDemoLocked(): Boolean =
|
||||
this is ApiResult.HttpError && code == DEMO_LOCKED_CODE
|
||||
|
||||
private fun errorObject(body: String?): JsonObject? {
|
||||
if (body.isNullOrBlank()) return null
|
||||
return runCatching { lenient.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.stringField(name: String): String? =
|
||||
(get(name) as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
@@ -5,6 +5,8 @@ package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||
import com.runicgateway.app.data.api.dto.DeleteAccountRequest
|
||||
import com.runicgateway.app.data.api.dto.DeleteAccountResultDto
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||
@@ -27,6 +29,7 @@ import retrofit2.http.HTTP
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* The role-agnostic self-service surface (PLAN.md §6.3, §6.4): account, credential
|
||||
@@ -60,8 +63,10 @@ interface MeApi {
|
||||
|
||||
// DELETE with no body — a plain @DELETE would suffice, but @HTTP keeps the
|
||||
// path template explicit alongside the provider argument.
|
||||
// `id` (optional) removes that one identity rather than every one of the
|
||||
// provider — a person may link several Steam accounts (STEAM_SIGNIN.md).
|
||||
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
|
||||
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit
|
||||
suspend fun unlinkIdentity(@Path("provider") provider: String, @Query("id") id: Int? = null): Unit
|
||||
|
||||
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) — devices allowed to skip TOTP ──
|
||||
|
||||
@@ -86,4 +91,11 @@ interface MeApi {
|
||||
|
||||
@POST("api/v1/auth/me/account/recovery-codes/generate")
|
||||
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
|
||||
|
||||
// ── Deleting the account (ACCOUNT_DELETION.md §4.1) ──────────────────────
|
||||
|
||||
// A DELETE with a body needs @HTTP. Raw [Response] so the caller can read the
|
||||
// refusal's `code` off a 400 or 409, which a thrown HttpException would not carry.
|
||||
@HTTP(method = "DELETE", path = "api/v1/auth/me/account", hasBody = true)
|
||||
suspend fun deleteAccount(@Body body: DeleteAccountRequest): Response<DeleteAccountResultDto>
|
||||
}
|
||||
|
||||
104
app/src/main/java/com/runicgateway/app/data/api/PlayerRustApi.kt
Normal file
104
app/src/main/java/com/runicgateway/app/data/api/PlayerRustApi.kt
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustBackpackDto
|
||||
import com.runicgateway.app.data.api.dto.RustEconomicsDto
|
||||
import com.runicgateway.app.data.api.dto.RustLinkListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLinkRequest
|
||||
import com.runicgateway.app.data.api.dto.RustLinkResultDto
|
||||
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import com.runicgateway.app.data.api.dto.RustUnlinkResultDto
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* A player's own Rust identity and what it earns them
|
||||
* (`docs/modules/rust/PLAN.md` §19, §20, §22; M15), over the bearer-gated
|
||||
* `/player/rust/…` surface.
|
||||
*
|
||||
* Its own interface beside [RustApi] rather than four more methods on it, for
|
||||
* the same reason [PlayerShardApi] is separate from [PublicApi]: these need a
|
||||
* session and those do not, and one interface holding both makes the tier a
|
||||
* property of the method name instead of the type.
|
||||
*
|
||||
* **The paths are hardcoded, which is the contract and not a shortcut.**
|
||||
* `MODULE_API.md` §2.9 forbids a client inferring a route from a capability, so
|
||||
* the app cannot build `/<module id>/links` from what `GET /public/modules`
|
||||
* reports. A capability answers *is the module there*; these four addresses are
|
||||
* knowledge the app has because someone read the module's router.
|
||||
*/
|
||||
interface PlayerRustApi {
|
||||
|
||||
/** The Steam accounts the caller holds. Fleet-wide: a link is not per server. */
|
||||
@GET("api/v1/player/rust/links")
|
||||
suspend fun links(): RustLinkListDto
|
||||
|
||||
/**
|
||||
* Redeem the code `/link` handed the player in game.
|
||||
*
|
||||
* **The refusals are not interchangeable and the screen must not flatten
|
||||
* them.** A 400 is a code that is unknown or expired — go and get another;
|
||||
* a 409 is a Steam account another website account holds — run `/unlink` in
|
||||
* game; a 503 is a server that could not be reached, where the code is still
|
||||
* good and the only right advice is to wait a minute. A player told to run
|
||||
* `/link` again when the server their code came from was merely down will
|
||||
* get another code from the same down server. The server sends a sentence
|
||||
* for each; this leg renders it rather than writing one of its own.
|
||||
*
|
||||
* Rate-limited server-side (ten per quarter-hour per IP), so a 429 is an
|
||||
* ordinary answer here rather than a bug.
|
||||
*/
|
||||
@POST("api/v1/player/rust/link")
|
||||
suspend fun link(@Body body: RustLinkRequest): RustLinkResultDto
|
||||
|
||||
/**
|
||||
* Release a link the caller holds.
|
||||
*
|
||||
* Scoped to the caller inside the server's statement, so a Steam id that is
|
||||
* somebody else's answers the same 404 as one that is nobody's.
|
||||
*/
|
||||
@DELETE("api/v1/player/rust/links/{steamId}")
|
||||
suspend fun unlink(@Path("steamId") steamId: String): RustUnlinkResultDto
|
||||
|
||||
/**
|
||||
* What the site has given this player in game — ranks and direct grants,
|
||||
* each already resolved to the servers its scope reaches.
|
||||
*
|
||||
* Read-only by construction: everything that authors one of these rows is an
|
||||
* admin route.
|
||||
*/
|
||||
@GET("api/v1/player/rust/permissions")
|
||||
suspend fun permissions(): RustPlayerPermissionsDto
|
||||
|
||||
/**
|
||||
* The caller's coin balances on every server, per linked account
|
||||
* (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2.2). Live where a server
|
||||
* answers, the last balance it reported where it does not. A module older
|
||||
* than protocol 14 answers 404, and the screen then shows no coins at all.
|
||||
*/
|
||||
@GET("api/v1/player/rust/economics")
|
||||
suspend fun economics(): RustEconomicsDto
|
||||
|
||||
/**
|
||||
* The servers this site follows, on the authenticated tier — the choices the
|
||||
* backpack section offers. The same list as the public one.
|
||||
*/
|
||||
@GET("api/v1/player/rust/servers")
|
||||
suspend fun servers(): RustServerListDto
|
||||
|
||||
/**
|
||||
* One of the caller's own backpacks on one server
|
||||
* (`docs/modules/rust/PLAN_REDESIGNS.md` §11.3.2), read live from the game.
|
||||
* 409 is a server without Backpacks; a module older than protocol 14 answers
|
||||
* 404, and the screen then says the site cannot show backpacks yet.
|
||||
*/
|
||||
@GET("api/v1/player/rust/backpacks")
|
||||
suspend fun backpack(@Query("serverId") serverId: String, @Query("steamId") steamId: String): RustBackpackDto
|
||||
}
|
||||
126
app/src/main/java/com/runicgateway/app/data/api/RustApi.kt
Normal file
126
app/src/main/java/com/runicgateway/app/data/api/RustApi.kt
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||
import com.runicgateway.app.data.api.dto.RustWipeListDto
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* `module-rust`'s public read path (PLAN.md §9 M14; `docs/modules/rust/PLAN.md`
|
||||
* §17).
|
||||
*
|
||||
* **These paths are hardcoded, and that is the contract rather than a shortcut.**
|
||||
* `MODULE_API.md` §2.9 forbids a client inferring a route from a capability, so
|
||||
* the app cannot build `/<module id>/servers` from what `GET /public/modules`
|
||||
* reports. A capability answers one question — *is the module there* — and these
|
||||
* five addresses are knowledge the app has because someone read the module's
|
||||
* router, exactly as the nine `/uo/` paths in [NavPaths] are.
|
||||
*
|
||||
* Its own interface, not a section of [PublicApi], for the reason [EventsApi] is
|
||||
* its own: these exist only where the Rust module is installed, and a backend
|
||||
* running a different game answers none of them.
|
||||
*/
|
||||
interface RustApi {
|
||||
|
||||
/**
|
||||
* Every Rust server this site follows.
|
||||
*
|
||||
* Answers from the module's own tables and never from a live call to a game
|
||||
* host, so it succeeds while every server in the fleet is off — a server
|
||||
* nobody can reach comes back `online: false, stale: true` with everything it
|
||||
* last said still attached. There is no failure case here for the game being
|
||||
* down, only for the website being down.
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers")
|
||||
suspend fun getServers(): RustServerListDto
|
||||
|
||||
/**
|
||||
* One server, or a **404**.
|
||||
*
|
||||
* The only route under `/servers/{id}` that can say a server is not there:
|
||||
* the four below answer an empty list for an id nobody configured, because an
|
||||
* unknown server genuinely has no events and nobody online. A server an
|
||||
* operator **disabled** answers the same 404 — switching one off is not
|
||||
* switching it into a refusal.
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers/{id}")
|
||||
suspend fun getServer(@Path("id") id: String): RustServerResponse
|
||||
|
||||
/**
|
||||
* The feed, newest first.
|
||||
*
|
||||
* [kind] is comma-separated and [wipe] a wipe id; both are optional, and an
|
||||
* **absent one must be absent rather than empty** — `?wipe=` asks for a wipe
|
||||
* whose id is the empty string and answers nothing, with no error to notice.
|
||||
* Retrofit drops a null `@Query` entirely, which is why these are nullable
|
||||
* and never defaulted to `""`.
|
||||
*
|
||||
* The server serves a default-deny allowlist: moderation events, login
|
||||
* attempts and anything carrying an IP address are stored and never returned
|
||||
* here, whatever is asked for.
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers/{id}/events")
|
||||
suspend fun getEvents(
|
||||
@Path("id") id: String,
|
||||
@Query("kind") kind: String? = null,
|
||||
@Query("wipe") wipe: String? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
): RustEventListDto
|
||||
|
||||
/** Per wipe when [wipe] is given, all-time otherwise — the same rows summed. */
|
||||
@GET("api/v1/public/rust/servers/{id}/leaderboard")
|
||||
suspend fun getLeaderboard(
|
||||
@Path("id") id: String,
|
||||
@Query("wipe") wipe: String? = null,
|
||||
@Query("sort") sort: String? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
): RustLeaderboardDto
|
||||
|
||||
/** Every wipe this server has had, newest first. */
|
||||
@GET("api/v1/public/rust/servers/{id}/wipes")
|
||||
suspend fun getWipes(@Path("id") id: String): RustWipeListDto
|
||||
|
||||
/** The presence board, which an unreachable server does not clear. */
|
||||
@GET("api/v1/public/rust/servers/{id}/online")
|
||||
suspend fun getOnline(@Path("id") id: String): RustOnlineDto
|
||||
|
||||
/**
|
||||
* The map: where its picture is, the frame to draw it in, and which layers
|
||||
* this viewer gets (Rust phase 14). A module older than phase 14 answers 404.
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers/{id}/map")
|
||||
suspend fun getMap(@Path("id") id: String): RustMapDto
|
||||
|
||||
/**
|
||||
* What moves, already cut down to this viewer on the server: a layer they
|
||||
* may not see is absent. The module asks the game at most once per five
|
||||
* seconds per server, however many viewers there are (D111).
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers/{id}/map/live")
|
||||
suspend fun getMapLive(@Path("id") id: String): RustMapLiveDto
|
||||
|
||||
// ── Staff's hide for everyone (USER_SAFETY.md §4.2, S10) ─────────────────
|
||||
|
||||
/**
|
||||
* Hide one chat line for everybody but staff, who still get it with `hidden:
|
||||
* true`. Admins and moderators; anybody else is a 403. [lineId] is the event
|
||||
* row's id, and an id that is not a chat line is a 404.
|
||||
*/
|
||||
@POST("api/v1/admin/rust/chat/{id}/hide")
|
||||
suspend fun hideChat(@Path("id") lineId: Long): Unit
|
||||
|
||||
/** Undo [hideChat] — the only place a hide for everyone is undone. */
|
||||
@POST("api/v1/admin/rust/chat/{id}/unhide")
|
||||
suspend fun unhideChat(@Path("id") lineId: Long): Unit
|
||||
}
|
||||
65
app/src/main/java/com/runicgateway/app/data/api/SafetyApi.kt
Normal file
65
app/src/main/java/com/runicgateway/app/data/api/SafetyApi.kt
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.BlockListDto
|
||||
import com.runicgateway.app.data.api.dto.BlockResultDto
|
||||
import com.runicgateway.app.data.api.dto.HiddenListDto
|
||||
import com.runicgateway.app.data.api.dto.ReportRequest
|
||||
import com.runicgateway.app.data.api.dto.ReportResultDto
|
||||
import com.runicgateway.app.data.api.dto.SafetyOkDto
|
||||
import com.runicgateway.app.data.api.dto.SafetyTargetRequest
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Report, block and hide (`docs/website/USER_SAFETY.md` §4.1, MODULE_API 1.21.0).
|
||||
*
|
||||
* **Core's routes, not a module's**: the Reports queue, a member's blocks and their
|
||||
* *Hide for me* list are the site's, and hold for the forum and every game module
|
||||
* alike. A block made here hides the same author on the website, and one made there
|
||||
* hides them here — they are saved on the account (S2).
|
||||
*
|
||||
* All of them need a signed-in account (S6), and all of them are role-agnostic: a
|
||||
* staff member reports and hides like anybody else. They ride the main client, so
|
||||
* the bearer is attached and a 401 is refreshed.
|
||||
*/
|
||||
interface SafetyApi {
|
||||
|
||||
/**
|
||||
* File a report. A report changes nothing about the item; it opens a row in the
|
||||
* staff Reports queue. An item this account could not have seen is a 404, the
|
||||
* same as one that does not exist; an open report of its own on the same item is
|
||||
* a 409.
|
||||
*/
|
||||
@POST("api/v1/player/reports")
|
||||
suspend fun report(@Body body: ReportRequest): ReportResultDto
|
||||
|
||||
@GET("api/v1/auth/me/blocks")
|
||||
suspend fun blocks(): BlockListDto
|
||||
|
||||
/**
|
||||
* Block the author of an item. `403` is S8's refusal (the author is staff), `409`
|
||||
* an existing block, `404` an item this account cannot see.
|
||||
*/
|
||||
@POST("api/v1/auth/me/blocks")
|
||||
suspend fun block(@Body body: SafetyTargetRequest): BlockResultDto
|
||||
|
||||
@DELETE("api/v1/auth/me/blocks/{id}")
|
||||
suspend fun unblock(@Path("id") id: Long): SafetyOkDto
|
||||
|
||||
@GET("api/v1/auth/me/hidden")
|
||||
suspend fun hidden(): HiddenListDto
|
||||
|
||||
/** *Hide for me*: this one item, for this account, on every device. */
|
||||
@POST("api/v1/auth/me/hidden")
|
||||
suspend fun hide(@Body body: SafetyTargetRequest): SafetyOkDto
|
||||
|
||||
/** Account → Blocked's *Show them again*: every item this account hid. */
|
||||
@DELETE("api/v1/auth/me/hidden")
|
||||
suspend fun clearHidden(): SafetyOkDto
|
||||
}
|
||||
@@ -116,6 +116,22 @@ data class RevokedFlagDto(val revoked: Boolean = false)
|
||||
@Serializable
|
||||
data class RevokedCountDto(val revoked: Int = 0)
|
||||
|
||||
/**
|
||||
* `DELETE /auth/me/account` body (ACCOUNT_DELETION.md §4.1). The password is always
|
||||
* sent (AD7); a two-factor [code] or a [recoveryCode] only when two-factor is on.
|
||||
*/
|
||||
@Serializable
|
||||
data class DeleteAccountRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val code: String? = null,
|
||||
val recoveryCode: String? = null,
|
||||
)
|
||||
|
||||
/** The `{ deleted: true }` a successful deletion answers. */
|
||||
@Serializable
|
||||
data class DeleteAccountResultDto(val deleted: Boolean = false)
|
||||
|
||||
/** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */
|
||||
@Serializable
|
||||
data class RecoveryStatusDto(val remaining: Int = 0)
|
||||
@@ -128,10 +144,18 @@ data class RecoveryGenerateRequest(val currentPassword: String? = null)
|
||||
@Serializable
|
||||
data class RecoveryCodesDto(val recoveryCodes: List<String> = emptyList())
|
||||
|
||||
/** A linked external identity (`GET /auth/me/account/identities`). */
|
||||
/**
|
||||
* A linked external identity (`GET /auth/me/account/identities`). [id] unlinks this
|
||||
* one identity when a provider has several (a person may link more than one Steam
|
||||
* account); [subject] is the person's own external id — for Steam the SteamID64 —
|
||||
* and [display_name] the Steam persona name (null for other providers).
|
||||
*/
|
||||
@Serializable
|
||||
data class LinkedIdentityDto(
|
||||
val id: Int? = null,
|
||||
val provider: String = "",
|
||||
val subject: String? = null,
|
||||
val email: String? = null,
|
||||
val display_name: String? = null,
|
||||
val linked_at: String? = null,
|
||||
)
|
||||
|
||||
@@ -37,10 +37,15 @@ import kotlinx.serialization.Serializable
|
||||
*
|
||||
* [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never
|
||||
* the reader's. See [com.runicgateway.app.ui.events.eventTime].
|
||||
*
|
||||
* [runId] is on a run and never on a projection, which has nothing committed to
|
||||
* it. Core added it for Rust D125, so a map marker naming a run can find its
|
||||
* event; a core older than that omits it, and the marker stays unlinked.
|
||||
*/
|
||||
@Serializable
|
||||
data class EventCalendarEntryDto(
|
||||
val kind: String = "run",
|
||||
val runId: Long? = null,
|
||||
val title: String = "",
|
||||
val slug: String = "",
|
||||
val seriesName: String? = null,
|
||||
@@ -83,6 +88,24 @@ data class EventOccurrenceDto(
|
||||
val scope: String? = null,
|
||||
val phase: String? = null,
|
||||
val resultsPublishedAt: String? = null,
|
||||
/**
|
||||
* On the live occurrence only: how its steps stand, in the words their modules
|
||||
* chose (MODULE_API 1.12.0; runicnpc D304). Core interprets none of it, and nor
|
||||
* does the app: each line is a label with a count or a percent.
|
||||
*/
|
||||
val progress: List<EventProgressDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One progress line: a [label] with [left] of [of] ("Bandits: 3 of 8 left"), or a
|
||||
* [percent] ("The Juggernaut: 62%"). Never both.
|
||||
*/
|
||||
@Serializable
|
||||
data class EventProgressDto(
|
||||
val label: String = "",
|
||||
val left: Int? = null,
|
||||
val of: Int? = null,
|
||||
val percent: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* DTOs for a player's own half of `module-rust` (`docs/modules/rust/PLAN.md`
|
||||
* §19, §20, §22; M15).
|
||||
*
|
||||
* Two surfaces, and they are deliberately separate reads rather than one:
|
||||
*
|
||||
* * **the links** — which Steam accounts this website account holds. A link is
|
||||
* fleet-wide, because a Steam account is one person on every server an
|
||||
* operator runs, while stats are per server and per wipe.
|
||||
* * **what the site has given them in game** — groups and direct grants, each
|
||||
* already resolved to the servers its scope reaches. It is its own read
|
||||
* because an entitlement is authored against the *website* account, so it
|
||||
* exists before a Steam id does; the person who has just been given something
|
||||
* and has not linked yet is exactly the one who needs to see both halves at
|
||||
* once.
|
||||
*
|
||||
* Nothing here is a write except the code redemption. A grant a player could
|
||||
* change would not be a grant.
|
||||
*/
|
||||
|
||||
/** `GET /player/rust/links` — the Steam accounts the caller holds. */
|
||||
@Serializable
|
||||
data class RustLinkListDto(
|
||||
val links: List<RustLinkDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One linked Steam account.
|
||||
*
|
||||
* [name] is what the player was called in game when they linked — a display
|
||||
* name only, and a Rust name changes on a whim. [serverId] is where the code was
|
||||
* minted, which is not part of the identity but is where a support conversation
|
||||
* starts.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustLinkDto(
|
||||
val steamId: String = "",
|
||||
val name: String? = null,
|
||||
val serverId: String? = null,
|
||||
val linkedAt: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /player/rust/link` body — the six-character code `/link` gives in game. */
|
||||
@Serializable
|
||||
data class RustLinkRequest(val code: String)
|
||||
|
||||
/**
|
||||
* `POST /player/rust/link` result.
|
||||
*
|
||||
* [already] is a second press of the button rather than an error: the code was
|
||||
* good and that Steam id was already this caller's.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustLinkResultDto(
|
||||
val linked: Boolean = false,
|
||||
val link: RustLinkDto? = null,
|
||||
val already: Boolean = false,
|
||||
)
|
||||
|
||||
/** `DELETE /player/rust/links/{steamId}` result. */
|
||||
@Serializable
|
||||
data class RustUnlinkResultDto(
|
||||
val unlinked: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /player/rust/permissions` — what the site has given this player in game.
|
||||
*
|
||||
* [accounts] is how many Steam accounts they have linked, and it is on the
|
||||
* envelope for one reason: zero is why an entitlement can be authored and reach
|
||||
* nobody, and the screen has to be able to say that without inferring it.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustPlayerPermissionsDto(
|
||||
val accounts: Int = 0,
|
||||
val groups: List<RustPlayerGroupDto> = emptyList(),
|
||||
val grants: List<RustPlayerGrantDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** A rank the site holds for this player, and what it carries. */
|
||||
@Serializable
|
||||
data class RustPlayerGroupDto(
|
||||
val name: String = "",
|
||||
val title: String = "",
|
||||
val scope: String = "*",
|
||||
val since: String? = null,
|
||||
val permissions: List<String> = emptyList(),
|
||||
val reach: List<RustReachDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** One permission held directly, without a rank. */
|
||||
@Serializable
|
||||
data class RustPlayerGrantDto(
|
||||
val permission: String = "",
|
||||
val scope: String = "*",
|
||||
val source: String = "admin",
|
||||
val note: String? = null,
|
||||
val since: String? = null,
|
||||
val reach: List<RustReachDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One server an entitlement's scope reaches, and whether it is there yet.
|
||||
*
|
||||
* **The scope arithmetic is the server's.** A client handed `scope: "*"` would
|
||||
* have to know what the fleet is to say anything useful, and then the rule
|
||||
* exists in two places; the website resolves it and marks each server instead.
|
||||
*
|
||||
* [live] is the pushed ledger rather than the authored row: a grant is not a
|
||||
* privilege in a game until a sync confirmed it. `false` covers every way it has
|
||||
* not arrived — the server is offline, no loaded plugin registered the name, the
|
||||
* store has never seen the account — and telling those apart is an operator's
|
||||
* diagnosis, not a player's.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustReachDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val live: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /player/rust/economics` — the caller's coin balances
|
||||
* (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2.2): every server, each linked
|
||||
* Steam account. Economics is optional on a server, so one without it is listed
|
||||
* with [RustEconomicsServerDto.economics] false rather than left out.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustEconomicsDto(
|
||||
val servers: List<RustEconomicsServerDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One server's balances. [startingKnown] false means the server's Economics
|
||||
* config could not be read, and [startingBalance] is Economics' default.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustEconomicsServerDto(
|
||||
val serverId: String = "",
|
||||
val server: String = "",
|
||||
val economics: Boolean = false,
|
||||
val reason: String? = null,
|
||||
val startingBalance: Double? = null,
|
||||
val startingKnown: Boolean = true,
|
||||
val live: Boolean = false,
|
||||
val accounts: List<RustBalanceDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One linked account's balance on one server. [source] is `live` (the server
|
||||
* answered just now), `last-known` (it did not, and this is the last balance it
|
||||
* reported), or null with [balance] null when neither knows.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustBalanceDto(
|
||||
val steamId: String = "",
|
||||
val name: String? = null,
|
||||
val balance: Double? = null,
|
||||
val source: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /player/rust/backpacks?serverId=&steamId=` — one of the caller's own
|
||||
* Backpacks backpacks on one server (`docs/modules/rust/PLAN_REDESIGNS.md`
|
||||
* §11.3.2), read live and never stored. [exists] false is an account that has
|
||||
* never had one, and then [capacity], [pageSize] and [items] are absent.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustBackpackDto(
|
||||
val serverId: String = "",
|
||||
val server: String = "",
|
||||
val steamId: String = "",
|
||||
val exists: Boolean = false,
|
||||
val capacity: Int = 0,
|
||||
val pageSize: Int = 48,
|
||||
val items: List<RustBackpackStackDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One stack. [slot] runs across pages; [display] is the game's English name
|
||||
* (D340), and for a blueprint the name of what it makes, with [blueprint] that
|
||||
* item's shortname (D341). [overflow] is a stack past the capacity, which moves
|
||||
* to the owner's inventory the next time they open the backpack (D338).
|
||||
* [contents] is one level deep: a weapon's attachments, a container's items.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustBackpackStackDto(
|
||||
val slot: Int = 0,
|
||||
val shortname: String? = null,
|
||||
val display: String? = null,
|
||||
val amount: Int = 0,
|
||||
val itemId: Int? = null,
|
||||
val blueprint: String? = null,
|
||||
val skin: String? = null,
|
||||
val name: String? = null,
|
||||
val overflow: Boolean = false,
|
||||
val contents: List<RustBackpackStackDto> = emptyList(),
|
||||
)
|
||||
@@ -146,4 +146,14 @@ data class SettingsDto(
|
||||
* nav.
|
||||
*/
|
||||
@SerialName("nav_public") val navPublic: String? = null,
|
||||
/**
|
||||
* The public demo's banner data, `{ resetsAt, logins: [{ username, role, … }] }`
|
||||
* (`docs/website/DEMO_MODE.md` §6). Sent only while the server runs with
|
||||
* `DEMO_MODE` on, so its absence is how a real shard draws no banner.
|
||||
*
|
||||
* Raw for the same reason as [theme]: the banner is a nicety, and a value of an
|
||||
* unexpected shape must not fail the decode of the whole payload. Read by
|
||||
* `DemoInfo.from`.
|
||||
*/
|
||||
val demo: JsonElement? = null,
|
||||
)
|
||||
|
||||
255
app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt
Normal file
255
app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt
Normal file
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* DTOs for `module-rust`'s public read path (`docs/modules/rust/PLAN.md` §17,
|
||||
* §18; M14).
|
||||
*
|
||||
* **Every one of these renders while the game is off**, which is the module's own
|
||||
* promise and therefore this leg's: the website never calls a game server from a
|
||||
* page, it answers from its own tables, and a server nobody can reach answers
|
||||
* `online: false` with everything it last said still attached. Nothing here has
|
||||
* an "unavailable" shape, because there is no such answer on this wire.
|
||||
*/
|
||||
|
||||
/** `GET /public/rust/servers` — every server this site follows. */
|
||||
@Serializable
|
||||
data class RustServerListDto(
|
||||
val servers: List<RustServerDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** `GET /public/rust/servers/{id}` — one of them, or a 404. */
|
||||
@Serializable
|
||||
data class RustServerResponse(
|
||||
val server: RustServerDto = RustServerDto(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One Rust server and what it last reported.
|
||||
*
|
||||
* **[online] and [stale] are not the same fact and the screen needs both.**
|
||||
* `online` is what the last frame said; `stale` is whether anything has arrived
|
||||
* recently enough to believe it. The server computes `online` as *"the row says
|
||||
* up AND the row is fresh"*, so a stale row can never claim a server is up — but
|
||||
* `stale` still has to come through, because a fresh row saying "down" and a row
|
||||
* nobody has written in an hour are different things to say to a reader.
|
||||
*
|
||||
* **[lastSeenAt] is what a page means by "last reported", and [updatedAt] is
|
||||
* not.** The module shipped a defect on exactly this in phase 3 and fixed it in
|
||||
* phase 4: `updatedAt` moves on every poll including a FAILED one, so reading it
|
||||
* as "last reported" made an offline server claim it had just checked in, every
|
||||
* thirty seconds, for as long as it stayed down. Only a frame moves
|
||||
* `lastSeenAt`. The app must not repeat the mistake one tier along.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustServerDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val online: Boolean = false,
|
||||
val players: Int = 0,
|
||||
val maxPlayers: Int = 0,
|
||||
val hostname: String? = null,
|
||||
val level: String? = null,
|
||||
val worldSize: Int? = null,
|
||||
val seed: Long? = null,
|
||||
/** The CURRENT wipe, from the state row rather than the newest ingested wipe. */
|
||||
val wipeId: String? = null,
|
||||
val wipedAt: String? = null,
|
||||
/** When a frame last arrived. What "last reported" means. */
|
||||
val lastSeenAt: String? = null,
|
||||
/** When this module last wrote the row — a failed poll moves it too. */
|
||||
val updatedAt: String? = null,
|
||||
val stale: Boolean = false,
|
||||
/**
|
||||
* When the server wipes next (module-rust phase 16, D130), or null. Null
|
||||
* both when the operator set no schedule and against a module older than
|
||||
* phase 16, which does not send the field — either way nothing is drawn.
|
||||
*/
|
||||
val nextWipe: RustNextWipeDto? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The next wipe, as the module computed it from the operator's schedule on this
|
||||
* read. [source] is `forced` (Facepunch's monthly forced wipe), `rule` (the
|
||||
* server's own weekly or biweekly day) or `once` (a one-off date the operator
|
||||
* set — a delayed or an extra wipe). An unknown word is shown like `rule`.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustNextWipeDto(
|
||||
val at: String? = null,
|
||||
val source: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /public/rust/servers/{id}/events` — the killfeed and everything else public.
|
||||
*
|
||||
* **Nothing names who is online by default** (org lead, 2026-09-22). Below the
|
||||
* operator's presence audience — staff unless widened — the server withholds
|
||||
* every item that says a named player was on (joins, deaths, chat, tallies) and
|
||||
* says so with [presenceHidden]; [presenceAudience] is who CAN see them. The
|
||||
* screen says it, so a thin feed reads as withheld rather than as a quiet server.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustEventListDto(
|
||||
val events: List<RustEventDto> = emptyList(),
|
||||
val presenceHidden: Boolean = false,
|
||||
val presenceAudience: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One stored frame.
|
||||
*
|
||||
* **[frame] is deliberately untyped.** The module stores the whole frame the
|
||||
* bridge plugin emitted and indexes only the columns it serves, so the fields
|
||||
* differ per [kind] and a later protocol adds more. A sealed hierarchy here would
|
||||
* have to be extended in this repo before a server running a newer plugin could
|
||||
* say anything new, and the module's own rule is the opposite: an unknown kind
|
||||
* renders as itself rather than being dropped. [RustFeed] is the one place that
|
||||
* knows the field names.
|
||||
*
|
||||
* [t] is epoch milliseconds — the stamp the plugin put on the frame, not a
|
||||
* database column, so it is a number here and an ISO string everywhere else on
|
||||
* this wire.
|
||||
*
|
||||
* [canBlock] and [hidden] are about the row rather than in the frame, and only chat
|
||||
* lines carry them (`USER_SAFETY.md` §4.2). [canBlock] is sent to a signed-in reader
|
||||
* only (S13): null means "not said", and the menu offers no Block on it. [hidden]
|
||||
* reaches staff only — everybody else is never sent a line staff hid (S10).
|
||||
*/
|
||||
@Serializable
|
||||
data class RustEventDto(
|
||||
val id: Long = 0,
|
||||
val kind: String = "",
|
||||
val t: Long = 0,
|
||||
val wipeId: String? = null,
|
||||
val steamId: String? = null,
|
||||
val frame: JsonObject = JsonObject(emptyMap()),
|
||||
val canBlock: Boolean? = null,
|
||||
val hidden: Boolean = false,
|
||||
) {
|
||||
/**
|
||||
* One frame field as text, or null.
|
||||
*
|
||||
* **A JSON `null` answers null, not the four letters.** The plugin writes
|
||||
* explicit nulls — `reason` on a clean disconnect, `weapon` on a fall — and a
|
||||
* primitive's `content` is the string `"null"` for every one of them, which
|
||||
* would put the word into a killfeed line. An empty string answers null too:
|
||||
* the callers here all mean "is there something to show".
|
||||
*/
|
||||
fun str(key: String): String? = primitive(key)?.content?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/** One frame field as a number, or null when it is absent, null or not one. */
|
||||
fun num(key: String): Double? = primitive(key)?.content?.toDoubleOrNull()
|
||||
|
||||
/** One frame field as a flag. Absent, null and anything non-boolean are all false. */
|
||||
fun flag(key: String): Boolean = primitive(key)?.content == "true"
|
||||
|
||||
/** The raw element, for a caller that wants to decide for itself. */
|
||||
fun raw(key: String): JsonElement? = frame[key]
|
||||
|
||||
private fun primitive(key: String): JsonPrimitive? =
|
||||
(frame[key] as? JsonPrimitive)?.takeIf { it !is JsonNull }
|
||||
}
|
||||
|
||||
/** `GET /public/rust/servers/{id}/leaderboard` — per wipe, or all-time. */
|
||||
@Serializable
|
||||
data class RustLeaderboardDto(
|
||||
val leaderboard: List<RustLeaderboardRowDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One player's standing.
|
||||
*
|
||||
* All-time is these same per-wipe rows summed rather than a second set of
|
||||
* counters, so the two can never disagree — which is why a player who appears
|
||||
* only in an older wipe **drops out** of the current one rather than reading
|
||||
* zero. The screen must not fill that gap in with zeroes.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustLeaderboardRowDto(
|
||||
val steamId: String = "",
|
||||
val name: String? = null,
|
||||
val kills: Int = 0,
|
||||
val deaths: Int = 0,
|
||||
val npcKills: Int = 0,
|
||||
val structures: Int = 0,
|
||||
val playtimeSec: Long = 0,
|
||||
val lastSeen: String? = null,
|
||||
/**
|
||||
* The chat titles this player holds now (module-rust phase 17, M19) — the
|
||||
* ones the game shows in chat, after the server's mode. They rank the
|
||||
* CURRENT wipe whichever wipe the board is showing. Absent from an older
|
||||
* module, which is why it defaults to empty: nothing is drawn.
|
||||
*/
|
||||
val titles: List<RustTitleDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** One chat title: its text, and the operator's `#rrggbb` colour for it. */
|
||||
@Serializable
|
||||
data class RustTitleDto(
|
||||
val text: String = "",
|
||||
val color: String = "",
|
||||
)
|
||||
|
||||
/** `GET /public/rust/servers/{id}/wipes` — every wipe this server has had, newest first. */
|
||||
@Serializable
|
||||
data class RustWipeListDto(
|
||||
val wipes: List<RustWipeDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One wipe.
|
||||
*
|
||||
* [wipeId] is derived by the bridge plugin from the save's creation time and
|
||||
* stamped on every frame, so it is the same id the feed and the leaderboard are
|
||||
* filtered by — which is what makes the per-wipe view navigable at all.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustWipeDto(
|
||||
val wipeId: String = "",
|
||||
val saveCreatedAt: String? = null,
|
||||
val firstSeen: String? = null,
|
||||
val lastSeen: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /public/rust/servers/{id}/online` — who is on right now.
|
||||
*
|
||||
* Below the operator's presence audience the names are withheld: [hidden] is
|
||||
* true, [players] is empty and [count] is still the real number — a count names
|
||||
* nobody, and it is already on the server line. An empty list with [hidden] set
|
||||
* must never render as "nobody is on".
|
||||
*/
|
||||
@Serializable
|
||||
data class RustOnlineDto(
|
||||
val players: List<RustPresenceDto> = emptyList(),
|
||||
val hidden: Boolean = false,
|
||||
val count: Int = 0,
|
||||
val audience: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One row of the presence board.
|
||||
*
|
||||
* Read from the board the bridge re-sends on every connect and every minute,
|
||||
* rather than counted from connect and disconnect events — so it is right even
|
||||
* after the website has missed one. **An unreachable server does not clear it**,
|
||||
* deliberately: these rows are still the best answer anybody has. Presented bare
|
||||
* they read as *who is on right now*, which is the one thing an offline server
|
||||
* cannot be saying, so the screen has to say which it is.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustPresenceDto(
|
||||
val steamId: String = "",
|
||||
val name: String? = null,
|
||||
val sleeping: Boolean = false,
|
||||
val connectedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* DTOs for `module-rust`'s map (`docs/modules/rust/PLAN.md` §30, §31; M17).
|
||||
*
|
||||
* **Who may see what is decided on the server, and these shapes only carry the
|
||||
* answer.** Each of the four layers has its own audience, and the module removes
|
||||
* a layer the viewer may not see before it answers: the layer is *absent*, not
|
||||
* empty and not flagged. That is why every layer on [RustMapLiveDto] is nullable.
|
||||
* A null list is "not yours"; an empty one is "yours, and nothing is there".
|
||||
* The app has no gate of its own, so it has none to get wrong.
|
||||
*/
|
||||
|
||||
/** `GET /public/rust/servers/{id}/map`: the picture, the frame, and which layers this viewer gets. */
|
||||
@Serializable
|
||||
data class RustMapDto(
|
||||
val serverId: String = "",
|
||||
/** Changes on a wipe or a new seed. A live answer naming another key means a new map. */
|
||||
val mapKey: String? = null,
|
||||
/** Null when the game has no picture: the layers are drawn on [RustMapGeometryDto.background]. */
|
||||
val picture: RustMapPictureDto? = null,
|
||||
/** Null when the server has never described its map. Nothing can be placed without it. */
|
||||
val geometry: RustMapGeometryDto? = null,
|
||||
/** Present only when the viewer may see the world layer. */
|
||||
val monuments: List<RustMonumentDto>? = null,
|
||||
val layers: RustMapLayersDto = RustMapLayersDto(),
|
||||
val mates: RustMapMatesDto = RustMapMatesDto(),
|
||||
val pollMs: Long? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Where the picture is.
|
||||
*
|
||||
* [path] is relative to `/api/v1` and carries the picture's hash, so it is
|
||||
* immutable: a hash that is no longer current is a 404, never the new bytes.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustMapPictureDto(
|
||||
val path: String = "",
|
||||
val source: String? = null,
|
||||
val fetchedAt: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* How a world position reaches a pixel (§30.3). [oceanMargin] is in pixels and is
|
||||
* not scaled; [gridCells] and [gridCellSize] are the game's own grid (D119).
|
||||
*/
|
||||
@Serializable
|
||||
data class RustMapGeometryDto(
|
||||
val worldSize: Double = 0.0,
|
||||
val oceanMargin: Double = 0.0,
|
||||
val width: Double = 0.0,
|
||||
val height: Double = 0.0,
|
||||
val gridCells: Int = 0,
|
||||
val gridCellSize: Double = 0.0,
|
||||
val background: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RustMonumentDto(
|
||||
val value: String = "",
|
||||
val kind: String = "",
|
||||
val label: String = "",
|
||||
val grid: String? = null,
|
||||
val x: Double = 0.0,
|
||||
val z: Double = 0.0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RustMapLayersDto(
|
||||
val world: RustMapLayerDto = RustMapLayerDto(),
|
||||
val events: RustMapLayerDto = RustMapLayerDto(),
|
||||
val players: RustMapLayerDto = RustMapLayerDto(),
|
||||
val bases: RustMapLayerDto = RustMapLayerDto(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether this viewer gets one layer, and who does. A hidden layer never says
|
||||
* what it holds. [cappedByPresence] is the players layer's alone: it is narrower
|
||||
* than its own switch because who may see who is online is narrower (D113).
|
||||
*/
|
||||
@Serializable
|
||||
data class RustMapLayerDto(
|
||||
val visible: Boolean = false,
|
||||
val audience: String? = null,
|
||||
val cappedByPresence: Boolean = false,
|
||||
)
|
||||
|
||||
/** Whether this viewer gets their own position and their online clan mates' (D115). */
|
||||
@Serializable
|
||||
data class RustMapMatesDto(
|
||||
val visible: Boolean = false,
|
||||
/** The server's switch. */
|
||||
val on: Boolean = false,
|
||||
val linked: Boolean = false,
|
||||
val signedIn: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /public/rust/servers/{id}/map/live`: what moves, cut down to this viewer.
|
||||
*
|
||||
* [live] false means the game did not answer and [reason] says why; the picture
|
||||
* stays up. Positions are never stored by the site, so there is no last-known
|
||||
* answer to fall back on here the way the presence board has one.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustMapLiveDto(
|
||||
val live: Boolean = false,
|
||||
val reason: String? = null,
|
||||
val mapKey: String? = null,
|
||||
val world: List<RustMapWorldDto>? = null,
|
||||
val events: List<RustMapEventDto>? = null,
|
||||
val players: List<RustMapPlayerDto>? = null,
|
||||
val playersTruncated: Boolean = false,
|
||||
val bases: List<RustMapBaseDto>? = null,
|
||||
val basesTruncated: Boolean = false,
|
||||
val mates: List<RustMapPlayerDto>? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A world event: `cargo`, `heli`, `chinook`, `bradley`, `supply`, `crate`, or a RunicNPC
|
||||
* `boss` placed outside any event (runicnpc D302), with its [name].
|
||||
*/
|
||||
@Serializable
|
||||
data class RustMapWorldDto(
|
||||
val kind: String = "",
|
||||
val x: Double = 0.0,
|
||||
val z: Double = 0.0,
|
||||
/** A locked crate being hacked: seconds left. */
|
||||
val hackLeftSec: Int? = null,
|
||||
val hacked: Boolean = false,
|
||||
val name: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* What one of this site's events placed: a `zone`, `crate` or `npc` (phase 13a).
|
||||
*
|
||||
* [runId] is core's run id **as a string**, because the plugin holds it as one.
|
||||
* Core's own shapes carry it as a number, so the two are matched as text.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustMapEventDto(
|
||||
val kind: String = "",
|
||||
val runId: String? = null,
|
||||
val x: Double = 0.0,
|
||||
val z: Double = 0.0,
|
||||
val radius: Double? = null,
|
||||
/** A zone's name, or a RunicNPC NPC's (runicnpc stage 8). */
|
||||
val name: String? = null,
|
||||
val prefab: String? = null,
|
||||
/** A RunicNPC boss: drawn bigger (D303). Its health is not sent. */
|
||||
val boss: Boolean = false,
|
||||
/** One of a boss's adds, placed by the boss rather than by a step. */
|
||||
val add: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* A player on the players layer, or a mate. [self] is only ever set on a mate:
|
||||
* one of the viewer's own accounts.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustMapPlayerDto(
|
||||
val steamId: String = "",
|
||||
val name: String? = null,
|
||||
val x: Double = 0.0,
|
||||
val z: Double = 0.0,
|
||||
val sleeping: Boolean = false,
|
||||
val online: Boolean = false,
|
||||
val self: Boolean = false,
|
||||
)
|
||||
|
||||
/** A base: `tc` or `vending`. Positions only: no owner, no authorised list, no shop name. */
|
||||
@Serializable
|
||||
data class RustMapBaseDto(
|
||||
val kind: String = "",
|
||||
val x: Double = 0.0,
|
||||
val z: Double = 0.0,
|
||||
)
|
||||
103
app/src/main/java/com/runicgateway/app/data/api/dto/SafetyDto.kt
Normal file
103
app/src/main/java/com/runicgateway/app/data/api/dto/SafetyDto.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Report, block and hide (`docs/website/USER_SAFETY.md` §4.1): core's routes, which
|
||||
* hold for every module and the forum alike.
|
||||
*
|
||||
* **Every request names an item, never a person.** The server reads who wrote it
|
||||
* through the same resolver a report uses, so the app can only block someone whose
|
||||
* words it was shown. A Rust chat line is `{ targetType: "rust.chat", targetId:
|
||||
* <the event row's id> }`.
|
||||
*
|
||||
* No field on a request has a default: kotlinx leaves out a property equal to its
|
||||
* default, and a request missing `targetId` is a 400 nobody would see coming.
|
||||
*/
|
||||
@Serializable
|
||||
data class SafetyTargetRequest(
|
||||
val targetType: String,
|
||||
val targetId: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /player/reports`. [detail] is optional on the wire, and a null one is left
|
||||
* out (the app's `explicitNulls = false`) — the server's validator accepts an absent
|
||||
* detail and refuses a null one.
|
||||
*/
|
||||
@Serializable
|
||||
data class ReportRequest(
|
||||
val targetType: String,
|
||||
val targetId: Long,
|
||||
val reason: String,
|
||||
val detail: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReportResultDto(
|
||||
val ok: Boolean = false,
|
||||
val reportId: Long? = null,
|
||||
)
|
||||
|
||||
/** `GET /auth/me/blocks`. */
|
||||
@Serializable
|
||||
data class BlockListDto(
|
||||
val blocks: List<UserBlockDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** `POST /auth/me/blocks` answers the block it made. */
|
||||
@Serializable
|
||||
data class BlockResultDto(
|
||||
val block: UserBlockDto? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One block.
|
||||
*
|
||||
* [kind] is `account` (a member, or a game player whose identity is linked to one —
|
||||
* S12) or `game` (an identity nobody has linked yet, which is when [identity] is
|
||||
* set). [applies] is false when the target is now staff, who cannot be blocked (S8),
|
||||
* and [reason] then says `staff`: the row stays so the member can see why it stopped.
|
||||
*/
|
||||
@Serializable
|
||||
data class UserBlockDto(
|
||||
val id: Long = 0,
|
||||
val kind: String = "account",
|
||||
val name: String? = null,
|
||||
val identity: UserBlockIdentityDto? = null,
|
||||
val applies: Boolean = true,
|
||||
val reason: String? = null,
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
|
||||
/** The game identity a `game` block names: `Steam` and a 17-digit id, for Rust. */
|
||||
@Serializable
|
||||
data class UserBlockIdentityDto(
|
||||
val module: String? = null,
|
||||
val kind: String? = null,
|
||||
val label: String? = null,
|
||||
val id: String? = null,
|
||||
)
|
||||
|
||||
/** `GET /auth/me/hidden` — the items this member hid for themself. */
|
||||
@Serializable
|
||||
data class HiddenListDto(
|
||||
val hidden: List<HiddenItemDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HiddenItemDto(
|
||||
val targetType: String = "",
|
||||
val targetId: Long = 0,
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
|
||||
/** The `{ ok }` most of these routes answer, and `{ ok, cleared }` for clear-all. */
|
||||
@Serializable
|
||||
data class SafetyOkDto(
|
||||
val ok: Boolean = false,
|
||||
val cleared: Int? = null,
|
||||
)
|
||||
@@ -16,7 +16,7 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One entry of `GET /auth/providers` — public discovery, never secrets. [icon] is
|
||||
* the provider kind (`google` | `discord` | `oidc` | `oauth2`); the app renders a
|
||||
* the provider kind (`google` | `discord` | `steam` | `oidc` | `oauth2`); the app renders a
|
||||
* button per provider from this list rather than hardcoding a set. [loginUrl] is
|
||||
* the *website* start path (unused by the app, which builds its own
|
||||
* `/auth/mobile/sso/start` URL); kept so the shape matches the backend exactly.
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.DEMO_LOCKED_CODE
|
||||
import com.runicgateway.app.core.result.errorCodeOf
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
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.DeleteAccountRequest
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||
@@ -56,8 +59,8 @@ class AccountRepository @Inject constructor(
|
||||
|
||||
suspend fun identities(): ApiResult<List<LinkedIdentityDto>> = safeApiCall { api.identities() }
|
||||
|
||||
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
|
||||
safeApiCall { api.unlinkIdentity(provider) }
|
||||
suspend fun unlinkIdentity(provider: String, id: Int? = null): ApiResult<Unit> =
|
||||
safeApiCall { api.unlinkIdentity(provider, id) }
|
||||
|
||||
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) ───────────────────────────
|
||||
|
||||
@@ -117,4 +120,70 @@ class AccountRepository @Inject constructor(
|
||||
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
|
||||
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
|
||||
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
|
||||
|
||||
// ── Deleting the account (ACCOUNT_DELETION.md §4.1, §4.3) ──────────────
|
||||
|
||||
/** Why the server refused a deletion, one per refusal `code` it sends. */
|
||||
enum class DeleteRefusal {
|
||||
USERNAME_MISMATCH,
|
||||
WRONG_PASSWORD,
|
||||
CODE_REQUIRED,
|
||||
WRONG_CODE,
|
||||
|
||||
/** The account has no password yet; it sets one on Account first (AD7). */
|
||||
PASSWORD_REQUIRED,
|
||||
LAST_ADMIN,
|
||||
|
||||
/** The public demo's refusal; the shell's snackbar has already said why. */
|
||||
DEMO_LOCKED,
|
||||
RATE_LIMITED,
|
||||
|
||||
/** Any other answer — a 5xx, or a refusal this app does not know. */
|
||||
OTHER,
|
||||
}
|
||||
|
||||
sealed interface DeleteOutcome {
|
||||
/** Gone on the server, with every session; the caller forgets it locally. */
|
||||
data object Deleted : DeleteOutcome
|
||||
data class Refused(val reason: DeleteRefusal) : DeleteOutcome
|
||||
data object NetworkError : DeleteOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the signed-in account. A second factor is a [code] or a [recoveryCode],
|
||||
* sent only when two-factor is on. Reads the raw response so a refusal's `code`
|
||||
* survives; a wrong password is a 400, never a 401 (AD12), so the authenticator
|
||||
* never replays this call.
|
||||
*/
|
||||
suspend fun deleteAccount(
|
||||
username: String,
|
||||
password: String,
|
||||
code: String? = null,
|
||||
recoveryCode: String? = null,
|
||||
): DeleteOutcome {
|
||||
val response = try {
|
||||
api.deleteAccount(DeleteAccountRequest(username, password, code, recoveryCode))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IOException) {
|
||||
return DeleteOutcome.NetworkError
|
||||
} catch (_: Exception) {
|
||||
return DeleteOutcome.Refused(DeleteRefusal.OTHER)
|
||||
}
|
||||
if (response.isSuccessful) return DeleteOutcome.Deleted
|
||||
if (response.code() == 429) return DeleteOutcome.Refused(DeleteRefusal.RATE_LIMITED)
|
||||
val refusal = runCatching { errorCodeOf(response.errorBody()?.string()) }.getOrNull()
|
||||
return DeleteOutcome.Refused(deleteRefusalOf(refusal))
|
||||
}
|
||||
|
||||
private fun deleteRefusalOf(code: String?): DeleteRefusal = when (code) {
|
||||
"username_mismatch" -> DeleteRefusal.USERNAME_MISMATCH
|
||||
"wrong_password" -> DeleteRefusal.WRONG_PASSWORD
|
||||
"code_required" -> DeleteRefusal.CODE_REQUIRED
|
||||
"wrong_code" -> DeleteRefusal.WRONG_CODE
|
||||
"password_required" -> DeleteRefusal.PASSWORD_REQUIRED
|
||||
"last_admin" -> DeleteRefusal.LAST_ADMIN
|
||||
DEMO_LOCKED_CODE -> DeleteRefusal.DEMO_LOCKED
|
||||
else -> DeleteRefusal.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ 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.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
@@ -208,6 +210,28 @@ class AuthRepository @Inject constructor(
|
||||
sessionManager.onSignedOut()
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget an account the server has just deleted (ACCOUNT_DELETION.md §4.3): what
|
||||
* [logout] does, without its two calls, because the server dropped the sessions,
|
||||
* trusted devices and push registrations with the row and would answer 401.
|
||||
*
|
||||
* The trust token goes too, unlike a sign-out: it belongs to an account that no
|
||||
* longer exists. The push registration goes with the session, through
|
||||
* [PushManager]'s sign-out observer, as on a dead refresh.
|
||||
*
|
||||
* Not cancellable: the account is already gone, so a caller leaving the screen
|
||||
* must not leave its session behind on the phone.
|
||||
*/
|
||||
suspend fun forgetDeletedAccount() = withContext(NonCancellable) {
|
||||
try {
|
||||
inboxCache.clear()
|
||||
} catch (_: Exception) {
|
||||
// Ignore — the snapshot is scoped to (base URL, user id) and is never shown again.
|
||||
}
|
||||
trustTokenStore.clear()
|
||||
sessionManager.onSignedOut()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-validate the session against `GET /auth/me` on app resume (§4.3). A
|
||||
* success refreshes the cached role (roles change server-side); a `401` that
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** A game module whose player surface can say whether this user has linked an account. */
|
||||
enum class LinkSource { RUST, SHARD }
|
||||
|
||||
/**
|
||||
* Which module's link read answers for this site (`docs/modules/rust/PLAN.md` D69).
|
||||
*
|
||||
* **Read off the capabilities, never assumed.** Before phase 11 the answer was
|
||||
* always `module-uo`'s `/player/shard/accounts`, which does not exist on a Rust
|
||||
* site. That call failed, the failure read as "not linked", and the raid alert
|
||||
* was locked in every channel.
|
||||
*
|
||||
* A host that has **never** answered asks both, and a failed read is simply no
|
||||
* link. A site runs one module (§24.5), so at most one of the two answers. That
|
||||
* is the same fail-open direction [canUse] takes. A host that answered and named
|
||||
* neither module has no link to ask about.
|
||||
*/
|
||||
fun linkSourcesFor(capabilities: SiteCapabilities?): List<LinkSource> {
|
||||
if (capabilities == null) return LinkSource.entries
|
||||
return buildList {
|
||||
if (Capability.RUST in capabilities) add(LinkSource.RUST)
|
||||
if (Capability.SHARD in capabilities) add(LinkSource.SHARD)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the signed-in user holds at least one linked game account on this site.
|
||||
*
|
||||
* It exists for the settings screen's one courtesy gate: a `requiresLinkedAccount`
|
||||
* stream cannot have push switched **on** without a link. It is a courtesy and not
|
||||
* a boundary. The server enforces no such flag, and who is actually alerted is the
|
||||
* module's recipient computation (Rust D59), so this never needs to be more than
|
||||
* a best guess.
|
||||
*/
|
||||
@Singleton
|
||||
class LinkedAccountRepository @Inject constructor(
|
||||
private val capabilities: SiteCapabilitiesRepository,
|
||||
private val rust: PlayerRustRepository,
|
||||
private val shard: PlayerShardRepository,
|
||||
) {
|
||||
suspend fun hasLinkedAccount(): Boolean =
|
||||
linkSourcesFor(capabilities.capabilities.value).any { source ->
|
||||
when (source) {
|
||||
LinkSource.RUST -> (rust.links() as? ApiResult.Ok)?.data?.isNotEmpty() == true
|
||||
LinkSource.SHARD -> (shard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.map
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
import com.runicgateway.app.data.api.PlayerRustApi
|
||||
import com.runicgateway.app.data.api.dto.RustBackpackDto
|
||||
import com.runicgateway.app.data.api.dto.RustEconomicsServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustLinkDto
|
||||
import com.runicgateway.app.data.api.dto.RustLinkRequest
|
||||
import com.runicgateway.app.data.api.dto.RustLinkResultDto
|
||||
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* A player's own Rust identity and what it earns them (PLAN.md §9 M15), over the
|
||||
* bearer-gated `/player/rust/…` surface.
|
||||
*
|
||||
* Every call returns a typed [ApiResult] rather than throwing, like every other
|
||||
* repository here — and on this surface the **status is the message**: the
|
||||
* website answers a refused code `400`, a Steam account somebody else holds
|
||||
* `409`, an unreachable server `503` and a capped attempt `429`, precisely so a
|
||||
* client can tell a player what to do next without reading prose. The view model
|
||||
* is where that mapping lives.
|
||||
*
|
||||
* Nothing is cached. The entitlement read in particular is a picture of what the
|
||||
* site has confirmed into a game, and a stale copy of that would be the one kind
|
||||
* of wrong answer this whole surface exists to avoid.
|
||||
*/
|
||||
@Singleton
|
||||
class PlayerRustRepository @Inject constructor(
|
||||
private val api: PlayerRustApi,
|
||||
) {
|
||||
/** The Steam accounts the caller holds, newest first. */
|
||||
suspend fun links(): ApiResult<List<RustLinkDto>> =
|
||||
safeApiCall { api.links() }.map { it.links }
|
||||
|
||||
/** Redeem a code from `/link` in game. */
|
||||
suspend fun link(code: String): ApiResult<RustLinkResultDto> =
|
||||
safeApiCall { api.link(RustLinkRequest(code)) }
|
||||
|
||||
/** Release one of the caller's own links. */
|
||||
suspend fun unlink(steamId: String): ApiResult<Boolean> =
|
||||
safeApiCall { api.unlink(steamId) }.map { it.unlinked }
|
||||
|
||||
/** Ranks and grants the site holds for the caller, resolved per server. */
|
||||
suspend fun permissions(): ApiResult<RustPlayerPermissionsDto> =
|
||||
safeApiCall { api.permissions() }
|
||||
|
||||
/** The caller's coin balances per server and linked account (§11.2.2). */
|
||||
suspend fun economics(): ApiResult<List<RustEconomicsServerDto>> =
|
||||
safeApiCall { api.economics() }.map { it.servers }
|
||||
|
||||
/** The servers the backpack section offers (§11.3.2). */
|
||||
suspend fun servers(): ApiResult<List<RustServerDto>> =
|
||||
safeApiCall { api.servers() }.map { it.servers }
|
||||
|
||||
/** One of the caller's own backpacks, read live (§11.3.2). Never cached: it is the game's state now. */
|
||||
suspend fun backpack(serverId: String, steamId: String): ApiResult<RustBackpackDto> =
|
||||
safeApiCall { api.backpack(serverId, steamId) }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.map
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
import com.runicgateway.app.data.api.RustApi
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The Rust module's public read path (PLAN.md §9 M14).
|
||||
*
|
||||
* Every read unwraps its envelope here rather than in a view model, so no screen
|
||||
* holds a `…Dto` whose only job was to carry one list. Nothing is cached and
|
||||
* nothing is merged: the module's tables are already the cache — the site's whole
|
||||
* premise is that it answers from what a server last said rather than from the
|
||||
* server — so a second copy in the app would only add a way for the two to
|
||||
* disagree.
|
||||
*/
|
||||
@Singleton
|
||||
class RustRepository @Inject constructor(
|
||||
private val api: RustApi,
|
||||
) {
|
||||
/** Every server this site follows, with what each last reported. */
|
||||
suspend fun servers(): ApiResult<List<RustServerDto>> =
|
||||
safeApiCall { api.getServers() }.map { it.servers }
|
||||
|
||||
/** One server. A 404 here means no such server, or one an operator disabled. */
|
||||
suspend fun server(id: String): ApiResult<RustServerDto> =
|
||||
safeApiCall { api.getServer(id) }.map { it.server }
|
||||
|
||||
/**
|
||||
* The feed.
|
||||
*
|
||||
* [kinds] is joined here rather than by a caller, so the query string this
|
||||
* app sends exists in one place — and an **empty** list is sent as no `kind`
|
||||
* parameter at all, which asks for the whole allowlist. Sending `kind=` would
|
||||
* ask for a kind named the empty string.
|
||||
*/
|
||||
suspend fun events(
|
||||
id: String,
|
||||
kinds: List<String> = emptyList(),
|
||||
wipe: String? = null,
|
||||
limit: Int? = null,
|
||||
): ApiResult<RustEventListDto> = safeApiCall {
|
||||
api.getEvents(
|
||||
id = id,
|
||||
kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","),
|
||||
wipe = wipe?.takeIf { it.isNotBlank() },
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
/** The leaderboard: per wipe when [wipe] is given, all-time otherwise. */
|
||||
suspend fun leaderboard(
|
||||
id: String,
|
||||
wipe: String? = null,
|
||||
sort: String? = null,
|
||||
limit: Int? = null,
|
||||
): ApiResult<List<RustLeaderboardRowDto>> = safeApiCall {
|
||||
api.getLeaderboard(
|
||||
id = id,
|
||||
wipe = wipe?.takeIf { it.isNotBlank() },
|
||||
sort = sort?.takeIf { it.isNotBlank() },
|
||||
limit = limit,
|
||||
)
|
||||
}.map { it.leaderboard }
|
||||
|
||||
/** Every wipe this server has had, newest first. */
|
||||
suspend fun wipes(id: String): ApiResult<List<RustWipeDto>> =
|
||||
safeApiCall { api.getWipes(id) }.map { it.wipes }
|
||||
|
||||
/**
|
||||
* The presence board. Rows survive an unreachable server, by design.
|
||||
*
|
||||
* Answered whole rather than as its rows: `hidden` and `count` are what let
|
||||
* the screen tell "withheld from you" from "nobody is on".
|
||||
*/
|
||||
suspend fun online(id: String): ApiResult<RustOnlineDto> =
|
||||
safeApiCall { api.getOnline(id) }
|
||||
|
||||
/** The map's picture, frame and layer gates for this viewer. */
|
||||
suspend fun map(id: String): ApiResult<RustMapDto> =
|
||||
safeApiCall { api.getMap(id) }
|
||||
|
||||
/** What moves on the map, as this viewer may see it. */
|
||||
suspend fun mapLive(id: String): ApiResult<RustMapLiveDto> =
|
||||
safeApiCall { api.getMapLive(id) }
|
||||
|
||||
/**
|
||||
* Staff's hide for everyone on one chat line, or its undo (`USER_SAFETY.md`
|
||||
* §4.2, S10). The module's own route, not core's: the line and its flag live here.
|
||||
*/
|
||||
suspend fun setChatHidden(lineId: Long, hidden: Boolean): ApiResult<Unit> = safeApiCall {
|
||||
if (hidden) api.hideChat(lineId) else api.unhideChat(lineId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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.isDemoLocked
|
||||
import com.runicgateway.app.core.result.map
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
import com.runicgateway.app.data.api.SafetyApi
|
||||
import com.runicgateway.app.data.api.dto.UserBlockDto
|
||||
import com.runicgateway.app.data.api.dto.HiddenItemDto
|
||||
import com.runicgateway.app.data.api.dto.ReportRequest
|
||||
import com.runicgateway.app.data.api.dto.SafetyTargetRequest
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Why the server refused a report, a block or a hide — one per answer a screen words differently. */
|
||||
enum class SafetyRefusal {
|
||||
/** `409`: an open report of this account's own on the item, or an existing block. */
|
||||
ALREADY_DONE,
|
||||
|
||||
/** `404`: gone, or never something this account could see. */
|
||||
GONE,
|
||||
|
||||
/** `403` on a block: the author is staff, who cannot be blocked (S8). */
|
||||
STAFF,
|
||||
|
||||
/** `403` anywhere else: this account may not (a demoted moderator hiding for everyone). */
|
||||
FORBIDDEN,
|
||||
|
||||
/** `400` on a block: their own line, or an author whose account is gone. */
|
||||
NOT_BLOCKABLE,
|
||||
RATE_LIMITED,
|
||||
|
||||
/** `503`: the game module that owns the item did not answer in time. */
|
||||
UNAVAILABLE,
|
||||
|
||||
/** The public demo's refusal; the shell's snackbar has already said why. */
|
||||
DEMO_LOCKED,
|
||||
|
||||
/** Anything else — a 5xx, or an answer this app does not know. */
|
||||
OTHER,
|
||||
}
|
||||
|
||||
sealed interface SafetyOutcome {
|
||||
data object Done : SafetyOutcome
|
||||
data class Refused(val reason: SafetyRefusal) : SafetyOutcome
|
||||
data object NetworkError : SafetyOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Report, block and hide (`docs/website/USER_SAFETY.md` §4.1, §4.3) over core's
|
||||
* routes.
|
||||
*
|
||||
* **Nothing here keeps a list.** The server filters every feed for its reader (S11),
|
||||
* so a block shows itself by the feed being read again — the app never drops a line
|
||||
* on its own, and never holds a block list that could disagree with the website's.
|
||||
*/
|
||||
@Singleton
|
||||
class SafetyRepository @Inject constructor(
|
||||
private val api: SafetyApi,
|
||||
) {
|
||||
suspend fun report(targetType: String, targetId: Long, reason: String, detail: String?): SafetyOutcome =
|
||||
outcome(blocking = false) {
|
||||
api.report(ReportRequest(targetType, targetId, reason, detail?.trim()?.takeIf { it.isNotEmpty() }))
|
||||
}
|
||||
|
||||
/** Block whoever wrote this item. The server decides who that is (S12). */
|
||||
suspend fun blockAuthorOf(targetType: String, targetId: Long): SafetyOutcome =
|
||||
outcome(blocking = true) { api.block(SafetyTargetRequest(targetType, targetId)) }
|
||||
|
||||
/** *Hide for me*. */
|
||||
suspend fun hide(targetType: String, targetId: Long): SafetyOutcome =
|
||||
outcome(blocking = false) { api.hide(SafetyTargetRequest(targetType, targetId)) }
|
||||
|
||||
suspend fun blocks(): ApiResult<List<UserBlockDto>> = safeApiCall { api.blocks() }.map { it.blocks }
|
||||
|
||||
suspend fun unblock(id: Long): SafetyOutcome = outcome(blocking = false) { api.unblock(id) }
|
||||
|
||||
suspend fun hidden(): ApiResult<List<HiddenItemDto>> = safeApiCall { api.hidden() }.map { it.hidden }
|
||||
|
||||
suspend fun clearHidden(): SafetyOutcome = outcome(blocking = false) { api.clearHidden() }
|
||||
|
||||
private suspend fun outcome(blocking: Boolean, call: suspend () -> Any): SafetyOutcome =
|
||||
safetyOutcomeOf(safeApiCall { call() }, blocking)
|
||||
}
|
||||
|
||||
/**
|
||||
* One answer as a [SafetyOutcome]. [blocking] is whether the call was a block, the
|
||||
* one route whose `403` means "staff" and whose `400` means "not someone you can
|
||||
* block". Shared with the staff hide, which is the module's route but answers in the
|
||||
* same terms.
|
||||
*/
|
||||
fun safetyOutcomeOf(result: ApiResult<*>, blocking: Boolean = false): SafetyOutcome = when (result) {
|
||||
is ApiResult.Ok -> SafetyOutcome.Done
|
||||
is ApiResult.NetworkError -> SafetyOutcome.NetworkError
|
||||
is ApiResult.HttpError -> SafetyOutcome.Refused(
|
||||
when {
|
||||
result.isDemoLocked() -> SafetyRefusal.DEMO_LOCKED
|
||||
result.status == 409 -> SafetyRefusal.ALREADY_DONE
|
||||
result.status == 404 -> SafetyRefusal.GONE
|
||||
result.status == 403 -> if (blocking) SafetyRefusal.STAFF else SafetyRefusal.FORBIDDEN
|
||||
result.status == 400 && blocking -> SafetyRefusal.NOT_BLOCKABLE
|
||||
result.status == 429 -> SafetyRefusal.RATE_LIMITED
|
||||
result.status == 503 -> SafetyRefusal.UNAVAILABLE
|
||||
else -> SafetyRefusal.OTHER
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -159,6 +159,36 @@ object Capability {
|
||||
*/
|
||||
const val SHARD = "shard"
|
||||
|
||||
/**
|
||||
* The Rust module (`docs/modules/rust/PLAN.md` D16, phase 5).
|
||||
*
|
||||
* A second game module, and therefore a second string rather than a second
|
||||
* meaning for [SHARD]: a Rust site is a **fleet of servers** with a list
|
||||
* above them, where a shard is one place — the surfaces are not the same
|
||||
* shape and a client cannot render one as the other.
|
||||
*
|
||||
* `module-rust` also declares `servers`, `killfeed`, `leaderboard`,
|
||||
* `presence` and `wipes`, and this gates on none of them. Every one of those
|
||||
* names a SURFACE, and core flattens all modules' capabilities into one list
|
||||
* — so `servers` is a word another module could declare tomorrow, which would
|
||||
* silently reveal these rows on a site that does not run Rust. `rust` is the
|
||||
* string only that module can mean, which is the same job [SHARD] does for
|
||||
* `module-uo`.
|
||||
*/
|
||||
const val RUST = "rust"
|
||||
|
||||
/** Core's event system (events Phase 14a). Never a module's. */
|
||||
const val EVENTS = "events"
|
||||
|
||||
/**
|
||||
* The Rust module's live map (`docs/modules/rust/PLAN.md` phase 14, D122).
|
||||
*
|
||||
* **A surface name, which [RUST]'s note warns against for a menu row** — and
|
||||
* that warning is about a row reachable on any site. This gates one tab on
|
||||
* the Rust server screen, which is reachable only where [RUST] already
|
||||
* answered, so another module declaring `map` cannot reveal it anywhere the
|
||||
* Rust module is absent. What it does answer is the one question that
|
||||
* matters here: a module older than phase 14 does not declare it.
|
||||
*/
|
||||
const val MAP = "map"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact
|
||||
import com.runicgateway.app.BuildConfig
|
||||
import com.runicgateway.app.core.net.AuthInterceptor
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.net.DemoLockInterceptor
|
||||
import com.runicgateway.app.core.net.HostSelectionInterceptor
|
||||
import com.runicgateway.app.core.net.ShardStream
|
||||
import com.runicgateway.app.core.net.ShardStreamClient
|
||||
@@ -19,8 +20,11 @@ import com.runicgateway.app.data.api.EventsApi
|
||||
import com.runicgateway.app.data.api.MeApi
|
||||
import com.runicgateway.app.data.api.AdminApi
|
||||
import com.runicgateway.app.data.api.NotificationsApi
|
||||
import com.runicgateway.app.data.api.PlayerRustApi
|
||||
import com.runicgateway.app.data.api.PlayerShardApi
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import com.runicgateway.app.data.api.RustApi
|
||||
import com.runicgateway.app.data.api.SafetyApi
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -56,7 +60,8 @@ object NetworkModule {
|
||||
|
||||
/**
|
||||
* The main client every API and the SSE stream ride on. Order: identify (UA),
|
||||
* retarget onto the configured shard host, then attach the bearer; the
|
||||
* retarget onto the configured shard host, attach the bearer, then spot the
|
||||
* public demo's refusal on the way back (DEMO_MODE.md §3.1); the
|
||||
* [TokenAuthenticator] handles silent refresh-on-401 (§4.1). Logging sits last
|
||||
* so it observes the final, authed request.
|
||||
*/
|
||||
@@ -66,12 +71,14 @@ object NetworkModule {
|
||||
hostSelectionInterceptor: HostSelectionInterceptor,
|
||||
userAgentInterceptor: UserAgentInterceptor,
|
||||
authInterceptor: AuthInterceptor,
|
||||
demoLockInterceptor: DemoLockInterceptor,
|
||||
tokenAuthenticator: TokenAuthenticator,
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.addInterceptor(userAgentInterceptor)
|
||||
.addInterceptor(hostSelectionInterceptor)
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(demoLockInterceptor)
|
||||
.authenticator(tokenAuthenticator)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
@@ -117,6 +124,11 @@ object NetworkModule {
|
||||
@Singleton
|
||||
fun provideMeApi(retrofit: Retrofit): MeApi = retrofit.create(MeApi::class.java)
|
||||
|
||||
/** Report, block and hide (USER_SAFETY.md §4.1) — core's routes, bearer-authed. */
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSafetyApi(retrofit: Retrofit): SafetyApi = retrofit.create(SafetyApi::class.java)
|
||||
|
||||
/** A player's own game data + linking (§6.3) — bearer-authed on the main client. */
|
||||
@Provides
|
||||
@Singleton
|
||||
@@ -132,6 +144,28 @@ object NetworkModule {
|
||||
@Singleton
|
||||
fun provideEventsApi(retrofit: Retrofit): EventsApi = retrofit.create(EventsApi::class.java)
|
||||
|
||||
/**
|
||||
* `module-rust`'s public read path (§9 M14).
|
||||
*
|
||||
* A MODULE's routes, unlike [provideEventsApi] beside it — they exist only on
|
||||
* a backend where an operator installed the Rust module, and the drawer rows
|
||||
* that lead to them are gated on its `rust` capability. Provided
|
||||
* unconditionally all the same: a Retrofit interface costs nothing until
|
||||
* something calls it, and there is nowhere at injection time to ask.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRustApi(retrofit: Retrofit): RustApi = retrofit.create(RustApi::class.java)
|
||||
|
||||
/**
|
||||
* A player's own Rust identity and entitlements (§9 M15) — bearer-authed on
|
||||
* the main client, like [providePlayerShardApi] one game along.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePlayerRustApi(retrofit: Retrofit): PlayerRustApi =
|
||||
retrofit.create(PlayerRustApi::class.java)
|
||||
|
||||
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
|
||||
@Provides
|
||||
@Singleton
|
||||
|
||||
110
app/src/main/java/com/runicgateway/app/ui/Polling.kt
Normal file
110
app/src/main/java/com/runicgateway/app/ui/Polling.kt
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Repeated reads of a surface that changes while somebody is looking at it
|
||||
* (`docs/modules/rust/PLAN.md` D14, and D17 for this leg).
|
||||
*
|
||||
* ## Why a refresh is not a load
|
||||
*
|
||||
* The app has had exactly one shape for a read until now: set [UiState.Loading],
|
||||
* ask, replace. That is right for opening a screen and wrong for a poll — a
|
||||
* twenty-second refresh built on it would clear the killfeed, put a spinner where
|
||||
* it was and re-fill it, three times a minute, for ever. The website hit the same
|
||||
* wall one tier along: core's `useAsync` blanks its data on every dependency
|
||||
* change, so `module-rust` bundles its own `usePolled`. This is that hook's other
|
||||
* half.
|
||||
*
|
||||
* The rule both ends keep: **a refresh is invisible when it succeeds, and keeps
|
||||
* the rows when it fails.** A site whose whole premise is "it renders while the
|
||||
* game is off" must not blank itself the first time a request does.
|
||||
*/
|
||||
|
||||
/** How often a live surface re-reads itself while somebody is looking at it (D17). */
|
||||
const val POLL_INTERVAL_MS = 20_000L
|
||||
|
||||
/**
|
||||
* What a poll produced: the state to render, and whether the last attempt failed.
|
||||
*
|
||||
* Two fields rather than a wider [UiState] because they are two facts and a
|
||||
* screen renders them in different places — the rows in the list, the failure as
|
||||
* a quiet line above it. Collapsing them would force the choice this exists to
|
||||
* avoid: show the error and lose the rows, or keep the rows and say nothing.
|
||||
*/
|
||||
data class Polled<out T>(
|
||||
val state: UiState<T> = UiState.Loading,
|
||||
/** True when the most recent refresh failed **and there were rows to keep**. */
|
||||
val refreshFailed: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Fold a refresh into what is already on screen.
|
||||
*
|
||||
* Three cases, and the middle one is the whole point:
|
||||
*
|
||||
* - **It answered.** The new data replaces the old and any previous failure
|
||||
* clears. This is the ordinary path and it is silent.
|
||||
* - **It failed, and there are rows.** The rows stay exactly as they are and the
|
||||
* failure is reported beside them. Nothing is blanked and nothing is retried
|
||||
* on the reader's behalf — the next tick is twenty seconds away.
|
||||
* - **It failed, and there is nothing yet.** There is nothing to protect, so it
|
||||
* becomes an ordinary error with a retry — which is what the first load
|
||||
* failing means.
|
||||
*
|
||||
* Pure, and takes the current state rather than reading one, so the rule is
|
||||
* tested without a dispatcher, a view model or Compose.
|
||||
*/
|
||||
fun <T> refreshInto(current: UiState<T>, result: ApiResult<T>): Polled<T> = when {
|
||||
result is ApiResult.Ok -> Polled(UiState.Success(result.data), refreshFailed = false)
|
||||
current is UiState.Success -> Polled(current, refreshFailed = true)
|
||||
else -> Polled(result.toUiState(), refreshFailed = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run [block] now and every [intervalMs] for as long as this screen is resumed.
|
||||
*
|
||||
* `repeatOnLifecycle` is what makes this the phone's version of D14's Page
|
||||
* Visibility gate, and it gets three behaviours from one line:
|
||||
*
|
||||
* - **Nothing runs while the app is away.** The coroutine is cancelled at
|
||||
* `onPause`, so a backgrounded app makes no requests at all — not a slower
|
||||
* poll, none.
|
||||
* - **Coming back refreshes immediately.** The block is restarted from the top
|
||||
* at `onResume`, which calls [block] before the first [delay] — so the first
|
||||
* thing a returning reader sees is current, not up to twenty seconds old.
|
||||
* - **A dialog or the recents switcher pauses it**, because that is what RESUMED
|
||||
* means. The alternative, STARTED, keeps polling behind a partially
|
||||
* obscured screen, which is precisely the reader who is not reading.
|
||||
*
|
||||
* **Keyed on the lifecycle owner alone, and [block] is held through
|
||||
* `rememberUpdatedState`.** Keying on the block would restart the loop on every
|
||||
* recomposition, because a lambda is a new object each time; capturing it without
|
||||
* `rememberUpdatedState` would freeze the *first* one, so a tab change or a
|
||||
* newly chosen wipe would keep refreshing the question the reader has stopped
|
||||
* asking. The loop is stable and what it calls is current.
|
||||
*/
|
||||
@Composable
|
||||
fun PollWhileResumed(intervalMs: Long = POLL_INTERVAL_MS, block: suspend () -> Unit) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val current by rememberUpdatedState(block)
|
||||
LaunchedEffect(lifecycleOwner) {
|
||||
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
while (true) {
|
||||
current()
|
||||
delay(intervalMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.NavigationDrawerItemColors
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
@@ -33,6 +35,7 @@ import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -57,13 +60,18 @@ import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.web.WebHandoff
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
import com.runicgateway.app.data.repository.Capability
|
||||
import com.runicgateway.app.ui.auth.AccountScreen
|
||||
import com.runicgateway.app.ui.auth.BlockedScreen
|
||||
import com.runicgateway.app.ui.auth.DeleteAccountScreen
|
||||
import com.runicgateway.app.ui.auth.DeleteAccountViewModel
|
||||
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.components.BrandLogo
|
||||
import com.runicgateway.app.ui.contact.ContactScreen
|
||||
import com.runicgateway.app.ui.demo.DemoNoticeViewModel
|
||||
import com.runicgateway.app.ui.events.EventScreen
|
||||
import com.runicgateway.app.ui.events.EventSeriesScreen
|
||||
import com.runicgateway.app.ui.events.EventsScreen
|
||||
@@ -101,10 +109,15 @@ 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.rust.RustBadgeViewModel
|
||||
import com.runicgateway.app.ui.rust.RustAccountScreen
|
||||
import com.runicgateway.app.ui.rust.RustServerScreen
|
||||
import com.runicgateway.app.ui.rust.RustServersScreen
|
||||
import com.runicgateway.app.ui.shard.ShardScreen
|
||||
import com.runicgateway.app.ui.theme.LocalShardStructure
|
||||
import com.runicgateway.app.ui.wiki.WikiPageScreen
|
||||
import com.runicgateway.app.ui.wiki.WikiScreen
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
||||
@@ -118,7 +131,12 @@ private val TOP_LEVEL_ROUTES = setOf(
|
||||
// gesture works on them. The event page and an arc are detail screens and are
|
||||
// deliberately absent — a back gesture there means "back", not "open the menu".
|
||||
Routes.EVENTS, Routes.MY_EVENTS,
|
||||
// The Rust server list is a drawer row (M14); one server's page is a detail
|
||||
// screen and is deliberately absent — a back gesture there means "back".
|
||||
Routes.RUST,
|
||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||
// The player's own Rust account (M15) — a drawer row like the three above it.
|
||||
Routes.PLAYER_RUST,
|
||||
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||
)
|
||||
|
||||
@@ -140,6 +158,8 @@ fun RunicApp(
|
||||
onDeepLinkConsumed: () -> Unit = {},
|
||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
|
||||
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
|
||||
demoNoticeViewModel: DemoNoticeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val brand = appearance.brand
|
||||
val navController = rememberNavController()
|
||||
@@ -154,19 +174,46 @@ fun RunicApp(
|
||||
val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle()
|
||||
|
||||
// Re-validate the cached role each time the app returns to the foreground (§4.3),
|
||||
// and re-read the unread count with it: a tickle that arrived while the app was
|
||||
// away is exactly what brings someone back to it.
|
||||
LifecycleResumeEffect(Unit) {
|
||||
// and re-read the two drawer counts with it: a tickle that arrived while the app
|
||||
// was away is exactly what brings someone back to it, and a live player count is
|
||||
// only live if it is re-read when somebody looks.
|
||||
LifecycleResumeEffect(capabilities) {
|
||||
sessionViewModel.revalidate()
|
||||
inboxBadgeViewModel.refresh()
|
||||
// The Rust count is a LIVE number, so it is re-read on the same clock the
|
||||
// unread badge is: coming back to the app is exactly when a stale one
|
||||
// would be noticed. Keyed on the capability answer as well as on resume,
|
||||
// because the very first resume happens before this host has said whether
|
||||
// the module is there — and asking then would either make a request on a
|
||||
// site that has no Rust, or never make one at all.
|
||||
capabilities?.let { rustBadgeViewModel.refresh(Capability.RUST in it) }
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle()
|
||||
// How many people are on the Rust fleet, for the drawer row's badge — the
|
||||
// phone's answer to D15's footer count (M14). Refreshed on resume, never on a
|
||||
// timer: a badge is a glance, not a feed. Gated here rather than inside the
|
||||
// view model because this is the only place that knows whether the module is
|
||||
// installed at all, and a host that has not answered yet asks nothing.
|
||||
val rustOnline by rustBadgeViewModel.online.collectAsStateWithLifecycle()
|
||||
// The badge follows the session, so signing out clears it rather than leaving
|
||||
// the previous account's count on the drawer.
|
||||
LaunchedEffect(session) { inboxBadgeViewModel.refresh() }
|
||||
|
||||
// The public demo's refusals (DEMO_MODE.md §3.1, DM19): one snackbar for every
|
||||
// screen, the app's counterpart of the web's toast. The screen keeps its own
|
||||
// failure line; this says why, in the server's words. A newer refusal replaces
|
||||
// the one showing rather than queueing behind it.
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val demoLockedFallback = stringResource(R.string.error_demo_locked)
|
||||
val accountDeletedNotice = stringResource(R.string.account_delete_done)
|
||||
LaunchedEffect(Unit) {
|
||||
demoNoticeViewModel.refusals.collectLatest { refusal ->
|
||||
snackbarHostState.showSnackbar(refusal.message ?: demoLockedFallback)
|
||||
}
|
||||
}
|
||||
|
||||
// A tapped push notification deep-links to its stream's screen (§11, item 7).
|
||||
LaunchedEffect(deepLinkStream, deepLinkRef) {
|
||||
val stream = deepLinkStream ?: return@LaunchedEffect
|
||||
@@ -267,10 +314,17 @@ fun RunicApp(
|
||||
colors = drawerItemColors,
|
||||
indented = true,
|
||||
unread = unread,
|
||||
rustOnline = rustOnline,
|
||||
) { openNode(child) }
|
||||
}
|
||||
} else {
|
||||
NavRow(node, currentRoute, drawerItemColors, unread = unread) { openNode(node) }
|
||||
NavRow(
|
||||
node = node,
|
||||
currentRoute = currentRoute,
|
||||
colors = drawerItemColors,
|
||||
unread = unread,
|
||||
rustOnline = rustOnline,
|
||||
) { openNode(node) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +369,7 @@ fun RunicApp(
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
@@ -366,6 +421,15 @@ fun RunicApp(
|
||||
session = session,
|
||||
onSignOut = { sessionViewModel.signOut() },
|
||||
onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) },
|
||||
onAccountDeleted = {
|
||||
// To sign-in with the notice, as the web lands home with its own.
|
||||
// Home stays beneath it, so back leaves sign-in for Home.
|
||||
navController.navigate(Routes.LOGIN) {
|
||||
popUpTo(Routes.HOME)
|
||||
launchSingleTop = true
|
||||
}
|
||||
scope.launch { snackbarHostState.showSnackbar(accountDeletedNotice) }
|
||||
},
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
@@ -386,6 +450,7 @@ private fun NavRow(
|
||||
colors: NavigationDrawerItemColors,
|
||||
indented: Boolean = false,
|
||||
unread: Int = 0,
|
||||
rustOnline: Int = 0,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val route = when (node) {
|
||||
@@ -405,6 +470,15 @@ private fun NavRow(
|
||||
// admin's own nav override pointing at it, since the badge belongs to the
|
||||
// destination, not to the bundled entry.
|
||||
val showsUnread = !handsOff && unread > 0 && route == Routes.NOTIFICATIONS
|
||||
// The live player count rides on whichever row leads to the Rust list, for the
|
||||
// same reason the unread count rides on whichever leads to the inbox — the
|
||||
// number belongs to the destination, not to the bundled entry, so an admin's
|
||||
// own nav override pointing there carries it too.
|
||||
//
|
||||
// **Zero renders nothing**, rather than a `0`: an empty fleet is not a
|
||||
// notification, and a badge that read `0` on a site whose servers are simply
|
||||
// quiet would be worse than no badge at all.
|
||||
val showsRustOnline = !handsOff && rustOnline > 0 && route == Routes.RUST
|
||||
|
||||
NavigationDrawerItem(
|
||||
label = { Text(label) },
|
||||
@@ -432,6 +506,19 @@ private fun NavRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
showsRustOnline -> {
|
||||
{
|
||||
// Named for a screen reader: "42" beside "Rust servers" reads
|
||||
// as a count to a sighted user and as a bare number to
|
||||
// everyone else.
|
||||
val spoken = stringResource(R.string.rust_online_badge, rustOnline)
|
||||
Text(
|
||||
text = rustOnline.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.semantics { contentDescription = spoken },
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
},
|
||||
colors = colors,
|
||||
@@ -453,6 +540,7 @@ private fun RunicNavHost(
|
||||
session: Session,
|
||||
onSignOut: () -> Unit,
|
||||
onSignOutEverywhere: () -> Unit,
|
||||
onAccountDeleted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
NavHost(
|
||||
@@ -573,6 +661,30 @@ private fun RunicNavHost(
|
||||
) { entry ->
|
||||
AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty())
|
||||
}
|
||||
// The Rust module's two screens (M14). Not under `shard/`: a different game,
|
||||
// a different shape — a fleet with a list above it rather than one place.
|
||||
composable(Routes.RUST) {
|
||||
RustServersScreen(onOpenServer = { id -> navController.navigate(Routes.rustServer(id)) })
|
||||
}
|
||||
composable(
|
||||
route = Routes.RUST_SERVER,
|
||||
arguments = listOf(
|
||||
navArgument(Routes.Args.SERVER_ID) { type = NavType.StringType },
|
||||
navArgument(Routes.Args.TAB) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
),
|
||||
) {
|
||||
RustServerScreen(
|
||||
onBack = { navController.navigateTopLevel(Routes.RUST) },
|
||||
// D123: a site-event marker opens the app's own event page on its run.
|
||||
onOpenEvent = { slug, runId -> navController.navigate(Routes.event(slug, runId)) },
|
||||
// A chat line's menu signed out (S6): report and block need an account.
|
||||
onSignIn = { navController.navigate(Routes.LOGIN) },
|
||||
)
|
||||
}
|
||||
composable(Routes.WIKI) {
|
||||
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
||||
}
|
||||
@@ -613,6 +725,8 @@ private fun RunicNavHost(
|
||||
onSignOutEverywhere = onSignOutEverywhere,
|
||||
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
|
||||
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
|
||||
onOpenDeleteAccount = { navController.navigate(Routes.ACCOUNT_DELETE) },
|
||||
onOpenBlocked = { navController.navigate(Routes.ACCOUNT_BLOCKED) },
|
||||
)
|
||||
Session.SignedOut -> LaunchedEffect(Unit) {
|
||||
navController.navigateTopLevel(Routes.HOME)
|
||||
@@ -626,12 +740,30 @@ private fun RunicNavHost(
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
composable(Routes.ACCOUNT_BLOCKED) {
|
||||
when (session) {
|
||||
is Session.SignedIn -> BlockedScreen()
|
||||
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.ACCOUNT_DELETE) {
|
||||
// The view model is held here, not in the screen, so this route can tell a
|
||||
// deletion from any other sign-out: the view model marks it deleted before
|
||||
// the session drops, and both land in the same frame (ACCOUNT_DELETION.md §4.3).
|
||||
val viewModel: DeleteAccountViewModel = hiltViewModel()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
when {
|
||||
state.deleted -> LaunchedEffect(Unit) { onAccountDeleted() }
|
||||
session is Session.SignedIn -> DeleteAccountScreen(viewModel)
|
||||
else -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
composable(Routes.NOTIFICATIONS) {
|
||||
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
||||
// leaving another account's items up. The backend gates every call
|
||||
@@ -676,6 +808,12 @@ private fun RunicNavHost(
|
||||
composable(Routes.PLAYER_HOUSES) {
|
||||
PlayerGate(session, navController) { MyHousesScreen() }
|
||||
}
|
||||
// The player's own Rust account (M15). Behind the same gate as the three
|
||||
// above: the route is `requireAuth` server-side, and the gate is here so a
|
||||
// signed-out reader is sent home rather than left on a screen that 401s.
|
||||
composable(Routes.PLAYER_RUST) {
|
||||
PlayerGate(session, navController) { RustAccountScreen() }
|
||||
}
|
||||
|
||||
// ── Staff operations (§1, §6.4, M10) — reached from the staff menu section.
|
||||
// The backend re-checks role on every /admin/… call; these gates only mirror
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.isDemoLocked
|
||||
|
||||
/**
|
||||
* The load state a screen renders. View models expose a `StateFlow<UiState<T>>`;
|
||||
@@ -41,6 +42,14 @@ enum class ErrorKind {
|
||||
*/
|
||||
FEATURE_UNAVAILABLE,
|
||||
|
||||
/**
|
||||
* The public demo refused this (`403 DEMO_LOCKED`, `docs/website/DEMO_MODE.md`
|
||||
* §3.1). Checked ahead of the status, because a `403` otherwise reads as "you
|
||||
* may not", which blames the role of somebody signed in as an admin. Like
|
||||
* [FEATURE_UNAVAILABLE], retrying cannot change the answer.
|
||||
*/
|
||||
DEMO_LOCKED,
|
||||
|
||||
/** Any other non-2xx server response. */
|
||||
SERVER,
|
||||
}
|
||||
@@ -50,10 +59,11 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
|
||||
is ApiResult.Ok -> UiState.Success(data)
|
||||
is ApiResult.NetworkError -> UiState.Error(ErrorKind.NETWORK)
|
||||
is ApiResult.HttpError -> UiState.Error(
|
||||
kind = when (status) {
|
||||
404 -> ErrorKind.NOT_FOUND
|
||||
429 -> ErrorKind.RATE_LIMITED
|
||||
503 -> ErrorKind.SHARD_OFFLINE
|
||||
kind = when {
|
||||
isDemoLocked() -> ErrorKind.DEMO_LOCKED
|
||||
status == 404 -> ErrorKind.NOT_FOUND
|
||||
status == 429 -> ErrorKind.RATE_LIMITED
|
||||
status == 503 -> ErrorKind.SHARD_OFFLINE
|
||||
else -> ErrorKind.SERVER
|
||||
},
|
||||
httpStatus = status,
|
||||
@@ -76,7 +86,7 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
|
||||
* player or admin route, which is not an admin's visibility setting.
|
||||
*/
|
||||
fun <T> ApiResult<T>.toShardUiState(): UiState<T> = when (this) {
|
||||
is ApiResult.HttpError -> if (status == 403 || status == 404) {
|
||||
is ApiResult.HttpError -> if ((status == 403 || status == 404) && !isDemoLocked()) {
|
||||
UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status)
|
||||
} else {
|
||||
toUiState()
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.isDemoLocked
|
||||
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||
@@ -133,8 +134,14 @@ class AdminContentViewModel @Inject constructor(
|
||||
/** Map an [ApiResult] to a [Feedback], with role/permission-aware failure copy. */
|
||||
private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) {
|
||||
is ApiResult.Ok -> Feedback(true, okRes)
|
||||
is ApiResult.HttpError ->
|
||||
Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed)
|
||||
is ApiResult.HttpError -> Feedback(
|
||||
false,
|
||||
when {
|
||||
isDemoLocked() -> R.string.error_demo_locked
|
||||
status == 403 -> R.string.admin_forbidden
|
||||
else -> R.string.admin_action_failed
|
||||
},
|
||||
)
|
||||
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.isDemoLocked
|
||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
@@ -79,7 +80,11 @@ class AdminDashboardViewModel @Inject constructor(
|
||||
switching = false,
|
||||
feedback = Feedback(
|
||||
false,
|
||||
if (result.status == 403) R.string.admin_forbidden else R.string.admin_action_failed,
|
||||
when {
|
||||
result.isDemoLocked() -> R.string.error_demo_locked
|
||||
result.status == 403 -> R.string.admin_forbidden
|
||||
else -> R.string.admin_action_failed
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.isDemoLocked
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -75,9 +76,10 @@ class AdminModerationViewModel @Inject constructor(
|
||||
is ApiResult.Ok -> Feedback(true, okRes)
|
||||
is ApiResult.HttpError -> Feedback(
|
||||
false,
|
||||
when (r.status) {
|
||||
403 -> R.string.admin_forbidden
|
||||
503 -> R.string.admin_mod_shard_offline
|
||||
when {
|
||||
r.isDemoLocked() -> R.string.error_demo_locked
|
||||
r.status == 403 -> R.string.admin_forbidden
|
||||
r.status == 503 -> R.string.admin_mod_shard_offline
|
||||
else -> R.string.admin_action_failed
|
||||
},
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.isDemoLocked
|
||||
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
@@ -71,10 +72,11 @@ class AdminSupportViewModel @Inject constructor(
|
||||
is ApiResult.Ok -> Feedback(true, okRes)
|
||||
is ApiResult.HttpError -> Feedback(
|
||||
false,
|
||||
when (r.status) {
|
||||
403 -> R.string.admin_forbidden
|
||||
404 -> R.string.admin_support_unknown_page
|
||||
503 -> R.string.admin_mod_shard_offline
|
||||
when {
|
||||
r.isDemoLocked() -> R.string.error_demo_locked
|
||||
r.status == 403 -> R.string.admin_forbidden
|
||||
r.status == 404 -> R.string.admin_support_unknown_page
|
||||
r.status == 503 -> R.string.admin_mod_shard_offline
|
||||
else -> R.string.admin_action_failed
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.repository.AccountRepository.DeleteRefusal
|
||||
|
||||
/*
|
||||
* What the Delete my account screen decides (ACCOUNT_DELETION.md §4.3), kept out
|
||||
* of the composable so it can be tested. The website's `lib/accountDeletion.js`
|
||||
* holds the same rules and words.
|
||||
*
|
||||
* The server is the guard: it checks the username, the password and the code again
|
||||
* whatever this file decides. What is here only keeps a mis-tap from reaching it
|
||||
* and turns its refusals into sentences.
|
||||
*/
|
||||
|
||||
/** Delete is live once the typed name matches the account's, ignoring case. */
|
||||
fun deleteUsernameMatches(typed: String, username: String): Boolean =
|
||||
typed.isNotBlank() && typed.trim().equals(username, ignoreCase = true)
|
||||
|
||||
/** Every role but player is staff, whose username is never free again (AD9). */
|
||||
fun isStaffRole(role: String): Boolean = role.isNotBlank() && !role.equals("player", ignoreCase = true)
|
||||
|
||||
/** The sentence for a refusal. */
|
||||
@StringRes
|
||||
fun deleteRefusalRes(reason: DeleteRefusal): Int = when (reason) {
|
||||
DeleteRefusal.USERNAME_MISMATCH -> R.string.account_delete_error_username
|
||||
DeleteRefusal.WRONG_PASSWORD -> R.string.account_delete_error_password
|
||||
DeleteRefusal.CODE_REQUIRED -> R.string.account_delete_error_code_required
|
||||
DeleteRefusal.WRONG_CODE -> R.string.account_delete_error_code
|
||||
DeleteRefusal.PASSWORD_REQUIRED -> R.string.account_delete_no_password
|
||||
DeleteRefusal.LAST_ADMIN -> R.string.account_delete_error_last_admin
|
||||
DeleteRefusal.DEMO_LOCKED -> R.string.error_demo_locked
|
||||
DeleteRefusal.RATE_LIMITED -> R.string.account_delete_error_rate_limited
|
||||
DeleteRefusal.OTHER -> R.string.account_delete_error
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package com.runicgateway.app.ui.auth
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -17,6 +18,7 @@ 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.ButtonDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -67,6 +69,8 @@ fun AccountScreen(
|
||||
onSignOutEverywhere: () -> Unit,
|
||||
onOpenTrustedDevices: () -> Unit,
|
||||
onOpenRecoveryCodes: () -> Unit,
|
||||
onOpenDeleteAccount: () -> Unit,
|
||||
onOpenBlocked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -91,6 +95,8 @@ fun AccountScreen(
|
||||
is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
|
||||
}
|
||||
|
||||
BlockedRow(onOpenBlocked)
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||
|
||||
OutlinedButton(onClick = onSignOut, modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -102,6 +108,58 @@ fun AccountScreen(
|
||||
) {
|
||||
Text(stringResource(R.string.account_sign_out_all))
|
||||
}
|
||||
|
||||
DeleteAccountRow(onOpenDeleteAccount)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Last on the screen and set apart (ACCOUNT_DELETION.md §4.3). Always shown, on the
|
||||
* demo too, where the server refuses: Play's reviewer must be able to find it.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeleteAccountRow(onOpenDeleteAccount: () -> Unit) {
|
||||
HorizontalDivider(
|
||||
Modifier.padding(top = 20.dp, bottom = 16.dp),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.account_delete_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.account_delete_row_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = onOpenDeleteAccount,
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.account_delete_open)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Account → Blocked (USER_SAFETY.md §4.3). Outside the loaded sections on purpose:
|
||||
* the blocks are their own read, and a failed account read is no reason to hide
|
||||
* the way to undo one.
|
||||
*/
|
||||
@Composable
|
||||
private fun BlockedRow(onOpenBlocked: () -> Unit) {
|
||||
SectionCard(R.string.blocked_title) {
|
||||
Text(
|
||||
stringResource(R.string.blocked_row_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = onOpenBlocked,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.blocked_open)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,15 +387,18 @@ private fun IdentityRow(identity: LinkedIdentityDto, busy: Boolean, viewModel: A
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
val provider = identity.provider.replaceFirstChar { it.uppercase() }
|
||||
Text(
|
||||
identity.provider.replaceFirstChar { it.uppercase() },
|
||||
// Steam: "Steam: CoolGuy99" — a person may hold several, so the name tells them apart.
|
||||
identity.display_name?.let { stringResource(R.string.account_identity_named, provider, it) } ?: provider,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
identity.email?.let {
|
||||
// Steam sends no email; its SteamID64 identifies the account instead.
|
||||
(identity.email ?: identity.subject?.takeIf { identity.provider == "steam" })?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = { viewModel.unlinkIdentity(identity.provider) }, enabled = !busy) {
|
||||
TextButton(onClick = { viewModel.unlinkIdentity(identity) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.account_identity_unlink))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,11 +158,12 @@ class AccountViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
// ── Identities ───────────────────────────────────────────────────────
|
||||
fun unlinkIdentity(provider: String) {
|
||||
/** Unlink [identity] alone — by its id, so one of several Steam accounts goes and the rest stay. */
|
||||
fun unlinkIdentity(identity: LinkedIdentityDto) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.unlinkIdentity(provider)) {
|
||||
when (accountRepository.unlinkIdentity(identity.provider, identity.id)) {
|
||||
is ApiResult.Ok -> {
|
||||
finish(Section.IDENTITY, true, R.string.account_identity_unlinked)
|
||||
when (val ids = accountRepository.identities()) {
|
||||
|
||||
156
app/src/main/java/com/runicgateway/app/ui/auth/BlockedScreen.kt
Normal file
156
app/src/main/java/com/runicgateway/app/ui/auth/BlockedScreen.kt
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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.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.pluralStringResource
|
||||
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.UserBlockDto
|
||||
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 → Blocked (`USER_SAFETY.md` §4.3), mirroring the website's panel: who this
|
||||
* account blocked, each with *Unblock*, and *Show them again* for what it hid.
|
||||
*/
|
||||
@Composable
|
||||
fun BlockedScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: BlockedViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.blocked_title), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
stringResource(R.string.blocked_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
state.feedback?.let { fb ->
|
||||
Text(
|
||||
text = fb.arg?.let { stringResource(fb.messageRes, it) } ?: 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 blocks = state.blocks) {
|
||||
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
|
||||
is UiState.Error -> ErrorView(blocks.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
|
||||
is UiState.Success -> {
|
||||
if (blocks.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.blocked_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
} else {
|
||||
blocks.data.forEach { block ->
|
||||
BlockRow(block, state.busy, onUnblock = { viewModel.unblock(block) })
|
||||
}
|
||||
}
|
||||
|
||||
if (state.hiddenCount > 0) {
|
||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||
Text(
|
||||
pluralStringResource(R.plurals.blocked_hidden_count, state.hiddenCount, state.hiddenCount),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::showHidden,
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) { Text(stringResource(R.string.blocked_show_hidden)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlockRow(block: UserBlockDto, busy: Boolean, onUnblock: () -> Unit) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = block.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.blocked_unknown_player),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = blockSubtitle(block),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
// No line limit: a Steam block's "not linked to an account" and
|
||||
// its date are the point of the row, and on the walk one line cut
|
||||
// both off and two still cut the year.
|
||||
)
|
||||
// S8: a block whose target is now staff stays listed, and says why
|
||||
// it stopped, rather than vanishing.
|
||||
if (!block.applies && block.reason == "staff") {
|
||||
Text(
|
||||
text = stringResource(R.string.blocked_not_applied_staff),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onUnblock, enabled = !busy) {
|
||||
Text(stringResource(R.string.blocked_unblock))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The second line of a row: what was blocked, and when. An account block is the
|
||||
* usual one (S12); a game block names the identity and says it is not linked to an
|
||||
* account yet, since that is why it is a Steam id and not a username.
|
||||
*/
|
||||
@Composable
|
||||
private fun blockSubtitle(block: UserBlockDto): String {
|
||||
val identity = block.identity
|
||||
val what = if (block.kind == "game" && identity?.id != null) {
|
||||
stringResource(R.string.blocked_kind_game, identity.label ?: identity.kind.orEmpty(), identity.id)
|
||||
} else {
|
||||
stringResource(R.string.blocked_kind_account)
|
||||
}
|
||||
val on = blockedOn(block.createdAt) ?: return what
|
||||
return stringResource(R.string.blocked_subtitle_when, what, on)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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.core.time.parseWireInstant
|
||||
import com.runicgateway.app.data.api.dto.UserBlockDto
|
||||
import com.runicgateway.app.data.repository.SafetyOutcome
|
||||
import com.runicgateway.app.data.repository.SafetyRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
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 java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Account → Blocked (`USER_SAFETY.md` §4.3), the app's copy of the website's panel:
|
||||
* the members and players this account blocked, each with *Unblock*, and a count of
|
||||
* the items it hid for itself with one *Show them again*.
|
||||
*
|
||||
* Blocks and hides are saved on the account (S2), so this is the same list the
|
||||
* website shows, and undoing one here undoes it there.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class BlockedViewModel @Inject constructor(
|
||||
private val safety: SafetyRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** A one-shot result line above the list. [arg] fills a name into [messageRes]. */
|
||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int, val arg: String? = null)
|
||||
|
||||
data class State(
|
||||
val blocks: UiState<List<UserBlockDto>> = UiState.Loading,
|
||||
/** Items hidden for this account. Zero when the list could not be read: the row just isn't offered. */
|
||||
val hiddenCount: Int = 0,
|
||||
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(blocks = UiState.Loading) }
|
||||
viewModelScope.launch { reload() }
|
||||
}
|
||||
|
||||
fun unblock(block: UserBlockDto) = act(
|
||||
call = { safety.unblock(block.id) },
|
||||
done = block.name?.let { Feedback(true, R.string.blocked_unblocked_named, it) }
|
||||
?: Feedback(true, R.string.blocked_unblocked),
|
||||
failed = R.string.blocked_unblock_failed,
|
||||
)
|
||||
|
||||
fun showHidden() = act(
|
||||
call = { safety.clearHidden() },
|
||||
done = Feedback(true, R.string.blocked_hidden_cleared),
|
||||
failed = R.string.blocked_hidden_failed,
|
||||
)
|
||||
|
||||
private fun act(call: suspend () -> SafetyOutcome, done: Feedback, @StringRes failed: Int) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
val feedback = when (call()) {
|
||||
SafetyOutcome.Done -> done
|
||||
SafetyOutcome.NetworkError -> Feedback(false, R.string.error_network)
|
||||
is SafetyOutcome.Refused -> Feedback(false, failed)
|
||||
}
|
||||
reload()
|
||||
_state.update { it.copy(busy = false, feedback = feedback) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reload() {
|
||||
// The two reads at once; a failed hidden-list read costs the one row, not the page.
|
||||
val (blocks, hidden) = coroutineScope {
|
||||
val b = async { safety.blocks() }
|
||||
val h = async { safety.hidden() }
|
||||
b.await() to h.await()
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
blocks = blocks.toUiState(),
|
||||
hiddenCount = (hidden as? ApiResult.Ok)?.data?.size ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a block was made, as a date in the reader's zone, or null when the server
|
||||
* sent no stamp it could read.
|
||||
*/
|
||||
fun blockedOn(createdAt: String?, zone: ZoneId = ZoneId.systemDefault()): String? =
|
||||
parseWireInstant(createdAt)?.let {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withZone(zone).format(it)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
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.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
|
||||
/** What goes with the account. The same list as the website's and BACKEND_DESIGN.md's. */
|
||||
private val WHAT_GOES = listOf(
|
||||
R.string.account_delete_goes_sign_in,
|
||||
R.string.account_delete_goes_devices,
|
||||
R.string.account_delete_goes_identities,
|
||||
R.string.account_delete_goes_game_links,
|
||||
R.string.account_delete_goes_notifications,
|
||||
)
|
||||
|
||||
/** What stays, and who sees what of it. */
|
||||
private val WHAT_STAYS = listOf(
|
||||
R.string.account_delete_stays_content,
|
||||
R.string.account_delete_stays_reports,
|
||||
R.string.account_delete_stays_events,
|
||||
R.string.account_delete_stays_game_account,
|
||||
)
|
||||
|
||||
/**
|
||||
* Account → Delete my account (ACCOUNT_DELETION.md §4.3): what goes and what stays,
|
||||
* then the confirm step — the typed username, the password, and a code when
|
||||
* two-factor is on. Delete stays off until the username matches. On the demo the
|
||||
* server refuses and the shell's snackbar says why; the screen stays reachable,
|
||||
* which is what Play's reviewer needs to find.
|
||||
*/
|
||||
@Composable
|
||||
fun DeleteAccountScreen(
|
||||
viewModel: DeleteAccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.account_delete_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Paragraph(R.string.account_delete_intro, top = 12)
|
||||
WHAT_GOES.forEach { Bullet(it) }
|
||||
Paragraph(R.string.account_delete_stays_title, top = 16)
|
||||
WHAT_STAYS.forEach { Bullet(it) }
|
||||
|
||||
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 -> ConfirmStep(account.data, state, viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmStep(
|
||||
account: PlayerAccountDto,
|
||||
state: DeleteAccountViewModel.State,
|
||||
viewModel: DeleteAccountViewModel,
|
||||
) {
|
||||
var typed by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var code by rememberSaveable { mutableStateOf("") }
|
||||
var useRecovery by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val needsCode = account.totp_enabled
|
||||
val ready = account.has_password &&
|
||||
deleteUsernameMatches(typed, account.username) &&
|
||||
password.isNotEmpty() &&
|
||||
(!needsCode || code.isNotBlank())
|
||||
val enabled = !state.busy && !state.deleted
|
||||
|
||||
if (isStaffRole(account.role)) Paragraph(R.string.account_delete_staff_note, top = 16)
|
||||
|
||||
if (!account.has_password) {
|
||||
Text(
|
||||
stringResource(R.string.account_delete_no_password),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = typed,
|
||||
onValueChange = { typed = it },
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
label = { Text(stringResource(R.string.account_delete_type_username, account.username)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 20.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
label = { Text(stringResource(R.string.login_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
if (needsCode) {
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = if (useRecovery) it else it.filter(Char::isDigit).take(8) },
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
label = {
|
||||
Text(stringResource(if (useRecovery) R.string.account_delete_recovery_code else R.string.login_totp_code))
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (useRecovery) KeyboardType.Ascii else KeyboardType.NumberPassword,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
TextButton(
|
||||
onClick = { useRecovery = !useRecovery; code = "" },
|
||||
enabled = enabled,
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (useRecovery) R.string.account_delete_use_authenticator else R.string.account_delete_use_recovery,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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.delete(typed, password, code, useRecovery) },
|
||||
enabled = enabled && ready,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
) {
|
||||
Text(stringResource(if (state.busy) R.string.account_delete_busy else R.string.account_delete_action))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Paragraph(@StringRes res: Int, top: Int) {
|
||||
Text(
|
||||
stringResource(res),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = top.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bullet(@StringRes res: Int) {
|
||||
Row(Modifier.padding(top = 6.dp, start = 4.dp)) {
|
||||
Text("•", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
stringResource(res),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.repository.AccountRepository
|
||||
import com.runicgateway.app.data.repository.AccountRepository.DeleteOutcome
|
||||
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 Account → Delete my account (ACCOUNT_DELETION.md §4.3). It loads the
|
||||
* account to know whether a code is asked for and whether there is a password at
|
||||
* all, sends `DELETE /auth/me/account`, and on success forgets the account on the
|
||||
* phone. There is no grace period (AD4): the confirm step is the safeguard.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class DeleteAccountViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
data class State(
|
||||
val account: UiState<PlayerAccountDto> = UiState.Loading,
|
||||
val busy: Boolean = false,
|
||||
@param:StringRes val error: Int? = null,
|
||||
/**
|
||||
* Set before the session is dropped, so the route sees it in the same frame as
|
||||
* the sign-out and goes to sign-in with the notice rather than home.
|
||||
*/
|
||||
val deleted: Boolean = false,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(account = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
val account = accountRepository.getAccount()
|
||||
_state.update { it.copy(account = account.toUiState()) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete the account. [useRecovery] sends [code] as a recovery code instead. */
|
||||
fun delete(typed: String, password: String, code: String, useRecovery: Boolean) {
|
||||
val account = (_state.value.account as? UiState.Success)?.data ?: return
|
||||
if (_state.value.busy || _state.value.deleted) return
|
||||
_state.update { it.copy(busy = true, error = null) }
|
||||
val secondFactor = code.trim().takeIf { account.totp_enabled && it.isNotEmpty() }
|
||||
viewModelScope.launch {
|
||||
val outcome = accountRepository.deleteAccount(
|
||||
username = typed.trim(),
|
||||
password = password,
|
||||
code = secondFactor.takeUnless { useRecovery },
|
||||
recoveryCode = secondFactor.takeIf { useRecovery },
|
||||
)
|
||||
when (outcome) {
|
||||
DeleteOutcome.Deleted -> {
|
||||
_state.update { it.copy(busy = false, deleted = true) }
|
||||
authRepository.forgetDeletedAccount()
|
||||
}
|
||||
is DeleteOutcome.Refused ->
|
||||
_state.update { it.copy(busy = false, error = deleteRefusalRes(outcome.reason)) }
|
||||
DeleteOutcome.NetworkError ->
|
||||
_state.update { it.copy(busy = false, error = R.string.error_network) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,9 @@ fun LoadingView(modifier: Modifier = Modifier) {
|
||||
*
|
||||
* [ErrorKind.FEATURE_UNAVAILABLE] renders **without** the button: an admin decides
|
||||
* whether the shard publishes that surface, so retrying cannot change the answer and
|
||||
* offering it would read as a transient failure the user could wait out (M11).
|
||||
* offering it would read as a transient failure the user could wait out (M11). The
|
||||
* public demo's refusal ([ErrorKind.DEMO_LOCKED]) is the same: it is fixed until the
|
||||
* site stops being a demo.
|
||||
*/
|
||||
@Composable
|
||||
fun ErrorView(
|
||||
@@ -69,7 +71,8 @@ fun ErrorView(
|
||||
}
|
||||
|
||||
/** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */
|
||||
fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE
|
||||
fun isRetryable(kind: ErrorKind): Boolean =
|
||||
kind != ErrorKind.FEATURE_UNAVAILABLE && kind != ErrorKind.DEMO_LOCKED
|
||||
|
||||
/** Centered informational message for an empty list (§7). */
|
||||
@Composable
|
||||
@@ -93,5 +96,6 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
|
||||
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
|
||||
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
|
||||
ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable
|
||||
ErrorKind.DEMO_LOCKED -> R.string.error_demo_locked
|
||||
ErrorKind.SERVER -> R.string.error_server
|
||||
}
|
||||
|
||||
@@ -117,5 +117,6 @@ private fun ResultMessage(result: Result) {
|
||||
private fun errorText(kind: ErrorKind): String = when (kind) {
|
||||
ErrorKind.NETWORK -> stringResource(R.string.error_network)
|
||||
ErrorKind.RATE_LIMITED -> stringResource(R.string.error_rate_limited)
|
||||
ErrorKind.DEMO_LOCKED -> stringResource(R.string.error_demo_locked)
|
||||
else -> stringResource(R.string.contact_error)
|
||||
}
|
||||
|
||||
65
app/src/main/java/com/runicgateway/app/ui/demo/DemoBanner.kt
Normal file
65
app/src/main/java/com/runicgateway/app/ui/demo/DemoBanner.kt
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.demo
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.runicgateway.app.R
|
||||
|
||||
/**
|
||||
* The public demo's strip on Home (`docs/website/DEMO_MODE.md` §6), worded like the
|
||||
* web's: "**Demo site** — anything you change is wiped at 14:00 UTC. Sign-ins:
|
||||
* demo-admin / demo-player — passwords at demo.runicgateway.com/account/login."
|
||||
*
|
||||
* Drawn only from a [DemoInfo] the server sent, so a real shard never shows it and
|
||||
* the app needs no schedule of its own. [signInPage] is null when the site address
|
||||
* is unknown, and the logins sentence then names the sign-in page without it.
|
||||
*/
|
||||
@Composable
|
||||
fun DemoBanner(info: DemoInfo, signInPage: String?, modifier: Modifier = Modifier) {
|
||||
val wipe = info.resetsAt
|
||||
?.let { stringResource(R.string.demo_banner_wipe_at, it) }
|
||||
?: stringResource(R.string.demo_banner_wipe_soon)
|
||||
val logins = info.usernames.takeIf { it.isNotEmpty() }?.joinToString(" / ")?.let { names ->
|
||||
if (signInPage != null) {
|
||||
stringResource(R.string.demo_banner_logins_at, names, signInPage)
|
||||
} else {
|
||||
stringResource(R.string.demo_banner_logins, names)
|
||||
}
|
||||
}
|
||||
val title = stringResource(R.string.demo_banner_title)
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = accent, fontWeight = FontWeight.Bold)) { append(title) }
|
||||
append(wipe)
|
||||
logins?.let { append(" $it") }
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
HorizontalDivider(thickness = 1.dp, color = accent)
|
||||
}
|
||||
}
|
||||
73
app/src/main/java/com/runicgateway/app/ui/demo/DemoInfo.kt
Normal file
73
app/src/main/java/com/runicgateway/app/ui/demo/DemoInfo.kt
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.demo
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import okhttp3.HttpUrl
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
|
||||
/**
|
||||
* What the Home strip says on the public demo (`docs/website/DEMO_MODE.md` §6).
|
||||
*
|
||||
* @property resetsAt when the next wipe runs, already worded ("14:00 UTC"), or null
|
||||
* when the server sent no readable time.
|
||||
* @property usernames the published logins, in the server's order. Their passwords
|
||||
* are on the site's sign-in page, not in the app (DM20).
|
||||
*/
|
||||
data class DemoInfo(
|
||||
val resetsAt: String?,
|
||||
val usernames: List<String>,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* The banner data from `GET /public/settings`' raw `demo` value, or null when
|
||||
* there is none, which is every real shard. Forgiving field by field: a
|
||||
* missing time or a malformed login drops that part, never the banner.
|
||||
*/
|
||||
fun from(demo: JsonElement?): DemoInfo? {
|
||||
val obj = demo as? JsonObject ?: return null
|
||||
val resetsAt = (obj["resetsAt"] as? JsonPrimitive)
|
||||
?.takeIf { it.isString }
|
||||
?.let { formatResetTime(it.content) }
|
||||
val usernames = (obj["logins"] as? JsonArray).orEmpty().mapNotNull { login ->
|
||||
((login as? JsonObject)?.get("username") as? JsonPrimitive)
|
||||
?.takeIf { it.isString }
|
||||
?.content
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
return DemoInfo(resetsAt, usernames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val RESET_TIME: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("HH:mm 'UTC'").withZone(ZoneOffset.UTC)
|
||||
|
||||
/**
|
||||
* "14:00 UTC" for an ISO-8601 instant, or null when it does not parse. The wipe runs
|
||||
* on the hour in UTC (DM7), so the strip says UTC rather than converting, the same as
|
||||
* the web's: a time in the viewer's own zone reads as a different schedule from the
|
||||
* one published on `/beta`.
|
||||
*/
|
||||
fun formatResetTime(iso: String): String? = try {
|
||||
RESET_TIME.format(Instant.parse(iso))
|
||||
} catch (_: DateTimeParseException) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* The site's sign-in page as the strip prints it, `demo.runicgateway.com/account/login`:
|
||||
* no scheme, and the port only when it is not the scheme's default.
|
||||
*/
|
||||
fun signInPageLabel(base: HttpUrl): String? {
|
||||
val page = base.resolve("account/login") ?: return null
|
||||
val port = if (page.port == HttpUrl.defaultPort(page.scheme)) "" else ":${page.port}"
|
||||
return page.host + port + page.encodedPath
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.demo
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.runicgateway.app.core.net.DemoNotices
|
||||
import com.runicgateway.app.core.result.DemoRefusal
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Hands the shell the public demo's refusals (DM19), for its one snackbar. Only a
|
||||
* window onto [DemoNotices]: the shell collects while it is on screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class DemoNoticeViewModel @Inject constructor(
|
||||
notices: DemoNotices,
|
||||
) : ViewModel() {
|
||||
val refusals: SharedFlow<DemoRefusal> = notices.refusals
|
||||
}
|
||||
@@ -27,7 +27,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.EventOccurrenceDto
|
||||
import com.runicgateway.app.data.api.dto.EventParticipantDto
|
||||
import com.runicgateway.app.data.api.dto.EventProgressDto
|
||||
import com.runicgateway.app.data.api.dto.PublicEventDto
|
||||
import com.runicgateway.app.ui.PollWhileResumed
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.HtmlText
|
||||
@@ -62,10 +64,30 @@ fun EventScreen(
|
||||
when (val s = state) {
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Success -> EventBody(s.data, onOpenSeries, onOpenRun, modifier)
|
||||
is UiState.Success -> {
|
||||
// A live event's progress lines move (MODULE_API 1.12.0, runicnpc D304),
|
||||
// so the page is re-read while it is live and on screen.
|
||||
if (s.data.live) PollWhileResumed(intervalMs = PROGRESS_REFRESH_MS) { viewModel.refresh() }
|
||||
EventBody(s.data, onOpenSeries, onOpenRun, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How often a live event's page is re-read, as the web page does. */
|
||||
private const val PROGRESS_REFRESH_MS = 15_000L
|
||||
|
||||
/**
|
||||
* One progress line as text: "Bandits: 3 of 8 left", or "The Juggernaut: 62%".
|
||||
* Anything else renders nothing. Internal for the test.
|
||||
*/
|
||||
internal fun progressText(line: EventProgressDto, count: (String, Int, Int) -> String, percent: (String, Int) -> String): String? {
|
||||
if (line.label.isBlank()) return null
|
||||
val left = line.left
|
||||
val of = line.of
|
||||
if (left != null && of != null) return count(line.label, left, of)
|
||||
return line.percent?.let { percent(line.label, it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventBody(
|
||||
event: PublicEventDto,
|
||||
@@ -196,6 +218,22 @@ private fun Headline(event: PublicEventDto) {
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
// How it stands, in the words the steps' modules chose.
|
||||
val countWords = stringResource(R.string.events_progress_count)
|
||||
val percentWords = stringResource(R.string.events_progress_percent)
|
||||
current.progress.forEach { line ->
|
||||
progressText(
|
||||
line,
|
||||
count = { label, left, of -> countWords.format(label, left, of) },
|
||||
percent = { label, p -> percentWords.format(label, p) },
|
||||
)?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
event.next != null -> {
|
||||
Text(
|
||||
|
||||
@@ -56,4 +56,16 @@ class EventViewModel @Inject constructor(
|
||||
_state.value = repository.event(slug, run).toUiState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read while the event is live, for its progress lines (MODULE_API 1.12.0,
|
||||
* runicnpc D304). Quiet: the page stays on screen, and a failed re-read keeps
|
||||
* what is shown rather than replacing the page with an error.
|
||||
*/
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
val fresh = repository.event(slug, run).toUiState()
|
||||
if (fresh is UiState.Success) _state.value = fresh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.runicgateway.app.ui.components.FeatureCard
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.OnlineDot
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.demo.DemoBanner
|
||||
|
||||
/**
|
||||
* Home / status (PLAN.md §6.1): the shard's name + tagline from branding, and a
|
||||
@@ -44,11 +45,18 @@ fun HomeScreen(
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val demo by viewModel.demo.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> HomeContent(brand, s.data, modifier)
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
// The public demo's strip (DEMO_MODE.md §6), above whatever the status read
|
||||
// came back with, so it shows even while the status is loading or failed.
|
||||
demo?.let { DemoBanner(info = it, signInPage = viewModel.signInPage) }
|
||||
val content = Modifier.weight(1f)
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView(content)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = content)
|
||||
is UiState.Success -> HomeContent(brand, s.data, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,13 @@ package com.runicgateway.app.ui.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.data.repository.SettingsRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.demo.DemoInfo
|
||||
import com.runicgateway.app.ui.demo.signInPageLabel
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -16,15 +20,27 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Loads site status (mode + version) for the Home screen (PLAN.md §6.1). */
|
||||
/**
|
||||
* Loads site status (mode + version) for the Home screen (PLAN.md §6.1), and the
|
||||
* public demo's banner data when the site is one (`docs/website/DEMO_MODE.md` §6).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<UiState<StatusDto>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<StatusDto>> = _state.asStateFlow()
|
||||
|
||||
private val _demo = MutableStateFlow<DemoInfo?>(null)
|
||||
|
||||
/** The demo strip's data; null on every real shard, and while it is unknown. */
|
||||
val demo: StateFlow<DemoInfo?> = _demo.asStateFlow()
|
||||
|
||||
/** The site's sign-in page, for the strip's logins sentence; null if no site is set. */
|
||||
val signInPage: String? get() = baseUrlHolder.current?.let(::signInPageLabel)
|
||||
|
||||
init { load() }
|
||||
|
||||
fun load() {
|
||||
@@ -32,5 +48,11 @@ class HomeViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
_state.value = settingsRepository.getStatus().toUiState()
|
||||
}
|
||||
// A separate read, and silent: the strip is a nicety, so a failed settings
|
||||
// call keeps whatever was shown rather than surfacing an error of its own.
|
||||
viewModelScope.launch {
|
||||
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
|
||||
_demo.value = DemoInfo.from(settings.demo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,17 @@ val APP_MENU: List<MenuEntry> = listOf(
|
||||
feature = ShardFeature.MARKET,
|
||||
capability = Capability.SHARD,
|
||||
),
|
||||
// The Rust module (M14). ONE row, because the module's whole public surface is
|
||||
// one list and one page beneath it — `/rust` IS the server list, not a hub
|
||||
// above one.
|
||||
//
|
||||
// **No `feature`, and that is not an omission.** The visibility framework is
|
||||
// `module-uo`'s own (`shardVisibility`, six files under `module-uo/server/`
|
||||
// and none under core's), and §2.7 forbids a module importing another's — so
|
||||
// `module-rust` has no per-viewer visibility layer yet. Its phase 14 builds
|
||||
// one; until then these routes are public to everyone the site is public to,
|
||||
// and a `feature` here would be gating on a flag nothing publishes.
|
||||
MenuEntry(Routes.RUST, R.string.menu_rust, capability = Capability.RUST),
|
||||
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),
|
||||
@@ -166,11 +177,40 @@ val APP_MENU: List<MenuEntry> = listOf(
|
||||
MenuAccess.PLAYER,
|
||||
capability = Capability.SHARD,
|
||||
),
|
||||
// The player's own Rust account (M15). Beside the three UO rows above and
|
||||
// gated exactly as they are: the module's `rust` capability (a row needs the
|
||||
// code behind it INSTALLED) and `PLAYER` access, which is `isPlayer ||
|
||||
// isStaff` — staff play too, and `/player/rust/*` is `requireAuth` alone.
|
||||
//
|
||||
// **`rust`, not `identity`.** The module declares a surface word for each of
|
||||
// its features, and D16's rule is that a capability answers one question —
|
||||
// *is the module there* — so a surface word is not what a row hangs on. The
|
||||
// paths these screens call are knowledge the app has from reading the
|
||||
// module's router, exactly as §2.9 requires.
|
||||
MenuEntry(
|
||||
Routes.PLAYER_RUST,
|
||||
R.string.menu_rust_account,
|
||||
MenuAccess.PLAYER,
|
||||
capability = Capability.RUST,
|
||||
),
|
||||
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
|
||||
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
|
||||
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
|
||||
MenuEntry(Routes.ADMIN_MODERATION, R.string.menu_admin_moderation, MenuAccess.MODERATOR),
|
||||
MenuEntry(Routes.ADMIN_SUPPORT, R.string.menu_admin_support, MenuAccess.MODERATOR),
|
||||
// Moderation and the support queue act on the shard (`/admin/shard/*`, module-uo's
|
||||
// admin mount), so they need the module installed like the player rows do. On a
|
||||
// Rust-only site they once showed a UO form whose every action failed (M20).
|
||||
MenuEntry(
|
||||
Routes.ADMIN_MODERATION,
|
||||
R.string.menu_admin_moderation,
|
||||
MenuAccess.MODERATOR,
|
||||
capability = Capability.SHARD,
|
||||
),
|
||||
MenuEntry(
|
||||
Routes.ADMIN_SUPPORT,
|
||||
R.string.menu_admin_support,
|
||||
MenuAccess.MODERATOR,
|
||||
capability = Capability.SHARD,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||
import com.runicgateway.app.ui.rust.RustTab
|
||||
|
||||
/**
|
||||
* The website path → app route table (THEMING_AND_NAV.md §6.2).
|
||||
@@ -70,6 +71,15 @@ import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* and from `module-rust/client/src/entry.jsx`, which registers one (M14):
|
||||
*
|
||||
* ```jsx
|
||||
* registry.registerNav(ID, {
|
||||
* area: 'public',
|
||||
* items: [{ label: 'Servers', to: '/rust' }],
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* None of those nine declares an `order`, so `mergeFlat` appends them after core's
|
||||
* rows in registration order — which is the order they are listed in below.
|
||||
*
|
||||
@@ -119,6 +129,16 @@ val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
|
||||
WebNavPath("/uo/atlas", Routes.ATLAS),
|
||||
WebNavPath("/uo/leaderboards", Routes.SHARD_LEADERBOARDS),
|
||||
WebNavPath("/uo/market", Routes.SHARD_MARKET),
|
||||
// module-rust's one row (M14). It registers `{ label: 'Servers', to: '/rust' }`
|
||||
// and nothing else — `/rust` IS the server list, because core strips the
|
||||
// trailing separator from a module route registered with `path: ''`.
|
||||
//
|
||||
// **Both modules can be installed on one backend**, and then the nav is core's
|
||||
// eight plus ten. This table is a superset by design: a path here for a module
|
||||
// an operator has NOT installed never appears in that backend's nav and so is
|
||||
// never looked up, while a path missing from it makes a link that exists hand
|
||||
// off to a browser.
|
||||
WebNavPath("/rust", Routes.RUST),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -178,7 +198,12 @@ private val RESERVED_TOP_LEVEL = setOf(
|
||||
// Only ids the app knows about need listing: an unknown module's `/<id>` would
|
||||
// resolve to a CMS page that 404s, which is the same answer the browser gives
|
||||
// it, and core cannot enumerate them for us here anyway.
|
||||
"uo",
|
||||
//
|
||||
// `rust` is here for the opposite reason to the rest: `/rust` DOES resolve, to
|
||||
// the server list, and it does so through the nav table above — this set only
|
||||
// stops the CMS-page fallback claiming it. Without the entry a site with the
|
||||
// module absent would open a page-not-found screen instead of the browser.
|
||||
"uo", "rust",
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -209,6 +234,7 @@ private val RESERVED_TOP_LEVEL = setOf(
|
||||
* // /uo/shard, /uo/shard/activity, /uo/champs, /uo/guilds, /uo/guilds/:id,
|
||||
* // /uo/governors, /uo/houses, /uo/rules, /uo/leaderboards, /uo/market,
|
||||
* // /uo/market/vendors/:serial, /uo/atlas, /uo/atlas/:slug
|
||||
* // /rust, /rust/servers/:id
|
||||
* // CMS pages: top-level /:slug, matched only after the named routes above
|
||||
* <Route path="/:slug" element={<CmsPage />} />
|
||||
* ```
|
||||
@@ -231,6 +257,9 @@ private val RESERVED_TOP_LEVEL = setOf(
|
||||
* /uo/<shard surface> → the mapped shard route (§6.2)
|
||||
* /uo/atlas/<slug> → ATLAS_CREATURE
|
||||
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
||||
* /rust → RUST (module-rust's server list)
|
||||
* /rust/servers/<id>[?tab=<tab>] → RUST_SERVER (a tab the app has; Rust D71)
|
||||
* /player/rust → PLAYER_RUST (Rust D71)
|
||||
* /site/about → PAGE("about")
|
||||
* /<slug> → PAGE(slug), unless <slug> is reserved
|
||||
* anything else → null, i.e. the Custom Tab
|
||||
@@ -245,6 +274,12 @@ private val RESERVED_TOP_LEVEL = setOf(
|
||||
* string, any second parameter, and any fragment still hand off** — the carve-out
|
||||
* is one key on one path, not a general "parse the query".
|
||||
*
|
||||
* **Rust D71 adds the second, on the same terms:** `tab` on a Rust server's path,
|
||||
* which is what the *new leader* notice links to. It resolves only when the value
|
||||
* is a tab the app has. `?tab=clans` still hands off, because the website has a
|
||||
* Clans tab and the app does not, and opening the feed instead would be the quiet
|
||||
* drop this rule exists to prevent.
|
||||
*
|
||||
* That narrowness is the point: an admin who writes `/site/events/x?utm=mail` gets
|
||||
* the browser, which honors `utm`, rather than an app screen that silently ignored
|
||||
* it.
|
||||
@@ -282,6 +317,10 @@ fun resolveWebPath(path: String?): String? {
|
||||
val run = runParam(query) ?: return null
|
||||
return Routes.event(segments[2], run)
|
||||
}
|
||||
if (segments.size == 3 && segments[0] == MODULE_RUST && segments[1] == "servers" && query.isNotEmpty()) {
|
||||
val tab = rustTabParam(query) ?: return null
|
||||
return Routes.rustServer(segments[2], tab)
|
||||
}
|
||||
if (query.isNotEmpty()) return null
|
||||
|
||||
return when {
|
||||
@@ -295,6 +334,14 @@ fun resolveWebPath(path: String?): String? {
|
||||
Routes.atlasCreature(segments[2])
|
||||
segments[0] == MODULE_UO && segments.size == 4 && segments[1] == "market" &&
|
||||
segments[2] == "vendors" -> Routes.marketVendor(segments[3])
|
||||
// module-rust's one page below the list. `/rust` itself is already
|
||||
// answered by the nav table above, before this fallback is reached.
|
||||
segments[0] == MODULE_RUST && segments.size == 3 && segments[1] == "servers" ->
|
||||
Routes.rustServer(segments[2])
|
||||
// The player's own Rust account, which the *account linked* notice links
|
||||
// to (D71). Not in the nav table above, which is built from the PUBLIC
|
||||
// nav; a signed-out tap is caught by the player gate like any other.
|
||||
segments.size == 2 && segments[0] == "player" && segments[1] == MODULE_RUST -> Routes.PLAYER_RUST
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -315,6 +362,17 @@ private fun runParam(query: String): String? {
|
||||
return value.takeIf { '&' !in it && '=' !in it }
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab a query of **exactly** `tab=<something>` names, when the app has that
|
||||
* tab (Rust D71), or null, which hands the link to the browser. Same shape as
|
||||
* [runParam], and for the same reasons.
|
||||
*/
|
||||
private fun rustTabParam(query: String): RustTab? {
|
||||
val value = query.removePrefix("tab=")
|
||||
if (value.length == query.length || value.isEmpty() || '&' in value || '=' in value) return null
|
||||
return RustTab.fromWire(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The module id whose public pages this table maps.
|
||||
*
|
||||
@@ -322,3 +380,6 @@ private fun runParam(query: String): String? {
|
||||
* module is countable. It is a literal on purpose — see the file header.
|
||||
*/
|
||||
private const val MODULE_UO = "uo"
|
||||
|
||||
/** The second game module's id (M14). A literal for the same reason. */
|
||||
private const val MODULE_RUST = "rust"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.data.repository.ContentRepository
|
||||
import com.runicgateway.app.ui.rust.RustTab
|
||||
|
||||
/**
|
||||
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
|
||||
@@ -37,6 +38,12 @@ object Routes {
|
||||
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
|
||||
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
|
||||
|
||||
/** Delete my account (ACCOUNT_DELETION.md §4.3), reached from Account. Signed-in only. */
|
||||
const val ACCOUNT_DELETE = "account/delete"
|
||||
|
||||
/** Account → Blocked (USER_SAFETY.md §4.3), reached from Account. Signed-in only. */
|
||||
const val ACCOUNT_BLOCKED = "account/blocked"
|
||||
|
||||
/**
|
||||
* The in-app inbox (ENGAGEMENT.md phase 8, signed-in) and its settings.
|
||||
*
|
||||
@@ -70,6 +77,33 @@ object Routes {
|
||||
const val EVENT_SERIES = "events/series/{slug}"
|
||||
const val MY_EVENTS = "account/events"
|
||||
|
||||
/**
|
||||
* The Rust module's surface (§9 M14, `docs/modules/rust/PLAN.md` D12, D13).
|
||||
*
|
||||
* **[RUST] is the server list, not a hub above one.** The module registers its
|
||||
* pages with `path: ''` and core strips the trailing separator, so `/rust` on
|
||||
* the website *is* the list — there is no landing page between the drawer row
|
||||
* and the servers, and adding one here would invent a screen the website does
|
||||
* not have.
|
||||
*
|
||||
* **A different game, so a different route tree.** These are deliberately not
|
||||
* folded into [SHARD]: one shard is a place, and a Rust site is a fleet. The
|
||||
* two can be installed on the same backend, and then both trees exist at once.
|
||||
*/
|
||||
const val RUST = "rust"
|
||||
const val RUST_SERVER = "rust/servers/{serverId}?tab={tab}"
|
||||
|
||||
/**
|
||||
* The player's own Rust account (§9 M15) — the Steam accounts they hold and
|
||||
* what the site has given them in game.
|
||||
*
|
||||
* Under `player/` with the three UO rows rather than under `rust/` with the
|
||||
* two public ones, because the grouping that matters to a reader is *whose
|
||||
* data is this*: these are the signed-in, self-scoped screens. The website
|
||||
* agrees — it serves this from the player tier, at `/player/rust`.
|
||||
*/
|
||||
const val PLAYER_RUST = "player/rust"
|
||||
|
||||
/** Public shard hub (§6.2). */
|
||||
const val SHARD = "shard"
|
||||
|
||||
@@ -121,6 +155,8 @@ object Routes {
|
||||
const val ID_OR_SLUG = "idOrSlug"
|
||||
const val SERIAL = "serial"
|
||||
const val RUN = "run"
|
||||
const val SERVER_ID = "serverId"
|
||||
const val TAB = "tab"
|
||||
}
|
||||
|
||||
fun page(slug: String) = "page/$slug"
|
||||
@@ -140,6 +176,48 @@ object Routes {
|
||||
/** One player vendor's shop, by in-game (hex) serial. */
|
||||
fun marketVendor(serial: String) = "shard/market/$serial"
|
||||
|
||||
/**
|
||||
* One Rust server's page.
|
||||
*
|
||||
* The id is a slug an operator chose, so it is encoded: nothing stops one
|
||||
* carrying a character a path would otherwise eat, and a server nobody can
|
||||
* open is a worse failure than a name nobody can read.
|
||||
*
|
||||
* **Encoded here rather than with `android.net.Uri`**, which is a stub in a
|
||||
* JVM unit test and throws "not mocked" — this object is pure and every test
|
||||
* that builds a route would have to become an instrumented one to keep it
|
||||
* that way.
|
||||
*
|
||||
* [tab] is what the *new leader* notice's link carries (Rust D71), and it is
|
||||
* dropped when absent, as `run` is on [event].
|
||||
*/
|
||||
fun rustServer(id: String, tab: RustTab? = null): String {
|
||||
val base = "rust/servers/${encodePathSegment(id)}"
|
||||
return if (tab == null) base else "$base?tab=${tab.wire}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-encode one path segment, allowing only the unreserved set.
|
||||
*
|
||||
* Deliberately stricter than it needs to be: encoding a character that did
|
||||
* not need it still round-trips, where missing one that did produces a route
|
||||
* NavHost matches differently from the one that was built. UTF-8 first, so a
|
||||
* non-ASCII name is encoded per byte rather than per character.
|
||||
*/
|
||||
private fun encodePathSegment(value: String): String = buildString {
|
||||
for (byte in value.toByteArray(Charsets.UTF_8)) {
|
||||
val code = byte.toInt() and 0xFF
|
||||
val char = code.toChar()
|
||||
if (code < 128 && (char.isLetterOrDigit() || char in UNRESERVED)) {
|
||||
append(char)
|
||||
} else {
|
||||
append('%').append(code.toString(16).uppercase().padStart(2, '0'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val UNRESERVED = "-._~"
|
||||
|
||||
/** One creature's atlas page, by slug. */
|
||||
fun atlasCreature(slug: String) = "atlas/$slug"
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ private fun ChannelPrefsList(
|
||||
SectionLabel(stringResource(R.string.notifications_section_general))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
general.forEach { item ->
|
||||
ItemRow(item, channelsById, hint = null, enabled = !busy, pushSupported = pushSupported, onSetMode = onSetMode)
|
||||
ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
|
||||
HorizontalDivider()
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
@@ -163,15 +163,7 @@ private fun ChannelPrefsList(
|
||||
SectionLabel(stringResource(R.string.notifications_section_personal))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
personal.forEach { item ->
|
||||
val selectable = itemSelectable(item, hasLinkedAccount)
|
||||
ItemRow(
|
||||
item = item,
|
||||
channelsById = channelsById,
|
||||
hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null,
|
||||
enabled = !busy && selectable,
|
||||
pushSupported = pushSupported,
|
||||
onSetMode = onSetMode,
|
||||
)
|
||||
ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
@@ -181,33 +173,40 @@ private fun ChannelPrefsList(
|
||||
private fun ItemRow(
|
||||
item: NotificationChannelItemDto,
|
||||
channelsById: Map<String, NotificationChannelDto>,
|
||||
hint: String?,
|
||||
enabled: Boolean,
|
||||
hasLinkedAccount: Boolean,
|
||||
busy: Boolean,
|
||||
pushSupported: Boolean,
|
||||
onSetMode: (NotificationChannelItemDto, String, String) -> Unit,
|
||||
) {
|
||||
// The link holds back one control, push switching on, so the row stays live
|
||||
// and the hint names that one control (Rust D69). Where this device has no
|
||||
// push at all, there is no control to hold back and nothing to explain.
|
||||
val pushHeld = pushSupported && pushNeedsLink(item, hasLinkedAccount)
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = hint ?: item.description,
|
||||
text = if (pushHeld) stringResource(R.string.notifications_requires_link) else item.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal,
|
||||
fontStyle = if (pushHeld) FontStyle.Italic else FontStyle.Normal,
|
||||
)
|
||||
// The item's OWN channel list, in the registry's order. An id nothing can
|
||||
// push carries no push control at all, rather than a dead switch.
|
||||
item.channels.forEach { channelId ->
|
||||
val channel = channelsById[channelId] ?: return@forEach
|
||||
if (channelId == CHANNEL_PUSH && !pushSupported) return@forEach
|
||||
val mode = item.modes[channelId] ?: channel.defaultMode
|
||||
ChannelControl(
|
||||
channel = channel,
|
||||
mode = item.modes[channelId] ?: channel.defaultMode,
|
||||
enabled = enabled,
|
||||
onSetMode = { mode -> onSetMode(item, channelId, mode) },
|
||||
mode = mode,
|
||||
// A held push switch that is already ON stays enabled, because
|
||||
// switching it off is never refused ([canSetMode]).
|
||||
enabled = !busy && !(channelId == CHANNEL_PUSH && pushHeld && mode == MODE_OFF),
|
||||
onSetMode = { next -> onSetMode(item, channelId, next) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
import com.runicgateway.app.data.repository.LinkedAccountRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -53,7 +53,7 @@ const val MODE_OFF = "off"
|
||||
@HiltViewModel
|
||||
class NotificationSettingsViewModel @Inject constructor(
|
||||
private val notifications: NotificationsRepository,
|
||||
private val playerShard: PlayerShardRepository,
|
||||
private val linkedAccounts: LinkedAccountRepository,
|
||||
private val pushManager: PushManager,
|
||||
sessionManager: SessionManager,
|
||||
) : ViewModel() {
|
||||
@@ -62,7 +62,10 @@ class NotificationSettingsViewModel @Inject constructor(
|
||||
|
||||
data class State(
|
||||
val prefs: UiState<NotificationChannelPrefsDto> = UiState.Loading,
|
||||
/** Whether the user has ≥1 linked game account — personal streams need it. */
|
||||
/**
|
||||
* Whether the user has ≥1 linked game account on this site's module, asked
|
||||
* of that module (Rust D69). It only gates switching push ON (see [canSetMode]).
|
||||
*/
|
||||
val hasLinkedAccount: Boolean = false,
|
||||
/** Whether this shard advertises a push relay at all (else the screen says so). */
|
||||
val supported: Boolean = true,
|
||||
@@ -94,9 +97,7 @@ class NotificationSettingsViewModel @Inject constructor(
|
||||
_state.update { it.copy(prefs = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) }
|
||||
// A linked game account gates the personal streams; failure → treat as none.
|
||||
val linked = (playerShard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
|
||||
_state.update { it.copy(hasLinkedAccount = linked) }
|
||||
_state.update { it.copy(hasLinkedAccount = linkedAccounts.hasLinkedAccount()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +115,7 @@ class NotificationSettingsViewModel @Inject constructor(
|
||||
fun setMode(item: NotificationChannelItemDto, channel: String, mode: String) {
|
||||
val current = _state.value
|
||||
if (current.busy) return
|
||||
if (channel == CHANNEL_PUSH && !itemSelectable(item, current.hasLinkedAccount)) return
|
||||
if (!canSetMode(item, channel, mode, current.hasLinkedAccount)) return
|
||||
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
@@ -160,8 +161,28 @@ class NotificationSettingsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an item's controls are selectable for a user: a personal stream needs a
|
||||
* linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose.
|
||||
* Whether this item's push is held back for want of a linked game account
|
||||
* (PLAN.md §11, `docs/modules/rust/PLAN.md` D69).
|
||||
*
|
||||
* **Push only.** Until phase 11 the whole row went dead, every channel, which
|
||||
* made a personal stream impossible to switch *off* on any site where the link
|
||||
* check failed. That was every Rust site, because the check asked `module-uo`.
|
||||
* In-app and email have nothing to do with the link, and neither does turning
|
||||
* anything off. Pure so the gating is unit-tested without Compose.
|
||||
*/
|
||||
fun itemSelectable(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean =
|
||||
!item.requiresLinkedAccount || hasLinkedAccount
|
||||
fun pushNeedsLink(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean =
|
||||
item.requiresLinkedAccount && !hasLinkedAccount && CHANNEL_PUSH in item.channels
|
||||
|
||||
/**
|
||||
* Whether [mode] may be set on [channel] for [item].
|
||||
*
|
||||
* The one thing refused is switching push **on** for an item [pushNeedsLink] holds
|
||||
* back. Switching anything off is never refused: a gate that can trap a switch in
|
||||
* the on position is worse than no gate.
|
||||
*/
|
||||
fun canSetMode(
|
||||
item: NotificationChannelItemDto,
|
||||
channel: String,
|
||||
mode: String,
|
||||
hasLinkedAccount: Boolean,
|
||||
): Boolean = !(channel == CHANNEL_PUSH && mode != MODE_OFF && pushNeedsLink(item, hasLinkedAccount))
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.isDemoLocked
|
||||
import com.runicgateway.app.core.result.map
|
||||
import com.runicgateway.app.data.api.dto.RosterCharDto
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
@@ -114,7 +115,7 @@ class CharactersViewModel @Inject constructor(
|
||||
load()
|
||||
}
|
||||
is ApiResult.HttpError -> _state.update {
|
||||
it.copy(busy = false, feedback = Feedback(false, createErrorRes(result.status)))
|
||||
it.copy(busy = false, feedback = Feedback(false, createErrorRes(result)))
|
||||
}
|
||||
is ApiResult.NetworkError -> _state.update {
|
||||
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
|
||||
@@ -129,12 +130,15 @@ class CharactersViewModel @Inject constructor(
|
||||
else -> R.string.player_link_error
|
||||
}
|
||||
|
||||
private fun createErrorRes(status: Int): Int = when (status) {
|
||||
409 -> R.string.player_create_taken
|
||||
429 -> R.string.player_create_capped
|
||||
403 -> R.string.player_create_unavailable
|
||||
400 -> R.string.player_create_rejected
|
||||
503 -> R.string.error_shard_offline
|
||||
// The demo's refusal comes first: its 403 would otherwise read as "signup is off
|
||||
// on this shard", which is not what refused it (DEMO_MODE.md §3.1).
|
||||
private fun createErrorRes(result: ApiResult.HttpError): Int = when {
|
||||
result.isDemoLocked() -> R.string.error_demo_locked
|
||||
result.status == 409 -> R.string.player_create_taken
|
||||
result.status == 429 -> R.string.player_create_capped
|
||||
result.status == 403 -> R.string.player_create_unavailable
|
||||
result.status == 400 -> R.string.player_create_rejected
|
||||
result.status == 503 -> R.string.error_shard_offline
|
||||
else -> R.string.player_create_error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustBackpackDto
|
||||
import com.runicgateway.app.data.api.dto.RustBackpackStackDto
|
||||
|
||||
/**
|
||||
* A backpack as a grid (`docs/modules/rust/PLAN_REDESIGNS.md` §11.3.2) — the
|
||||
* same rules as the website's `lib/backpack.js`, pure so the tests can ask them.
|
||||
*
|
||||
* A stack's slot runs across pages: page two of a 96-slot backpack starts at
|
||||
* slot 48 when the page size is 48. A stack at or past the capacity is
|
||||
* overflow, waiting for the owner's next open to be moved out (D338), so the
|
||||
* grid draws those slots too, after the backpack's own, marked.
|
||||
*/
|
||||
data class BackpackCell(val slot: Int, val stack: RustBackpackStackDto?, val overflow: Boolean)
|
||||
|
||||
data class BackpackPage(val index: Int, val cells: List<BackpackCell>)
|
||||
|
||||
/** Enough slots for the capacity and every stack; a backpack that does not exist is no pages. */
|
||||
fun backpackPages(backpack: RustBackpackDto?): List<BackpackPage> {
|
||||
if (backpack == null || !backpack.exists) return emptyList()
|
||||
|
||||
val capacity = backpack.capacity.coerceAtLeast(0)
|
||||
val pageSize = backpack.pageSize.coerceAtLeast(1)
|
||||
val bySlot = backpack.items.associateBy { it.slot }
|
||||
val furthest = backpack.items.maxOfOrNull { it.slot + 1 } ?: 0
|
||||
val total = maxOf(capacity, furthest)
|
||||
|
||||
return (0 until total step pageSize).mapIndexed { index, start ->
|
||||
val end = minOf(start + pageSize, total)
|
||||
BackpackPage(index, (start until end).map { slot -> BackpackCell(slot, bySlot[slot], slot >= capacity) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a slot says (D340): the game's name, "… blueprint" for a blueprint
|
||||
* (D341), then the shortname, then the bare item id for an item the game no
|
||||
* longer knows.
|
||||
*/
|
||||
fun stackLabel(stack: RustBackpackStackDto?): String {
|
||||
if (stack == null) return ""
|
||||
val name = stack.display ?: stack.shortname ?: stack.itemId?.let { "item $it" } ?: "unknown item"
|
||||
return if (stack.blueprint != null) "$name blueprint" else name
|
||||
}
|
||||
72
app/src/main/java/com/runicgateway/app/ui/rust/ChatSafety.kt
Normal file
72
app/src/main/java/com/runicgateway/app/ui/rust/ChatSafety.kt
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.SessionUser
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
|
||||
/**
|
||||
* Report, block and hide on a chat line: what its menu offers (`USER_SAFETY.md`
|
||||
* §4.3) — the Kotlin half of `module-rust`'s `client/src/lib/safety.js`.
|
||||
*
|
||||
* **Nothing here decides what a reader may see.** The server already left out what
|
||||
* they blocked or hid, and staff's hidden lines for everybody else (S11). This file
|
||||
* only turns what the server said about a line into which actions its menu shows,
|
||||
* and it is pure so the JVM tests can ask all of it.
|
||||
*/
|
||||
|
||||
/** The report target a chat line is filed as, registered by the module's `server/safety.js`. */
|
||||
const val CHAT_TARGET = "rust.chat"
|
||||
|
||||
/** One reason a report can give: the wire value, and the words for it. */
|
||||
data class ReportReason(val wire: String, @param:StringRes val labelRes: Int)
|
||||
|
||||
/**
|
||||
* Core's `content_reports.reason` values, in the order and words the website's
|
||||
* menus use, so a member reads one list wherever they report from.
|
||||
*/
|
||||
val REPORT_REASONS: List<ReportReason> = listOf(
|
||||
ReportReason("abuse", R.string.safety_reason_abuse),
|
||||
ReportReason("spam", R.string.safety_reason_spam),
|
||||
ReportReason("sexual", R.string.safety_reason_sexual),
|
||||
ReportReason("illegal", R.string.safety_reason_illegal),
|
||||
ReportReason("impersonation", R.string.safety_reason_impersonation),
|
||||
ReportReason("other", R.string.safety_reason_other),
|
||||
)
|
||||
|
||||
/** Staff's action on a line: hide it for everyone, or show a hidden one again (S10). */
|
||||
enum class StaffHide { HIDE, UNHIDE }
|
||||
|
||||
/**
|
||||
* What a chat line's menu offers one reader.
|
||||
*
|
||||
* - [signIn] a signed-out reader gets one line telling them to sign in (S6)
|
||||
* - [report] any signed-in reader, on any line
|
||||
* - [hide] *Hide for me*, likewise — a staff member's line included (S8)
|
||||
* - [block] only where the server said `canBlock` (S8, S13)
|
||||
* - [staff] for an admin or moderator, the two roles the module's hide route admits
|
||||
*/
|
||||
data class ChatActions(
|
||||
val signIn: Boolean,
|
||||
val report: Boolean,
|
||||
val hide: Boolean,
|
||||
val block: Boolean,
|
||||
val staff: StaffHide?,
|
||||
)
|
||||
|
||||
/** The menu for [row] as [user] sees it, or null for a row that is not a chat line. */
|
||||
fun chatActions(row: RustEventDto, user: SessionUser?): ChatActions? {
|
||||
if (row.kind != "player.chat") return null
|
||||
if (user == null) return ChatActions(signIn = true, report = false, hide = false, block = false, staff = null)
|
||||
return ChatActions(
|
||||
signIn = false,
|
||||
report = true,
|
||||
hide = true,
|
||||
// `canBlock` absent is "not said", and not saying is not permission.
|
||||
block = row.canBlock == true,
|
||||
staff = if (user.isModerator) (if (row.hidden) StaffHide.UNHIDE else StaffHide.HIDE) else null,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.ModalBottomSheetProperties
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.runicgateway.app.R
|
||||
|
||||
/**
|
||||
* A chat line's sheet (`USER_SAFETY.md` §4.3), opened by a long press. Its wording
|
||||
* follows the website's `ChatMenu.jsx`, so a member reads one set of sentences in
|
||||
* either place.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatSafetySheet(
|
||||
menu: ChatMenuUi,
|
||||
actions: ChatActions,
|
||||
viewModel: ChatSafetyViewModel,
|
||||
onSignIn: () -> Unit,
|
||||
) {
|
||||
val line = menu.line ?: return
|
||||
val name = line.str("name") ?: stringResource(R.string.safety_this_player)
|
||||
|
||||
// Back on the report form or the block confirmation returns to the menu rather
|
||||
// than closing the sheet. Found on the walk: Back to put the keyboard away closed
|
||||
// the whole sheet and threw away a half-written report. The reason and detail
|
||||
// live in the view model, so stepping back and in again keeps them.
|
||||
//
|
||||
// The sheet never dismisses itself on Back, and this handler decides instead:
|
||||
// the sheet reads its properties once, when it opens on the menu, so a property
|
||||
// that changed with the step would never take effect.
|
||||
val inForm = menu.step == ChatMenuStep.REPORT || menu.step == ChatMenuStep.BLOCK
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = viewModel::close,
|
||||
// Open all the way: half-open, the report form's Send button sat below the
|
||||
// fold and the walk had to drag the sheet up to find it.
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
properties = ModalBottomSheetProperties(shouldDismissOnBackPress = false),
|
||||
) {
|
||||
BackHandler { if (inForm) viewModel.go(ChatMenuStep.MENU) else viewModel.close() }
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
) {
|
||||
// The line itself, so the reader can see which one they pressed.
|
||||
Text(
|
||||
text = stringResource(R.string.safety_line_quote, name, line.str("message").orEmpty()),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
|
||||
when (menu.step) {
|
||||
ChatMenuStep.MENU -> MenuStep(actions, menu.busy, viewModel, onSignIn)
|
||||
ChatMenuStep.REPORT -> ReportStep(menu, viewModel)
|
||||
ChatMenuStep.BLOCK -> BlockStep(name, menu.busy, viewModel)
|
||||
ChatMenuStep.DONE -> {
|
||||
menu.doneRes?.let { Text(stringResource(it), style = MaterialTheme.typography.bodyMedium) }
|
||||
TextButton(onClick = viewModel::close, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Text(stringResource(R.string.safety_close))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
menu.errorRes?.let {
|
||||
Text(
|
||||
text = stringResource(it),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp)) // clears the gesture inset at the sheet's bottom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuStep(actions: ChatActions, busy: Boolean, viewModel: ChatSafetyViewModel, onSignIn: () -> Unit) {
|
||||
if (actions.signIn) {
|
||||
// S6: reporting, hiding and blocking all need an account.
|
||||
Text(stringResource(R.string.safety_sign_in_hint), style = MaterialTheme.typography.bodyMedium)
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.close()
|
||||
onSignIn()
|
||||
},
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.safety_sign_in)) }
|
||||
return
|
||||
}
|
||||
|
||||
if (actions.report) {
|
||||
MenuItem(R.string.safety_report, enabled = !busy) { viewModel.go(ChatMenuStep.REPORT) }
|
||||
}
|
||||
if (actions.hide) {
|
||||
MenuItem(R.string.safety_hide_for_me, enabled = !busy, onClick = viewModel::hideForMe)
|
||||
}
|
||||
if (actions.block) {
|
||||
MenuItem(R.string.safety_block_player, enabled = !busy) { viewModel.go(ChatMenuStep.BLOCK) }
|
||||
}
|
||||
when (actions.staff) {
|
||||
StaffHide.HIDE -> MenuItem(R.string.safety_hide_for_everyone, enabled = !busy) { viewModel.staffHide(true) }
|
||||
StaffHide.UNHIDE -> MenuItem(R.string.safety_show_to_everyone, enabled = !busy) { viewModel.staffHide(false) }
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuItem(labelRes: Int, enabled: Boolean, onClick: () -> Unit) {
|
||||
TextButton(onClick = onClick, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(labelRes), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Start)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReportStep(menu: ChatMenuUi, viewModel: ChatSafetyViewModel) {
|
||||
// A report changes nothing about the line, and says so: a member who expects it
|
||||
// to vanish and watches it stay will report it again.
|
||||
Text(stringResource(R.string.safety_report_explainer), style = MaterialTheme.typography.bodyMedium)
|
||||
|
||||
Column(Modifier.selectableGroup().padding(top = 8.dp)) {
|
||||
REPORT_REASONS.forEach { reason ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = menu.reason == reason.wire,
|
||||
onClick = { viewModel.setReason(reason.wire) },
|
||||
role = Role.RadioButton,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = menu.reason == reason.wire, onClick = null)
|
||||
Text(
|
||||
stringResource(reason.labelRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = menu.detail,
|
||||
onValueChange = viewModel::setDetail,
|
||||
label = { Text(stringResource(R.string.safety_report_detail)) },
|
||||
minLines = 2,
|
||||
maxLines = 5,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
|
||||
Row(Modifier.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = viewModel::report, enabled = !menu.busy) {
|
||||
Text(stringResource(R.string.safety_report_send))
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.go(ChatMenuStep.MENU) }) {
|
||||
Text(stringResource(R.string.safety_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlockStep(name: String, busy: Boolean, viewModel: ChatSafetyViewModel) {
|
||||
// S9: the blocked player is not told, and a member deciding whether to block is
|
||||
// usually deciding whether it will start something.
|
||||
Text(stringResource(R.string.safety_block_confirm, name), style = MaterialTheme.typography.bodyMedium)
|
||||
Row(Modifier.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = viewModel::block, enabled = !busy) {
|
||||
Text(stringResource(R.string.safety_block))
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.go(ChatMenuStep.MENU) }) {
|
||||
Text(stringResource(R.string.safety_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.SessionUser
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.data.repository.SafetyOutcome
|
||||
import com.runicgateway.app.data.repository.SafetyRefusal
|
||||
import com.runicgateway.app.data.repository.SafetyRepository
|
||||
import com.runicgateway.app.data.repository.safetyOutcomeOf
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Which part of a chat line's sheet is showing. */
|
||||
enum class ChatMenuStep { MENU, REPORT, BLOCK, DONE }
|
||||
|
||||
/**
|
||||
* The open sheet, or none.
|
||||
*
|
||||
* [line] is the row the sheet is about, held whole so the sheet still names the
|
||||
* player after the feed has been re-read without it. [error] is the last refusal,
|
||||
* dropped on every move between steps: "you have already reported this" is not
|
||||
* about the next action.
|
||||
*/
|
||||
data class ChatMenuUi(
|
||||
val line: RustEventDto? = null,
|
||||
val step: ChatMenuStep = ChatMenuStep.MENU,
|
||||
val reason: String = REPORT_REASONS.first().wire,
|
||||
val detail: String = "",
|
||||
val busy: Boolean = false,
|
||||
@param:StringRes val doneRes: Int? = null,
|
||||
@param:StringRes val errorRes: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A chat line's long-press menu (`USER_SAFETY.md` §4.3): report, *Hide for me*, block
|
||||
* the player, and for staff hide for everyone.
|
||||
*
|
||||
* **The feed is re-read after anything that changes what the reader sees** — a hide,
|
||||
* a block, staff's hide — and never edited here. The server is what filters it (S11),
|
||||
* so the honest way to show a block is to ask again; dropping the line locally would
|
||||
* show something the website does not, the moment a refresh disagreed. [changes]
|
||||
* tells the screen when to ask. A report changes nothing, and does not re-read.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ChatSafetyViewModel @Inject constructor(
|
||||
private val safety: SafetyRepository,
|
||||
private val rust: RustRepository,
|
||||
private val sessionManager: SessionManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _menu = MutableStateFlow(ChatMenuUi())
|
||||
val menu: StateFlow<ChatMenuUi> = _menu.asStateFlow()
|
||||
|
||||
private val _changes = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
/** One item per change to what this reader's feed should show. */
|
||||
val changes: Flow<Unit> = _changes.receiveAsFlow()
|
||||
|
||||
/** Who is reading, for [chatActions]. Read at the moment of asking, never cached. */
|
||||
val user: SessionUser? get() = (sessionManager.state.value as? Session.SignedIn)?.user
|
||||
|
||||
fun open(line: RustEventDto) {
|
||||
if (chatActions(line, user) == null) return
|
||||
_menu.value = ChatMenuUi(line = line)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
_menu.value = ChatMenuUi()
|
||||
}
|
||||
|
||||
fun go(step: ChatMenuStep) = _menu.update { it.copy(step = step, errorRes = null) }
|
||||
|
||||
fun setReason(reason: String) = _menu.update { it.copy(reason = reason) }
|
||||
|
||||
fun setDetail(detail: String) = _menu.update { it.copy(detail = detail.take(MAX_DETAIL)) }
|
||||
|
||||
fun report() {
|
||||
val m = _menu.value
|
||||
val line = m.line ?: return
|
||||
run(changesFeed = false, doneRes = R.string.safety_reported) {
|
||||
safety.report(CHAT_TARGET, line.id, m.reason, m.detail)
|
||||
}
|
||||
}
|
||||
|
||||
fun hideForMe() {
|
||||
val line = _menu.value.line ?: return
|
||||
run(changesFeed = true) { safety.hide(CHAT_TARGET, line.id) }
|
||||
}
|
||||
|
||||
fun block() {
|
||||
val line = _menu.value.line ?: return
|
||||
run(changesFeed = true, blocking = true) { safety.blockAuthorOf(CHAT_TARGET, line.id) }
|
||||
}
|
||||
|
||||
/** Staff's hide for everyone, or its undo, by what the line says it is now. */
|
||||
fun staffHide(hide: Boolean) {
|
||||
val line = _menu.value.line ?: return
|
||||
run(changesFeed = true) { safetyOutcomeOf(rust.setChatHidden(line.id, hide)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Do one action. Success closes the sheet, or shows [doneRes] when there is
|
||||
* something to say (a report, which changes nothing visible); a refusal stays
|
||||
* on the step it came from, with words for why.
|
||||
*/
|
||||
private fun run(
|
||||
changesFeed: Boolean,
|
||||
blocking: Boolean = false,
|
||||
@StringRes doneRes: Int? = null,
|
||||
call: suspend () -> SafetyOutcome,
|
||||
) {
|
||||
if (_menu.value.busy) return
|
||||
_menu.update { it.copy(busy = true, errorRes = null) }
|
||||
viewModelScope.launch {
|
||||
when (val outcome = call()) {
|
||||
SafetyOutcome.Done -> {
|
||||
_menu.value = if (doneRes != null) {
|
||||
_menu.value.copy(busy = false, step = ChatMenuStep.DONE, doneRes = doneRes)
|
||||
} else {
|
||||
ChatMenuUi()
|
||||
}
|
||||
if (changesFeed) _changes.trySend(Unit)
|
||||
}
|
||||
is SafetyOutcome.Refused -> {
|
||||
_menu.update { it.copy(busy = false, errorRes = refusalText(outcome.reason, blocking)) }
|
||||
// A line that is gone, or a block that already exists, means the
|
||||
// feed on screen is out of date: re-read it so it says what is true.
|
||||
if (outcome.reason == SafetyRefusal.GONE || outcome.reason == SafetyRefusal.ALREADY_DONE) {
|
||||
if (changesFeed) _changes.trySend(Unit)
|
||||
}
|
||||
}
|
||||
SafetyOutcome.NetworkError ->
|
||||
_menu.update { it.copy(busy = false, errorRes = R.string.error_network) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The server's own cap on a report's detail. */
|
||||
const val MAX_DETAIL = 500
|
||||
}
|
||||
}
|
||||
|
||||
/** Words for one refusal. [blocking] picks the block's own wording for a 409. */
|
||||
@StringRes
|
||||
fun refusalText(reason: SafetyRefusal, blocking: Boolean): Int = when (reason) {
|
||||
SafetyRefusal.ALREADY_DONE -> if (blocking) R.string.safety_error_already_blocked else R.string.safety_error_already_reported
|
||||
SafetyRefusal.GONE -> R.string.safety_error_gone
|
||||
SafetyRefusal.STAFF -> R.string.safety_error_staff
|
||||
SafetyRefusal.FORBIDDEN -> R.string.safety_error_forbidden
|
||||
SafetyRefusal.NOT_BLOCKABLE -> R.string.safety_error_not_blockable
|
||||
SafetyRefusal.RATE_LIMITED -> R.string.safety_error_rate_limited
|
||||
SafetyRefusal.UNAVAILABLE -> R.string.safety_error_unavailable
|
||||
SafetyRefusal.DEMO_LOCKED -> R.string.safety_error_demo
|
||||
SafetyRefusal.OTHER -> R.string.safety_error_other
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.repository.EventsRepository
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Which event a run on the map belongs to (`docs/modules/rust/PLAN.md` D123, D125).
|
||||
*
|
||||
* A site-event marker carries core's run id and nothing else about its event, and
|
||||
* the app's event page is addressed by the event's slug. Core's public calendar
|
||||
* maps one to the other: each `run` entry names its run (D125) and its slug. A
|
||||
* live run is in the calendar's default window however long ago it started, so
|
||||
* **one read of the default window** holds every run a marker could name that the
|
||||
* public may see.
|
||||
*
|
||||
* **What is absent stays unlinked, and that is the gate.** Rehearsals and
|
||||
* unlisted events are not on the public calendar, so a rehearsal's zone resolves
|
||||
* to nothing and its card says only *site event*. There is no second rule here
|
||||
* for which runs to hide.
|
||||
*
|
||||
* **It re-reads at most once a [refreshMs], and only for an id it does not
|
||||
* know.** A map with one zone on it asks the calendar once, not every ten
|
||||
* seconds; a new run appearing mid-session is found within a minute. A failed
|
||||
* read keeps what the last one found and still counts as a read, so a site whose
|
||||
* calendar is down is asked once a minute rather than on every poll.
|
||||
*
|
||||
* The calendar is the same for every viewer, so nothing here is per account.
|
||||
*/
|
||||
class EventRunResolver(
|
||||
private val events: EventsRepository,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
private val refreshMs: Long = REFRESH_MS,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
private var slugs: Map<String, String> = emptyMap()
|
||||
private var titles: Map<String, String> = emptyMap()
|
||||
private var readAt: Long? = null
|
||||
|
||||
/**
|
||||
* The event title for each of [runIds] the last read found (runicnpc D303: an NPC's
|
||||
* card names its event). Never reads by itself: [resolve] decides when the calendar
|
||||
* is asked, and this answers from what it found.
|
||||
*/
|
||||
suspend fun titles(runIds: Set<String>): Map<String, String> = mutex.withLock {
|
||||
runIds.mapNotNull { id -> titles[id]?.let { id to it } }.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* The slug for each of [runIds] the public calendar lists. Ids it does not
|
||||
* list are absent from the answer.
|
||||
*/
|
||||
suspend fun resolve(runIds: Set<String>): Map<String, String> = mutex.withLock {
|
||||
val unknown = runIds.any { it !in slugs }
|
||||
val last = readAt
|
||||
if (unknown && (last == null || now() - last >= refreshMs)) read()
|
||||
runIds.mapNotNull { id -> slugs[id]?.let { id to it } }.toMap()
|
||||
}
|
||||
|
||||
private suspend fun read() {
|
||||
readAt = now()
|
||||
val result = events.calendar()
|
||||
if (result is ApiResult.Ok) {
|
||||
// Core sends the id as a number and the plugin as a string, so they
|
||||
// meet as text.
|
||||
val runs = result.data.entries.filter { it.kind == "run" && it.runId != null && it.slug.isNotBlank() }
|
||||
slugs = runs.associate { it.runId.toString() to it.slug }
|
||||
titles = runs.filter { it.title.isNotBlank() }.associate { it.runId.toString() to it.title }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REFRESH_MS = 60_000L
|
||||
}
|
||||
}
|
||||
243
app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt
Normal file
243
app/src/main/java/com/runicgateway/app/ui/rust/MapFrame.kt
Normal file
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustMapGeometryDto
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* How a world position reaches a pixel (`docs/modules/rust/PLAN.md` §30.3, D121).
|
||||
*
|
||||
* Ported from the web's `mapGeometry.js`, with its test cases, so the phone and
|
||||
* the page cannot place the same crate in two squares. Rust's world is centred on
|
||||
* the origin, x east and z north. The picture is the world at a scale, with an
|
||||
* ocean margin around it that is measured in **pixels** and is not scaled (the
|
||||
* rig's 3 000 m map is 3 000 × 0.5 + 2 × 500 = 2 500 px). So:
|
||||
*
|
||||
* s = (width − 2 × margin) / worldSize
|
||||
* px = (x + worldSize / 2) × s + margin
|
||||
* py = (z + worldSize / 2) × s + margin measured UP from the bottom edge
|
||||
*
|
||||
* The web measures `py` up because Leaflet's simple frame grows north. A canvas
|
||||
* grows **down**, so [toPixel] answers `height − py`, and that flip happens here
|
||||
* and nowhere else.
|
||||
*
|
||||
* The grid is the **game's** (D119): [RustMapGeometryDto.gridCells] cells of
|
||||
* [RustMapGeometryDto.gridCellSize] metres per side, lettered from the west and
|
||||
* numbered from the north, `A0` at the north-west corner. Nothing here assumes a
|
||||
* cell size, and nothing here assumes the rig's world size either.
|
||||
*/
|
||||
class MapFrame(val geometry: RustMapGeometryDto) {
|
||||
|
||||
/** Picture pixels per world metre, or 0 for a geometry that cannot place anything. */
|
||||
val scale: Double = if (geometry.worldSize > 0 && geometry.width > 0) {
|
||||
(geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
|
||||
/** True when positions can be placed at all. */
|
||||
val canPlace: Boolean get() = scale > 0
|
||||
|
||||
val width: Double get() = geometry.width
|
||||
val height: Double get() = geometry.height
|
||||
|
||||
private val half: Double get() = geometry.worldSize / 2
|
||||
|
||||
/** A world position as a point in the picture's pixels, y measured down from the top. */
|
||||
fun toPixel(x: Double, z: Double): PicturePoint = PicturePoint(
|
||||
x = (x + half) * scale + geometry.oceanMargin,
|
||||
y = geometry.height - ((z + half) * scale + geometry.oceanMargin),
|
||||
)
|
||||
|
||||
/** A distance on the ground as picture pixels: a zone's radius. */
|
||||
fun metres(m: Double): Double = m * scale
|
||||
|
||||
/** The grid label for a world position, the way the in-game map writes it; null without a grid. */
|
||||
fun gridLabel(x: Double, z: Double): String? {
|
||||
val g = geometry
|
||||
if (g.gridCells <= 0 || g.gridCellSize <= 0) return null
|
||||
val col = floor((x + half) / g.gridCellSize).toInt().coerceIn(0, g.gridCells - 1)
|
||||
val row = floor((half - z) / g.gridCellSize).toInt().coerceIn(0, g.gridCells - 1)
|
||||
return "${column(col)}$row"
|
||||
}
|
||||
|
||||
/**
|
||||
* The grid in world metres: [MapGrid.lines] as pairs of ends, and a label at
|
||||
* each cell's north-west corner.
|
||||
*/
|
||||
fun grid(): MapGrid {
|
||||
val g = geometry
|
||||
if (g.gridCells <= 0 || g.gridCellSize <= 0) return MapGrid(emptyList(), emptyList())
|
||||
val n = g.gridCells
|
||||
val c = g.gridCellSize
|
||||
val lines = buildList {
|
||||
for (i in 0..n) {
|
||||
val at = -half + i * c
|
||||
add(WorldLine(at, half, at, half - n * c))
|
||||
add(WorldLine(-half, half - i * c, -half + n * c, half - i * c))
|
||||
}
|
||||
}
|
||||
val labels = buildList {
|
||||
for (col in 0 until n) {
|
||||
for (row in 0 until n) {
|
||||
add(GridLabel("${column(col)}$row", -half + col * c, half - row * c))
|
||||
}
|
||||
}
|
||||
}
|
||||
return MapGrid(lines, labels)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A column number as Rust spells it: 0 is A, 25 is Z, 26 is AA. */
|
||||
fun column(index: Int): String {
|
||||
val name = StringBuilder()
|
||||
var n = index + 1
|
||||
while (n > 0) {
|
||||
val r = (n - 1) % 26
|
||||
name.insert(0, ('A' + r))
|
||||
n = (n - 1) / 26
|
||||
}
|
||||
return name.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A point in the picture's own pixels, y down. */
|
||||
data class PicturePoint(val x: Double, val y: Double)
|
||||
|
||||
/** A line in world metres, from (x1, z1) to (x2, z2). */
|
||||
data class WorldLine(val x1: Double, val z1: Double, val x2: Double, val z2: Double)
|
||||
|
||||
/** A grid label and the world position of its cell's north-west corner. */
|
||||
data class GridLabel(val text: String, val x: Double, val z: Double)
|
||||
|
||||
data class MapGrid(val lines: List<WorldLine>, val labels: List<GridLabel>)
|
||||
|
||||
/**
|
||||
* The picture's address, relative to the site's base.
|
||||
*
|
||||
* The module hands out `path` relative to `/api/v1`, as it does every path. It
|
||||
* is joined here as **relative** (`api/v1/…`, no leading slash), the way every
|
||||
* Retrofit path in this app is written, so a site served under a prefix keeps
|
||||
* the prefix when [com.runicgateway.app.ui.LocalAssetResolver] resolves it.
|
||||
*/
|
||||
fun mapPictureUrl(path: String): String = "api/v1/" + path.trimStart('/')
|
||||
|
||||
/** Seconds as `m:ss`, for a locked crate's hack. */
|
||||
fun countdown(seconds: Number?): String {
|
||||
val s = max(0, (seconds?.toDouble() ?: 0.0).roundToInt())
|
||||
return "${s / 60}:${(s % 60).toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the picture sits on screen: screen pixels per picture pixel, and the
|
||||
* screen position of the picture's top-left corner.
|
||||
*
|
||||
* Pure, so the gesture arithmetic is tested without Compose. **Zoom runs from
|
||||
* fit-to-screen to [MAX_ZOOM] screen pixels per picture pixel** (§31.2), and a
|
||||
* pan may take the view a quarter of the picture past its edges: some things sail
|
||||
* off the edge of the world, and the rig's cargo ship spent phase 14's probe
|
||||
* outside the picture entirely.
|
||||
*/
|
||||
data class MapTransform(val scale: Float, val offsetX: Float, val offsetY: Float) {
|
||||
|
||||
fun screenX(pictureX: Double): Float = (pictureX * scale).toFloat() + offsetX
|
||||
fun screenY(pictureY: Double): Float = (pictureY * scale).toFloat() + offsetY
|
||||
|
||||
/** The picture pixel under a screen point. */
|
||||
fun pictureX(screenX: Float): Double = ((screenX - offsetX) / scale).toDouble()
|
||||
fun pictureY(screenY: Float): Double = ((screenY - offsetY) / scale).toDouble()
|
||||
|
||||
/**
|
||||
* One step of a pinch: zoom by [zoom] about [centroidX], [centroidY] and move
|
||||
* by [panX], [panY], then clamp to what the view allows.
|
||||
*/
|
||||
fun transformed(
|
||||
view: ViewSize,
|
||||
picture: ViewSize,
|
||||
centroidX: Float,
|
||||
centroidY: Float,
|
||||
panX: Float,
|
||||
panY: Float,
|
||||
zoom: Float,
|
||||
): MapTransform {
|
||||
val next = (scale * zoom).coerceIn(minScale(view, picture), maxScale(view, picture))
|
||||
val factor = next / scale
|
||||
return MapTransform(
|
||||
scale = next,
|
||||
offsetX = centroidX - (centroidX - offsetX) * factor + panX,
|
||||
offsetY = centroidY - (centroidY - offsetY) * factor + panY,
|
||||
).clamped(view, picture)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same view in a box of a different size: the picture pixel that was at
|
||||
* the centre stays at the centre, and the zoom is clamped to what the new box
|
||||
* allows.
|
||||
*
|
||||
* **Not a refit.** The walk found the reader's zoom thrown away when the
|
||||
* status line under the map went from two lines to one: the map's box grew by
|
||||
* a line's height, and a rule that refitted on every new size could not tell
|
||||
* that from a rotation. A rotation keeps its centre too, which is what a
|
||||
* reader turning the phone to see more of the same place wants.
|
||||
*/
|
||||
fun resized(from: ViewSize, to: ViewSize, picture: ViewSize): MapTransform {
|
||||
val centreX = pictureX(from.width / 2)
|
||||
val centreY = pictureY(from.height / 2)
|
||||
val s = scale.coerceIn(minScale(to, picture), maxScale(to, picture))
|
||||
return MapTransform(
|
||||
scale = s,
|
||||
offsetX = to.width / 2 - (centreX * s).toFloat(),
|
||||
offsetY = to.height / 2 - (centreY * s).toFloat(),
|
||||
).clamped(to, picture)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the view's centre within the picture plus a quarter of it on every
|
||||
* side, so a reader can follow something off the edge and cannot lose the map.
|
||||
*/
|
||||
fun clamped(view: ViewSize, picture: ViewSize): MapTransform {
|
||||
val cx = view.width / 2
|
||||
val cy = view.height / 2
|
||||
val w = picture.width * scale
|
||||
val h = picture.height * scale
|
||||
return copy(
|
||||
offsetX = offsetX.coerceIn(cx - w * (1 + OVERSCROLL), cx + w * OVERSCROLL),
|
||||
offsetY = offsetY.coerceIn(cy - h * (1 + OVERSCROLL), cy + h * OVERSCROLL),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Screen pixels per picture pixel at the closest zoom. */
|
||||
const val MAX_ZOOM = 4f
|
||||
|
||||
/** How far past its edges the view may be taken, as a share of the picture. */
|
||||
const val OVERSCROLL = 0.25f
|
||||
|
||||
/** The whole picture, centred. */
|
||||
fun fit(view: ViewSize, picture: ViewSize): MapTransform {
|
||||
val s = minScale(view, picture)
|
||||
return MapTransform(
|
||||
scale = s,
|
||||
offsetX = (view.width - picture.width * s) / 2,
|
||||
offsetY = (view.height - picture.height * s) / 2,
|
||||
)
|
||||
}
|
||||
|
||||
fun minScale(view: ViewSize, picture: ViewSize): Float {
|
||||
if (picture.width <= 0 || picture.height <= 0) return 1f
|
||||
return min(view.width / picture.width, view.height / picture.height)
|
||||
}
|
||||
|
||||
/** Never less than fit: a tiny picture on a large screen still fits. */
|
||||
fun maxScale(view: ViewSize, picture: ViewSize): Float = max(MAX_ZOOM, minScale(view, picture))
|
||||
}
|
||||
}
|
||||
|
||||
/** A width and height in pixels. */
|
||||
data class ViewSize(val width: Float, val height: Float)
|
||||
171
app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt
Normal file
171
app/src/main/java/com/runicgateway/app/ui/rust/MapMarkers.kt
Normal file
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* The switches on the legend. [GRID] is the reader's own and always offered;
|
||||
* [MATES] is offered only when the server sends the viewer's own position.
|
||||
*/
|
||||
enum class MapLayer { GRID, WORLD, EVENTS, PLAYERS, BASES, MATES }
|
||||
|
||||
/**
|
||||
* One thing drawn on the map, in world metres, carrying only what its layer sent.
|
||||
*
|
||||
* One flat shape rather than one per layer so that drawing, tapping and the card
|
||||
* all read the same list, and a marker can never be tappable in a place it is not
|
||||
* drawn. Nothing here is looked up: a player's name is the one the players layer
|
||||
* carried to a viewer entitled to it (§31.4).
|
||||
*/
|
||||
data class MapMarker(
|
||||
val layer: MapLayer,
|
||||
/** `monument`, a world kind (`cargo`, `crate`…), an event kind (`zone`, `npc`…), `tc`, `vending`, `player` or `mate`. */
|
||||
val kind: String,
|
||||
val x: Double,
|
||||
val z: Double,
|
||||
/** A monument's label, a zone's name, a player's or mate's name. */
|
||||
val name: String? = null,
|
||||
/** A monument's grid square as the game wrote it; others are computed from the frame. */
|
||||
val grid: String? = null,
|
||||
/** Core's run id, for something an event placed. */
|
||||
val runId: String? = null,
|
||||
/** A zone's reach, in metres on the ground. */
|
||||
val radiusMetres: Double? = null,
|
||||
val hackLeftSec: Int? = null,
|
||||
val hacked: Boolean = false,
|
||||
val online: Boolean = false,
|
||||
val sleeping: Boolean = false,
|
||||
val self: Boolean = false,
|
||||
/** A RunicNPC boss, in an event or the world layer: drawn bigger (runicnpc D303). */
|
||||
val boss: Boolean = false,
|
||||
/** One of a boss's adds. */
|
||||
val add: Boolean = false,
|
||||
) {
|
||||
/** A zone is ground, not a point: it is drawn and tapped as an area. */
|
||||
val isZone: Boolean get() = layer == MapLayer.EVENTS && kind == "zone"
|
||||
}
|
||||
|
||||
/**
|
||||
* Every marker to draw, in drawing order, bottom first: monuments, world events,
|
||||
* site events, bases, players, and the viewer's own and their mates' on top.
|
||||
*
|
||||
* A layer is drawn when the server sent it **and** the reader has not switched it
|
||||
* off. A layer the server did not send is null on [live] and contributes nothing,
|
||||
* which is the whole gate; there is no second check here to disagree with it.
|
||||
*/
|
||||
fun mapMarkers(map: RustMapDto, live: RustMapLiveDto?, shown: Set<MapLayer>): List<MapMarker> = buildList {
|
||||
if (MapLayer.WORLD in shown) {
|
||||
map.monuments.orEmpty().forEach {
|
||||
add(MapMarker(MapLayer.WORLD, "monument", it.x, it.z, name = it.label, grid = it.grid))
|
||||
}
|
||||
live?.world.orEmpty().forEach {
|
||||
add(
|
||||
MapMarker(
|
||||
MapLayer.WORLD,
|
||||
it.kind,
|
||||
it.x,
|
||||
it.z,
|
||||
name = it.name,
|
||||
hackLeftSec = it.hackLeftSec,
|
||||
hacked = it.hacked,
|
||||
boss = it.kind == "boss",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (MapLayer.EVENTS in shown) {
|
||||
live?.events.orEmpty().forEach {
|
||||
add(
|
||||
MapMarker(
|
||||
MapLayer.EVENTS,
|
||||
it.kind,
|
||||
it.x,
|
||||
it.z,
|
||||
name = it.name,
|
||||
runId = it.runId?.takeIf { id -> id.isNotBlank() },
|
||||
radiusMetres = it.radius,
|
||||
boss = it.boss,
|
||||
add = it.add,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (MapLayer.BASES in shown) {
|
||||
live?.bases.orEmpty().forEach { add(MapMarker(MapLayer.BASES, it.kind, it.x, it.z)) }
|
||||
}
|
||||
if (MapLayer.PLAYERS in shown) {
|
||||
live?.players.orEmpty().forEach {
|
||||
add(
|
||||
MapMarker(
|
||||
MapLayer.PLAYERS,
|
||||
"player",
|
||||
it.x,
|
||||
it.z,
|
||||
name = it.name?.takeIf { n -> n.isNotBlank() } ?: it.steamId,
|
||||
online = it.online,
|
||||
sleeping = it.sleeping,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (MapLayer.MATES in shown) {
|
||||
live?.mates.orEmpty().forEach {
|
||||
add(
|
||||
MapMarker(
|
||||
MapLayer.MATES,
|
||||
"mate",
|
||||
it.x,
|
||||
it.z,
|
||||
name = it.name?.takeIf { n -> n.isNotBlank() },
|
||||
online = it.online,
|
||||
sleeping = it.sleeping,
|
||||
self = it.self,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker a tap at ([tapX], [tapY]) on screen means, or null.
|
||||
*
|
||||
* **A point wins over a zone.** The nearest point within [reachPx] is the answer;
|
||||
* only when there is none does a zone answer, and then one whose ground the tap
|
||||
* is on or within reach of. Otherwise a zone drawn round a monument would swallow
|
||||
* every tap on the monument, and on the players standing in it. Among equals the
|
||||
* one drawn last, which is the one on top, wins.
|
||||
*/
|
||||
fun nearestMarker(
|
||||
markers: List<MapMarker>,
|
||||
frame: MapFrame,
|
||||
transform: MapTransform,
|
||||
tapX: Float,
|
||||
tapY: Float,
|
||||
reachPx: Float,
|
||||
): MapMarker? {
|
||||
fun distance(m: MapMarker): Double {
|
||||
val p = frame.toPixel(m.x, m.z)
|
||||
val d = hypot(transform.screenX(p.x) - tapX.toDouble(), transform.screenY(p.y) - tapY.toDouble())
|
||||
if (!m.isZone) return d
|
||||
val radius = frame.metres(m.radiusMetres ?: 0.0) * transform.scale
|
||||
return max(0.0, d - radius)
|
||||
}
|
||||
|
||||
fun closest(candidates: List<MapMarker>): MapMarker? = candidates
|
||||
.asReversed()
|
||||
.map { it to distance(it) }
|
||||
.filter { it.second <= reachPx }
|
||||
.minByOrNull { it.second }
|
||||
?.first
|
||||
|
||||
return closest(markers.filterNot { it.isZone }) ?: closest(markers.filter { it.isZone })
|
||||
}
|
||||
|
||||
/** The distinct run ids the events layer names, for [EventRunResolver]. */
|
||||
fun eventRunIds(live: RustMapLiveDto?): Set<String> =
|
||||
live?.events.orEmpty().mapNotNull { it.runId?.takeIf { id -> id.isNotBlank() } }.toSet()
|
||||
@@ -0,0 +1,542 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.RustBackpackDto
|
||||
import com.runicgateway.app.data.api.dto.RustEconomicsServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustLinkDto
|
||||
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
||||
import com.runicgateway.app.data.api.dto.RustReachDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* The player's own Rust account (PLAN.md §9 M15) — the app's mirror of the
|
||||
* module's `/player/rust` page, and the same shape as
|
||||
* [com.runicgateway.app.ui.player.CharactersScreen] one game along: the code
|
||||
* card first, then what the code got them.
|
||||
*
|
||||
* Two independent reads. The accounts can fail with the entitlements on screen,
|
||||
* and the other way round, because an entitlement is authored against the
|
||||
* website account and exists before a Steam id does.
|
||||
*/
|
||||
@Composable
|
||||
fun RustAccountScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RustAccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
LinkCard(state, viewModel)
|
||||
|
||||
when (val links = state.links) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(links.kind, onRetry = viewModel::load)
|
||||
is UiState.Success -> {
|
||||
if (links.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.rust_account_none),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
links.data.forEach { link ->
|
||||
LinkRow(
|
||||
link = link,
|
||||
busy = state.unlinking == link.steamId,
|
||||
onUnlink = { viewModel.unlink(link.steamId) },
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.rust_account_fleet_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.rust_held_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
when (val held = state.held) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(held.kind, onRetry = viewModel::load)
|
||||
is UiState.Success -> Held(held.data)
|
||||
}
|
||||
|
||||
// Coins (PLAN_REDESIGNS §11.2.2). A 404 is a module older than protocol
|
||||
// 14, which has no balances to give: the section is left out rather than
|
||||
// shown as an error the player can do nothing about.
|
||||
val coins = state.coins
|
||||
if (!(coins is UiState.Error && coins.httpStatus == 404)) {
|
||||
Text(
|
||||
stringResource(R.string.rust_coins_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
when (coins) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(coins.kind, onRetry = viewModel::load)
|
||||
is UiState.Success -> Coins(coins.data)
|
||||
}
|
||||
|
||||
// Backpacks (PLAN_REDESIGNS §11.3.2) shipped in the same protocol
|
||||
// as Economics, so the coins read's 404 gates both (R10).
|
||||
Text(
|
||||
stringResource(R.string.rust_backpack_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Backpacks(state, viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Your own backpack, one linked account on one server at a time, read when you
|
||||
* press Open and not again until you press it again (§11.3.2). No icons (D340):
|
||||
* each slot is the game's own name for the item.
|
||||
*/
|
||||
@Composable
|
||||
private fun Backpacks(state: RustAccountViewModel.State, viewModel: RustAccountViewModel) {
|
||||
val links = (state.links as? UiState.Success)?.data.orEmpty()
|
||||
val servers = (state.backpackServers as? UiState.Success)?.data.orEmpty()
|
||||
|
||||
if (links.isEmpty() || servers.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.rust_backpack_unlinked),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
servers.forEach { server ->
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(server.name.ifBlank { server.id }, style = MaterialTheme.typography.titleSmall)
|
||||
|
||||
links.forEach { link ->
|
||||
val key = RustAccountViewModel.BackpackKey(server.id, link.steamId)
|
||||
val open = state.backpackOpen == key
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (links.size > 1) {
|
||||
Text(
|
||||
playerLabel(link.name, link.steamId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { viewModel.openBackpack(server.id, link.steamId) }) {
|
||||
Text(stringResource(if (open) R.string.rust_backpack_read_again else R.string.rust_backpack_open))
|
||||
}
|
||||
if (open) {
|
||||
TextButton(onClick = viewModel::closeBackpack) {
|
||||
Text(stringResource(R.string.rust_backpack_close))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
when (val read = state.backpack) {
|
||||
null, is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> BackpackRefusal(read) { viewModel.openBackpack(server.id, link.steamId) }
|
||||
is UiState.Success -> BackpackGrid(read.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 409 and 404 are sentences, not retries: Backpacks is missing, or the site cannot show it yet. */
|
||||
@Composable
|
||||
private fun BackpackRefusal(error: UiState.Error, onRetry: () -> Unit) {
|
||||
val sentence = when (error.httpStatus) {
|
||||
409 -> R.string.rust_backpack_missing
|
||||
404 -> R.string.rust_backpack_unavailable
|
||||
else -> null
|
||||
}
|
||||
if (sentence == null) {
|
||||
ErrorView(error.kind, onRetry = onRetry)
|
||||
return
|
||||
}
|
||||
Text(
|
||||
stringResource(sentence),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Pages of four-wide rows: a phone is narrow, and a slot needs room for an item's name. */
|
||||
@Composable
|
||||
private fun BackpackGrid(backpack: RustBackpackDto) {
|
||||
if (!backpack.exists) {
|
||||
Text(
|
||||
stringResource(R.string.rust_backpack_none),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val pages = backpackPages(backpack)
|
||||
val waiting = backpack.items.count { it.overflow }
|
||||
|
||||
Text(
|
||||
stringResource(R.string.rust_backpack_summary, backpack.items.size, backpack.capacity),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
if (waiting > 0) {
|
||||
Text(
|
||||
stringResource(R.string.rust_backpack_overflow, waiting),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
|
||||
pages.forEach { page ->
|
||||
if (pages.size > 1) {
|
||||
Text(
|
||||
stringResource(R.string.rust_backpack_page, page.index + 1),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
page.cells.chunked(BACKPACK_COLUMNS).forEach { row ->
|
||||
Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
row.forEach { cell -> BackpackSlot(cell, Modifier.weight(1f)) }
|
||||
repeat(BACKPACK_COLUMNS - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val BACKPACK_COLUMNS = 4
|
||||
|
||||
@Composable
|
||||
private fun BackpackSlot(cell: BackpackCell, modifier: Modifier) {
|
||||
val stack = cell.stack
|
||||
val outline = if (cell.overflow) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.outlineVariant
|
||||
Column(
|
||||
modifier
|
||||
.heightIn(min = 56.dp)
|
||||
.border(1.dp, outline, RoundedCornerShape(4.dp))
|
||||
.padding(4.dp),
|
||||
) {
|
||||
if (stack != null) {
|
||||
Text(stackLabel(stack), style = MaterialTheme.typography.labelSmall, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
stack.contents.forEach { inner ->
|
||||
Text("+ ${stackLabel(inner)}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (stack.amount > 1) {
|
||||
Text("×${stack.amount}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One card per server: each linked account's balance where the server runs
|
||||
* Economics, and the reason where it does not (D199) — a player asking where
|
||||
* their coins are is owed the answer, not an empty space.
|
||||
*/
|
||||
@Composable
|
||||
private fun Coins(servers: List<RustEconomicsServerDto>) {
|
||||
if (servers.none { it.accounts.isNotEmpty() }) {
|
||||
Text(
|
||||
stringResource(R.string.rust_coins_unlinked),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
servers.forEach { server ->
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(server.server.ifBlank { server.serverId }, style = MaterialTheme.typography.titleSmall)
|
||||
|
||||
if (!server.economics) {
|
||||
Text(
|
||||
stringResource(R.string.rust_coins_missing),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
} else {
|
||||
server.accounts.forEach { account ->
|
||||
Text(
|
||||
stringResource(R.string.rust_coins_balance, playerLabel(account.name, account.steamId), coinAmount(account.balance)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
if (account.source == "last-known") {
|
||||
Text(
|
||||
stringResource(R.string.rust_coins_last_known),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The code form.
|
||||
*
|
||||
* The three-step instruction is not decoration: nothing else in the app tells a
|
||||
* player that the code comes from the game, and a code field with no explanation
|
||||
* is a code field nobody can use.
|
||||
*/
|
||||
@Composable
|
||||
private fun LinkCard(state: RustAccountViewModel.State, viewModel: RustAccountViewModel) {
|
||||
var code by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.rust_link_title), style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.rust_link_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it.uppercase() },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.rust_link_code)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.link(code); code = "" },
|
||||
enabled = !state.busy && code.isNotBlank(),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.rust_link_action)) }
|
||||
|
||||
// Beside the button that caused it. A refusal at the top of a long
|
||||
// scroll is a press that visibly did nothing (PLAN.md §21.5).
|
||||
state.feedback?.let { feedback ->
|
||||
Text(
|
||||
text = stringResource(feedback.messageRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (feedback.ok) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One linked Steam account, and the button that releases it. */
|
||||
@Composable
|
||||
private fun LinkRow(link: RustLinkDto, busy: Boolean, onUnlink: () -> Unit) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = link.name?.takeIf { it.isNotBlank() } ?: link.steamId,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = link.steamId,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// When, and which server minted the code. Not part of the
|
||||
// identity — a link is fleet-wide — but it is where a support
|
||||
// conversation starts, and the website's own row says it.
|
||||
val when_ = rustAgo(link.linkedAt)
|
||||
val where = link.serverId?.takeIf { it.isNotBlank() }
|
||||
|
||||
if (when_ != null || where != null) {
|
||||
Text(
|
||||
text = listOfNotNull(
|
||||
when_?.let { stringResource(R.string.rust_account_linked_when, it) },
|
||||
where,
|
||||
).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = onUnlink, enabled = !busy) {
|
||||
Text(stringResource(R.string.rust_unlink_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ranks and grants, drawn the same way because they read the same. */
|
||||
@Composable
|
||||
private fun Held(held: RustPlayerPermissionsDto) {
|
||||
if (held.groups.isEmpty() && held.grants.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.rust_held_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
held.groups.forEach { group ->
|
||||
HeldCard(
|
||||
title = group.title.ifBlank { group.name },
|
||||
detail = group.permissions.joinToString(" · ").takeIf { it.isNotBlank() },
|
||||
reach = group.reach,
|
||||
)
|
||||
}
|
||||
|
||||
held.grants.forEach { grant ->
|
||||
HeldCard(
|
||||
title = grant.permission,
|
||||
detail = grant.note?.takeIf { it.isNotBlank() },
|
||||
reach = grant.reach,
|
||||
)
|
||||
}
|
||||
|
||||
// Zero linked accounts is WHY everything above is waiting, and the screen
|
||||
// says so rather than leaving a page of hollow pills to be read as a fault.
|
||||
if (held.accounts == 0) {
|
||||
Text(
|
||||
stringResource(R.string.rust_held_unlinked),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else if (held.groups.any { it.reach.any { s -> !s.live } } ||
|
||||
held.grants.any { it.reach.any { s -> !s.live } }
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.rust_held_waiting_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun HeldCard(title: String, detail: String?, reach: List<RustReachDto>) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleSmall)
|
||||
|
||||
detail?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (reach.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.rust_held_no_servers),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
} else {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
) {
|
||||
reach.forEach { server ->
|
||||
// The tone IS the state: a server that has it and one
|
||||
// that has not are the two things this screen exists to
|
||||
// tell apart, and colour alone would not say which — so
|
||||
// the label carries the word as well.
|
||||
StatusPill(
|
||||
text = stringResource(
|
||||
if (server.live) R.string.rust_reach_live else R.string.rust_reach_waiting,
|
||||
server.name.ifBlank { server.id },
|
||||
),
|
||||
tone = if (server.live) PillTone.Success else PillTone.Neutral,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.RustBackpackDto
|
||||
import com.runicgateway.app.data.api.dto.RustEconomicsServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustLinkDto
|
||||
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.repository.PlayerRustRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The player's own Rust account (PLAN.md §9 M15; `docs/modules/rust/PLAN.md`
|
||||
* §19, §20, §22): link a Steam account with the code `/link` hands out in game,
|
||||
* release one, and see what the site has given them on which servers.
|
||||
*
|
||||
* **Two reads, one screen, and neither blocks the other.** The entitlement read
|
||||
* carries its own state because an entitlement is authored against the *website*
|
||||
* account: it exists before a Steam id does, and the person who has just been
|
||||
* given something and has not linked yet is exactly the one who needs to see
|
||||
* both halves at once. A failure on either side leaves the other on screen.
|
||||
*
|
||||
* **A refusal is chosen by status, not by prose** (the convention
|
||||
* [com.runicgateway.app.ui.player.CharactersViewModel] set one game along). The
|
||||
* four the website distinguishes are four different pieces of advice, and
|
||||
* flattening them is the failure worth naming: a player told to get a new code
|
||||
* when the server their code came from was merely unreachable will go and get
|
||||
* another code from the same unreachable server.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RustAccountViewModel @Inject constructor(
|
||||
private val repository: PlayerRustRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** A one-shot banner for the code form, rendered beside the button that caused it. */
|
||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||
|
||||
/** Which backpack: one linked account on one server. */
|
||||
data class BackpackKey(val serverId: String, val steamId: String)
|
||||
|
||||
data class State(
|
||||
val links: UiState<List<RustLinkDto>> = UiState.Loading,
|
||||
val held: UiState<RustPlayerPermissionsDto> = UiState.Loading,
|
||||
/** Coin balances (PLAN_REDESIGNS §11.2.2), a third independent read. */
|
||||
val coins: UiState<List<RustEconomicsServerDto>> = UiState.Loading,
|
||||
/** The servers the backpack section offers (PLAN_REDESIGNS §11.3.2). */
|
||||
val backpackServers: UiState<List<RustServerDto>> = UiState.Loading,
|
||||
/** The one backpack open, by server and account, or null when none is. */
|
||||
val backpackOpen: BackpackKey? = null,
|
||||
/** That backpack, read live when opened; null before the first open. */
|
||||
val backpack: UiState<RustBackpackDto>? = null,
|
||||
val busy: Boolean = false,
|
||||
val feedback: Feedback? = null,
|
||||
/** The Steam id currently being released, so only its own row shows it. */
|
||||
val unlinking: String? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(links = UiState.Loading, held = UiState.Loading, coins = UiState.Loading) }
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(links = repository.links().toUiState()) }
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(held = repository.permissions().toUiState()) }
|
||||
}
|
||||
|
||||
reloadCoins()
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(backpackServers = repository.servers().toUiState()) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one backpack (PLAN_REDESIGNS §11.3.2): **one read per press, and
|
||||
* nothing polls**, because a read runs on the game's main thread. Pressing
|
||||
* the open one again reads it again; opening another closes the first.
|
||||
*/
|
||||
fun openBackpack(serverId: String, steamId: String) {
|
||||
val key = BackpackKey(serverId, steamId)
|
||||
if (_state.value.backpackOpen == key && _state.value.backpack is UiState.Loading) return
|
||||
_state.update { it.copy(backpackOpen = key, backpack = UiState.Loading) }
|
||||
|
||||
viewModelScope.launch {
|
||||
val read = repository.backpack(serverId, steamId).toUiState()
|
||||
// A slow answer for a backpack the player has since closed or swapped is dropped.
|
||||
_state.update { if (it.backpackOpen == key) it.copy(backpack = read) else it }
|
||||
}
|
||||
}
|
||||
|
||||
fun closeBackpack() = _state.update { it.copy(backpackOpen = null, backpack = null) }
|
||||
|
||||
/** The balances follow the links: a new link is a new account to read. */
|
||||
private fun reloadCoins() {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(coins = repository.economics().toUiState()) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-read what the site holds without blanking the accounts above it. */
|
||||
private fun reloadHeld() {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(held = repository.permissions().toUiState()) }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
fun link(code: String) {
|
||||
if (_state.value.busy || code.isBlank()) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.link(code.trim())) {
|
||||
is ApiResult.Ok -> {
|
||||
// `already` is a second press of the button, not an error:
|
||||
// the code was good and that account was already theirs.
|
||||
val res =
|
||||
if (result.data.already) R.string.rust_link_already else R.string.rust_link_ok
|
||||
|
||||
_state.update { it.copy(busy = false, feedback = Feedback(true, res)) }
|
||||
refreshAfterChange()
|
||||
}
|
||||
|
||||
is ApiResult.HttpError -> _state.update {
|
||||
it.copy(busy = false, feedback = Feedback(false, linkErrorRes(result.status)))
|
||||
}
|
||||
|
||||
is ApiResult.NetworkError -> _state.update {
|
||||
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unlink(steamId: String) {
|
||||
if (_state.value.unlinking != null) return
|
||||
_state.update { it.copy(unlinking = steamId, feedback = null) }
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = repository.unlink(steamId)
|
||||
_state.update { it.copy(unlinking = null) }
|
||||
|
||||
when (result) {
|
||||
is ApiResult.Ok -> refreshAfterChange()
|
||||
|
||||
is ApiResult.HttpError -> _state.update {
|
||||
it.copy(feedback = Feedback(false, R.string.rust_unlink_error))
|
||||
}
|
||||
|
||||
is ApiResult.NetworkError -> _state.update {
|
||||
it.copy(feedback = Feedback(false, R.string.error_network))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Both halves, after the caller changed one of them.
|
||||
*
|
||||
* Linking an account does not change what the site has authored — but it
|
||||
* changes what reaches a game, and the next sync is what makes that true. So
|
||||
* the entitlement list is re-read too: its `live` marks are the only thing on
|
||||
* this screen that a link can silently invalidate.
|
||||
*/
|
||||
private fun refreshAfterChange() {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(links = repository.links().toUiState()) }
|
||||
}
|
||||
reloadHeld()
|
||||
reloadCoins()
|
||||
}
|
||||
|
||||
private fun linkErrorRes(status: Int): Int = when (status) {
|
||||
// Unknown or expired: the code is spent, and the way out is a new one.
|
||||
400 -> R.string.rust_link_bad_code
|
||||
// Another website account holds that Steam id. It is never moved
|
||||
// silently; `/unlink` in game is the release (D23).
|
||||
409 -> R.string.rust_link_taken
|
||||
429 -> R.string.rust_link_capped
|
||||
// A server could not be reached. **The code is still good**, which is
|
||||
// why this may not say "get a new one".
|
||||
503 -> R.string.rust_link_unreachable
|
||||
else -> R.string.rust_link_error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
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 live player count on the drawer's Rust row — the phone's answer to D15
|
||||
* (`docs/modules/rust/PLAN.md` §17.4).
|
||||
*
|
||||
* ## Why the drawer and not a footer
|
||||
*
|
||||
* D15 put "2 servers · 42 online" in core's `site.footer.status` slot, which
|
||||
* exists because every page of the website renders the same footer. The app has
|
||||
* no footer and no slot; what it has is a drawer row per surface and, already, a
|
||||
* precedent for a number beside one — the inbox's unread badge, in the same
|
||||
* `NavigationDrawerItem` badge slot, with the same screen-reader treatment. So
|
||||
* the count rides there.
|
||||
*
|
||||
* **The number is players, not servers.** A badge is one integer, and of the two
|
||||
* halves of D15's line the live one is how many people are on: a server count
|
||||
* changes when an operator edits configuration, which is not news, and is visible
|
||||
* on the page the row opens anyway.
|
||||
*
|
||||
* ## What keeps it honest
|
||||
*
|
||||
* The website's version renders nothing until it has an answer, nothing at all if
|
||||
* the request fails, and never polls — because one request per page view is a
|
||||
* cost and a timer in a footer on every page is a different kind of thing. All
|
||||
* three rules hold here:
|
||||
*
|
||||
* - **Zero renders nothing.** No badge, rather than a `0` — an empty server is
|
||||
* not a notification.
|
||||
* - **A failure leaves the last count** rather than dropping to zero. A moment
|
||||
* with no connectivity is not everybody logging off.
|
||||
* - **It refreshes on resume, with the unread badge**, and never on a timer. The
|
||||
* count is a glance, not a feed.
|
||||
*
|
||||
* It is asked for **only when the module is installed** — the caller gates on the
|
||||
* `rust` capability — so a site running a different game makes no request at all.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RustBadgeViewModel @Inject constructor(
|
||||
private val repository: RustRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _online = MutableStateFlow(0)
|
||||
|
||||
/** How many people are on across every server, or 0 when there is nothing to say. */
|
||||
val online: StateFlow<Int> = _online.asStateFlow()
|
||||
|
||||
/**
|
||||
* Ask, if the Rust module is there.
|
||||
*
|
||||
* [installed] is passed in rather than read here so this holds no opinion
|
||||
* about capabilities: the drawer already knows, and a view model that
|
||||
* re-derived it would be a second copy of a rule that lives in one place.
|
||||
* Absent — the host has not answered yet — makes no request and keeps
|
||||
* whatever is showing.
|
||||
*/
|
||||
fun refresh(installed: Boolean) {
|
||||
if (!installed) {
|
||||
_online.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.servers()) {
|
||||
// A server that is stale or unreachable already answers `online:
|
||||
// false` with `players: 0`, so summing the whole list needs no
|
||||
// second staleness rule here.
|
||||
is ApiResult.Ok -> _online.value = result.data.sumOf { it.players }
|
||||
// Keep the last number. See the class doc.
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
199
app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt
Normal file
199
app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt
Normal file
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
|
||||
/**
|
||||
* One stored frame as one line of a feed — the Kotlin half of `module-rust`'s
|
||||
* `client/src/lib/feed.js` (PLAN.md §9 M14).
|
||||
*
|
||||
* `GET /public/rust/servers/{id}/events` answers rows shaped
|
||||
* `{ id, kind, t, wipeId, steamId, frame }`, where `frame` is the whole frame the
|
||||
* bridge plugin emitted. Everything a killfeed line needs is in there, under the
|
||||
* names the plugin wrote, and **this file is the one place in the app that knows
|
||||
* them**.
|
||||
*
|
||||
* ## It returns parts, not a sentence
|
||||
*
|
||||
* A row wants the names emphasised and the detail muted, and a function returning
|
||||
* `"Alice killed Bob"` would force the screen to re-parse its own output to style
|
||||
* it. Parts also make this testable without Compose, which is the only way this
|
||||
* leg has real coverage of what a feed row says.
|
||||
*
|
||||
* ## The rule for an unknown kind
|
||||
*
|
||||
* **It renders as itself.** A later protocol adds kinds and an operator's module
|
||||
* may be older than their game host, so a feed that dropped what it did not
|
||||
* recognise would be a screen quietly saying less than the truth. The server's
|
||||
* allowlist has already decided the row may be seen; what is left here is
|
||||
* presentation, and the honest presentation of a kind we have no words for is its
|
||||
* own name.
|
||||
*/
|
||||
|
||||
/** The row's category, for the small colour a screen gives it — never for meaning. */
|
||||
enum class FeedTone { KILL, DEATH, JOIN, LEAVE, CHAT, SERVER, OTHER }
|
||||
|
||||
/**
|
||||
* One row, ready to render.
|
||||
*
|
||||
* [actor] and [subject] are names and are emphasised; [verb] and [detail] are
|
||||
* prose. Any of them may be null or empty.
|
||||
*
|
||||
* [join] is what goes between the actor and the verb, and it exists for exactly
|
||||
* one case: chat. "Brannock see you in september" is not a sentence anybody
|
||||
* writes, and putting the colon in the message would put presentation inside text
|
||||
* a player typed.
|
||||
*/
|
||||
data class FeedLine(
|
||||
val tone: FeedTone,
|
||||
val actor: String? = null,
|
||||
val join: String = " ",
|
||||
val verb: String = "",
|
||||
val subject: String? = null,
|
||||
val detail: String = "",
|
||||
)
|
||||
|
||||
/** One filter the feed offers, and the kinds it asks the API for. */
|
||||
data class FeedFilter(val id: String, val label: String, val kinds: List<String>)
|
||||
|
||||
/**
|
||||
* Kinds this feed asks for.
|
||||
*
|
||||
* `player.tally` is public and deliberately **not** here: it is an aggregate the
|
||||
* plugin flushes every sixty seconds per active player, so a feed including it
|
||||
* would be mostly wood counts. It is the leaderboard's input, and the leaderboard
|
||||
* is where it shows up.
|
||||
*/
|
||||
val FEED_KINDS: List<String> = listOf(
|
||||
"player.death",
|
||||
"player.connected",
|
||||
"player.disconnected",
|
||||
"player.respawned",
|
||||
"player.chat",
|
||||
"server.wipe",
|
||||
"server.initialized",
|
||||
"server.shutdown",
|
||||
)
|
||||
|
||||
/** The filters the feed offers. The first is the default and asks for everything. */
|
||||
val FEED_FILTERS: List<FeedFilter> = listOf(
|
||||
FeedFilter("all", "Everything", FEED_KINDS),
|
||||
FeedFilter("kills", "Kills", listOf("player.death")),
|
||||
FeedFilter("chat", "Chat", listOf("player.chat")),
|
||||
FeedFilter(
|
||||
"sessions",
|
||||
"Comings and goings",
|
||||
listOf("player.connected", "player.disconnected", "player.respawned"),
|
||||
),
|
||||
FeedFilter("server", "Server", listOf("server.wipe", "server.initialized", "server.shutdown")),
|
||||
)
|
||||
|
||||
/** The kinds a filter id asks for; an id nobody offers falls back to everything. */
|
||||
fun kindsFor(filterId: String): List<String> =
|
||||
(FEED_FILTERS.firstOrNull { it.id == filterId } ?: FEED_FILTERS.first()).kinds
|
||||
|
||||
/** One row as the parts a screen renders. */
|
||||
fun describe(row: RustEventDto): FeedLine {
|
||||
val name = row.str("name")
|
||||
|
||||
return when (row.kind) {
|
||||
"player.death" -> death(row, name)
|
||||
|
||||
"player.connected" ->
|
||||
FeedLine(FeedTone.JOIN, actor = name, verb = "connected")
|
||||
|
||||
"player.disconnected" -> FeedLine(
|
||||
tone = FeedTone.LEAVE,
|
||||
actor = name,
|
||||
verb = "disconnected",
|
||||
// Two optional halves, and the session is the interesting one. The
|
||||
// plugin OMITS `sessionSec` for a player who was already on when it
|
||||
// loaded, so an absent value means "unknown" and never zero — which is
|
||||
// why this reads the parsed number rather than trusting a default.
|
||||
detail = listOfNotNull(
|
||||
row.str("reason"),
|
||||
row.num("sessionSec")?.takeIf { it > 0 }?.let { "after ${playtime(it.toLong())}" },
|
||||
).joinToString(" · "),
|
||||
)
|
||||
|
||||
"player.respawned" ->
|
||||
FeedLine(FeedTone.JOIN, actor = name, verb = "respawned")
|
||||
|
||||
"player.chat" -> FeedLine(
|
||||
tone = FeedTone.CHAT,
|
||||
actor = name,
|
||||
join = ": ",
|
||||
// The message is the row, so it goes in `verb` where a screen renders
|
||||
// it unemphasised — and it is the one field on this wire whose bytes a
|
||||
// player chooses. Compose renders it as text and never as markup;
|
||||
// nothing here may ever stop doing that.
|
||||
verb = row.str("message").orEmpty(),
|
||||
detail = row.str("channel")?.takeIf { it != "Global" }.orEmpty(),
|
||||
)
|
||||
|
||||
"server.wipe" -> FeedLine(
|
||||
tone = FeedTone.SERVER,
|
||||
verb = "The map was wiped",
|
||||
detail = row.str("wipeId")?.let { "new wipe $it" }.orEmpty(),
|
||||
)
|
||||
|
||||
"server.initialized" -> FeedLine(FeedTone.SERVER, verb = "The server came up")
|
||||
|
||||
"server.shutdown" -> FeedLine(FeedTone.SERVER, verb = "The server went down")
|
||||
|
||||
else -> FeedLine(
|
||||
tone = FeedTone.OTHER,
|
||||
actor = name,
|
||||
verb = row.kind.takeIf { it.isNotBlank() } ?: "unknown",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A death, which is four different sentences.
|
||||
*
|
||||
* The plugin distinguishes `player`, `self`, `npc` and `environment` precisely so
|
||||
* a reader does not have to guess from an absent field, and collapsing any two of
|
||||
* them loses something. A killfeed reporting a fall as a kill by nobody is the
|
||||
* failure this avoids.
|
||||
*/
|
||||
private fun death(row: RustEventDto, name: String?): FeedLine {
|
||||
val where = listOfNotNull(
|
||||
row.str("weapon")?.let { "with ${weaponName(it, row.str("weaponName"))}" },
|
||||
row.num("distance")?.let { "${Math.round(it)}m" },
|
||||
row.str("grid"),
|
||||
if (row.flag("sleeping")) "while sleeping" else null,
|
||||
).joinToString(" · ")
|
||||
|
||||
return when (row.str("attackerType")) {
|
||||
"player" -> FeedLine(
|
||||
tone = FeedTone.KILL,
|
||||
actor = row.str("attackerName"),
|
||||
verb = "killed",
|
||||
subject = name,
|
||||
detail = where,
|
||||
)
|
||||
|
||||
"self" -> FeedLine(
|
||||
tone = FeedTone.DEATH,
|
||||
actor = name,
|
||||
verb = "died by their own hand",
|
||||
detail = where,
|
||||
)
|
||||
|
||||
"npc" -> FeedLine(
|
||||
tone = FeedTone.DEATH,
|
||||
actor = prefabName(row.str("attackerName")).ifBlank { "Something" },
|
||||
verb = "killed",
|
||||
subject = name,
|
||||
detail = where,
|
||||
)
|
||||
|
||||
// `environment` and anything else: falling, drowning, the world. The
|
||||
// plugin legitimately has no attacker on this path, so an ABSENT type is
|
||||
// this case rather than a missing field to complain about.
|
||||
else -> FeedLine(tone = FeedTone.DEATH, actor = name, verb = "died", detail = where)
|
||||
}
|
||||
}
|
||||
233
app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt
Normal file
233
app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt
Normal file
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.core.time.parseWireInstant
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Formatting for the Rust screens — pure, no Compose, no Android (PLAN.md §9
|
||||
* M14).
|
||||
*
|
||||
* The Kotlin half of `module-rust`'s `client/src/lib/format.js`, and it is a
|
||||
* deliberate second implementation rather than something shared: the two clients
|
||||
* have different formatting libraries under them (`Intl` there, `java.time`
|
||||
* here), and the thing worth keeping identical is the **rules**, not the code.
|
||||
* Those rules are restated here beside each function so a reader can check them
|
||||
* against the website without opening it.
|
||||
*
|
||||
* Everything takes `now` as a parameter, so a boundary is testable rather than a
|
||||
* property of the machine the test runs on.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The stamp on a feed row.
|
||||
*
|
||||
* **Today's rows get a time; everything older gets a date as well.** The feed can
|
||||
* be filtered to a past wipe, and a row from six weeks ago rendered as `14:03`
|
||||
* reads as this afternoon — which is exactly what the website's own page walk
|
||||
* found, three events from August all apparently a few minutes old. The boundary
|
||||
* is the **calendar day**, not a duration, because that is what a reader means by
|
||||
* "what time was that".
|
||||
*/
|
||||
fun feedClock(
|
||||
value: String?,
|
||||
epochMillis: Long? = null,
|
||||
now: Instant = Instant.now(),
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String {
|
||||
val at = epochMillis?.takeIf { it > 0 }?.let(Instant::ofEpochMilli) ?: parseWireInstant(value) ?: return ""
|
||||
|
||||
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
.withLocale(locale)
|
||||
.format(at.atZone(zone))
|
||||
|
||||
val sameDay = at.atZone(zone).toLocalDate() == now.atZone(zone).toLocalDate()
|
||||
if (sameDay) return time
|
||||
|
||||
val date = DateTimeFormatter.ofPattern("d MMM", locale).format(at.atZone(zone))
|
||||
return "$date $time"
|
||||
}
|
||||
|
||||
/** A date, for a wipe: the thing people actually compare wipes by. */
|
||||
fun wipeDay(
|
||||
value: String?,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String? {
|
||||
val at = parseWireInstant(value) ?: return null
|
||||
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(locale)
|
||||
.format(at.atZone(zone))
|
||||
}
|
||||
|
||||
/**
|
||||
* The next wipe (module-rust phase 16), as `Thu 1 Oct, 19:00 · in 6 days` in
|
||||
* the PHONE's zone — or null when there is no instant to show.
|
||||
*
|
||||
* The module computes the instant from the operator's rule in the operator's
|
||||
* zone; the phone only says it the reader's way, which is the same split the
|
||||
* website's `nextWipe()` makes. The `(rescheduled)` mark for a one-off date is
|
||||
* the caller's, from a string resource.
|
||||
*/
|
||||
fun nextWipeWhen(
|
||||
value: String?,
|
||||
now: Instant = Instant.now(),
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String? {
|
||||
val at = parseWireInstant(value) ?: return null
|
||||
val local = at.atZone(zone)
|
||||
val date = DateTimeFormatter.ofPattern("EEE d MMM", locale).format(local)
|
||||
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale).format(local)
|
||||
val distance = rustAgo(value, now) ?: return "$date, $time"
|
||||
return "$date, $time · $distance"
|
||||
}
|
||||
|
||||
/**
|
||||
* "3 minutes ago", for a "last reported" line.
|
||||
*
|
||||
* Returns null rather than a word for an absent stamp, so the caller decides what
|
||||
* "never" looks like in its own layout — on this surface a server that has never
|
||||
* reported is a real and ordinary state, not a missing value to apologise for.
|
||||
*/
|
||||
fun rustAgo(value: String?, now: Instant = Instant.now()): String? {
|
||||
val at = parseWireInstant(value) ?: return null
|
||||
val seconds = java.time.Duration.between(at, now).seconds
|
||||
|
||||
// Under a minute in either direction, say the thing rather than "in 0 seconds".
|
||||
if (kotlin.math.abs(seconds) < 45) return "just now"
|
||||
|
||||
val future = seconds < 0
|
||||
val magnitude = kotlin.math.abs(seconds)
|
||||
val (unit, size) = AGO_UNITS.first { magnitude >= it.second }
|
||||
val amount = Math.round(magnitude.toDouble() / size)
|
||||
val plural = if (amount == 1L) unit else "${unit}s"
|
||||
|
||||
return if (future) "in $amount $plural" else "$amount $plural ago"
|
||||
}
|
||||
|
||||
private val AGO_UNITS = listOf(
|
||||
"year" to 31_536_000L,
|
||||
"month" to 2_592_000L,
|
||||
"week" to 604_800L,
|
||||
"day" to 86_400L,
|
||||
"hour" to 3_600L,
|
||||
"minute" to 60L,
|
||||
"second" to 1L,
|
||||
)
|
||||
|
||||
/**
|
||||
* A session or a playtime, as `4h 12m`.
|
||||
*
|
||||
* Seconds are dropped above a minute and kept below it: a two-hour session
|
||||
* reported to the second is noise, and a forty-second one reported as "0m" is
|
||||
* wrong.
|
||||
*/
|
||||
fun playtime(seconds: Long?): String {
|
||||
val total = seconds ?: return "—"
|
||||
if (total <= 0) return "—"
|
||||
if (total < 60) return "${total}s"
|
||||
|
||||
val hours = total / 3600
|
||||
val minutes = Math.round((total % 3600) / 60.0)
|
||||
|
||||
return when {
|
||||
hours == 0L -> "${minutes}m"
|
||||
minutes == 0L -> "${hours}h"
|
||||
else -> "${hours}h ${minutes}m"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefab short name as something readable — `patrolhelicopter` stays itself,
|
||||
* `rifle.ak` becomes `rifle ak`.
|
||||
*
|
||||
* Deliberately a light touch rather than a lookup table: a table mapping every
|
||||
* Rust prefab to a pretty name is a second copy of the game's item list that goes
|
||||
* stale every wipe, and the short name is what a Rust player reads on their own
|
||||
* server console anyway.
|
||||
*/
|
||||
fun prefabName(name: String?): String {
|
||||
if (name.isNullOrBlank()) return ""
|
||||
return name.replace(Regex("[_.]+"), " ").trim()
|
||||
}
|
||||
|
||||
/** The endings a held entity's prefab name carries that no player says. */
|
||||
private val WEAPON_ENDING = Regex("[._](entity|weapon|deployed)$")
|
||||
|
||||
/**
|
||||
* The weapon in a kill, the Kotlin half of `module-rust`'s `format.js weapon()`.
|
||||
* The module sends `weaponName` (Module-Rust#60), which is the game's own name for a
|
||||
* gun or the prefab name as words (`server/model/titles/weaponNames.js`), and the
|
||||
* app prints that. A module too old to send it gets the same fallback here: the
|
||||
* `.entity` / `.weapon` / `.deployed` ending is dropped, then [prefabName] turns the
|
||||
* rest into words. So the app never prints "ak47u entity".
|
||||
*/
|
||||
fun weaponName(weapon: String?, weaponName: String?): String {
|
||||
if (weapon.isNullOrBlank()) return ""
|
||||
if (!weaponName.isNullOrBlank()) return weaponName
|
||||
return prefabName(weapon.trim().replace(WEAPON_ENDING, ""))
|
||||
}
|
||||
|
||||
/**
|
||||
* An Economics balance (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2.2): grouped,
|
||||
* at most two places, and a dash for one the server never reported — not "0",
|
||||
* which is a claim about somebody's balance that nobody made.
|
||||
*/
|
||||
fun coinAmount(value: Double?, locale: java.util.Locale = java.util.Locale.getDefault()): String {
|
||||
val n = value ?: return "—"
|
||||
if (!n.isFinite()) return "—"
|
||||
return java.text.NumberFormat.getNumberInstance(locale).apply { maximumFractionDigits = 2 }.format(n)
|
||||
}
|
||||
|
||||
/** A steam id, shortened for a table cell, without pretending it is a name. */
|
||||
fun shortSteamId(steamId: String?): String {
|
||||
val id = steamId.orEmpty()
|
||||
return if (id.length > 10) "…${id.takeLast(6)}" else id
|
||||
}
|
||||
|
||||
/**
|
||||
* What to call a player who has no name yet.
|
||||
*
|
||||
* The presence board and the leaderboard both carry a nullable `name`: the plugin
|
||||
* knows a steam id before it knows anything else. Showing a shortened id is
|
||||
* honest — it is not a name and does not look like one — where "Unknown" would
|
||||
* lose the only identifier there is.
|
||||
*/
|
||||
fun playerLabel(name: String?, steamId: String?): String =
|
||||
name?.takeIf { it.isNotBlank() } ?: shortSteamId(steamId)
|
||||
|
||||
/**
|
||||
* A chat title's `#rrggbb` colour as opaque ARGB, or null for anything else —
|
||||
* a title with a colour this build cannot read is drawn in the theme's own chip
|
||||
* colours rather than guessed at (M19).
|
||||
*/
|
||||
fun titleArgb(hex: String?): Long? {
|
||||
val m = Regex("^#([0-9a-fA-F]{6})$").find(hex.orEmpty()) ?: return null
|
||||
return 0xFF000000L or m.groupValues[1].toLong(16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a title chip of this colour needs DARK ink — the same rule as the
|
||||
* website's `contrastInk`: black or white, whichever has the higher WCAG
|
||||
* contrast. The colour is the operator's, chosen for a dark game chat, and the
|
||||
* app draws in the reader's theme, so the colour becomes the chip and the ink is
|
||||
* picked for it.
|
||||
*/
|
||||
fun titleInkIsDark(argb: Long): Boolean {
|
||||
fun linear(channel: Long): Double {
|
||||
val v = channel / 255.0
|
||||
return if (v <= 0.03928) v / 12.92 else Math.pow((v + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
val l = 0.2126 * linear((argb shr 16) and 0xFF) +
|
||||
0.7152 * linear((argb shr 8) and 0xFF) +
|
||||
0.0722 * linear(argb and 0xFF)
|
||||
return (l + 0.05) / 0.05 >= 1.05 / (l + 0.05)
|
||||
}
|
||||
698
app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt
Normal file
698
app/src/main/java/com/runicgateway/app/ui/rust/RustMapPanel.kt
Normal file
@@ -0,0 +1,698 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import coil.size.Size
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLayerDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import com.runicgateway.app.ui.LocalAssetResolver
|
||||
import com.runicgateway.app.ui.PollWhileResumed
|
||||
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
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The Map tab (`docs/modules/rust/PLAN.md` §31, D121–D124).
|
||||
*
|
||||
* **The phone draws the map itself** (D121): Coil fetches the picture once per
|
||||
* map into its disk cache, since the URL carries the hash, and a [Canvas] draws
|
||||
* the picture, the grid and the markers, with pinch, pan and double tap.
|
||||
*
|
||||
* **Nothing here decides who may see what.** The server sends only the layers
|
||||
* this viewer may see. The switches on the legend are the reader's convenience
|
||||
* and never a boundary, and a layer the viewer was not sent is still listed,
|
||||
* disabled, with who can see it: "staff only" explains an empty map where
|
||||
* silence would imply an empty server (§23.3's shape).
|
||||
*/
|
||||
@Composable
|
||||
fun RustMapPanel(
|
||||
serverOnline: Boolean,
|
||||
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||
viewModel: RustMapViewModel = hiltViewModel(),
|
||||
) {
|
||||
val ui by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = ui.map) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
|
||||
is UiState.Success -> {
|
||||
val map = s.data
|
||||
val geometry = map.geometry
|
||||
if (geometry == null) {
|
||||
EmptyView(stringResource(R.string.rust_map_none))
|
||||
return
|
||||
}
|
||||
|
||||
// D124: while RESUMED and on this tab, which is exactly while this
|
||||
// composable is on screen. Nothing is asked for a viewer who is sent
|
||||
// nothing that moves.
|
||||
if (map.anyLive) {
|
||||
val interval = map.pollMs?.takeIf { it > 0 } ?: DEFAULT_POLL_MS
|
||||
key(interval) { PollWhileResumed(intervalMs = interval) { viewModel.poll() } }
|
||||
}
|
||||
|
||||
MapContent(
|
||||
map = map,
|
||||
frame = remember(geometry) { MapFrame(geometry) },
|
||||
ui = ui,
|
||||
serverOnline = serverOnline,
|
||||
onToggle = viewModel::toggle,
|
||||
onSelect = viewModel::select,
|
||||
onOpenEvent = onOpenEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_POLL_MS = 10_000L
|
||||
|
||||
/** What became of the picture. */
|
||||
private sealed interface Picture {
|
||||
data object None : Picture
|
||||
data object Loading : Picture
|
||||
data object Failed : Picture
|
||||
data class Ready(val bitmap: ImageBitmap) : Picture
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapContent(
|
||||
map: RustMapDto,
|
||||
frame: MapFrame,
|
||||
ui: RustMapUi,
|
||||
serverOnline: Boolean,
|
||||
onToggle: (MapLayer) -> Unit,
|
||||
onSelect: (MapMarker?) -> Unit,
|
||||
onOpenEvent: (String, String) -> Unit,
|
||||
) {
|
||||
val picture = rememberPicture(map.picture?.path)
|
||||
val markers = remember(map, ui.live, ui.shown) { mapMarkers(map, ui.live, ui.shown) }
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
when (picture) {
|
||||
Picture.None -> Note(stringResource(R.string.rust_map_no_picture))
|
||||
Picture.Failed -> Note(stringResource(R.string.rust_map_picture_failed))
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||
MapCanvas(
|
||||
frame = frame,
|
||||
background = parseColour(frame.geometry.background) ?: OCEAN,
|
||||
picture = (picture as? Picture.Ready)?.bitmap,
|
||||
showGrid = MapLayer.GRID in ui.shown,
|
||||
markers = markers,
|
||||
selected = ui.selected,
|
||||
// A new map is a new frame; the reader's zoom on the old one means nothing.
|
||||
resetKey = map.mapKey,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
ui.selected?.let { marker ->
|
||||
MarkerCard(
|
||||
marker = marker,
|
||||
grid = marker.grid ?: frame.gridLabel(marker.x, marker.z),
|
||||
eventSlug = marker.runId?.let { ui.eventSlugs[it] },
|
||||
eventTitle = marker.runId?.let { ui.eventTitles[it] },
|
||||
onOpenEvent = onOpenEvent,
|
||||
onClose = { onSelect(null) },
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Note(liveStatus(map, ui, serverOnline))
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = LEGEND_MAX_HEIGHT)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Legend(map, ui.live, ui.shown, onToggle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val LEGEND_MAX_HEIGHT = 240.dp
|
||||
|
||||
/**
|
||||
* The picture, decoded once at its own size as `RGB_565` with hardware bitmaps
|
||||
* off: half the memory of ARGB, 12.5 MB at the rig's 2 500 px (§31.2).
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberPicture(path: String?): Picture {
|
||||
val context = LocalContext.current
|
||||
val resolve = LocalAssetResolver.current
|
||||
val url = path?.takeIf { it.isNotBlank() }?.let { resolve(mapPictureUrl(it)) }
|
||||
|
||||
// Keyed on the URL, which carries the picture's hash: a new map is a new
|
||||
// load, and the old picture stays drawn until the new one has arrived.
|
||||
var picture by remember { mutableStateOf(if (url == null) Picture.None else Picture.Loading) }
|
||||
LaunchedEffect(url) {
|
||||
picture = if (url == null) Picture.None else loadPicture(context, url)
|
||||
}
|
||||
return picture
|
||||
}
|
||||
|
||||
private suspend fun loadPicture(context: Context, url: String): Picture {
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.size(Size.ORIGINAL)
|
||||
.bitmapConfig(Bitmap.Config.RGB_565)
|
||||
.allowHardware(false)
|
||||
.build()
|
||||
val result = context.imageLoader.execute(request) as? SuccessResult
|
||||
val bitmap = (result?.drawable as? BitmapDrawable)?.bitmap ?: return Picture.Failed
|
||||
return Picture.Ready(bitmap.asImageBitmap())
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapCanvas(
|
||||
frame: MapFrame,
|
||||
background: Color,
|
||||
picture: ImageBitmap?,
|
||||
showGrid: Boolean,
|
||||
markers: List<MapMarker>,
|
||||
selected: MapMarker?,
|
||||
resetKey: String?,
|
||||
onSelect: (MapMarker?) -> Unit,
|
||||
) {
|
||||
val pictureSize = ViewSize(frame.width.toFloat(), frame.height.toFloat())
|
||||
var view by remember { mutableStateOf<ViewSize?>(null) }
|
||||
// Null until the reader moves the map: until then, and after a new map
|
||||
// resets it, the map is drawn fitted to the view. **Saveable**, because
|
||||
// Open event leaves this screen and Back returns to it: the walk found the
|
||||
// reader's zoom gone on the way back, the card still up over a whole-world
|
||||
// view of the marker it described.
|
||||
var moved by rememberSaveable(resetKey, pictureSize, stateSaver = TransformSaver) {
|
||||
mutableStateOf<MapTransform?>(null)
|
||||
}
|
||||
val transform = moved ?: view?.let { MapTransform.fit(it, pictureSize) }
|
||||
|
||||
// Read through updated state so the gesture handlers, which are installed
|
||||
// once, act on what is drawn now rather than on what was drawn when they were.
|
||||
val currentMarkers by rememberUpdatedState(markers)
|
||||
val currentTransform by rememberUpdatedState(transform)
|
||||
val currentFrame by rememberUpdatedState(frame)
|
||||
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val labelStyle = TextStyle(fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = GRID_LABEL)
|
||||
val grid = remember(frame) { frame.grid() }
|
||||
val labels: List<Pair<GridLabel, TextLayoutResult>> = remember(grid, textMeasurer) {
|
||||
grid.labels.map { it to textMeasurer.measure(it.text, labelStyle) }
|
||||
}
|
||||
val description = stringResource(R.string.rust_map_description)
|
||||
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clipToBounds()
|
||||
.background(SURROUND)
|
||||
.semantics { contentDescription = description }
|
||||
.onSizeChanged {
|
||||
val next = ViewSize(it.width.toFloat(), it.height.toFloat())
|
||||
// A new size keeps the reader's view rather than refitting: the
|
||||
// box grows and shrinks with the status line under it, and a
|
||||
// rotation is still the same place. The first size after a
|
||||
// return is not a new one, and keeps the restored zoom.
|
||||
val previous = view
|
||||
if (next != previous) {
|
||||
val m = moved
|
||||
if (previous != null && m != null) moved = m.resized(previous, next, pictureSize)
|
||||
view = next
|
||||
}
|
||||
}
|
||||
.pointerInput(pictureSize) {
|
||||
detectTransformGestures { centroid, pan, zoom, _ ->
|
||||
val v = view ?: return@detectTransformGestures
|
||||
val t = currentTransform ?: return@detectTransformGestures
|
||||
moved = t.transformed(v, pictureSize, centroid.x, centroid.y, pan.x, pan.y, zoom)
|
||||
}
|
||||
}
|
||||
.pointerInput(pictureSize) {
|
||||
detectTapGestures(
|
||||
onTap = { at ->
|
||||
val t = currentTransform ?: return@detectTapGestures
|
||||
onSelect(nearestMarker(currentMarkers, currentFrame, t, at.x, at.y, TAP_REACH.toPx()))
|
||||
},
|
||||
onDoubleTap = { at ->
|
||||
val v = view ?: return@detectTapGestures
|
||||
val t = currentTransform ?: return@detectTapGestures
|
||||
moved = t.transformed(v, pictureSize, at.x, at.y, 0f, 0f, 2f)
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
val t = transform ?: return@Canvas
|
||||
val left = t.offsetX
|
||||
val top = t.offsetY
|
||||
val w = pictureSize.width * t.scale
|
||||
val h = pictureSize.height * t.scale
|
||||
|
||||
// Without a picture the geometry is filled with the game's own colour.
|
||||
drawRect(background, topLeft = Offset(left, top), size = androidx.compose.ui.geometry.Size(w, h))
|
||||
picture?.let {
|
||||
drawImage(
|
||||
image = it,
|
||||
dstOffset = IntOffset(left.roundToInt(), top.roundToInt()),
|
||||
dstSize = IntSize(w.roundToInt(), h.roundToInt()),
|
||||
filterQuality = FilterQuality.Low,
|
||||
)
|
||||
}
|
||||
|
||||
if (showGrid) drawGrid(frame, t, grid, labels)
|
||||
markers.forEach { drawMarker(frame, t, it, highlighted = it == selected) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawGrid(
|
||||
frame: MapFrame,
|
||||
t: MapTransform,
|
||||
grid: MapGrid,
|
||||
labels: List<Pair<GridLabel, TextLayoutResult>>,
|
||||
) {
|
||||
val stroke = 1.dp.toPx()
|
||||
fun at(x: Double, z: Double): Offset {
|
||||
val p = frame.toPixel(x, z)
|
||||
return Offset(t.screenX(p.x), t.screenY(p.y))
|
||||
}
|
||||
grid.lines.forEach { drawLine(GRID_LINE, at(it.x1, it.z1), at(it.x2, it.z2), strokeWidth = stroke) }
|
||||
|
||||
// Labels only once a cell is wide enough on screen to hold one: at the
|
||||
// fitted zoom a 20-cell map's labels overlap into a wall of text (found on
|
||||
// the phase 14 walk, and the web's rule).
|
||||
val cellPx = frame.metres(frame.geometry.gridCellSize) * t.scale
|
||||
if (cellPx < LABEL_MIN_CELL.toPx()) return
|
||||
val pad = Offset(3.dp.toPx(), 2.dp.toPx())
|
||||
labels.forEach { (label, layout) ->
|
||||
val corner = at(label.x, label.z)
|
||||
// Only the ones on screen: a zoomed-in 20-cell map has 400 and shows a few.
|
||||
if (corner.x > size.width || corner.y > size.height) return@forEach
|
||||
if (corner.x + cellPx < 0 || corner.y + cellPx < 0) return@forEach
|
||||
drawText(layout, topLeft = corner + pad)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One marker. **Points are drawn at a fixed size on screen** whatever the zoom, so
|
||||
* a dot does not grow over the monument it sits on at 4×; **a zone is drawn on
|
||||
* the ground**, in metres, because how far it reaches is what it says (§31.4).
|
||||
*/
|
||||
private fun DrawScope.drawMarker(frame: MapFrame, t: MapTransform, m: MapMarker, highlighted: Boolean) {
|
||||
val p = frame.toPixel(m.x, m.z)
|
||||
val centre = Offset(t.screenX(p.x), t.screenY(p.y))
|
||||
|
||||
if (m.isZone) {
|
||||
val radius = (frame.metres(m.radiusMetres ?: 0.0) * t.scale).toFloat()
|
||||
drawCircle(EVENT.copy(alpha = 0.12f), radius, centre)
|
||||
drawCircle(EVENT, radius, centre, style = Stroke(width = (if (highlighted) 3 else 2).dp.toPx()))
|
||||
return
|
||||
}
|
||||
|
||||
val style = markerStyle(m)
|
||||
val radius = style.radius.dp.toPx()
|
||||
drawCircle(style.colour.copy(alpha = 0.95f), radius, centre)
|
||||
drawCircle(Color.Black, radius, centre, style = Stroke(width = style.outline.dp.toPx()))
|
||||
if (highlighted) drawCircle(Color.White, radius + 4.dp.toPx(), centre, style = Stroke(width = 2.dp.toPx()))
|
||||
}
|
||||
|
||||
/** The zoom and pan as three floats, so they survive leaving the screen. */
|
||||
private val TransformSaver = Saver<MapTransform?, FloatArray>(
|
||||
save = { t -> t?.let { floatArrayOf(it.scale, it.offsetX, it.offsetY) } },
|
||||
restore = { MapTransform(it[0], it[1], it[2]) },
|
||||
)
|
||||
|
||||
private data class MarkerStyle(val colour: Color, val radius: Int, val outline: Int = 1)
|
||||
|
||||
/** The web's colours and sizes (`MapView.jsx`), so the two maps read alike. */
|
||||
private fun markerStyle(m: MapMarker): MarkerStyle = when (m.layer) {
|
||||
MapLayer.WORLD -> when (m.kind) {
|
||||
"monument" -> MarkerStyle(MONUMENT, 4)
|
||||
"cargo" -> MarkerStyle(WORLD_COLOURS.getValue("cargo"), 7)
|
||||
// A RunicNPC boss outside any event (runicnpc D302): as big as a ship.
|
||||
"boss" -> MarkerStyle(BOSS, 7, 2)
|
||||
else -> MarkerStyle(WORLD_COLOURS[m.kind] ?: WORLD_COLOURS.getValue("crate"), 5)
|
||||
}
|
||||
// An event's boss is bigger (runicnpc D303).
|
||||
MapLayer.EVENTS -> if (m.boss) MarkerStyle(BOSS, 8, 2) else MarkerStyle(EVENT, 5)
|
||||
MapLayer.PLAYERS -> if (m.online) MarkerStyle(ONLINE, 5) else MarkerStyle(SLEEPING, 4)
|
||||
MapLayer.BASES -> MarkerStyle(if (m.kind == "vending") VENDING else TC, 4)
|
||||
MapLayer.MATES -> if (m.self) MarkerStyle(SELF, 8, 2) else MarkerStyle(MATE, 6, 2)
|
||||
MapLayer.GRID -> MarkerStyle(Color.White, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* What was tapped: what it is, its grid square, and what its layer carried.
|
||||
* Nothing is looked up beyond what was sent (§31.4).
|
||||
*/
|
||||
@Composable
|
||||
private fun MarkerCard(
|
||||
marker: MapMarker,
|
||||
grid: String?,
|
||||
eventSlug: String?,
|
||||
eventTitle: String?,
|
||||
onOpenEvent: (String, String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ShardCard(modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)) {
|
||||
Text(markerTitle(marker), style = MaterialTheme.typography.titleSmall)
|
||||
markerDetail(marker, eventTitle)?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
grid?.let {
|
||||
Text(
|
||||
stringResource(R.string.rust_map_grid, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
// D123: a run core's public calendar lists opens the app's own event
|
||||
// page on that run. A run it does not list (a rehearsal, an
|
||||
// unlisted event) says only what it is.
|
||||
val runId = marker.runId
|
||||
if (marker.layer == MapLayer.EVENTS && runId != null && eventSlug != null) {
|
||||
TextButton(onClick = { onOpenEvent(eventSlug, runId) }) {
|
||||
Text(stringResource(R.string.rust_map_open_event))
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onClose) { Text(stringResource(R.string.rust_map_close)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun markerTitle(m: MapMarker): String = when (m.layer) {
|
||||
MapLayer.WORLD -> when (m.kind) {
|
||||
"monument" -> m.name ?: m.kind
|
||||
"cargo" -> stringResource(R.string.rust_map_cargo)
|
||||
"heli" -> stringResource(R.string.rust_map_heli)
|
||||
"chinook" -> stringResource(R.string.rust_map_chinook)
|
||||
"bradley" -> stringResource(R.string.rust_map_bradley)
|
||||
"supply" -> stringResource(R.string.rust_map_supply)
|
||||
"crate" -> stringResource(R.string.rust_map_crate)
|
||||
"boss" -> stringResource(R.string.rust_map_boss, m.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.rust_map_boss_unnamed))
|
||||
else -> prefabName(m.kind)
|
||||
}
|
||||
MapLayer.EVENTS -> when (m.kind) {
|
||||
"zone" -> m.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.rust_map_event_zone)
|
||||
// A RunicNPC NPC says its name, and whether it is a boss or a boss's add (D303).
|
||||
"npc" -> {
|
||||
val name = m.name?.takeIf { it.isNotBlank() }
|
||||
when {
|
||||
name == null -> stringResource(R.string.rust_map_event_npc)
|
||||
m.boss -> stringResource(R.string.rust_map_boss, name)
|
||||
m.add -> stringResource(R.string.rust_map_boss_add, name)
|
||||
else -> name
|
||||
}
|
||||
}
|
||||
else -> stringResource(R.string.rust_map_event_crate)
|
||||
}
|
||||
MapLayer.PLAYERS -> m.name ?: ""
|
||||
MapLayer.BASES -> stringResource(if (m.kind == "vending") R.string.rust_map_vending else R.string.rust_map_tc)
|
||||
MapLayer.MATES -> if (m.self) stringResource(R.string.rust_map_you) else m.name ?: stringResource(R.string.rust_map_clan_mate)
|
||||
MapLayer.GRID -> ""
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun markerDetail(m: MapMarker, eventTitle: String? = null): String? = when (m.layer) {
|
||||
MapLayer.WORLD -> when {
|
||||
m.kind != "crate" -> null
|
||||
m.hacked -> stringResource(R.string.rust_map_crate_hacked)
|
||||
m.hackLeftSec != null -> stringResource(R.string.rust_map_crate_hack, countdown(m.hackLeftSec))
|
||||
else -> null
|
||||
}
|
||||
// Every site-event marker says so, linked or not (§31.2): a run the public
|
||||
// calendar does not list gets this line and no button. One it lists names its
|
||||
// event (runicnpc D303).
|
||||
MapLayer.EVENTS -> eventTitle?.let { stringResource(R.string.rust_map_site_event_named, it) }
|
||||
?: stringResource(R.string.rust_map_site_event)
|
||||
MapLayer.PLAYERS, MapLayer.MATES -> when {
|
||||
!m.online -> stringResource(R.string.rust_map_player_offline)
|
||||
m.sleeping -> stringResource(R.string.rust_map_player_sleeping)
|
||||
else -> null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* One row for each layer. A layer the viewer was sent has a switch; one they
|
||||
* were not is listed disabled with who can see it, and the players layer says
|
||||
* when it is narrower than its own switch because presence is (D113).
|
||||
*/
|
||||
@Composable
|
||||
private fun Legend(map: RustMapDto, live: RustMapLiveDto?, shown: Set<MapLayer>, onToggle: (MapLayer) -> Unit) {
|
||||
LegendRow(stringResource(R.string.rust_map_layer_grid), emptyList(), true, MapLayer.GRID in shown, null) {
|
||||
onToggle(MapLayer.GRID)
|
||||
}
|
||||
LAYER_ROWS.forEach { (layer, labelRes) ->
|
||||
val gate = map.layers.of(layer)
|
||||
var note = if (gate.visible) null else hiddenNote(gate)
|
||||
if (layer == MapLayer.PLAYERS && gate.cappedByPresence && !gate.visible) {
|
||||
note = stringResource(R.string.rust_map_capped, note.orEmpty())
|
||||
}
|
||||
if (gate.visible && layer == MapLayer.PLAYERS && live?.playersTruncated == true) {
|
||||
note = stringResource(R.string.rust_map_players_truncated)
|
||||
}
|
||||
if (gate.visible && layer == MapLayer.BASES && live?.basesTruncated == true) {
|
||||
note = stringResource(R.string.rust_map_bases_truncated)
|
||||
}
|
||||
LegendRow(stringResource(labelRes), SWATCHES.getValue(layer), gate.visible, gate.visible && layer in shown, note) {
|
||||
onToggle(layer)
|
||||
}
|
||||
}
|
||||
val mates = map.mates
|
||||
if (mates.visible) {
|
||||
LegendRow(
|
||||
stringResource(R.string.rust_map_layer_mates),
|
||||
SWATCHES.getValue(MapLayer.MATES),
|
||||
true,
|
||||
MapLayer.MATES in shown,
|
||||
stringResource(R.string.rust_map_mates_note),
|
||||
) { onToggle(MapLayer.MATES) }
|
||||
} else if (mates.on && mates.signedIn && !mates.linked) {
|
||||
// Offered only to a signed-in, unlinked viewer on a server with the switch
|
||||
// on, as the web does: anyone else could not act on it.
|
||||
LegendRow(
|
||||
stringResource(R.string.rust_map_layer_mates),
|
||||
SWATCHES.getValue(MapLayer.MATES),
|
||||
false,
|
||||
false,
|
||||
stringResource(R.string.rust_map_mates_link),
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun hiddenNote(gate: RustMapLayerDto): String = stringResource(
|
||||
when (gate.audience) {
|
||||
"signed_in" -> R.string.rust_map_hidden_signin
|
||||
"public" -> R.string.rust_map_hidden_public
|
||||
else -> R.string.rust_map_hidden_staff
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun LegendRow(
|
||||
label: String,
|
||||
swatches: List<Color>,
|
||||
enabled: Boolean,
|
||||
checked: Boolean,
|
||||
note: String?,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Switch(checked = checked, onCheckedChange = { onToggle() }, enabled = enabled)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
swatches.forEach {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Box(Modifier.size(10.dp).background(it, CircleShape))
|
||||
}
|
||||
}
|
||||
note?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.Note(text: String) {
|
||||
if (text.isBlank()) return
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** The line under the map, in the web's words (`liveStatus` in `MapView.jsx`). */
|
||||
@Composable
|
||||
private fun liveStatus(map: RustMapDto, ui: RustMapUi, serverOnline: Boolean): String {
|
||||
if (!map.anyLive) return stringResource(R.string.rust_map_status_nothing_live)
|
||||
val live = ui.live
|
||||
if (live == null) {
|
||||
return stringResource(if (ui.liveFailed) R.string.rust_map_status_failed else R.string.rust_map_status_asking)
|
||||
}
|
||||
if (ui.liveFailed) return stringResource(R.string.rust_map_status_failed_kept)
|
||||
if (!live.live) {
|
||||
return stringResource(if (serverOnline) R.string.rust_map_status_not_now else R.string.rust_map_status_offline)
|
||||
}
|
||||
val at = ui.liveAt ?: return ""
|
||||
return stringResource(R.string.rust_map_status_as_of, CLOCK.format(Instant.ofEpochMilli(at)))
|
||||
}
|
||||
|
||||
private val CLOCK: DateTimeFormatter =
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault())
|
||||
|
||||
private fun com.runicgateway.app.data.api.dto.RustMapLayersDto.of(layer: MapLayer): RustMapLayerDto = when (layer) {
|
||||
MapLayer.WORLD -> world
|
||||
MapLayer.EVENTS -> events
|
||||
MapLayer.PLAYERS -> players
|
||||
MapLayer.BASES -> bases
|
||||
else -> RustMapLayerDto()
|
||||
}
|
||||
|
||||
/** `#RRGGBB` as the plugin sends it, or null for anything else. */
|
||||
internal fun parseColour(value: String?): Color? {
|
||||
val hex = value?.trim()?.removePrefix("#") ?: return null
|
||||
if (hex.length != 6) return null
|
||||
val rgb = hex.toLongOrNull(16) ?: return null
|
||||
return Color(0xFF000000 or rgb)
|
||||
}
|
||||
|
||||
private val TAP_REACH = 24.dp
|
||||
private val LABEL_MIN_CELL = 30.dp
|
||||
|
||||
// The web's palette (`MapView.jsx` COLOURS), so a reader moving between the two
|
||||
// reads the same marker as the same thing.
|
||||
private val OCEAN = Color(0xFF0B3B4A)
|
||||
private val SURROUND = Color(0xFF071F27)
|
||||
private val GRID_LINE = Color.White.copy(alpha = 0.18f)
|
||||
private val GRID_LABEL = Color.White.copy(alpha = 0.55f)
|
||||
private val MONUMENT = Color(0xFFE8D9A8)
|
||||
private val EVENT = Color(0xFFCE93D8)
|
||||
private val BOSS = Color(0xFFD500F9)
|
||||
private val ONLINE = Color(0xFFFFFFFF)
|
||||
private val SLEEPING = Color(0xFF9E9E9E)
|
||||
private val TC = Color(0xFFFF7043)
|
||||
private val VENDING = Color(0xFF26A69A)
|
||||
private val SELF = Color(0xFF00E5FF)
|
||||
private val MATE = Color(0xFF7CFFB2)
|
||||
private val WORLD_COLOURS = mapOf(
|
||||
"cargo" to Color(0xFF4FC3F7),
|
||||
"heli" to Color(0xFFEF5350),
|
||||
"chinook" to Color(0xFFFFA726),
|
||||
"bradley" to Color(0xFFA1887F),
|
||||
"supply" to Color(0xFF66BB6A),
|
||||
"crate" to Color(0xFFFFEE58),
|
||||
)
|
||||
|
||||
private val LAYER_ROWS = listOf(
|
||||
MapLayer.WORLD to R.string.rust_map_layer_world,
|
||||
MapLayer.EVENTS to R.string.rust_map_layer_events,
|
||||
MapLayer.PLAYERS to R.string.rust_map_layer_players,
|
||||
MapLayer.BASES to R.string.rust_map_layer_bases,
|
||||
)
|
||||
|
||||
private val SWATCHES = mapOf(
|
||||
MapLayer.WORLD to listOf(MONUMENT, WORLD_COLOURS.getValue("cargo"), WORLD_COLOURS.getValue("heli"), WORLD_COLOURS.getValue("crate")),
|
||||
MapLayer.EVENTS to listOf(EVENT),
|
||||
MapLayer.PLAYERS to listOf(ONLINE, SLEEPING),
|
||||
MapLayer.BASES to listOf(TC, VENDING),
|
||||
MapLayer.MATES to listOf(SELF, MATE),
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import com.runicgateway.app.data.repository.EventsRepository
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
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.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Everything the Map tab is showing. */
|
||||
data class RustMapUi(
|
||||
val map: UiState<RustMapDto> = UiState.Loading,
|
||||
/** The last live answer, or null before the first. Kept when a later ask fails. */
|
||||
val live: RustMapLiveDto? = null,
|
||||
/** True when the most recent ask failed; [live] is then the one before it. */
|
||||
val liveFailed: Boolean = false,
|
||||
/** When [live] arrived, epoch ms. */
|
||||
val liveAt: Long? = null,
|
||||
/** The legend's switches: the reader's convenience, never a boundary. */
|
||||
val shown: Set<MapLayer> = MapLayer.entries.toSet(),
|
||||
val selected: MapMarker? = null,
|
||||
/** Run id → event slug, for the runs core's public calendar lists. */
|
||||
val eventSlugs: Map<String, String> = emptyMap(),
|
||||
/** Run id → event title, from the same read: an NPC's card names its event (runicnpc D303). */
|
||||
val eventTitles: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One server's map (`docs/modules/rust/PLAN.md` §31, D121–D125; PLAN.md M17).
|
||||
*
|
||||
* ## It draws what it is sent
|
||||
*
|
||||
* `map` and `map/live` are projected per viewer **on the server**, from the same
|
||||
* bearer token every other request carries. A layer this viewer may not see is
|
||||
* absent from the answer, so nothing here decides who sees what.
|
||||
*
|
||||
* ## Keyed on the signed-in account
|
||||
*
|
||||
* Mates and the players layer are per viewer, and this view model lives as long
|
||||
* as the server screen's back-stack entry, across a sign-out and a sign-in as
|
||||
* somebody else. Events phase 14b found what that costs: a view model that loads
|
||||
* once showed the next account the previous one's answer without asking. So the
|
||||
* account is what this keys on. A change of account drops everything drawn,
|
||||
* including the tapped card, and asks again, and an answer to a question asked
|
||||
* for the previous account is thrown away when it lands (the [generation]).
|
||||
* Signing out is a change too: the map is public, so a signed-out reader gets
|
||||
* the public map rather than an empty one.
|
||||
*
|
||||
* ## When it asks
|
||||
*
|
||||
* `map` once when the tab opens, and again when a live answer names a different
|
||||
* `mapKey`: a wipe or a new seed, whose picture follows without leaving the tab.
|
||||
* `map/live` on the screen's poll (D124), and not at all when the server sends
|
||||
* this viewer no moving layer.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RustMapViewModel @Inject constructor(
|
||||
private val repository: RustRepository,
|
||||
events: EventsRepository,
|
||||
sessionManager: SessionManager,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
|
||||
|
||||
private val resolver = EventRunResolver(events)
|
||||
|
||||
private val _state = MutableStateFlow(RustMapUi())
|
||||
val state: StateFlow<RustMapUi> = _state.asStateFlow()
|
||||
|
||||
/** Bumped on every change of account; an answer from an older one is dropped. */
|
||||
private var generation = 0
|
||||
|
||||
/** A poll still waiting for its answer: the next tick does not stack another. */
|
||||
private var polling = false
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
sessionManager.state
|
||||
.map { (it as? Session.SignedIn)?.user?.id }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
generation++
|
||||
polling = false
|
||||
_state.update { s -> RustMapUi(shown = s.shown) }
|
||||
load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The map's picture, frame and gates. A retry after a failure too. */
|
||||
fun load() {
|
||||
val asked = generation
|
||||
_state.update { it.copy(map = UiState.Loading) }
|
||||
viewModelScope.launch { askMap(asked) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The poll tick. Asks only once the map has a frame to place things in and
|
||||
* only when this viewer is sent something that moves.
|
||||
*/
|
||||
fun poll() {
|
||||
val map = (_state.value.map as? UiState.Success)?.data ?: return
|
||||
if (map.geometry == null || !map.anyLive || polling) return
|
||||
|
||||
val asked = generation
|
||||
polling = true
|
||||
viewModelScope.launch {
|
||||
val result = repository.mapLive(serverId)
|
||||
if (asked != generation) return@launch
|
||||
polling = false
|
||||
|
||||
when (result) {
|
||||
is ApiResult.Ok -> {
|
||||
val answer = result.data
|
||||
_state.update {
|
||||
it.copy(live = answer, liveFailed = false, liveAt = System.currentTimeMillis())
|
||||
}
|
||||
// A new map under the same server: the picture follows.
|
||||
if (answer.mapKey != null && map.mapKey != null && answer.mapKey != map.mapKey) {
|
||||
askMap(asked)
|
||||
}
|
||||
resolveRuns(answer, asked)
|
||||
}
|
||||
// Positions are kept on a failure, in Polling.kt's shape: the last
|
||||
// answer stays drawn and the status line says the ask failed.
|
||||
else -> _state.update { it.copy(liveFailed = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggle(layer: MapLayer) {
|
||||
_state.update {
|
||||
val shown = if (layer in it.shown) it.shown - layer else it.shown + layer
|
||||
// A card for something the reader just hid would describe a marker
|
||||
// that is no longer on the map.
|
||||
val selected = it.selected?.takeIf { m -> m.layer in shown }
|
||||
it.copy(shown = shown, selected = selected)
|
||||
}
|
||||
}
|
||||
|
||||
fun select(marker: MapMarker?) {
|
||||
_state.update { it.copy(selected = marker) }
|
||||
}
|
||||
|
||||
private suspend fun askMap(asked: Int) {
|
||||
val result = repository.map(serverId)
|
||||
if (asked != generation) return
|
||||
_state.update { current ->
|
||||
// A failed RE-read after a new mapKey keeps the map that is drawn:
|
||||
// the old picture under new positions is better than an error screen,
|
||||
// and the next poll asks again.
|
||||
if (result !is ApiResult.Ok && current.map is UiState.Success) current
|
||||
else current.copy(map = result.toUiState())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveRuns(answer: RustMapLiveDto, asked: Int) {
|
||||
val ids = eventRunIds(answer)
|
||||
if (ids.isEmpty()) return
|
||||
val slugs = resolver.resolve(ids)
|
||||
val titles = resolver.titles(ids)
|
||||
if (asked != generation) return
|
||||
_state.update { it.copy(eventSlugs = slugs, eventTitles = titles) }
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the server sends this viewer at least one layer that moves. */
|
||||
val RustMapDto.anyLive: Boolean
|
||||
get() = layers.world.visible || layers.events.visible || layers.players.visible ||
|
||||
layers.bases.visible || mates.visible
|
||||
@@ -0,0 +1,718 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.PaddingValues
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.RustEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustTitleDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.ui.PollWhileResumed
|
||||
import com.runicgateway.app.ui.Polled
|
||||
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
|
||||
|
||||
/**
|
||||
* One Rust server: the feed, the leaderboard, who is on, the map when the module
|
||||
* has one (D122), and the wipes (D13).
|
||||
*
|
||||
* **One screen with tabs, not four destinations** — the same call the website
|
||||
* makes, and more obviously right on a phone: the four panels are four questions
|
||||
* about one thing, and a reader moving between them is not navigating.
|
||||
*
|
||||
* The phase criterion lives here. With the server unreachable this still renders
|
||||
* its map, size, seed, wipe date, killfeed, leaderboards, last known presence
|
||||
* board and wipe history, because every one of those is read from the website's
|
||||
* own tables rather than from the game.
|
||||
*/
|
||||
@Composable
|
||||
fun RustServerScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||
onSignIn: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RustServerViewModel = hiltViewModel(),
|
||||
safety: ChatSafetyViewModel = hiltViewModel(),
|
||||
) {
|
||||
val ui by viewModel.state.collectAsStateWithLifecycle()
|
||||
val menu by safety.menu.collectAsStateWithLifecycle()
|
||||
|
||||
PollWhileResumed { viewModel.refresh() }
|
||||
|
||||
// The server filters the feed for its reader (S11), so a block or a hide shows
|
||||
// itself by the feed being read again, never by a line being dropped here.
|
||||
LaunchedEffect(safety) { safety.changes.collect { viewModel.reloadFeed() } }
|
||||
|
||||
menu.line?.let { line ->
|
||||
chatActions(line, safety.user)?.let { actions -> ChatSafetySheet(menu, actions, safety, onSignIn) }
|
||||
}
|
||||
|
||||
when (val s = ui.server.state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
|
||||
// **A mistyped address is not a fault and must not be dressed as one.**
|
||||
// The website's first version put its generic error panel under this
|
||||
// heading, so an unknown id read "No such server / Something went wrong"
|
||||
// and sent a reader looking for an outage. A 404 is its own answer; the
|
||||
// error panel is kept for a request that failed for a reason nobody can
|
||||
// see. A server an operator disabled answers the same 404 — switching one
|
||||
// off is not switching it into a refusal.
|
||||
is UiState.Error -> if (s.kind == ErrorKind.NOT_FOUND) {
|
||||
MissingServer(onBack, modifier)
|
||||
} else {
|
||||
ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
}
|
||||
|
||||
is UiState.Success -> ServerDetail(s.data, ui, viewModel, onOpenEvent, safety::open, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MissingServer(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.rust_no_such_server), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
text = stringResource(R.string.rust_no_such_server_detail),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.rust_back_to_servers),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onBack).padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerDetail(
|
||||
server: RustServerDto,
|
||||
ui: RustServerUi,
|
||||
viewModel: RustServerViewModel,
|
||||
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||
onLinePressed: (RustEventDto) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier.fillMaxSize()) {
|
||||
ServerHeader(server, ui.selectedWipe, viewModel::selectWipe, ui.wipes)
|
||||
|
||||
val tabs = ui.tabs
|
||||
ScrollableTabRow(selectedTabIndex = tabs.indexOf(ui.tab), edgePadding = 16.dp) {
|
||||
tabs.forEach { tab ->
|
||||
Tab(
|
||||
selected = tab == ui.tab,
|
||||
onClick = { viewModel.selectTab(tab) },
|
||||
text = { Text(stringResource(tabLabel(tab))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (ui.tab) {
|
||||
RustTab.FEED -> FeedPanel(
|
||||
ui.feed,
|
||||
ui.filterId,
|
||||
viewModel::selectFilter,
|
||||
viewModel::retryFeed,
|
||||
onLinePressed,
|
||||
)
|
||||
RustTab.LEADERBOARD -> LeaderboardPanel(
|
||||
ui.leaderboard,
|
||||
ui.sort,
|
||||
viewModel::selectSort,
|
||||
viewModel::retryLeaderboard,
|
||||
)
|
||||
RustTab.ONLINE -> OnlinePanel(ui.online, server.online, viewModel::retryOnline)
|
||||
RustTab.MAP -> RustMapPanel(serverOnline = server.online, onOpenEvent = onOpenEvent)
|
||||
RustTab.WIPES -> WipesPanel(
|
||||
ui.wipes,
|
||||
server.wipeId,
|
||||
ui.selectedWipe,
|
||||
viewModel::openWipe,
|
||||
viewModel::retryWipes,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tabLabel(tab: RustTab): Int = when (tab) {
|
||||
RustTab.FEED -> R.string.rust_tab_feed
|
||||
RustTab.LEADERBOARD -> R.string.rust_tab_leaderboard
|
||||
RustTab.ONLINE -> R.string.rust_tab_online
|
||||
RustTab.MAP -> R.string.rust_tab_map
|
||||
RustTab.WIPES -> R.string.rust_tab_wipes
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerHeader(
|
||||
server: RustServerDto,
|
||||
selectedWipe: String?,
|
||||
onSelectWipe: (String?) -> Unit,
|
||||
wipes: UiState<List<RustWipeDto>>,
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Text(server.name.ifBlank { server.id }, style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
describeWorld(server)?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
nextWipeLine(server)?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (server.online) {
|
||||
StatusPill(
|
||||
text = stringResource(R.string.rust_online_count, server.players, server.maxPlayers),
|
||||
tone = PillTone.Success,
|
||||
)
|
||||
} else {
|
||||
StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral)
|
||||
}
|
||||
Text(
|
||||
text = lastReported(server),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// The wipe picker sits above the tabs because it filters two of them. It
|
||||
// is absent until the wipe list has loaded — offering a filter with one
|
||||
// option would look like a server that has only ever had one wipe.
|
||||
val available = (wipes as? UiState.Success)?.data.orEmpty()
|
||||
if (available.isNotEmpty()) {
|
||||
WipeFilter(available, server.wipeId, selectedWipe, onSelectWipe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WipeFilter(
|
||||
wipes: List<RustWipeDto>,
|
||||
currentWipeId: String?,
|
||||
selected: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// **Null is all time, and it is the default.** It is a real choice rather
|
||||
// than an absent filter: all-time is the per-wipe rows summed, which is
|
||||
// the answer to "who plays here", where a wipe is the answer to "who is
|
||||
// winning now".
|
||||
FilterChip(
|
||||
selected = selected == null,
|
||||
onClick = { onSelect(null) },
|
||||
label = { Text(stringResource(R.string.rust_all_time)) },
|
||||
)
|
||||
wipes.forEach { wipe ->
|
||||
val label = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId
|
||||
FilterChip(
|
||||
selected = selected == wipe.wipeId,
|
||||
onClick = { onSelect(wipe.wipeId) },
|
||||
label = {
|
||||
Text(
|
||||
if (wipe.wipeId == currentWipeId) {
|
||||
stringResource(R.string.rust_wipe_current, label)
|
||||
} else {
|
||||
label
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feed ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun FeedPanel(
|
||||
feed: Polled<RustEventListDto>,
|
||||
filterId: String,
|
||||
onFilter: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onLinePressed: (RustEventDto) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FEED_FILTERS.forEach { filter ->
|
||||
FilterChip(
|
||||
selected = filter.id == filterId,
|
||||
onClick = { onFilter(filter.id) },
|
||||
label = { Text(filter.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (val s = feed.state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
||||
is UiState.Success -> {
|
||||
// Said once, above the rows, so a thin feed reads as withheld
|
||||
// rather than as a quiet server (org lead: nothing names who is
|
||||
// online by default).
|
||||
if (s.data.presenceHidden) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (s.data.presenceAudience == "signed_in") {
|
||||
R.string.rust_feed_presence_hidden_signin
|
||||
} else {
|
||||
R.string.rust_feed_presence_hidden_staff
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
if (s.data.events.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.rust_feed_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (feed.refreshFailed) {
|
||||
item { RefreshFailedLine() }
|
||||
}
|
||||
items(s.data.events, key = { it.id }) { FeedRow(it, onLinePressed) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun FeedRow(row: RustEventDto, onLinePressed: (RustEventDto) -> Unit) {
|
||||
val line = describe(row)
|
||||
val menuLabel = stringResource(R.string.safety_line_actions)
|
||||
|
||||
// A chat line answers a long press with its report / hide / block menu
|
||||
// (USER_SAFETY.md §4.3). Only chat: a block hides what someone says, and the
|
||||
// other rows are things that happened to them. A line staff hid for everyone
|
||||
// reaches staff alone, dimmed and marked (S10).
|
||||
val chat = row.kind == "player.chat"
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (chat) {
|
||||
Modifier.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { onLinePressed(row) },
|
||||
onLongClickLabel = menuLabel,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.then(if (row.hidden) Modifier.alpha(HIDDEN_LINE_ALPHA) else Modifier),
|
||||
) {
|
||||
// The stamp carries a date for anything not from today — a row from six
|
||||
// weeks ago rendered as a bare time reads as this afternoon, which is
|
||||
// exactly what happens the moment the feed is filtered to a past wipe.
|
||||
Text(
|
||||
text = feedClock(value = null, epochMillis = row.t),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 10.dp, top = 2.dp),
|
||||
)
|
||||
Column {
|
||||
Row {
|
||||
line.actor?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(line.join, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
// The chat message lands here, and it is the one field on this
|
||||
// wire whose bytes a player chooses. Compose renders it as text
|
||||
// and never as markup; nothing here may ever stop doing that.
|
||||
Text(line.verb, style = MaterialTheme.typography.bodyMedium)
|
||||
line.subject?.let {
|
||||
Text(" ", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
if (line.detail.isNotBlank()) {
|
||||
Text(
|
||||
text = line.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (row.hidden) {
|
||||
Text(
|
||||
text = stringResource(R.string.safety_hidden_by_staff),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How far a line staff hid for everyone is faded, for the staff who still see it. */
|
||||
private const val HIDDEN_LINE_ALPHA = 0.6f
|
||||
|
||||
// ── Leaderboard ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The columns, and which of them the API can sort by.
|
||||
*
|
||||
* `structures` has no sort on the wire and therefore no tap here — a header that
|
||||
* sorts by something other than what it says is worse than one that does not
|
||||
* sort.
|
||||
*/
|
||||
private data class RustColumn(val labelRes: Int, val sort: String?, val value: (RustLeaderboardRowDto) -> String)
|
||||
|
||||
/** The name's share of the row against one numeric column's. */
|
||||
private const val NAME_WEIGHT = 1.7f
|
||||
|
||||
/** How far a header that is not the current sort is faded. */
|
||||
private const val SORTED_AWAY = 0.55f
|
||||
|
||||
private val RUST_COLUMNS = listOf(
|
||||
RustColumn(R.string.rust_col_kills, RustSort.KILLS) { it.kills.toString() },
|
||||
RustColumn(R.string.rust_col_deaths, RustSort.DEATHS) { it.deaths.toString() },
|
||||
RustColumn(R.string.rust_col_npc_kills, RustSort.NPC_KILLS) { it.npcKills.toString() },
|
||||
RustColumn(R.string.rust_col_structures, null) { it.structures.toString() },
|
||||
RustColumn(R.string.rust_col_played, RustSort.PLAYTIME) { playtime(it.playtimeSec) },
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun LeaderboardPanel(
|
||||
state: UiState<List<RustLeaderboardRowDto>>,
|
||||
sort: String,
|
||||
onSort: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||
is UiState.Success -> if (state.data.isEmpty()) {
|
||||
// **Empty is a real answer here and is not "no data".** All-time is
|
||||
// the per-wipe rows summed, so a player who appears only in an older
|
||||
// wipe drops out of the current one rather than reading zero — an
|
||||
// empty board for a wipe means nobody scored on that map.
|
||||
EmptyView(stringResource(R.string.rust_leaderboard_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
SectionLabel(
|
||||
text = stringResource(R.string.rust_col_player),
|
||||
modifier = Modifier.weight(NAME_WEIGHT),
|
||||
)
|
||||
RUST_COLUMNS.forEach { column ->
|
||||
// The ACTIVE sort is marked on the header, not on the
|
||||
// values: the header is the control, and tinting a
|
||||
// column of numbers instead says "these are special"
|
||||
// rather than "this is what the table is ordered by".
|
||||
SectionLabel(
|
||||
text = stringResource(column.labelRes),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.then(
|
||||
if (column.sort != null) {
|
||||
Modifier.clickable { onSort(column.sort) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.then(
|
||||
if (column.sort == sort) {
|
||||
Modifier.alpha(1f)
|
||||
} else {
|
||||
Modifier.alpha(SORTED_AWAY)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(state.data, key = { it.steamId }) { row ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
// A wider share for the name, and one line with an ellipsis.
|
||||
// Five numeric columns beside an equal-weight name column
|
||||
// left "Brannock" touching its own kill count, which the
|
||||
// walk read as one field.
|
||||
Column(Modifier.weight(NAME_WEIGHT).padding(end = 8.dp)) {
|
||||
Text(
|
||||
text = playerLabel(row.name, row.steamId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// M19: the chat titles this player holds now, under the
|
||||
// name — the column is too narrow to put them beside it.
|
||||
if (row.titles.isNotEmpty()) {
|
||||
FlowRow(
|
||||
Modifier.padding(top = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
row.titles.forEach { title -> TitleChip(title) }
|
||||
}
|
||||
}
|
||||
}
|
||||
RUST_COLUMNS.forEach { column ->
|
||||
Text(
|
||||
text = column.value(row),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One chat title (M19): the operator's colour as the chip, and black or white
|
||||
* ink chosen for it, as the website draws it. A colour this build cannot read
|
||||
* falls back to the theme's own chip colours.
|
||||
*/
|
||||
@Composable
|
||||
private fun TitleChip(title: RustTitleDto) {
|
||||
val argb = titleArgb(title.color)
|
||||
val background = argb?.let { Color(it) } ?: MaterialTheme.colorScheme.secondaryContainer
|
||||
val ink = when {
|
||||
argb == null -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
titleInkIsDark(argb) -> Color.Black
|
||||
else -> Color.White
|
||||
}
|
||||
Text(
|
||||
text = title.text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = ink,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(background)
|
||||
.padding(horizontal = 6.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Online ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun OnlinePanel(
|
||||
online: Polled<RustOnlineDto>,
|
||||
serverOnline: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (val s = online.state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
||||
// Withheld is not empty. Below the operator's audience the server sends
|
||||
// the count and no names, and an empty list rendered as "nobody is on"
|
||||
// would be a false statement about a full server.
|
||||
is UiState.Success -> if (s.data.hidden) {
|
||||
val count = s.data.count
|
||||
EmptyView(
|
||||
pluralStringResource(R.plurals.rust_online_hidden_count, count, count) + "\n" +
|
||||
stringResource(
|
||||
when (s.data.audience) {
|
||||
"signed_in" -> R.string.rust_online_hidden_signin
|
||||
"public" -> R.string.rust_online_hidden_public
|
||||
else -> R.string.rust_online_hidden_staff
|
||||
},
|
||||
),
|
||||
)
|
||||
} else if (s.data.players.isEmpty()) {
|
||||
EmptyView(
|
||||
stringResource(
|
||||
if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// **The board is the last one that ARRIVED, and an unreachable
|
||||
// server does not clear it** — deliberately, because these rows
|
||||
// are still the best answer anybody has. Presented bare they read
|
||||
// as "these people are on right now", which is the one thing an
|
||||
// offline server cannot be saying. So the panel says which it is.
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (serverOnline) R.string.rust_presence_live else R.string.rust_presence_last_known,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (online.refreshFailed) {
|
||||
item { RefreshFailedLine() }
|
||||
}
|
||||
items(s.data.players, key = { it.steamId }) { player ->
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = playerLabel(player.name, player.steamId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (player.sleeping) {
|
||||
StatusPill(
|
||||
text = stringResource(R.string.rust_sleeping),
|
||||
tone = PillTone.Neutral,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wipes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun WipesPanel(
|
||||
state: UiState<List<RustWipeDto>>,
|
||||
currentWipeId: String?,
|
||||
selected: String?,
|
||||
onOpenWipe: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||
is UiState.Success -> if (state.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.rust_wipes_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(state.data, key = { it.wipeId }) { wipe ->
|
||||
ShardCard(
|
||||
Modifier.fillMaxWidth().clickable { onOpenWipe(wipe.wipeId) },
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (wipe.wipeId == currentWipeId) {
|
||||
StatusPill(
|
||||
text = stringResource(R.string.rust_wipe_this_one),
|
||||
tone = PillTone.Success,
|
||||
)
|
||||
} else if (wipe.wipeId == selected) {
|
||||
StatusPill(
|
||||
text = stringResource(R.string.rust_wipe_selected),
|
||||
tone = PillTone.Info,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RefreshFailedLine() {
|
||||
Text(
|
||||
text = stringResource(R.string.rust_refresh_failed),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||
import com.runicgateway.app.data.repository.Capability
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
|
||||
import com.runicgateway.app.data.repository.canUse
|
||||
import com.runicgateway.app.ui.Polled
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.refreshInto
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The sections of a server's page (D13), in the website's order. [MAP] sits
|
||||
* between Online and Wipes where the web has it (D122), and is shown only when
|
||||
* the site's module declares `map`.
|
||||
*/
|
||||
enum class RustTab {
|
||||
FEED, LEADERBOARD, ONLINE, MAP, WIPES;
|
||||
|
||||
/** The website's name for this tab, as `?tab=` carries it (`ServerDetail.jsx`). */
|
||||
val wire: String get() = name.lowercase()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The tab the website calls [wire], or null for one the app does not have.
|
||||
* The website also has `clans`, and a link to it is not a link to the feed.
|
||||
*/
|
||||
fun fromWire(wire: String?): RustTab? = entries.firstOrNull { it.wire == wire }
|
||||
}
|
||||
}
|
||||
|
||||
/** What a leaderboard column sorts by — the API's own vocabulary, not the app's. */
|
||||
object RustSort {
|
||||
const val KILLS = "kills"
|
||||
const val DEATHS = "deaths"
|
||||
const val NPC_KILLS = "npcKills"
|
||||
const val PLAYTIME = "playtime"
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything one server's page is showing.
|
||||
*
|
||||
* One state object rather than eight flows: every panel on the page is about the
|
||||
* same server and the same selected wipe, and a screen that collected them
|
||||
* separately could render a leaderboard for one wipe beside a feed for another
|
||||
* for a frame.
|
||||
*/
|
||||
data class RustServerUi(
|
||||
val serverId: String = "",
|
||||
val server: Polled<RustServerDto> = Polled(),
|
||||
val tab: RustTab = RustTab.FEED,
|
||||
val filterId: String = "all",
|
||||
val sort: String = RustSort.KILLS,
|
||||
/** The wipe every panel is filtered to. **Null is all time**, not "unknown". */
|
||||
val selectedWipe: String? = null,
|
||||
/** The whole answer, not its rows: `presenceHidden` is part of what it says. */
|
||||
val feed: Polled<RustEventListDto> = Polled(),
|
||||
/** Likewise — `hidden` and `count` are what tell "withheld" from "nobody". */
|
||||
val online: Polled<RustOnlineDto> = Polled(),
|
||||
val leaderboard: UiState<List<RustLeaderboardRowDto>> = UiState.Loading,
|
||||
val wipes: UiState<List<RustWipeDto>> = UiState.Loading,
|
||||
/** Whether the Map tab is offered, under the app's one capability rule (D122). */
|
||||
val mapAvailable: Boolean = true,
|
||||
) {
|
||||
/** The tabs this site offers, in order. */
|
||||
val tabs: List<RustTab> get() = RustTab.entries.filter { it != RustTab.MAP || mapAvailable }
|
||||
}
|
||||
|
||||
/**
|
||||
* One Rust server (PLAN.md §9 M14; `docs/modules/rust/PLAN.md` D13, D14).
|
||||
*
|
||||
* ## What polls and what does not
|
||||
*
|
||||
* D14, and it is a statement about the questions rather than about cost: the
|
||||
* **feed**, **who is on** and the **server's own line** change while somebody is
|
||||
* looking at the page, and the **leaderboard** and the **wipe list** do not in
|
||||
* any way a reader would want to watch. A leaderboard that re-sorted itself under
|
||||
* a finger every twenty seconds would be worse than a stale one.
|
||||
*
|
||||
* Only the **visible** live panel is polled. The website can afford to mount the
|
||||
* one tab it is showing; here the tabs are one screen, so the refresh asks what
|
||||
* the reader is actually looking at.
|
||||
*
|
||||
* ## Changing the question versus asking it again
|
||||
*
|
||||
* A poll is the same question asked again, so it keeps what is on screen
|
||||
* ([refreshInto]). Changing the filter, the sort or the wipe is a **different
|
||||
* question**, so the panel blanks and loads — what is there is an answer to
|
||||
* something the reader has stopped asking, and leaving it up while the new one
|
||||
* arrives would show a killfeed for last wipe under a heading naming this one.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RustServerViewModel @Inject constructor(
|
||||
private val repository: RustRepository,
|
||||
capabilities: SiteCapabilitiesRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
|
||||
|
||||
/** The tab a link asked for (D71), or null for the feed every page opens on. */
|
||||
private val initialTab: RustTab? = RustTab.fromWire(savedStateHandle[Routes.Args.TAB])
|
||||
|
||||
private val _state = MutableStateFlow(RustServerUi(serverId = serverId))
|
||||
val state: StateFlow<RustServerUi> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
// Before the tab a link asked for, so `?tab=map` on a site without a map
|
||||
// is refused by [selectTab] and opens the feed.
|
||||
viewModelScope.launch {
|
||||
capabilities.capabilities.collect { caps ->
|
||||
val available = canUse(caps, Capability.MAP)
|
||||
_state.update {
|
||||
// The tab going away under the reader lands them on the feed,
|
||||
// which every page has and has already loaded.
|
||||
val tab = if (!available && it.tab == RustTab.MAP) RustTab.FEED else it.tab
|
||||
it.copy(mapAvailable = available, tab = tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
initialTab?.takeIf { it != RustTab.FEED }?.let(::selectTab)
|
||||
}
|
||||
|
||||
/** A first load or a retry of the whole page. */
|
||||
fun load() {
|
||||
_state.update { it.copy(server = Polled(UiState.Loading), feed = Polled(UiState.Loading)) }
|
||||
viewModelScope.launch {
|
||||
askServer()
|
||||
askFeed()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The poll tick.
|
||||
*
|
||||
* The server line always, and then whichever live panel is on screen. A tab
|
||||
* showing the leaderboard or the wipes does no extra work — the reader is
|
||||
* looking at something that does not move.
|
||||
*/
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
askServer()
|
||||
when (_state.value.tab) {
|
||||
RustTab.FEED -> askFeed()
|
||||
RustTab.ONLINE -> askOnline()
|
||||
// The map keeps its own cadence (D124) in [RustMapViewModel].
|
||||
RustTab.LEADERBOARD, RustTab.WIPES, RustTab.MAP -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a tab, loading its panel the first time it is opened.
|
||||
*
|
||||
* The two that do not poll are loaded exactly once per question: re-asking on
|
||||
* every tab switch would put a spinner over a leaderboard the reader has
|
||||
* already read, for an answer that cannot have changed while they were three
|
||||
* taps away.
|
||||
*/
|
||||
fun selectTab(tab: RustTab) {
|
||||
val already = _state.value
|
||||
if (tab !in already.tabs) return
|
||||
_state.update { it.copy(tab = tab) }
|
||||
|
||||
viewModelScope.launch {
|
||||
when (tab) {
|
||||
RustTab.FEED -> if (already.feed.state !is UiState.Success) askFeed()
|
||||
RustTab.ONLINE -> if (already.online.state !is UiState.Success) askOnline()
|
||||
RustTab.LEADERBOARD -> if (already.leaderboard !is UiState.Success) askLeaderboard()
|
||||
RustTab.WIPES -> if (already.wipes !is UiState.Success) askWipes()
|
||||
// Loaded by its own view model when the tab composes.
|
||||
RustTab.MAP -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A different question for the feed: blank it and ask. */
|
||||
fun selectFilter(filterId: String) {
|
||||
if (filterId == _state.value.filterId) return
|
||||
_state.update { it.copy(filterId = filterId, feed = Polled(UiState.Loading)) }
|
||||
viewModelScope.launch { askFeed() }
|
||||
}
|
||||
|
||||
/** A different question for the leaderboard: blank it and ask. */
|
||||
fun selectSort(sort: String) {
|
||||
if (sort == _state.value.sort) return
|
||||
_state.update { it.copy(sort = sort, leaderboard = UiState.Loading) }
|
||||
viewModelScope.launch { askLeaderboard() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a wipe, or all time with null.
|
||||
*
|
||||
* It is the one selection that changes **two** panels, so both are blanked —
|
||||
* and only the loaded ones are re-asked, so choosing a wipe from the Wipes tab
|
||||
* does not fetch a leaderboard nobody has opened.
|
||||
*/
|
||||
fun selectWipe(wipeId: String?) {
|
||||
if (wipeId == _state.value.selectedWipe) return
|
||||
|
||||
val hadLeaderboard = _state.value.leaderboard is UiState.Success
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedWipe = wipeId,
|
||||
feed = Polled(UiState.Loading),
|
||||
leaderboard = if (hadLeaderboard) UiState.Loading else it.leaderboard,
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
askFeed()
|
||||
if (hadLeaderboard) askLeaderboard()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a wipe from the Wipes tab, which is a navigation as much as a filter.
|
||||
*
|
||||
* The question it asks is "what happened during that map", and the answer is
|
||||
* the feed — so it lands there rather than leaving the reader on a list of
|
||||
* dates with nothing visibly changed.
|
||||
*/
|
||||
fun openWipe(wipeId: String) {
|
||||
selectWipe(wipeId)
|
||||
selectTab(RustTab.FEED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry one panel after its own load failed.
|
||||
*
|
||||
* Four entry points rather than one, because a failed leaderboard is not a
|
||||
* reason to re-read the feed the reader can already see — and [load] is the
|
||||
* whole page, which is right for a failed *server* read and heavy-handed for
|
||||
* anything else.
|
||||
*/
|
||||
fun retryFeed() {
|
||||
_state.update { it.copy(feed = Polled(UiState.Loading)) }
|
||||
viewModelScope.launch { askFeed() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the feed again after a report, block or hide changed what this reader
|
||||
* should see (`USER_SAFETY.md` §4.3). The same question, so what is on screen
|
||||
* stays up until the answer replaces it, as a poll's does.
|
||||
*/
|
||||
fun reloadFeed() {
|
||||
viewModelScope.launch { askFeed() }
|
||||
}
|
||||
|
||||
fun retryLeaderboard() {
|
||||
_state.update { it.copy(leaderboard = UiState.Loading) }
|
||||
viewModelScope.launch { askLeaderboard() }
|
||||
}
|
||||
|
||||
fun retryOnline() {
|
||||
_state.update { it.copy(online = Polled(UiState.Loading)) }
|
||||
viewModelScope.launch { askOnline() }
|
||||
}
|
||||
|
||||
fun retryWipes() {
|
||||
_state.update { it.copy(wipes = UiState.Loading) }
|
||||
viewModelScope.launch { askWipes() }
|
||||
}
|
||||
|
||||
private suspend fun askServer() {
|
||||
val result = repository.server(serverId)
|
||||
_state.update { it.copy(server = refreshInto(it.server.state, result)) }
|
||||
}
|
||||
|
||||
private suspend fun askFeed() {
|
||||
val current = _state.value
|
||||
val result = repository.events(
|
||||
id = serverId,
|
||||
kinds = kindsFor(current.filterId),
|
||||
wipe = current.selectedWipe,
|
||||
limit = FEED_LIMIT,
|
||||
)
|
||||
_state.update { it.copy(feed = refreshInto(it.feed.state, result)) }
|
||||
}
|
||||
|
||||
private suspend fun askOnline() {
|
||||
val result = repository.online(serverId)
|
||||
_state.update { it.copy(online = refreshInto(it.online.state, result)) }
|
||||
}
|
||||
|
||||
private suspend fun askLeaderboard() {
|
||||
val current = _state.value
|
||||
val result = repository.leaderboard(
|
||||
id = serverId,
|
||||
wipe = current.selectedWipe,
|
||||
sort = current.sort,
|
||||
limit = LEADERBOARD_LIMIT,
|
||||
)
|
||||
_state.update { it.copy(leaderboard = result.toUiState()) }
|
||||
}
|
||||
|
||||
private suspend fun askWipes() {
|
||||
_state.update { it.copy(wipes = repository.wipes(serverId).toUiState()) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Matches the website's feed page size; the server caps at 200 regardless. */
|
||||
const val FEED_LIMIT = 100
|
||||
const val LEADERBOARD_LIMIT = 50
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
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.PaddingValues
|
||||
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.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.RustServerDto
|
||||
import com.runicgateway.app.ui.PollWhileResumed
|
||||
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
|
||||
|
||||
/**
|
||||
* Every Rust server this site follows — the module's landing page (D8, D12).
|
||||
*
|
||||
* **The phase criterion is this screen with every server off.** Nothing here is a
|
||||
* live call to a game host: the website answers from its own tables, so a fleet
|
||||
* that has been down for a week renders a week of last-known state rather than an
|
||||
* error. The one thing that can fail is the website itself.
|
||||
*/
|
||||
@Composable
|
||||
fun RustServersScreen(
|
||||
onOpenServer: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RustServersViewModel = hiltViewModel(),
|
||||
) {
|
||||
val polled by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
PollWhileResumed { viewModel.refresh() }
|
||||
|
||||
when (val s = polled.state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> {
|
||||
if (s.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.rust_servers_empty), modifier)
|
||||
} else {
|
||||
ServerList(s.data, polled.refreshFailed, onOpenServer, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerList(
|
||||
servers: List<RustServerDto>,
|
||||
refreshFailed: Boolean,
|
||||
onOpenServer: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// A failed refresh says so and changes nothing else. The rows below it are
|
||||
// the last good answer and stay exactly as they were — blanking them is
|
||||
// the one thing a site whose premise is "it renders while the game is off"
|
||||
// must not do when a request fails.
|
||||
if (refreshFailed) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.rust_refresh_failed),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(servers, key = { it.id }) { server ->
|
||||
ServerRow(server) { onOpenServer(server.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) {
|
||||
ShardCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen)) {
|
||||
// `ShardCard` is the themed Card and nothing more — it carries no padding
|
||||
// of its own, so every caller pads its own content. Without this the text
|
||||
// sits flush against the card's edge and the first glyph of each line
|
||||
// reads as clipped, which is what the walk saw.
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = server.name.ifBlank { server.id },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// `online` already has staleness folded into it server-side — a row
|
||||
// nobody has written recently cannot claim a server is up — so this
|
||||
// renders the field rather than second-guessing it.
|
||||
if (server.online) {
|
||||
StatusPill(
|
||||
text = stringResource(
|
||||
R.string.rust_online_count,
|
||||
server.players,
|
||||
server.maxPlayers,
|
||||
),
|
||||
tone = PillTone.Success,
|
||||
)
|
||||
} else {
|
||||
StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral)
|
||||
}
|
||||
}
|
||||
|
||||
val world = describeWorld(server)
|
||||
if (world != null) {
|
||||
Text(
|
||||
text = world,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
nextWipeLine(server)?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = lastReported(server),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The world line — the things a Rust player asks first.
|
||||
*
|
||||
* Null when the server has never described itself, so the caller leaves the line
|
||||
* out rather than printing an empty one. A server configured this morning that
|
||||
* has not connected yet is in exactly that state, and it is not an error.
|
||||
*/
|
||||
@Composable
|
||||
internal fun describeWorld(server: RustServerDto): String? {
|
||||
val parts = listOfNotNull(
|
||||
server.level,
|
||||
server.worldSize?.let { stringResource(R.string.rust_world_size, it) },
|
||||
server.seed?.let { stringResource(R.string.rust_world_seed, it) },
|
||||
wipeDay(server.wipedAt)?.let { stringResource(R.string.rust_wiped_on, it) },
|
||||
)
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
|
||||
}
|
||||
|
||||
/**
|
||||
* "Next wipe Thu 1 Oct, 19:00 · in 6 days", with "(rescheduled)" for a one-off
|
||||
* date — or null, and the caller draws no line, when the server has no schedule
|
||||
* or the module predates phase 16 and sends no field.
|
||||
*/
|
||||
@Composable
|
||||
internal fun nextWipeLine(server: RustServerDto): String? {
|
||||
val next = server.nextWipe ?: return null
|
||||
val whenText = nextWipeWhen(next.at) ?: return null
|
||||
return if (next.source == "once") {
|
||||
stringResource(R.string.rust_next_wipe_rescheduled, whenText)
|
||||
} else {
|
||||
stringResource(R.string.rust_next_wipe, whenText)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "last reported 3 minutes ago".
|
||||
*
|
||||
* **Reads `lastSeenAt` and never `updatedAt`.** The module shipped that exact
|
||||
* confusion and fixed it in phase 4: `updatedAt` moves on every poll including a
|
||||
* failed one, so an offline server claimed it had just checked in, every thirty
|
||||
* seconds, for as long as it stayed down. Only a frame moves `lastSeenAt`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun lastReported(server: RustServerDto): String {
|
||||
val ago = rustAgo(server.lastSeenAt)
|
||||
?: return stringResource(R.string.rust_never_reported)
|
||||
|
||||
return if (server.stale) {
|
||||
stringResource(R.string.rust_last_reported_stale, ago)
|
||||
} else {
|
||||
stringResource(R.string.rust_last_reported, ago)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.ui.Polled
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.refreshInto
|
||||
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 Rust server list — the module's landing page, one tier along (PLAN.md §9
|
||||
* M14; `docs/modules/rust/PLAN.md` D12).
|
||||
*
|
||||
* **`toUiState`, not `toShardUiState`.** These routes carry no `requireFeature`
|
||||
* gate, so a `404` here is a genuinely missing thing and never an admin's
|
||||
* visibility switch. Offering "this shard doesn't publish it" for one would name
|
||||
* a cause that does not exist on this surface.
|
||||
*
|
||||
* [refresh] is what the screen's poll calls and [load] is what a retry calls, and
|
||||
* the difference is the whole of [refreshInto]: a refresh keeps the rows when it
|
||||
* fails, a load is allowed to blank them because there is nothing on screen to
|
||||
* protect.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RustServersViewModel @Inject constructor(
|
||||
private val repository: RustRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(Polled<List<RustServerDto>>())
|
||||
val state: StateFlow<Polled<List<RustServerDto>>> = _state.asStateFlow()
|
||||
|
||||
/** A first load or a retry: show the spinner, then replace whatever comes back. */
|
||||
fun load() {
|
||||
_state.value = Polled(UiState.Loading)
|
||||
viewModelScope.launch { ask() }
|
||||
}
|
||||
|
||||
/** A poll: silent on success, and it keeps the rows on failure. */
|
||||
fun refresh() {
|
||||
viewModelScope.launch { ask() }
|
||||
}
|
||||
|
||||
private suspend fun ask() {
|
||||
_state.value = refreshInto(_state.value.state, repository.servers())
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,14 @@
|
||||
All user-facing strings are externalized from day one (PLAN.md §2): English is
|
||||
the only bundled locale, but the structure invites community translations.
|
||||
No hardcoded UI strings in Kotlin.
|
||||
|
||||
The strings are split by owner (PLAN.md M20), and Android merges the three files
|
||||
into one R.string: this file is CORE — every screen any deployment can show — and
|
||||
uses core's words only. A game's words live in strings_uo.xml and strings_rust.xml.
|
||||
StringVocabularyTest fails the build when a game's word lands in the wrong file.
|
||||
-->
|
||||
<resources>
|
||||
<!-- Fixed launcher name, baked at build even though in-app branding is per-shard (PLAN.md §13). -->
|
||||
<!-- Fixed launcher name, baked at build even though in-app branding is per-site (PLAN.md §13). -->
|
||||
<string name="app_name">Runic Gateway</string>
|
||||
|
||||
<!-- ── Shared actions / errors ─────────────────────────────────────── -->
|
||||
@@ -15,22 +20,31 @@
|
||||
<string name="error_network">Can\'t reach the site. Check your connection and try again.</string>
|
||||
<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_shard_offline">The game server is offline right now.</string>
|
||||
<string name="error_feature_unavailable">This site doesn\'t publish this here.</string>
|
||||
<string name="error_server">Something went wrong on the server. Please try again.</string>
|
||||
<!-- The public demo's refusal (DEMO_MODE.md §3.1): the server's own words, used when its answer carries none. -->
|
||||
<string name="error_demo_locked">This is turned off on the demo site.</string>
|
||||
|
||||
<!-- The public demo's strip on Home (DEMO_MODE.md §6). The time is said in UTC, like the web's. -->
|
||||
<string name="demo_banner_title">Demo site</string>
|
||||
<string name="demo_banner_wipe_at">" — anything you change is wiped at %1$s."</string>
|
||||
<string name="demo_banner_wipe_soon">" — anything you change is wiped every two hours."</string>
|
||||
<string name="demo_banner_logins_at">Sign-ins: %1$s — passwords at %2$s.</string>
|
||||
<string name="demo_banner_logins">Sign-ins: %1$s — passwords on the site\'s sign-in page.</string>
|
||||
|
||||
<!-- ── First-run connect (§3) ──────────────────────────────────────── -->
|
||||
<string name="connect_title">Connect to your shard</string>
|
||||
<string name="connect_subtitle">Enter your shard\'s website address to get started.</string>
|
||||
<string name="connect_title">Connect to your community</string>
|
||||
<string name="connect_subtitle">Enter your community\'s website address to get started.</string>
|
||||
<string name="connect_url_label">Website address</string>
|
||||
<string name="connect_url_hint">https://your-shard.example.com</string>
|
||||
<string name="connect_url_hint">https://your-community.example.com</string>
|
||||
<string name="connect_button">Connect</string>
|
||||
<string name="connect_connecting">Connecting…</string>
|
||||
<string name="connect_error_blank">Enter a website address.</string>
|
||||
<string name="connect_error_malformed">That doesn\'t look like a valid web address.</string>
|
||||
<string name="connect_error_scheme">Only http and https addresses are supported.</string>
|
||||
<string name="connect_error_insecure">A secure https address is required.</string>
|
||||
<string name="connect_error_not_runic">That site isn\'t a Runic Gateway shard.</string>
|
||||
<string name="connect_error_not_runic">That site isn\'t a Runic Gateway site.</string>
|
||||
<string name="connect_error_version">This app is out of date for that site (it speaks API %1$s). Update the app and try again.</string>
|
||||
<string name="connect_error_unreachable">Couldn\'t reach that site. Check the address and your connection.</string>
|
||||
<string name="connect_error_server">The site responded with an error (%1$d). Try again shortly.</string>
|
||||
@@ -43,22 +57,12 @@
|
||||
<string name="menu_news">News</string>
|
||||
<string name="menu_events">Events</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>
|
||||
<string name="menu_my_events">My events</string>
|
||||
<string name="menu_my_characters">My characters</string>
|
||||
<string name="menu_my_vendors">My vendors</string>
|
||||
<string name="menu_my_houses">My houses</string>
|
||||
<string name="menu_admin_dashboard">Dashboard</string>
|
||||
<string name="menu_admin_content">Content</string>
|
||||
<string name="menu_admin_moderation">Moderation</string>
|
||||
<string name="menu_admin_support">Support queue</string>
|
||||
<string name="menu_sign_in">Sign in</string>
|
||||
<string name="menu_sign_out">Sign out</string>
|
||||
<string name="menu_change_server">Change server</string>
|
||||
@@ -109,41 +113,9 @@
|
||||
<string name="admin_content_title_required">A title is required.</string>
|
||||
<string name="admin_content_cat_fields_required">Slug and title are required.</string>
|
||||
|
||||
<!-- Staff moderation (shard write plane) -->
|
||||
<string name="admin_mod_account_action">Account action</string>
|
||||
<string name="admin_mod_account">Account</string>
|
||||
<string name="admin_mod_serial">Serial (0x…)</string>
|
||||
<string name="admin_mod_reason">Reason (ban)</string>
|
||||
<string name="admin_mod_duration">Ban duration (seconds; blank = indefinite)</string>
|
||||
<string name="admin_mod_kick">Kick</string>
|
||||
<string name="admin_mod_ban">Ban</string>
|
||||
<string name="admin_mod_unban">Unban</string>
|
||||
<string name="admin_mod_broadcast_section">Broadcast</string>
|
||||
<string name="admin_mod_broadcast_text">Message to everyone online</string>
|
||||
<string name="admin_mod_broadcast">Broadcast</string>
|
||||
<string name="admin_mod_kicked">Account kicked.</string>
|
||||
<string name="admin_mod_banned">Account banned.</string>
|
||||
<string name="admin_mod_unbanned">Ban cleared.</string>
|
||||
<string name="admin_mod_broadcasted">Message broadcast.</string>
|
||||
<string name="admin_mod_target_required">Enter an account or serial.</string>
|
||||
<string name="admin_mod_text_required">Enter a message to broadcast.</string>
|
||||
<string name="admin_mod_shard_offline">The shard is offline — the action couldn\'t be delivered.</string>
|
||||
|
||||
<!-- Staff support queue -->
|
||||
<string name="admin_support_empty">No open help pages.</string>
|
||||
<string name="admin_support_reply">Reply</string>
|
||||
<string name="admin_support_close">Close</string>
|
||||
<string name="admin_support_send">Send</string>
|
||||
<string name="admin_support_message">Reply message</string>
|
||||
<string name="admin_support_close_after">Close the page after replying</string>
|
||||
<string name="admin_support_responded">Reply sent.</string>
|
||||
<string name="admin_support_closed">Page closed.</string>
|
||||
<string name="admin_support_message_required">Enter a reply message.</string>
|
||||
<string name="admin_support_unknown_page">That page is no longer in the queue.</string>
|
||||
|
||||
<!-- ── Auth: login (§4.1) ──────────────────────────────────────────── -->
|
||||
<string name="login_title">Sign in</string>
|
||||
<string name="login_subtitle">Sign in with your shard account.</string>
|
||||
<string name="login_subtitle">Sign in with your account on this site.</string>
|
||||
<string name="login_username">Username</string>
|
||||
<string name="login_password">Password</string>
|
||||
<string name="login_totp_code">Authentication code</string>
|
||||
@@ -151,7 +123,7 @@
|
||||
<string name="login_button">Sign in</string>
|
||||
<string name="login_register">Create an account</string>
|
||||
<string name="login_forgot">Forgot your password?</string>
|
||||
<!-- Single SSO entry point; the picker lists the shard's providers (native SSO, M9/M10). -->
|
||||
<!-- Single SSO entry point; the picker lists the site's providers (native SSO, M9/M10). -->
|
||||
<string name="login_sso_button">Sign in with SSO</string>
|
||||
<string name="login_sso_pick_title">Choose a sign-in provider</string>
|
||||
<!-- %1$s is the provider name, e.g. "Google" or "Discord". -->
|
||||
@@ -207,6 +179,8 @@
|
||||
<string name="account_identities_title">Linked accounts</string>
|
||||
<string name="account_identities_empty">No linked sign-in providers.</string>
|
||||
<string name="account_identity_unlink">Unlink</string>
|
||||
<!-- A provider that reports a name (Steam): "Steam: CoolGuy99". -->
|
||||
<string name="account_identity_named">%1$s: %2$s</string>
|
||||
<string name="account_identity_unlinked">Account unlinked.</string>
|
||||
<string name="account_identity_error">Couldn\'t unlink that account.</string>
|
||||
|
||||
@@ -223,6 +197,38 @@
|
||||
<string name="account_security_trusted_devices">Trusted devices</string>
|
||||
<string name="account_security_recovery_codes">Recovery codes</string>
|
||||
|
||||
<!-- Delete my account (ACCOUNT_DELETION.md §4.3). The same words as the website's lib/accountDeletion.js. -->
|
||||
<string name="account_delete_title">Delete my account</string>
|
||||
<string name="account_delete_row_hint">Deletes your account straight away. It cannot be restored.</string>
|
||||
<string name="account_delete_open">Delete my account…</string>
|
||||
<string name="account_delete_intro">Your account is deleted straight away and cannot be restored. These go with it:</string>
|
||||
<string name="account_delete_goes_sign_in">your sign-in, password, email address, two-factor settings and recovery codes</string>
|
||||
<string name="account_delete_goes_devices">every signed-in device, trusted device and push registration</string>
|
||||
<string name="account_delete_goes_identities">your linked sign-ins, such as Google, Discord or Steam</string>
|
||||
<string name="account_delete_goes_game_links">your game-account links, and anything this site granted in the game through them</string>
|
||||
<string name="account_delete_goes_notifications">your notification settings and inbox</string>
|
||||
<string name="account_delete_stays_title">What stays:</string>
|
||||
<string name="account_delete_stays_content">What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”.</string>
|
||||
<string name="account_delete_stays_reports">Reports you filed and appeals you made stay as moderation records. Staff can still see who it was, and the activity log keeps its IP addresses.</string>
|
||||
<string name="account_delete_stays_events">Event results stay with the score and the in-game name, with no site account behind them.</string>
|
||||
<string name="account_delete_stays_game_account">A game account stays on the game server. Only its link to this site goes.</string>
|
||||
<string name="account_delete_staff_note">You are staff, so your username can never be used again, by anyone, once this account is deleted.</string>
|
||||
<string name="account_delete_no_password">This account has no password yet. Set one in the Password section on My account first; the password is how you confirm a deletion.</string>
|
||||
<string name="account_delete_type_username">Type %1$s to confirm</string>
|
||||
<string name="account_delete_recovery_code">Recovery code</string>
|
||||
<string name="account_delete_use_recovery">Use a recovery code instead</string>
|
||||
<string name="account_delete_use_authenticator">Use an authenticator code instead</string>
|
||||
<string name="account_delete_action">Delete my account</string>
|
||||
<string name="account_delete_busy">Deleting…</string>
|
||||
<string name="account_delete_done">Your account has been deleted.</string>
|
||||
<string name="account_delete_error_username">Type your username exactly as it is shown.</string>
|
||||
<string name="account_delete_error_password">Your password is incorrect.</string>
|
||||
<string name="account_delete_error_code_required">Enter a code from your authenticator app, or a recovery code.</string>
|
||||
<string name="account_delete_error_code">That code is not valid. Try again.</string>
|
||||
<string name="account_delete_error_last_admin">You are the only admin. Make someone else an admin before you delete your account.</string>
|
||||
<string name="account_delete_error_rate_limited">Too many attempts. Wait a few minutes and try again.</string>
|
||||
<string name="account_delete_error">Could not delete your account. Try again.</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>
|
||||
@@ -253,77 +259,7 @@
|
||||
<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>
|
||||
<string name="player_link_code">Link code</string>
|
||||
<string name="player_link_action">Link account</string>
|
||||
<string name="player_link_ok">Account linked.</string>
|
||||
<string name="player_link_bad_code">That code is unknown or has expired. Run [link in game for a new one.</string>
|
||||
<string name="player_link_error">Couldn\'t link that code. Please try again.</string>
|
||||
|
||||
<string name="player_create_title">Create a game account</string>
|
||||
<string name="player_create_account">Account name</string>
|
||||
<string name="player_create_password">Password</string>
|
||||
<string name="player_create_action">Create account</string>
|
||||
<string name="player_create_ok">Game account created and linked.</string>
|
||||
<string name="player_create_taken">That account name is already taken.</string>
|
||||
<string name="player_create_capped">The account limit for your network has been reached.</string>
|
||||
<string name="player_create_unavailable">Game-account signup isn\'t available right now.</string>
|
||||
<string name="player_create_rejected">That account name or password wasn\'t accepted.</string>
|
||||
<string name="player_create_error">Couldn\'t create that account. Please try again.</string>
|
||||
|
||||
<!-- ── Player: characters (§6.3) ───────────────────────────────────── -->
|
||||
<string name="player_characters_empty">Link a game account above to see your characters.</string>
|
||||
<string name="player_roster_loading">Loading…</string>
|
||||
<string name="player_roster_empty">No characters on this account.</string>
|
||||
<string name="player_char_unknown">Unknown</string>
|
||||
<string name="player_char_online">Online</string>
|
||||
<string name="player_char_offline">Offline</string>
|
||||
|
||||
<!-- Character sheet -->
|
||||
<string name="player_char_governor">Governor of %1$s</string>
|
||||
<string name="player_char_guildmaster">Guildmaster, %1$s</string>
|
||||
<string name="player_char_attributes">Attributes</string>
|
||||
<string name="player_char_vitals">Vitals</string>
|
||||
<string name="player_char_str">STR</string>
|
||||
<string name="player_char_dex">DEX</string>
|
||||
<string name="player_char_int">INT</string>
|
||||
<string name="player_char_hits">Hits</string>
|
||||
<string name="player_char_mana">Mana</string>
|
||||
<string name="player_char_stam">Stamina</string>
|
||||
<string name="player_char_resistances">Resistances</string>
|
||||
<string name="player_char_phys">Physical</string>
|
||||
<string name="player_char_fire">Fire</string>
|
||||
<string name="player_char_cold">Cold</string>
|
||||
<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>
|
||||
<string name="player_char_item_hue">hue %1$d</string>
|
||||
|
||||
<!-- ── Player: vendors & sales (§6.3) ──────────────────────────── -->
|
||||
<string name="player_sales_title">Recent sales</string>
|
||||
<string name="player_sales_empty">No recent sales.</string>
|
||||
<string name="player_gold_amount">%1$s gp</string>
|
||||
<string name="player_vendors_no_accounts">Link a game account to see your vendors.</string>
|
||||
<string name="player_vendors_empty">No vendors on this account.</string>
|
||||
<string name="player_vendor_fallback">Player vendor</string>
|
||||
<string name="player_vendor_hold">Holding %1$s gp</string>
|
||||
<string name="player_vendor_no_listings">No items listed.</string>
|
||||
<string name="player_listing_item">Item %1$d ×%2$d</string>
|
||||
|
||||
<!-- ── Player: houses (§6.3) ───────────────────────────────────────── -->
|
||||
<string name="player_houses_empty">You don\'t have any houses on your linked accounts.</string>
|
||||
<string name="player_house_fallback">A house</string>
|
||||
<string name="player_house_stage">Status: %1$s</string>
|
||||
<!-- ── Roles (the account screen, §5) ──────────────────────────────── -->
|
||||
<string name="role_player">Player</string>
|
||||
<string name="role_moderator">Moderator</string>
|
||||
<string name="role_editor">Editor</string>
|
||||
@@ -362,140 +298,19 @@
|
||||
<string name="contact_validation">Please fill in every field.</string>
|
||||
<string name="contact_error">Couldn\'t send your message. Please try again.</string>
|
||||
|
||||
<!-- ── Shard hub & live feed (§6.2) ─────────────────────────────── -->
|
||||
<string name="shard_live">Live</string>
|
||||
<string name="shard_offline">Offline</string>
|
||||
<string name="shard_status_online">Shard online</string>
|
||||
<string name="shard_status_offline">Shard offline</string>
|
||||
<string name="shard_online_count">%1$d players online</string>
|
||||
<string name="shard_economy_gold">Gold supply: %1$s</string>
|
||||
<string name="shard_section_staff">Staff online</string>
|
||||
<string name="shard_section_activity">Live activity</string>
|
||||
<string name="shard_feed_empty">No recent activity.</string>
|
||||
<string name="shard_nav_champs">Champion spawns</string>
|
||||
<string name="shard_nav_guilds">Guilds</string>
|
||||
<string name="shard_nav_governors">Governors</string>
|
||||
<string name="shard_nav_houses">Falling houses</string>
|
||||
|
||||
<!-- ── Champion spawns (§6.2) ──────────────────────────────────────── -->
|
||||
<string name="champs_empty">No champion spawns are being tracked right now.</string>
|
||||
<string name="champs_fallback_name">Champion spawn</string>
|
||||
<string name="champ_level">Level %1$d</string>
|
||||
<string name="champ_cooldown">Restarting</string>
|
||||
|
||||
<!-- ── Guilds (§6.2) ───────────────────────────────────────────────── -->
|
||||
<string name="guilds_empty">No guilds are being tracked right now.</string>
|
||||
<string name="guilds_fallback_name">Guild</string>
|
||||
<string name="guilds_members">%1$d members</string>
|
||||
<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>
|
||||
<string name="governor_none">No governor</string>
|
||||
<string name="governor_election">Election: %1$s</string>
|
||||
<string name="governor_history">Term history</string>
|
||||
<string name="governor_history_loading">Loading…</string>
|
||||
<string name="governor_history_empty">No past terms recorded.</string>
|
||||
|
||||
<!-- ── Falling houses / IDOC (§6.2) ────────────────────────────────── -->
|
||||
<string name="houses_empty">No houses are in danger right now.</string>
|
||||
<string name="houses_fallback_name">A house</string>
|
||||
<string name="houses_idoc_badge">IDOC</string>
|
||||
|
||||
<!-- ── Push notifications (§11, M7 Part 2) ─────────────────────────── -->
|
||||
<string name="menu_notifications">Notifications</string>
|
||||
<string name="notifications_title">Notifications</string>
|
||||
<string name="notifications_subtitle">Choose what this shard notifies you about, and how it reaches you. Nothing is sent unless you turn it on.</string>
|
||||
<string name="notifications_subtitle">Choose what this site notifies you about, and how it reaches you. Nothing is sent unless you turn it on.</string>
|
||||
<string name="notifications_section_general">General</string>
|
||||
<string name="notifications_section_personal">Your game account</string>
|
||||
<string name="notifications_requires_link">Link a game account to enable this.</string>
|
||||
<string name="notifications_empty">This shard offers no notification streams yet.</string>
|
||||
<string name="notifications_unsupported">This shard hasn\'t set up push notifications yet.</string>
|
||||
<string name="notifications_requires_link">Link a game account to get this as a push notification.</string>
|
||||
<string name="notifications_empty">This site offers no notification streams yet.</string>
|
||||
<string name="notifications_unsupported">This site hasn\'t set up push notifications yet.</string>
|
||||
<string name="notifications_saved">Notification settings saved.</string>
|
||||
<string name="notifications_all_off">Notifications turned off.</string>
|
||||
<string name="notifications_save_error">Couldn\'t save your notification settings. Try again.</string>
|
||||
<string name="notifications_relay_error">This shard\'s push relay isn\'t reachable right now.</string>
|
||||
<string name="notifications_relay_error">This site\'s push relay isn\'t reachable right now.</string>
|
||||
|
||||
<!-- The in-app inbox and the per-channel settings (ENGAGEMENT.md phase 8). -->
|
||||
<string name="notifications_mode_off">Off</string>
|
||||
@@ -510,22 +325,15 @@
|
||||
<string name="inbox_offline_cached">Offline — showing what was saved on this device.</string>
|
||||
|
||||
<!-- Notification channels + the ongoing foreground-service notification. -->
|
||||
<string name="push_channel_messages">Shard notifications</string>
|
||||
<string name="push_channel_messages_desc">Alerts you opted into from this shard.</string>
|
||||
<string name="push_channel_messages">Notifications from your community</string>
|
||||
<string name="push_channel_messages_desc">Alerts you opted into from this site.</string>
|
||||
<string name="push_channel_service">Background connection</string>
|
||||
<string name="push_channel_service_desc">Keeps the connection open to deliver notifications.</string>
|
||||
<string name="push_service_title">Notifications active</string>
|
||||
<string name="push_service_text">Listening for shard notifications.</string>
|
||||
<string name="push_service_text">Listening for notifications from your community.</string>
|
||||
|
||||
<!-- Per-stream notification titles (content-free tickle → generic title, §11). -->
|
||||
<string name="push_stream_news_post">New post</string>
|
||||
<string name="push_stream_server_status">Shard status changed</string>
|
||||
<string name="push_stream_idoc_warning">A house is falling (IDOC)</string>
|
||||
<string name="push_stream_champ_start">Champion spawn started</string>
|
||||
<string name="push_stream_governor_election">New governor elected</string>
|
||||
<string name="push_stream_vendor_sale">Your vendor made a sale</string>
|
||||
<string name="push_stream_house_idoc">Your house entered IDOC</string>
|
||||
<string name="push_stream_account_login">Login to your account</string>
|
||||
<string name="push_stream_generic">New notification</string>
|
||||
|
||||
<!-- ── Events (§9 M13, EVENTS.md §I) ─────────────────────────── -->
|
||||
@@ -549,6 +357,8 @@
|
||||
<string name="events_part_of">Part of %1$s</string>
|
||||
<string name="events_next">Next</string>
|
||||
<string name="events_under_way">Under way</string>
|
||||
<string name="events_progress_count">%1$s: %2$d of %3$d left</string>
|
||||
<string name="events_progress_percent">%1$s: %2$d%%</string>
|
||||
<string name="events_nothing_scheduled">Nothing scheduled at the moment.</string>
|
||||
<string name="events_never_scheduled">This event has not been scheduled yet.</string>
|
||||
<string name="events_coming_up">Coming up</string>
|
||||
@@ -566,4 +376,27 @@
|
||||
<string name="events_score">Score %1$s</string>
|
||||
<string name="events_show_more">Show more</string>
|
||||
<string name="events_loading">Loading…</string>
|
||||
|
||||
<!-- ── Account → Blocked (USER_SAFETY.md §4.3; the web's BlockedPanel.jsx) ─ -->
|
||||
<string name="blocked_title">Blocked</string>
|
||||
<string name="blocked_row_hint">Players and members you blocked, and anything you hid for yourself.</string>
|
||||
<string name="blocked_open">Blocked players</string>
|
||||
<string name="blocked_subtitle">You don’t see anything a blocked member or player writes — forum posts and in-game chat — in the app or on the website. They aren’t told. Staff can’t be blocked.</string>
|
||||
<string name="blocked_empty">You haven’t blocked anyone.</string>
|
||||
<string name="blocked_unknown_player">Unknown player</string>
|
||||
<string name="blocked_kind_account">Account</string>
|
||||
<string name="blocked_kind_game">%1$s %2$s, not linked to an account</string>
|
||||
<string name="blocked_subtitle_when">%1$s · blocked %2$s</string>
|
||||
<string name="blocked_not_applied_staff">Not applied: this account is now a staff member, and staff can’t be blocked.</string>
|
||||
<string name="blocked_unblock">Unblock</string>
|
||||
<string name="blocked_unblocked">Unblocked.</string>
|
||||
<string name="blocked_unblocked_named">%1$s is unblocked.</string>
|
||||
<string name="blocked_unblock_failed">Could not unblock that.</string>
|
||||
<plurals name="blocked_hidden_count">
|
||||
<item quantity="one">You’ve hidden %d item for yourself.</item>
|
||||
<item quantity="other">You’ve hidden %d items for yourself.</item>
|
||||
</plurals>
|
||||
<string name="blocked_show_hidden">Show them again</string>
|
||||
<string name="blocked_hidden_cleared">Everything you hid is shown again.</string>
|
||||
<string name="blocked_hidden_failed">Could not show those again.</string>
|
||||
</resources>
|
||||
|
||||
201
app/src/main/res/values/strings_rust.xml
Normal file
201
app/src/main/res/values/strings_rust.xml
Normal file
@@ -0,0 +1,201 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<!--
|
||||
The Rust module's screens (PLAN.md M20): the server list and server page, the map,
|
||||
and the player's own Rust account. Every row that reaches these is gated on the rust
|
||||
capability, so Rust's words are right here. Core strings live in strings.xml.
|
||||
-->
|
||||
<resources>
|
||||
<!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
|
||||
<string name="menu_rust">Rust servers</string>
|
||||
|
||||
<!-- Rust module (M14, docs/modules/rust/PLAN.md §18) -->
|
||||
<string name="rust_servers_empty">No Rust servers are configured on this site yet.</string>
|
||||
<string name="rust_offline">Offline</string>
|
||||
<string name="rust_online_count">%1$d / %2$d online</string>
|
||||
<string name="rust_never_reported">has never reported</string>
|
||||
<string name="rust_last_reported">last reported %1$s</string>
|
||||
<string name="rust_last_reported_stale">last reported %1$s — out of date, so it is shown as offline</string>
|
||||
<string name="rust_world_size">size %1$d</string>
|
||||
<string name="rust_world_seed">seed %1$d</string>
|
||||
<string name="rust_wiped_on">wiped %1$s</string>
|
||||
<string name="rust_next_wipe">Next wipe %1$s</string>
|
||||
<string name="rust_next_wipe_rescheduled">Next wipe %1$s (rescheduled)</string>
|
||||
<string name="rust_refresh_failed">Could not refresh just now. This is the last thing the site heard.</string>
|
||||
<string name="rust_no_such_server">No such server</string>
|
||||
<string name="rust_no_such_server_detail">This address does not name a server this site follows.</string>
|
||||
<string name="rust_back_to_servers">Back to the server list</string>
|
||||
<string name="rust_tab_feed">Feed</string>
|
||||
<string name="rust_tab_leaderboard">Leaderboard</string>
|
||||
<string name="rust_tab_online">Online</string>
|
||||
<string name="rust_tab_wipes">Wipes</string>
|
||||
<string name="rust_tab_map">Map</string>
|
||||
<!-- The Map tab (Rust phase 15, D121–D125). The wording follows the web's Map
|
||||
tab, so a reader moving between the two reads the same sentences. -->
|
||||
<string name="rust_map_none">No map yet. This server has not told the site which map it is on. The map will appear once the server is up.</string>
|
||||
<string name="rust_map_no_picture">This server has no picture of its map, so the layers are drawn on a plain background.</string>
|
||||
<string name="rust_map_picture_failed">The picture of the map could not be loaded, so the layers are drawn on a plain background.</string>
|
||||
<string name="rust_map_description">Map of the server</string>
|
||||
<string name="rust_map_status_nothing_live">No moving layers are shown to you on this server.</string>
|
||||
<string name="rust_map_status_asking">Asking the server where things are…</string>
|
||||
<string name="rust_map_status_not_now">The server did not say where things are just now. The site asks again every ten seconds.</string>
|
||||
<string name="rust_map_status_offline">The server is offline, so nothing is moving on its map.</string>
|
||||
<string name="rust_map_status_failed">Positions could not be loaded.</string>
|
||||
<string name="rust_map_status_failed_kept">Could not refresh just now. These are the last positions the site heard.</string>
|
||||
<string name="rust_map_status_as_of">Positions as of %1$s. They refresh every ten seconds while this tab is open.</string>
|
||||
<string name="rust_map_layer_grid">Grid</string>
|
||||
<string name="rust_map_layer_world">Monuments & world events</string>
|
||||
<string name="rust_map_layer_events">Site events</string>
|
||||
<string name="rust_map_layer_players">Players</string>
|
||||
<string name="rust_map_layer_bases">Bases</string>
|
||||
<string name="rust_map_layer_mates">You and your clan</string>
|
||||
<string name="rust_map_hidden_signin">Sign in to see this layer.</string>
|
||||
<string name="rust_map_hidden_public">Shown to everyone.</string>
|
||||
<string name="rust_map_hidden_staff">Shown to staff only.</string>
|
||||
<string name="rust_map_capped">%1$s Limited by who may see who is online.</string>
|
||||
<string name="rust_map_players_truncated">Not every sleeper is shown.</string>
|
||||
<string name="rust_map_bases_truncated">Not every base is shown.</string>
|
||||
<string name="rust_map_mates_note">Your own position, and clan mates who are online.</string>
|
||||
<string name="rust_map_mates_link">Link your Steam account to see yourself and your clan here.</string>
|
||||
<string name="rust_map_cargo">Cargo ship</string>
|
||||
<string name="rust_map_heli">Patrol helicopter</string>
|
||||
<string name="rust_map_chinook">Chinook</string>
|
||||
<string name="rust_map_bradley">Bradley APC</string>
|
||||
<string name="rust_map_supply">Supply drop</string>
|
||||
<string name="rust_map_crate">Locked crate</string>
|
||||
<string name="rust_map_crate_hack">%1$s left on the hack</string>
|
||||
<string name="rust_map_crate_hacked">Hacked</string>
|
||||
<string name="rust_map_event_zone">Event zone</string>
|
||||
<string name="rust_map_event_crate">Event crate</string>
|
||||
<string name="rust_map_event_npc">Event NPC</string>
|
||||
<string name="rust_map_site_event">Site event</string>
|
||||
<string name="rust_map_site_event_named">Site event · %1$s</string>
|
||||
<string name="rust_map_boss">%1$s (boss)</string>
|
||||
<string name="rust_map_boss_unnamed">Boss</string>
|
||||
<string name="rust_map_boss_add">%1$s (a boss\'s add)</string>
|
||||
<string name="rust_map_open_event">Open event</string>
|
||||
<string name="rust_map_tc">Tool cupboard</string>
|
||||
<string name="rust_map_vending">Vending machine</string>
|
||||
<string name="rust_map_player_sleeping">Sleeping</string>
|
||||
<string name="rust_map_player_offline">Asleep, offline</string>
|
||||
<string name="rust_map_you">You</string>
|
||||
<string name="rust_map_clan_mate">Clan mate</string>
|
||||
<string name="rust_map_grid">Grid %1$s</string>
|
||||
<string name="rust_map_close">Close</string>
|
||||
<string name="rust_all_time">All time</string>
|
||||
<string name="rust_wipe_current">%1$s (this wipe)</string>
|
||||
<string name="rust_wipe_this_one">Current</string>
|
||||
<string name="rust_wipe_selected">Showing</string>
|
||||
<string name="rust_feed_empty">Nothing has happened on this server yet — or not during the wipe you are looking at.</string>
|
||||
<string name="rust_leaderboard_empty">Nobody has scored here yet.</string>
|
||||
<string name="rust_wipes_empty">This server has not reported a wipe yet.</string>
|
||||
<!-- Nothing names who is online by default (org lead, 2026-09-22). Below the
|
||||
operator's audience the server sends a count and no names. -->
|
||||
<plurals name="rust_online_hidden_count">
|
||||
<item quantity="one">%1$d player online</item>
|
||||
<item quantity="other">%1$d players online</item>
|
||||
</plurals>
|
||||
<string name="rust_online_hidden_staff">Only this site’s staff can see who they are.</string>
|
||||
<string name="rust_online_hidden_signin">Sign in to see who they are.</string>
|
||||
<string name="rust_online_hidden_public">This site is not showing who they are right now.</string>
|
||||
<string name="rust_feed_presence_hidden_staff">Joins, deaths and chat are not shown. Only this site’s staff can see what players did.</string>
|
||||
<string name="rust_feed_presence_hidden_signin">Joins, deaths and chat are not shown. Sign in to see what players did.</string>
|
||||
<string name="rust_nobody_on">The server is up and the island is empty. Somebody has to be first.</string>
|
||||
<string name="rust_presence_offline">Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.</string>
|
||||
<string name="rust_presence_live">On the server right now.</string>
|
||||
<string name="rust_presence_last_known">The last board this server sent. It is offline, so this is who was on then — not who is on now.</string>
|
||||
<string name="rust_sleeping">Sleeping</string>
|
||||
<string name="rust_col_player">Player</string>
|
||||
<string name="rust_col_kills">Kills</string>
|
||||
<string name="rust_col_deaths">Deaths</string>
|
||||
<string name="rust_col_npc_kills">NPC</string>
|
||||
<string name="rust_col_structures">Built</string>
|
||||
<string name="rust_col_played">Played</string>
|
||||
<string name="rust_online_badge">%1$d players online</string>
|
||||
|
||||
<!-- The player's own Rust account (M15 — module-rust phase 8) -->
|
||||
<string name="menu_rust_account">My Rust account</string>
|
||||
<string name="rust_link_title">Link your Steam account</string>
|
||||
<string name="rust_link_hint">Join any of our Rust servers and type /link in chat. The server replies with a six-character code only you can see, good for five minutes. Enter it here — it works once.</string>
|
||||
<string name="rust_link_code">Link code</string>
|
||||
<string name="rust_link_action">Link account</string>
|
||||
<string name="rust_link_ok">Linked. Your play on our servers now appears under your name here.</string>
|
||||
<string name="rust_link_already">That account was already linked to you.</string>
|
||||
<string name="rust_link_bad_code">That code is unknown or has expired. Type /link in game for a new one.</string>
|
||||
<string name="rust_link_taken">That Steam account is linked to another website account. Run /unlink in game to release it.</string>
|
||||
<string name="rust_link_capped">Too many attempts just now. Try again in a few minutes.</string>
|
||||
<string name="rust_link_unreachable">A server could not be reached, so that code could not be checked. Your code is still good — try again in a minute.</string>
|
||||
<string name="rust_link_error">Could not link that code.</string>
|
||||
<string name="rust_unlink_action">Unlink</string>
|
||||
<string name="rust_unlink_error">Could not unlink that account.</string>
|
||||
<string name="rust_account_linked_when">linked %1$s</string>
|
||||
<string name="rust_account_none">No Steam account is linked to this profile yet.</string>
|
||||
<string name="rust_account_fleet_note">A link covers every server this community runs — a Steam account is one person wherever they play. You can also type /unlink in game.</string>
|
||||
<string name="rust_held_title">What you can do in game</string>
|
||||
<string name="rust_held_empty">Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on the servers they cover.</string>
|
||||
<string name="rust_held_unlinked">None of this reaches the game yet — link a Steam account above and the site pushes it across on its next sync.</string>
|
||||
<string name="rust_held_waiting_note">A server marked waiting has not confirmed it yet. One that is offline catches up when it comes back.</string>
|
||||
<string name="rust_held_no_servers">No servers are configured yet.</string>
|
||||
<string name="rust_coins_title">Coins</string>
|
||||
<string name="rust_coins_unlinked">Link a Steam account to see your balance.</string>
|
||||
<string name="rust_coins_missing">Economics is not installed on this server.</string>
|
||||
<string name="rust_coins_balance">%1$s · %2$s coins</string>
|
||||
<string name="rust_coins_last_known">Last known — the server did not answer just now.</string>
|
||||
<string name="rust_backpack_title">Backpack</string>
|
||||
<string name="rust_backpack_unlinked">Link a Steam account to see your backpack.</string>
|
||||
<string name="rust_backpack_open">Open backpack</string>
|
||||
<string name="rust_backpack_read_again">Read again</string>
|
||||
<string name="rust_backpack_close">Close</string>
|
||||
<string name="rust_backpack_missing">Backpacks is not installed on this server.</string>
|
||||
<string name="rust_backpack_unavailable">This site cannot show backpacks yet.</string>
|
||||
<string name="rust_backpack_none">No backpack here yet — it has never been opened.</string>
|
||||
<string name="rust_backpack_summary">%1$d stacks in %2$d slots</string>
|
||||
<string name="rust_backpack_overflow">%1$d past your size — they move to your inventory, or drop at your feet if it is full, the next time you open it.</string>
|
||||
<string name="rust_backpack_page">Page %1$d</string>
|
||||
<string name="rust_reach_live">%1$s · has it</string>
|
||||
<string name="rust_reach_waiting">%1$s · waiting</string>
|
||||
|
||||
<!--
|
||||
A chat line's long-press menu: report, hide, block (docs/website/USER_SAFETY.md
|
||||
§4.3). The wording follows the website's ChatMenu.jsx, so a member reads one set
|
||||
of sentences in either place. Account → Blocked is core's, in strings.xml.
|
||||
-->
|
||||
<string name="safety_line_actions">Report, hide or block</string>
|
||||
<string name="safety_line_quote">%1$s: %2$s</string>
|
||||
<string name="safety_this_player">this player</string>
|
||||
<string name="safety_sign_in_hint">Sign in to report or block.</string>
|
||||
<string name="safety_sign_in">Sign in</string>
|
||||
<string name="safety_report">Report</string>
|
||||
<string name="safety_hide_for_me">Hide for me</string>
|
||||
<string name="safety_block_player">Block player</string>
|
||||
<string name="safety_hide_for_everyone">Hide for everyone</string>
|
||||
<string name="safety_show_to_everyone">Show to everyone again</string>
|
||||
<string name="safety_hidden_by_staff">Hidden by staff</string>
|
||||
<string name="safety_cancel">Cancel</string>
|
||||
<string name="safety_close">Close</string>
|
||||
|
||||
<string name="safety_report_explainer">This goes to site staff. Reporting does not hide or change anything — it asks a staffer to look.</string>
|
||||
<string name="safety_report_detail">Anything a staffer should know (optional)</string>
|
||||
<string name="safety_report_send">Send report</string>
|
||||
<string name="safety_reported">Reported to site staff.</string>
|
||||
<string name="safety_reason_abuse">Abusive or harassing</string>
|
||||
<string name="safety_reason_spam">Spam</string>
|
||||
<string name="safety_reason_sexual">Sexual content</string>
|
||||
<string name="safety_reason_illegal">Illegal content</string>
|
||||
<string name="safety_reason_impersonation">Impersonation</string>
|
||||
<string name="safety_reason_other">Something else</string>
|
||||
|
||||
<string name="safety_block_confirm">Block %1$s? You won’t see anything they write, here or on the website. They aren’t told. You can undo it from Account → Blocked.</string>
|
||||
<string name="safety_block">Block</string>
|
||||
|
||||
<string name="safety_error_already_reported">You have already reported this. Staff will look at it.</string>
|
||||
<string name="safety_error_already_blocked">You have already blocked this player.</string>
|
||||
<string name="safety_error_gone">That line is no longer there.</string>
|
||||
<string name="safety_error_staff">You can’t block a staff member.</string>
|
||||
<string name="safety_error_forbidden">Your account can’t do that.</string>
|
||||
<string name="safety_error_not_blockable">This player can’t be blocked.</string>
|
||||
<string name="safety_error_rate_limited">Too many reports. Please give staff a chance to look at the ones you have raised.</string>
|
||||
<string name="safety_error_unavailable">The game module did not answer. Try again in a moment.</string>
|
||||
<string name="safety_error_demo">This is turned off on the demo site.</string>
|
||||
<string name="safety_error_other">Could not do that. Try again.</string>
|
||||
</resources>
|
||||
256
app/src/main/res/values/strings_uo.xml
Normal file
256
app/src/main/res/values/strings_uo.xml
Normal file
@@ -0,0 +1,256 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<!--
|
||||
The Ultima Online module's screens (PLAN.md M20): the shard hub and its pages, the
|
||||
player's characters / vendors / houses, staff moderation and the support queue, and
|
||||
the module's push streams. Every row that reaches these is gated on the shard
|
||||
capability, so UO's words are right here. Core strings live in strings.xml.
|
||||
-->
|
||||
<resources>
|
||||
<!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
|
||||
<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_my_characters">My characters</string>
|
||||
<string name="menu_my_vendors">My vendors</string>
|
||||
<string name="menu_my_houses">My houses</string>
|
||||
<string name="menu_admin_moderation">Moderation</string>
|
||||
<string name="menu_admin_support">Support queue</string>
|
||||
|
||||
<!-- ── Staff operations (§1, M10) ──────────────────────────────────── -->
|
||||
<!-- Staff moderation (shard write plane) -->
|
||||
<string name="admin_mod_account_action">Account action</string>
|
||||
<string name="admin_mod_account">Account</string>
|
||||
<string name="admin_mod_serial">Serial (0x…)</string>
|
||||
<string name="admin_mod_reason">Reason (ban)</string>
|
||||
<string name="admin_mod_duration">Ban duration (seconds; blank = indefinite)</string>
|
||||
<string name="admin_mod_kick">Kick</string>
|
||||
<string name="admin_mod_ban">Ban</string>
|
||||
<string name="admin_mod_unban">Unban</string>
|
||||
<string name="admin_mod_broadcast_section">Broadcast</string>
|
||||
<string name="admin_mod_broadcast_text">Message to everyone online</string>
|
||||
<string name="admin_mod_broadcast">Broadcast</string>
|
||||
<string name="admin_mod_kicked">Account kicked.</string>
|
||||
<string name="admin_mod_banned">Account banned.</string>
|
||||
<string name="admin_mod_unbanned">Ban cleared.</string>
|
||||
<string name="admin_mod_broadcasted">Message broadcast.</string>
|
||||
<string name="admin_mod_target_required">Enter an account or serial.</string>
|
||||
<string name="admin_mod_text_required">Enter a message to broadcast.</string>
|
||||
<string name="admin_mod_shard_offline">The shard is offline — the action couldn\'t be delivered.</string>
|
||||
|
||||
<!-- Staff support queue -->
|
||||
<string name="admin_support_empty">No open help pages.</string>
|
||||
<string name="admin_support_reply">Reply</string>
|
||||
<string name="admin_support_close">Close</string>
|
||||
<string name="admin_support_send">Send</string>
|
||||
<string name="admin_support_message">Reply message</string>
|
||||
<string name="admin_support_close_after">Close the page after replying</string>
|
||||
<string name="admin_support_responded">Reply sent.</string>
|
||||
<string name="admin_support_closed">Page closed.</string>
|
||||
<string name="admin_support_message_required">Enter a reply message.</string>
|
||||
<string name="admin_support_unknown_page">That page is no longer in the queue.</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>
|
||||
<string name="player_link_code">Link code</string>
|
||||
<string name="player_link_action">Link account</string>
|
||||
<string name="player_link_ok">Account linked.</string>
|
||||
<string name="player_link_bad_code">That code is unknown or has expired. Run [link in game for a new one.</string>
|
||||
<string name="player_link_error">Couldn\'t link that code. Please try again.</string>
|
||||
|
||||
<string name="player_create_title">Create a game account</string>
|
||||
<string name="player_create_account">Account name</string>
|
||||
<string name="player_create_password">Password</string>
|
||||
<string name="player_create_action">Create account</string>
|
||||
<string name="player_create_ok">Game account created and linked.</string>
|
||||
<string name="player_create_taken">That account name is already taken.</string>
|
||||
<string name="player_create_capped">The account limit for your network has been reached.</string>
|
||||
<string name="player_create_unavailable">Game-account signup isn\'t available right now.</string>
|
||||
<string name="player_create_rejected">That account name or password wasn\'t accepted.</string>
|
||||
<string name="player_create_error">Couldn\'t create that account. Please try again.</string>
|
||||
|
||||
<!-- ── Player: characters (§6.3) ───────────────────────────────────── -->
|
||||
<string name="player_characters_empty">Link a game account above to see your characters.</string>
|
||||
<string name="player_roster_loading">Loading…</string>
|
||||
<string name="player_roster_empty">No characters on this account.</string>
|
||||
<string name="player_char_unknown">Unknown</string>
|
||||
<string name="player_char_online">Online</string>
|
||||
<string name="player_char_offline">Offline</string>
|
||||
|
||||
<!-- Character sheet -->
|
||||
<string name="player_char_governor">Governor of %1$s</string>
|
||||
<string name="player_char_guildmaster">Guildmaster, %1$s</string>
|
||||
<string name="player_char_attributes">Attributes</string>
|
||||
<string name="player_char_vitals">Vitals</string>
|
||||
<string name="player_char_str">STR</string>
|
||||
<string name="player_char_dex">DEX</string>
|
||||
<string name="player_char_int">INT</string>
|
||||
<string name="player_char_hits">Hits</string>
|
||||
<string name="player_char_mana">Mana</string>
|
||||
<string name="player_char_stam">Stamina</string>
|
||||
<string name="player_char_resistances">Resistances</string>
|
||||
<string name="player_char_phys">Physical</string>
|
||||
<string name="player_char_fire">Fire</string>
|
||||
<string name="player_char_cold">Cold</string>
|
||||
<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>
|
||||
<string name="player_char_item_hue">hue %1$d</string>
|
||||
|
||||
<!-- ── Player: vendors & sales (§6.3) ──────────────────────────── -->
|
||||
<string name="player_sales_title">Recent sales</string>
|
||||
<string name="player_sales_empty">No recent sales.</string>
|
||||
<string name="player_gold_amount">%1$s gp</string>
|
||||
<string name="player_vendors_no_accounts">Link a game account to see your vendors.</string>
|
||||
<string name="player_vendors_empty">No vendors on this account.</string>
|
||||
<string name="player_vendor_fallback">Player vendor</string>
|
||||
<string name="player_vendor_hold">Holding %1$s gp</string>
|
||||
<string name="player_vendor_no_listings">No items listed.</string>
|
||||
<string name="player_listing_item">Item %1$d ×%2$d</string>
|
||||
|
||||
<!-- ── Player: houses (§6.3) ───────────────────────────────────────── -->
|
||||
<string name="player_houses_empty">You don\'t have any houses on your linked accounts.</string>
|
||||
<string name="player_house_fallback">A house</string>
|
||||
<string name="player_house_stage">Status: %1$s</string>
|
||||
|
||||
<!-- ── Shard hub & live feed (§6.2) ─────────────────────────────── -->
|
||||
<string name="shard_live">Live</string>
|
||||
<string name="shard_offline">Offline</string>
|
||||
<string name="shard_status_online">Shard online</string>
|
||||
<string name="shard_status_offline">Shard offline</string>
|
||||
<string name="shard_online_count">%1$d players online</string>
|
||||
<string name="shard_economy_gold">Gold supply: %1$s</string>
|
||||
<string name="shard_section_staff">Staff online</string>
|
||||
<string name="shard_section_activity">Live activity</string>
|
||||
<string name="shard_feed_empty">No recent activity.</string>
|
||||
<string name="shard_nav_champs">Champion spawns</string>
|
||||
<string name="shard_nav_guilds">Guilds</string>
|
||||
<string name="shard_nav_governors">Governors</string>
|
||||
<string name="shard_nav_houses">Falling houses</string>
|
||||
|
||||
<!-- ── Champion spawns (§6.2) ──────────────────────────────────────── -->
|
||||
<string name="champs_empty">No champion spawns are being tracked right now.</string>
|
||||
<string name="champs_fallback_name">Champion spawn</string>
|
||||
<string name="champ_level">Level %1$d</string>
|
||||
<string name="champ_cooldown">Restarting</string>
|
||||
|
||||
<!-- ── Guilds (§6.2) ───────────────────────────────────────────────── -->
|
||||
<string name="guilds_empty">No guilds are being tracked right now.</string>
|
||||
<string name="guilds_fallback_name">Guild</string>
|
||||
<string name="guilds_members">%1$d members</string>
|
||||
<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>
|
||||
<string name="governor_none">No governor</string>
|
||||
<string name="governor_election">Election: %1$s</string>
|
||||
<string name="governor_history">Term history</string>
|
||||
<string name="governor_history_loading">Loading…</string>
|
||||
<string name="governor_history_empty">No past terms recorded.</string>
|
||||
|
||||
<!-- ── Falling houses / IDOC (§6.2) ────────────────────────────────── -->
|
||||
<string name="houses_empty">No houses are in danger right now.</string>
|
||||
<string name="houses_fallback_name">A house</string>
|
||||
<string name="houses_idoc_badge">IDOC</string>
|
||||
|
||||
<!-- ── Push notifications (§11, M7 Part 2) ─────────────────────────── -->
|
||||
<string name="push_stream_server_status">Shard status changed</string>
|
||||
<string name="push_stream_idoc_warning">A house is falling (IDOC)</string>
|
||||
<string name="push_stream_champ_start">Champion spawn started</string>
|
||||
<string name="push_stream_governor_election">New governor elected</string>
|
||||
<string name="push_stream_vendor_sale">Your vendor made a sale</string>
|
||||
<string name="push_stream_house_idoc">Your house entered IDOC</string>
|
||||
<string name="push_stream_account_login">Login to your account</string>
|
||||
</resources>
|
||||
@@ -3,7 +3,7 @@
|
||||
<!--
|
||||
The app is purely an HTTPS API client of a shard's website backend, so the base
|
||||
posture forbids all cleartext (HTTP) traffic. This makes explicit what minSdk 29 /
|
||||
targetSdk 35 already default to, satisfies the "usesCleartextTraffic implicitly
|
||||
targetSdk 36 already default to, satisfies the "usesCleartextTraffic implicitly
|
||||
enabled" scanner finding, and stops any merged library manifest from re-enabling
|
||||
cleartext. It also mirrors ServerUrl's release-build rule (HTTPS required) at the
|
||||
platform socket layer — defense in depth.
|
||||
|
||||
168
app/src/test/java/com/runicgateway/app/StringVocabularyTest.kt
Normal file
168
app/src/test/java/com/runicgateway/app/StringVocabularyTest.kt
Normal file
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import org.w3c.dom.Element
|
||||
import java.io.File
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
/**
|
||||
* The guard on the app's words (PLAN.md M20). A game's words stay on that game's
|
||||
* screens, and the screens every deployment shows use core's words. This is the
|
||||
* app's half of the rule the website docs and runicgateway.com already follow
|
||||
* (runicgateway.com PLAN.md §16, D63/D64).
|
||||
*
|
||||
* The strings are split by owner into three resource files that Android merges as
|
||||
* one `R.string`: `strings.xml` (core), `strings_uo.xml` and `strings_rust.xml`.
|
||||
* A string's owner is the screens that use it, not its key: a string any screen a
|
||||
* Rust deployment can reach is core, even when its key says `shard_`. This test
|
||||
* reads the three files and fails, naming the key and the word, when
|
||||
* - the core file holds either game's word;
|
||||
* - the UO file holds a Rust word;
|
||||
* - the Rust file holds a UO word.
|
||||
*
|
||||
* It checks every `values*` directory, so a translation is held to the same rule.
|
||||
* Comments are not read, because nobody using the app sees them.
|
||||
*/
|
||||
class StringVocabularyTest {
|
||||
|
||||
private enum class Owner(val file: String, val label: String) {
|
||||
CORE("strings.xml", "core"),
|
||||
UO("strings_uo.xml", "Ultima Online"),
|
||||
RUST("strings_rust.xml", "Rust"),
|
||||
}
|
||||
|
||||
private class Term(val game: Owner, val word: String, pattern: String) {
|
||||
val regex = Regex(pattern, RegexOption.IGNORE_CASE)
|
||||
}
|
||||
|
||||
/**
|
||||
* runicgateway.com's `scripts/checkTerms.mjs` list, word for word. Each word is a game's
|
||||
* name for something core names differently (shard → game server, guild and clan →
|
||||
* Team), or the name of one game's component. Game NAMES (Ultima Online, Rust) are not
|
||||
* on it, and neither is Steam, which is a core sign-in provider. Matching is on word
|
||||
* boundaries, so "wipe" does not match "wiped" and "shard" does not match `SHARD_X`.
|
||||
*/
|
||||
private val terms = listOf(
|
||||
Term(Owner.UO, "shard", """\bshards?\b"""),
|
||||
Term(Owner.UO, "ServUO", """\bservuo\b"""),
|
||||
Term(Owner.UO, "uo-link", """\buo-?link\b"""),
|
||||
Term(Owner.UO, "Module-uo", """\bmodule-uo\b"""),
|
||||
Term(Owner.UO, "Bridge.cfg", """\bbridge\.cfg\b"""),
|
||||
Term(Owner.UO, "cliloc", """\bclilocs?\b"""),
|
||||
Term(Owner.UO, "guild", """\bguilds?\b"""),
|
||||
Term(Owner.UO, "IDOC", """\bidocs?\b"""),
|
||||
Term(Owner.RUST, "rust-link", """\brust-?link\b"""),
|
||||
Term(Owner.RUST, "Rust-Plugins", """\brust-plugins\b"""),
|
||||
Term(Owner.RUST, "Module-Rust", """\bmodule-rust\b"""),
|
||||
Term(Owner.RUST, "Oxide", """\boxide\b"""),
|
||||
Term(Owner.RUST, "Carbon", """\bcarbon\b"""),
|
||||
Term(Owner.RUST, "uMod", """\bumod\b"""),
|
||||
Term(Owner.RUST, "clan", """\bclans?\b"""),
|
||||
Term(Owner.RUST, "wipe", """\bwipes?\b"""),
|
||||
)
|
||||
|
||||
/** The words [owner]'s file may not use. */
|
||||
private fun forbiddenIn(owner: Owner): List<Term> = when (owner) {
|
||||
Owner.CORE -> terms
|
||||
Owner.UO -> terms.filter { it.game == Owner.RUST }
|
||||
Owner.RUST -> terms.filter { it.game == Owner.UO }
|
||||
}
|
||||
|
||||
/** The words in [text] that [owner]'s file may not use. */
|
||||
private fun hits(owner: Owner, text: String): List<Pair<Term, String>> =
|
||||
forbiddenIn(owner).flatMap { term -> term.regex.findAll(text).map { term to it.value }.toList() }
|
||||
|
||||
/**
|
||||
* The module's `res`, wherever the test runs from: Gradle runs unit tests in the
|
||||
* module directory, and an IDE may run them from the repository root.
|
||||
*/
|
||||
private val res: File = listOf(File("src/main/res"), File("app/src/main/res"))
|
||||
.firstOrNull { it.isDirectory }
|
||||
?: error("Can't find src/main/res from ${File(".").absolutePath}")
|
||||
|
||||
private val valuesDirs: List<File>
|
||||
get() = res.listFiles { f -> f.isDirectory && f.name.startsWith("values") }!!.sortedBy { it.name }
|
||||
|
||||
/** Every resource in [file] that the app shows: key → its visible text. */
|
||||
private fun visibleText(file: File): List<Pair<String, String>> {
|
||||
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
|
||||
val children = doc.documentElement.childNodes
|
||||
return (0 until children.length)
|
||||
.map { children.item(it) }
|
||||
.filterIsInstance<Element>()
|
||||
// A plural's items arrive as one text, which is all a word scan needs.
|
||||
.map { it.getAttribute("name") to it.textContent }
|
||||
}
|
||||
|
||||
@Test fun eachFileUsesOnlyItsOwnGamesWords() {
|
||||
val failures = mutableListOf<String>()
|
||||
for (dir in valuesDirs) {
|
||||
for (owner in Owner.entries) {
|
||||
val file = File(dir, owner.file)
|
||||
if (!file.exists()) continue
|
||||
for ((key, text) in visibleText(file)) {
|
||||
for ((term, found) in hits(owner, text)) {
|
||||
failures += "${dir.name}/${owner.file}: $key uses \"$found\", " +
|
||||
"${term.game.label} vocabulary, in the ${owner.label} file"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failures.isNotEmpty()) {
|
||||
fail(
|
||||
failures.joinToString(
|
||||
separator = "\n ",
|
||||
prefix = "${failures.size} game word(s) outside their game's file:\n ",
|
||||
postfix = "\n\nPLAN.md M20: a string any deployment's screen shows is core, and uses " +
|
||||
"core's words (site, community, game server, Team). Reword it, or, if only one " +
|
||||
"game's screens use it, move it to that game's file.",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fourth strings file would be read by Android and skipped by this guard, so it
|
||||
* is refused. A new game gets its own file and its own row in [Owner].
|
||||
*/
|
||||
@Test fun everyStringsFileHasAnOwner() {
|
||||
val known = Owner.entries.map { it.file }.toSet()
|
||||
val strays = valuesDirs.flatMap { dir ->
|
||||
dir.listFiles { f -> f.name.startsWith("strings") && f.name !in known }!!.map { "${dir.name}/${it.name}" }
|
||||
}
|
||||
assertEquals("Strings files with no owner in StringVocabularyTest", emptyList<String>(), strays)
|
||||
}
|
||||
|
||||
/** Without this a wrong path would let the guard pass on nothing. */
|
||||
@Test fun theDefaultFilesAreAllThere() {
|
||||
for (owner in Owner.entries) {
|
||||
val file = File(res, "values/${owner.file}")
|
||||
assertTrue("${file.path} is missing", file.exists())
|
||||
assertTrue("${file.path} holds no strings", visibleText(file).isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
/** The matcher itself, so a broken pattern can't make every file look clean. */
|
||||
@Test fun theWordListCatchesWhatItShould() {
|
||||
fun words(owner: Owner, text: String) = hits(owner, text).map { it.first.word }
|
||||
|
||||
assertEquals(listOf("shard"), words(Owner.CORE, "Connect to your shard"))
|
||||
assertEquals(listOf("guild"), words(Owner.CORE, "Guilds"))
|
||||
assertEquals(listOf("clan"), words(Owner.CORE, "You and your clan"))
|
||||
assertEquals(listOf("wipe"), words(Owner.UO, "Next wipe %1\$s"))
|
||||
assertEquals(listOf("IDOC"), words(Owner.RUST, "Your house entered IDOC"))
|
||||
// Each game's own words are fine in its own file.
|
||||
assertEquals(emptyList<String>(), words(Owner.UO, "Shard online"))
|
||||
assertEquals(emptyList<String>(), words(Owner.RUST, "Clan mate"))
|
||||
// Word boundaries: not a prefix, not an identifier.
|
||||
assertEquals(emptyList<String>(), words(Owner.CORE, "wiped %1\$s"))
|
||||
assertEquals(emptyList<String>(), words(Owner.CORE, "Guildmaster"))
|
||||
assertEquals(emptyList<String>(), words(Owner.CORE, "SHARD_VISIBILITY"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import com.runicgateway.app.core.result.DemoRefusal
|
||||
import com.runicgateway.app.util.DEMO_LOCKED_BODY
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The main client's refusal spotter (DEMO_MODE.md §3.1, DM19). Runs a real OkHttp
|
||||
* client whose last interceptor answers in place of the network.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DemoLockInterceptorTest {
|
||||
|
||||
private val notices = DemoNotices()
|
||||
|
||||
private fun clientAnswering(code: Int, body: String): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(DemoLockInterceptor(notices))
|
||||
.addInterceptor(Interceptor { chain ->
|
||||
Response.Builder()
|
||||
.request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(code)
|
||||
.message("")
|
||||
.body(body.toResponseBody("application/json".toMediaType()))
|
||||
.build()
|
||||
})
|
||||
.build()
|
||||
|
||||
private fun call(client: OkHttpClient): String =
|
||||
client.newCall(Request.Builder().url("https://demo.example/api/v1/x").build())
|
||||
.execute()
|
||||
.use { it.body!!.string() }
|
||||
|
||||
@Test fun aRefusalIsReportedAndTheBodyStillReachesTheCaller() = runTest(UnconfinedTestDispatcher()) {
|
||||
val seen = mutableListOf<DemoRefusal>()
|
||||
backgroundScope.launch { notices.refusals.collect { seen += it } }
|
||||
|
||||
val body = call(clientAnswering(403, DEMO_LOCKED_BODY))
|
||||
|
||||
assertEquals(listOf(DemoRefusal("This is turned off on the demo site.")), seen)
|
||||
// Peeked, not consumed: Retrofit still reads the same body into its error.
|
||||
assertEquals(DEMO_LOCKED_BODY, body)
|
||||
}
|
||||
|
||||
@Test fun anythingElseIsNotReported() = runTest(UnconfinedTestDispatcher()) {
|
||||
val seen = mutableListOf<DemoRefusal>()
|
||||
backgroundScope.launch { notices.refusals.collect { seen += it } }
|
||||
|
||||
call(clientAnswering(403, """{"message":"Admins only"}"""))
|
||||
call(clientAnswering(403, "<html>Forbidden</html>"))
|
||||
call(clientAnswering(500, DEMO_LOCKED_BODY))
|
||||
call(clientAnswering(200, DEMO_LOCKED_BODY))
|
||||
|
||||
assertTrue(seen.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.push
|
||||
|
||||
import android.content.Intent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** When push comes back by itself after a reboot or an update (PLAN.md M8). */
|
||||
class PushBootReceiverTest {
|
||||
|
||||
private fun snapshot(
|
||||
enabled: Boolean = true,
|
||||
ntfyUrl: String? = "https://ntfy.tld",
|
||||
topic: String? = "upAbc123",
|
||||
) = PushPreferences.Snapshot(
|
||||
enabled = enabled,
|
||||
ntfyUrl = ntfyUrl,
|
||||
topic = topic,
|
||||
endpoint = topic?.let { "https://ntfy.tld/$it" },
|
||||
deviceId = topic?.let { 7L },
|
||||
)
|
||||
|
||||
@Test fun restartsWhenPushWasOnAndARegistrationIsSaved() {
|
||||
assertTrue(PushBootReceiver.shouldRestart(snapshot()))
|
||||
}
|
||||
|
||||
@Test fun staysOffWhenTheMemberTurnedPushOff() {
|
||||
assertFalse(PushBootReceiver.shouldRestart(snapshot(enabled = false)))
|
||||
}
|
||||
|
||||
@Test fun staysOffAfterASignOutClearedTheRegistration() {
|
||||
// A sign-out keeps the opt-in but forgets the topic: no signed-in member to push to.
|
||||
assertFalse(PushBootReceiver.shouldRestart(snapshot(topic = null)))
|
||||
assertFalse(PushBootReceiver.shouldRestart(snapshot(topic = " ")))
|
||||
}
|
||||
|
||||
@Test fun staysOffWhenTheServerNamesNoRelay() {
|
||||
assertFalse(PushBootReceiver.shouldRestart(snapshot(ntfyUrl = null)))
|
||||
}
|
||||
|
||||
@Test fun listensForBootAndForItsOwnUpdate() {
|
||||
assertEquals(
|
||||
setOf("android.intent.action.BOOT_COMPLETED", "android.intent.action.MY_PACKAGE_REPLACED"),
|
||||
PushBootReceiver.RESTART_ACTIONS,
|
||||
)
|
||||
assertTrue(Intent.ACTION_BOOT_COMPLETED in PushBootReceiver.RESTART_ACTIONS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.push
|
||||
|
||||
import com.runicgateway.app.data.api.dto.NotificationInboxDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||
import com.runicgateway.app.data.api.fake.FakeNotificationsApi
|
||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* A tickle titled from the inbox row it points at (`docs/modules/rust/PLAN.md`
|
||||
* D70), and every way that falls back to the per-stream title.
|
||||
*/
|
||||
class PushContentTest {
|
||||
|
||||
private val raid = NotificationItemDto(
|
||||
id = 42,
|
||||
triggerId = "rust.base.destroyed",
|
||||
title = "Your base is being raided",
|
||||
body = "A door was destroyed in S16 on Oxide rig.",
|
||||
)
|
||||
private val raidTickle = PushTickle(stream = "rust.base.destroyed", ref = "notification:42")
|
||||
|
||||
// ── The ref ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun aRefNamesAnInboxIdOnlyWhenItIsExactlyOne() {
|
||||
assertEquals(42L, inboxIdFromRef("notification:42"))
|
||||
assertNull(inboxIdFromRef(null))
|
||||
assertNull(inboxIdFromRef("notification:"))
|
||||
assertNull(inboxIdFromRef("notification:0"))
|
||||
assertNull(inboxIdFromRef("notification:-4"))
|
||||
assertNull(inboxIdFromRef("notification:4a"))
|
||||
assertNull(inboxIdFromRef("notification: 4"))
|
||||
assertNull(inboxIdFromRef("xnotification:4"))
|
||||
assertNull(inboxIdFromRef("0x24C"))
|
||||
assertNull(inboxIdFromRef("notification:" + "9".repeat(40)))
|
||||
}
|
||||
|
||||
// ── Which row ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun theRowMustBeTheNamedOneFromTheNamedTrigger() {
|
||||
val other = raid.copy(id = 41, title = "An older raid")
|
||||
assertEquals(raid, itemForTickle(raidTickle, listOf(other, raid)))
|
||||
// Same id, different trigger: the tickle disagrees with the row.
|
||||
assertNull(itemForTickle(raidTickle.copy(stream = "rust.server.online"), listOf(raid)))
|
||||
assertNull(itemForTickle(raidTickle.copy(ref = "notification:7"), listOf(raid)))
|
||||
assertNull(itemForTickle(raidTickle.copy(ref = null), listOf(raid)))
|
||||
}
|
||||
|
||||
// ── What the notification says ─────────────────────────────────────────
|
||||
|
||||
@Test fun theRowsTitleAndBodyAreUsed() {
|
||||
val text = notificationText(raid, generic = "New notification")
|
||||
assertEquals("Your base is being raided", text.title)
|
||||
assertEquals("A door was destroyed in S16 on Oxide rig.", text.body)
|
||||
}
|
||||
|
||||
@Test fun noRowOrABlankTitleFallsBackToTheStreamTitle() {
|
||||
assertEquals(NotificationText("New notification", null), notificationText(null, "New notification"))
|
||||
assertEquals(
|
||||
NotificationText("New notification", null),
|
||||
notificationText(raid.copy(title = " "), "New notification"),
|
||||
)
|
||||
assertEquals(null, notificationText(raid.copy(body = " "), "x").body)
|
||||
}
|
||||
|
||||
// ── The pull ───────────────────────────────────────────────────────────
|
||||
|
||||
private val api = FakeNotificationsApi()
|
||||
private val resolver = PushContentResolver(NotificationsRepository(api))
|
||||
|
||||
@Test fun theResolverPullsTheFirstPage() = runTest {
|
||||
api.pages = mapOf(null to NotificationInboxDto(items = listOf(raid), unread = 1))
|
||||
|
||||
assertEquals(raid, resolver.itemFor(raidTickle))
|
||||
assertEquals(listOf<Long?>(null), api.inboxCalls)
|
||||
}
|
||||
|
||||
@Test fun aTickleWithNoInboxRefIsNeverPulled() = runTest {
|
||||
// A classic M7 stream, or a push-only rule with no inbox row.
|
||||
assertNull(resolver.itemFor(PushTickle(stream = "news.post", ref = "0x24C")))
|
||||
assertTrue(api.inboxCalls.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun aFailedPullIsNullNotAnError() = runTest {
|
||||
api.error = IOException("offline")
|
||||
assertNull(resolver.itemFor(raidTickle))
|
||||
}
|
||||
|
||||
@Test fun aRowNotOnThePageIsNull() = runTest {
|
||||
api.pages = mapOf(null to NotificationInboxDto(items = listOf(raid.copy(id = 99))))
|
||||
assertNull(resolver.itemFor(raidTickle))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.result
|
||||
|
||||
import com.runicgateway.app.util.DEMO_LOCKED_BODY
|
||||
import com.runicgateway.app.util.demoLockedError
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
|
||||
/** Reading the public demo's refusal off an error body (DEMO_MODE.md §3.1). */
|
||||
class DemoLockedTest {
|
||||
|
||||
@Test fun theServersBodyIsARefusalWithItsMessage() {
|
||||
assertEquals(DemoRefusal("This is turned off on the demo site."), demoRefusalOf(DEMO_LOCKED_BODY))
|
||||
}
|
||||
|
||||
@Test fun aModulesOwnSentenceIsKept() {
|
||||
// A refused console command names the command (§3.4); the app must not
|
||||
// swap that for the generic line.
|
||||
val body = """{"message":"The command quit is turned off on the demo site.","code":"DEMO_LOCKED"}"""
|
||||
assertEquals("The command quit is turned off on the demo site.", demoRefusalOf(body)!!.message)
|
||||
}
|
||||
|
||||
@Test fun aRefusalWithNoMessageStillCounts() {
|
||||
assertEquals(DemoRefusal(null), demoRefusalOf("""{"code":"DEMO_LOCKED"}"""))
|
||||
assertEquals(DemoRefusal(null), demoRefusalOf("""{"code":"DEMO_LOCKED","message":" "}"""))
|
||||
}
|
||||
|
||||
@Test fun anythingElseIsNotARefusal() {
|
||||
assertNull(demoRefusalOf(null))
|
||||
assertNull(demoRefusalOf(""))
|
||||
assertNull(demoRefusalOf("{}"))
|
||||
assertNull(demoRefusalOf("""{"message":"Forbidden"}"""))
|
||||
assertNull(demoRefusalOf("""{"code":"FORBIDDEN"}"""))
|
||||
assertNull(demoRefusalOf("""{"code":7}"""))
|
||||
assertNull(demoRefusalOf("""["DEMO_LOCKED"]"""))
|
||||
assertNull(demoRefusalOf("<html>403 Forbidden</html>"))
|
||||
}
|
||||
|
||||
@Test fun safeApiCallCarriesTheCode() = runTest {
|
||||
val result = safeApiCall { throw demoLockedError() }
|
||||
assertEquals(DEMO_LOCKED_CODE, (result as ApiResult.HttpError).code)
|
||||
assertTrue(result.isDemoLocked())
|
||||
}
|
||||
|
||||
@Test fun anOrdinary403IsNotARefusal() = runTest {
|
||||
val result = safeApiCall {
|
||||
throw HttpException(Response.error<Any>(403, """{"message":"Admins only"}""".toResponseBody(null)))
|
||||
}
|
||||
assertEquals(403, (result as ApiResult.HttpError).status)
|
||||
assertNull(result.code)
|
||||
assertFalse(result.isDemoLocked())
|
||||
}
|
||||
|
||||
@Test fun onlyA403sBodyIsRead() = runTest {
|
||||
// The server answers the refusal with 403 and nothing else, so another
|
||||
// status's body is never buffered, even if it happens to carry the code.
|
||||
val result = safeApiCall {
|
||||
throw HttpException(Response.error<Any>(500, DEMO_LOCKED_BODY.toResponseBody(null)))
|
||||
}
|
||||
assertNull((result as ApiResult.HttpError).code)
|
||||
assertFalse(result.isDemoLocked())
|
||||
}
|
||||
|
||||
@Test fun otherResultsAreNeverRefusals() {
|
||||
assertFalse(ApiResult.Ok(1).isDemoLocked())
|
||||
assertFalse(ApiResult.NetworkError(java.io.IOException()).isDemoLocked())
|
||||
assertFalse(ApiResult.HttpError(403).isDemoLocked())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Nothing names who is online by default (org lead, 2026-09-22). These are the
|
||||
* answers module-rust's public routes give below and inside the operator's
|
||||
* presence audience, copied from a live walk against the module — and the old
|
||||
* shape, from a core running a module that predates the flag.
|
||||
*/
|
||||
class RustPresenceDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun withheldOnlineCarriesTheCountAndNoNames() {
|
||||
val dto = json.decodeFromString<RustOnlineDto>(
|
||||
"""{"players":[],"hidden":true,"count":2,"audience":"staff"}""",
|
||||
)
|
||||
assertTrue(dto.hidden)
|
||||
assertEquals(2, dto.count)
|
||||
assertEquals("staff", dto.audience)
|
||||
assertTrue(dto.players.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun visibleOnlineNamesThePlayers() {
|
||||
val dto = json.decodeFromString<RustOnlineDto>(
|
||||
"""{"players":[{"steamId":"76561198000000002","name":"Builder Bea","sleeping":false,
|
||||
"connectedAt":"2026-09-23T05:02:30.000Z"}],"hidden":false,"count":1,"audience":"staff"}""",
|
||||
)
|
||||
assertFalse(dto.hidden)
|
||||
assertEquals("Builder Bea", dto.players.single().name)
|
||||
}
|
||||
|
||||
@Test fun anOlderModuleWithoutTheFlagReadsAsVisible() {
|
||||
val dto = json.decodeFromString<RustOnlineDto>("""{"players":[]}""")
|
||||
assertFalse(dto.hidden)
|
||||
assertNull(dto.audience)
|
||||
}
|
||||
|
||||
@Test fun aWithheldFeedSaysSo() {
|
||||
val dto = json.decodeFromString<RustEventListDto>(
|
||||
"""{"events":[{"id":11,"kind":"server.wipe","t":1789500000000,"wipeId":"w-20260920T000000Z",
|
||||
"steamId":null,"frame":{}}],"presenceHidden":true,"presenceAudience":"signed_in"}""",
|
||||
)
|
||||
assertTrue(dto.presenceHidden)
|
||||
assertEquals("signed_in", dto.presenceAudience)
|
||||
assertEquals("server.wipe", dto.events.single().kind)
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,13 @@ class FakeEventsApi : EventsApi {
|
||||
return value
|
||||
}
|
||||
|
||||
override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto =
|
||||
reply(calendar)
|
||||
/** How many times the calendar was read, for the Rust map's run resolver. */
|
||||
var calendarCalls: Int = 0
|
||||
|
||||
override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto {
|
||||
calendarCalls++
|
||||
return reply(calendar)
|
||||
}
|
||||
|
||||
override suspend fun getEvent(slug: String, run: String?): PublicEventResponse {
|
||||
lastSlug = slug
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.PlayerRustApi
|
||||
import com.runicgateway.app.data.api.dto.RustBackpackDto
|
||||
import com.runicgateway.app.data.api.dto.RustEconomicsDto
|
||||
import com.runicgateway.app.data.api.dto.RustLinkListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLinkRequest
|
||||
import com.runicgateway.app.data.api.dto.RustLinkResultDto
|
||||
import com.runicgateway.app.data.api.dto.RustPlayerPermissionsDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import com.runicgateway.app.data.api.dto.RustUnlinkResultDto
|
||||
|
||||
/**
|
||||
* A configurable fake of [PlayerRustApi] (M15).
|
||||
*
|
||||
* [linkError] is its own field rather than a shared [error]: the tests that
|
||||
* matter here are about a **redemption** that fails while the reads around it
|
||||
* succeed — a refused code must leave the accounts and entitlements on screen,
|
||||
* and one `error` for the whole interface could not express that.
|
||||
*/
|
||||
class FakePlayerRustApi : PlayerRustApi {
|
||||
|
||||
/** Thrown by every call — the "the site is down" case. */
|
||||
var error: Throwable? = null
|
||||
|
||||
/** Thrown by one call each, so one half of the screen can fail alone. */
|
||||
var linksError: Throwable? = null
|
||||
var linkError: Throwable? = null
|
||||
var unlinkError: Throwable? = null
|
||||
var permissionsError: Throwable? = null
|
||||
|
||||
var links: RustLinkListDto = RustLinkListDto()
|
||||
var linkResult: RustLinkResultDto = RustLinkResultDto(linked = true)
|
||||
var permissions: RustPlayerPermissionsDto = RustPlayerPermissionsDto()
|
||||
|
||||
var linksCalls: Int = 0
|
||||
var permissionsCalls: Int = 0
|
||||
|
||||
/** The code the last redemption carried, exactly as the screen sent it. */
|
||||
var lastCode: String? = null
|
||||
|
||||
/** The Steam id the last release named. */
|
||||
var lastUnlinked: String? = null
|
||||
|
||||
override suspend fun links(): RustLinkListDto {
|
||||
linksCalls++
|
||||
linksError?.let { throw it }
|
||||
error?.let { throw it }
|
||||
return links
|
||||
}
|
||||
|
||||
override suspend fun link(body: RustLinkRequest): RustLinkResultDto {
|
||||
lastCode = body.code
|
||||
linkError?.let { throw it }
|
||||
return linkResult
|
||||
}
|
||||
|
||||
override suspend fun unlink(steamId: String): RustUnlinkResultDto {
|
||||
lastUnlinked = steamId
|
||||
unlinkError?.let { throw it }
|
||||
return RustUnlinkResultDto(unlinked = true)
|
||||
}
|
||||
|
||||
override suspend fun permissions(): RustPlayerPermissionsDto {
|
||||
permissionsCalls++
|
||||
permissionsError?.let { throw it }
|
||||
error?.let { throw it }
|
||||
return permissions
|
||||
}
|
||||
|
||||
/** The coin balances (§11.2.2), and a throw of their own: a 404 is an older module. */
|
||||
var economics: RustEconomicsDto = RustEconomicsDto()
|
||||
var economicsError: Throwable? = null
|
||||
var economicsCalls: Int = 0
|
||||
|
||||
override suspend fun economics(): RustEconomicsDto {
|
||||
economicsCalls++
|
||||
economicsError?.let { throw it }
|
||||
error?.let { throw it }
|
||||
return economics
|
||||
}
|
||||
|
||||
/** The servers the backpack section offers (§11.3.2). */
|
||||
var servers: RustServerListDto = RustServerListDto()
|
||||
|
||||
override suspend fun servers(): RustServerListDto {
|
||||
error?.let { throw it }
|
||||
return servers
|
||||
}
|
||||
|
||||
/** One backpack per `serverId|steamId`; a throw of its own, so a 409 can stand alone. */
|
||||
var backpacks: Map<String, RustBackpackDto> = emptyMap()
|
||||
var backpackError: Throwable? = null
|
||||
val backpackReads = mutableListOf<String>()
|
||||
|
||||
override suspend fun backpack(serverId: String, steamId: String): RustBackpackDto {
|
||||
backpackReads += "$serverId|$steamId"
|
||||
backpackError?.let { throw it }
|
||||
error?.let { throw it }
|
||||
return backpacks["$serverId|$steamId"] ?: RustBackpackDto(serverId = serverId, steamId = steamId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.RustApi
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapDto
|
||||
import com.runicgateway.app.data.api.dto.RustMapLiveDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||
import com.runicgateway.app.data.api.dto.RustWipeListDto
|
||||
|
||||
/**
|
||||
* A configurable fake of [RustApi] (M14). Set the `var` a call should answer
|
||||
* with; set [error] to make every call throw.
|
||||
*
|
||||
* **The `last…` fields are what the tests that matter assert on.** The module's
|
||||
* own API has one rule nothing about a successful response can show: an absent
|
||||
* query parameter must be **absent** rather than empty, because `?wipe=` asks for
|
||||
* a wipe whose id is the empty string and answers nothing, with no error to
|
||||
* notice. Recording what was asked is the only way to see that from here.
|
||||
*/
|
||||
class FakeRustApi : RustApi {
|
||||
|
||||
var error: Throwable? = null
|
||||
|
||||
var servers: RustServerListDto = RustServerListDto()
|
||||
var server: RustServerResponse = RustServerResponse()
|
||||
var events: RustEventListDto = RustEventListDto()
|
||||
var leaderboard: RustLeaderboardDto = RustLeaderboardDto()
|
||||
var wipes: RustWipeListDto = RustWipeListDto()
|
||||
var online: RustOnlineDto = RustOnlineDto()
|
||||
var map: RustMapDto = RustMapDto()
|
||||
var mapLive: RustMapLiveDto = RustMapLiveDto()
|
||||
|
||||
/**
|
||||
* Answers that replace [mapLive] one call at a time, for a test that needs an
|
||||
* answer to arrive AFTER something else happened. Consumed first to last;
|
||||
* [mapLive] answers once it is empty.
|
||||
*/
|
||||
val mapLiveQueue: ArrayDeque<suspend () -> RustMapLiveDto> = ArrayDeque()
|
||||
|
||||
var serversCalls: Int = 0
|
||||
var eventCalls: Int = 0
|
||||
var leaderboardCalls: Int = 0
|
||||
var onlineCalls: Int = 0
|
||||
var wipeCalls: Int = 0
|
||||
var mapCalls: Int = 0
|
||||
var mapLiveCalls: Int = 0
|
||||
|
||||
/** The `kind` the last feed read carried — null means it sent none at all. */
|
||||
var lastKind: String? = null
|
||||
|
||||
/** The `wipe` the last feed read carried; null means all wipes. */
|
||||
var lastFeedWipe: String? = null
|
||||
var lastLeaderboardWipe: String? = null
|
||||
var lastSort: String? = null
|
||||
var lastId: String? = null
|
||||
|
||||
private fun <T> reply(value: T): T {
|
||||
error?.let { throw it }
|
||||
return value
|
||||
}
|
||||
|
||||
override suspend fun getServers(): RustServerListDto {
|
||||
serversCalls++
|
||||
return reply(servers)
|
||||
}
|
||||
|
||||
override suspend fun getServer(id: String): RustServerResponse {
|
||||
lastId = id
|
||||
return reply(server)
|
||||
}
|
||||
|
||||
override suspend fun getEvents(id: String, kind: String?, wipe: String?, limit: Int?): RustEventListDto {
|
||||
eventCalls++
|
||||
lastId = id
|
||||
lastKind = kind
|
||||
lastFeedWipe = wipe
|
||||
return reply(events)
|
||||
}
|
||||
|
||||
override suspend fun getLeaderboard(
|
||||
id: String,
|
||||
wipe: String?,
|
||||
sort: String?,
|
||||
limit: Int?,
|
||||
): RustLeaderboardDto {
|
||||
leaderboardCalls++
|
||||
lastId = id
|
||||
lastLeaderboardWipe = wipe
|
||||
lastSort = sort
|
||||
return reply(leaderboard)
|
||||
}
|
||||
|
||||
override suspend fun getWipes(id: String): RustWipeListDto {
|
||||
wipeCalls++
|
||||
lastId = id
|
||||
return reply(wipes)
|
||||
}
|
||||
|
||||
override suspend fun getOnline(id: String): RustOnlineDto {
|
||||
onlineCalls++
|
||||
lastId = id
|
||||
return reply(online)
|
||||
}
|
||||
|
||||
override suspend fun getMap(id: String): RustMapDto {
|
||||
mapCalls++
|
||||
lastId = id
|
||||
return reply(map)
|
||||
}
|
||||
|
||||
override suspend fun getMapLive(id: String): RustMapLiveDto {
|
||||
mapLiveCalls++
|
||||
lastId = id
|
||||
error?.let { throw it }
|
||||
return mapLiveQueue.removeFirstOrNull()?.invoke() ?: mapLive
|
||||
}
|
||||
|
||||
/** Every hide or unhide asked for, in order: `(lineId, hidden)`. */
|
||||
val chatHides: MutableList<Pair<Long, Boolean>> = mutableListOf()
|
||||
|
||||
override suspend fun hideChat(lineId: Long) {
|
||||
error?.let { throw it }
|
||||
chatHides += lineId to true
|
||||
}
|
||||
|
||||
override suspend fun unhideChat(lineId: Long) {
|
||||
error?.let { throw it }
|
||||
chatHides += lineId to false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.SafetyApi
|
||||
import com.runicgateway.app.data.api.dto.BlockListDto
|
||||
import com.runicgateway.app.data.api.dto.BlockResultDto
|
||||
import com.runicgateway.app.data.api.dto.HiddenItemDto
|
||||
import com.runicgateway.app.data.api.dto.HiddenListDto
|
||||
import com.runicgateway.app.data.api.dto.ReportRequest
|
||||
import com.runicgateway.app.data.api.dto.ReportResultDto
|
||||
import com.runicgateway.app.data.api.dto.SafetyOkDto
|
||||
import com.runicgateway.app.data.api.dto.SafetyTargetRequest
|
||||
import com.runicgateway.app.data.api.dto.UserBlockDto
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* A configurable fake of [SafetyApi] (USER_SAFETY.md §4.3). It keeps blocks and
|
||||
* hides the way the server does, so a test can block, read the list back and
|
||||
* unblock; set [error] to make the next call throw it instead.
|
||||
*/
|
||||
class FakeSafetyApi : SafetyApi {
|
||||
|
||||
/** Thrown by the next call, then cleared. */
|
||||
var error: Throwable? = null
|
||||
|
||||
val reports: MutableList<ReportRequest> = mutableListOf()
|
||||
val blockRequests: MutableList<SafetyTargetRequest> = mutableListOf()
|
||||
val hides: MutableList<SafetyTargetRequest> = mutableListOf()
|
||||
val blockList: MutableList<UserBlockDto> = mutableListOf()
|
||||
val hiddenList: MutableList<HiddenItemDto> = mutableListOf()
|
||||
var hiddenReads: Int = 0
|
||||
|
||||
private fun fail() {
|
||||
error?.let {
|
||||
error = null
|
||||
throw it
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun report(body: ReportRequest): ReportResultDto {
|
||||
fail()
|
||||
reports += body
|
||||
return ReportResultDto(ok = true, reportId = reports.size.toLong())
|
||||
}
|
||||
|
||||
override suspend fun blocks(): BlockListDto {
|
||||
fail()
|
||||
return BlockListDto(blockList.toList())
|
||||
}
|
||||
|
||||
override suspend fun block(body: SafetyTargetRequest): BlockResultDto {
|
||||
fail()
|
||||
blockRequests += body
|
||||
val block = UserBlockDto(id = (blockList.size + 1).toLong(), kind = "game", name = "Brannock")
|
||||
blockList += block
|
||||
return BlockResultDto(block)
|
||||
}
|
||||
|
||||
override suspend fun unblock(id: Long): SafetyOkDto {
|
||||
fail()
|
||||
if (!blockList.removeAll { it.id == id }) throw httpError(404)
|
||||
return SafetyOkDto(ok = true)
|
||||
}
|
||||
|
||||
override suspend fun hidden(): HiddenListDto {
|
||||
hiddenReads++
|
||||
fail()
|
||||
return HiddenListDto(hiddenList.toList())
|
||||
}
|
||||
|
||||
override suspend fun hide(body: SafetyTargetRequest): SafetyOkDto {
|
||||
fail()
|
||||
hides += body
|
||||
hiddenList += HiddenItemDto(body.targetType, body.targetId)
|
||||
return SafetyOkDto(ok = true)
|
||||
}
|
||||
|
||||
override suspend fun clearHidden(): SafetyOkDto {
|
||||
fail()
|
||||
val n = hiddenList.size
|
||||
hiddenList.clear()
|
||||
return SafetyOkDto(ok = true, cleared = n)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A server refusal, with an optional JSON body. */
|
||||
fun httpError(status: Int, body: String = "{}"): HttpException =
|
||||
HttpException(Response.error<Any>(status, body.toResponseBody()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
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.DeleteAccountRequest
|
||||
import com.runicgateway.app.data.api.dto.DeleteAccountResultDto
|
||||
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.DeleteOutcome
|
||||
import com.runicgateway.app.data.repository.AccountRepository.DeleteRefusal
|
||||
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.assertNull
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* [AccountRepository.deleteAccount] (ACCOUNT_DELETION.md §4.1, §4.3) over a fake
|
||||
* [MeApi]: each refusal the server sends becomes its own [DeleteRefusal], read off
|
||||
* the body's `code`, so the screen can say which field is wrong.
|
||||
*/
|
||||
class AccountDeletionRepositoryTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
/** Only [deleteAccount] is wired; it answers [answer] and records the body. */
|
||||
private class FakeMeApi(val answer: () -> Response<DeleteAccountResultDto>) : MeApi {
|
||||
var lastBody: DeleteAccountRequest? = null
|
||||
|
||||
override suspend fun deleteAccount(body: DeleteAccountRequest): Response<DeleteAccountResultDto> {
|
||||
lastBody = body
|
||||
return answer()
|
||||
}
|
||||
|
||||
override suspend fun getAccount(): PlayerAccountDto = error("unused")
|
||||
override suspend fun changeUsername(body: ChangeUsernameRequest): UsernameResponse = error("unused")
|
||||
override suspend fun changePassword(body: ChangePasswordRequest): Unit = error("unused")
|
||||
override suspend fun totpSetup(): TotpSetupDto = error("unused")
|
||||
override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = error("unused")
|
||||
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = error("unused")
|
||||
override suspend fun identities(): List<LinkedIdentityDto> = error("unused")
|
||||
override suspend fun unlinkIdentity(provider: String, id: Int?): Unit = error("unused")
|
||||
override suspend fun trustedDevices(): List<TrustedDeviceDto> = error("unused")
|
||||
override suspend fun trustThisDevice(body: TrustDeviceRequest): Response<TrustDeviceResultDto> = error("unused")
|
||||
override suspend fun revokeTrustedDevice(id: Long): RevokedFlagDto = error("unused")
|
||||
override suspend fun revokeAllTrustedDevices(): RevokedCountDto = error("unused")
|
||||
override suspend fun recoveryCodesStatus(): RecoveryStatusDto = error("unused")
|
||||
override suspend fun generateRecoveryCodes(body: RecoveryGenerateRequest): RecoveryCodesDto = error("unused")
|
||||
}
|
||||
|
||||
private fun refusal(status: Int, body: String) = FakeMeApi {
|
||||
Response.error(status, body.toResponseBody("application/json".toMediaTypeOrNull()))
|
||||
}
|
||||
|
||||
private suspend fun outcomeOf(api: MeApi) = AccountRepository(api, json).deleteAccount("Ravenna", "pw")
|
||||
|
||||
@Test fun aDeletionIsDeletedAndSendsWhatItWasGiven() = runTest {
|
||||
val api = FakeMeApi { Response.success(DeleteAccountResultDto(deleted = true)) }
|
||||
val outcome = AccountRepository(api, json).deleteAccount("Ravenna", "pw", code = "123456")
|
||||
assertEquals(DeleteOutcome.Deleted, outcome)
|
||||
assertEquals(DeleteAccountRequest("Ravenna", "pw", "123456", null), api.lastBody)
|
||||
}
|
||||
|
||||
@Test fun aRecoveryCodeGoesInItsOwnField() = runTest {
|
||||
val api = FakeMeApi { Response.success(DeleteAccountResultDto(deleted = true)) }
|
||||
AccountRepository(api, json).deleteAccount("Ravenna", "pw", recoveryCode = "abcd-efgh")
|
||||
assertNull(api.lastBody?.code)
|
||||
assertEquals("abcd-efgh", api.lastBody?.recoveryCode)
|
||||
}
|
||||
|
||||
@Test fun eachRefusalCodeIsItsOwnReason() = runTest {
|
||||
val cases = mapOf(
|
||||
refusal(400, """{"code":"username_mismatch","message":"x"}""") to DeleteRefusal.USERNAME_MISMATCH,
|
||||
refusal(400, """{"code":"wrong_password","message":"x"}""") to DeleteRefusal.WRONG_PASSWORD,
|
||||
refusal(400, """{"code":"code_required","message":"x"}""") to DeleteRefusal.CODE_REQUIRED,
|
||||
refusal(400, """{"code":"wrong_code","message":"x"}""") to DeleteRefusal.WRONG_CODE,
|
||||
refusal(409, """{"code":"password_required","message":"x"}""") to DeleteRefusal.PASSWORD_REQUIRED,
|
||||
refusal(409, """{"code":"last_admin","message":"x"}""") to DeleteRefusal.LAST_ADMIN,
|
||||
refusal(403, """{"code":"DEMO_LOCKED","message":"This is turned off on the demo site."}""") to
|
||||
DeleteRefusal.DEMO_LOCKED,
|
||||
)
|
||||
cases.forEach { (api, reason) -> assertEquals(DeleteOutcome.Refused(reason), outcomeOf(api)) }
|
||||
}
|
||||
|
||||
@Test fun tooManyAttemptsIsRateLimitedWhateverTheBody() = runTest {
|
||||
assertEquals(DeleteOutcome.Refused(DeleteRefusal.RATE_LIMITED), outcomeOf(refusal(429, "Too many requests")))
|
||||
}
|
||||
|
||||
@Test fun anUnknownOrMissingCodeIsOther() = runTest {
|
||||
assertEquals(DeleteOutcome.Refused(DeleteRefusal.OTHER), outcomeOf(refusal(400, """{"code":"new_rule"}""")))
|
||||
assertEquals(DeleteOutcome.Refused(DeleteRefusal.OTHER), outcomeOf(refusal(500, """{"message":"boom"}""")))
|
||||
assertEquals(DeleteOutcome.Refused(DeleteRefusal.OTHER), outcomeOf(refusal(502, "<html>Bad gateway</html>")))
|
||||
}
|
||||
|
||||
@Test fun noAnswerIsANetworkError() = runTest {
|
||||
assertEquals(DeleteOutcome.NetworkError, outcomeOf(FakeMeApi { throw IOException("offline") }))
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ 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.DeleteAccountRequest
|
||||
import com.runicgateway.app.data.api.dto.DeleteAccountResultDto
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||
@@ -70,7 +72,9 @@ class AccountTrustedDevicesTest {
|
||||
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
|
||||
override suspend fun unlinkIdentity(provider: String, id: Int?) = Unit
|
||||
override suspend fun deleteAccount(body: DeleteAccountRequest): Response<DeleteAccountResultDto> =
|
||||
Response.success(DeleteAccountResultDto(deleted = true))
|
||||
}
|
||||
|
||||
private fun repo(api: MeApi) = AccountRepository(api, json)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.data.api.dto.InstalledModuleDto
|
||||
import com.runicgateway.app.data.api.dto.ModulesDto
|
||||
import com.runicgateway.app.data.api.dto.RustLinkDto
|
||||
import com.runicgateway.app.data.api.dto.RustLinkListDto
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkDto
|
||||
import com.runicgateway.app.data.api.fake.FakePlayerRustApi
|
||||
import com.runicgateway.app.data.api.fake.FakePlayerShardApi
|
||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.util.httpError
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* "Has this user linked a game account" is asked of the site's own module
|
||||
* (`docs/modules/rust/PLAN.md` D69).
|
||||
*
|
||||
* Before phase 11 it was always `module-uo`'s `/player/shard/accounts`. A Rust
|
||||
* site has no such route, so the question always answered no, and the raid
|
||||
* alert's row was locked in every channel.
|
||||
*/
|
||||
class LinkedAccountRepositoryTest {
|
||||
|
||||
private val publicApi = FakePublicApi()
|
||||
private val capabilities = SiteCapabilitiesRepository(publicApi)
|
||||
private val rustApi = FakePlayerRustApi()
|
||||
private val shardApi = FakePlayerShardApi()
|
||||
private val repository = LinkedAccountRepository(
|
||||
capabilities,
|
||||
PlayerRustRepository(rustApi),
|
||||
PlayerShardRepository(shardApi),
|
||||
)
|
||||
|
||||
private suspend fun running(module: String, vararg caps: String) {
|
||||
publicApi.modules = ModulesDto(modules = listOf(InstalledModuleDto(id = module, capabilities = caps.toList())))
|
||||
capabilities.refresh()
|
||||
}
|
||||
|
||||
@Test fun aRustSiteAsksTheRustModule() = runTest {
|
||||
running("rust", "rust", "servers", "identity")
|
||||
rustApi.links = RustLinkListDto(links = listOf(RustLinkDto(steamId = "76561198000000001")))
|
||||
// The UO read would say no, and it must not be the one that is asked.
|
||||
shardApi.error = httpError(404)
|
||||
|
||||
assertTrue(repository.hasLinkedAccount())
|
||||
assertEquals(1, rustApi.linksCalls)
|
||||
}
|
||||
|
||||
@Test fun aRustSiteWithNoLinkSaysNo() = runTest {
|
||||
running("rust", "rust")
|
||||
assertFalse(repository.hasLinkedAccount())
|
||||
}
|
||||
|
||||
@Test fun aUoSiteStillAsksTheShardModuleAndNeverTheRustOne() = runTest {
|
||||
running("uo", "shard", "atlas")
|
||||
shardApi.accounts = listOf(ShardLinkDto(account = "walker"))
|
||||
|
||||
assertTrue(repository.hasLinkedAccount())
|
||||
assertEquals(0, rustApi.linksCalls)
|
||||
}
|
||||
|
||||
@Test fun aSiteWithNeitherModuleHasNothingToAsk() = runTest {
|
||||
running("other", "something")
|
||||
rustApi.links = RustLinkListDto(links = listOf(RustLinkDto(steamId = "1")))
|
||||
|
||||
assertFalse(repository.hasLinkedAccount())
|
||||
assertEquals(0, rustApi.linksCalls)
|
||||
}
|
||||
|
||||
@Test fun aHostThatHasNeverAnsweredAsksBothAndAFailureIsNoLink() = runTest {
|
||||
// One site runs one module, so one of the two 404s; that is not an error.
|
||||
shardApi.error = httpError(404)
|
||||
rustApi.links = RustLinkListDto(links = listOf(RustLinkDto(steamId = "1")))
|
||||
|
||||
assertTrue(repository.hasLinkedAccount())
|
||||
}
|
||||
|
||||
@Test fun theSourcesAreReadOffTheCapabilities() {
|
||||
assertEquals(LinkSource.entries, linkSourcesFor(null))
|
||||
assertEquals(listOf(LinkSource.RUST), linkSourcesFor(SiteCapabilities(emptySet(), setOf("rust"))))
|
||||
assertEquals(listOf(LinkSource.SHARD), linkSourcesFor(SiteCapabilities(emptySet(), setOf("shard"))))
|
||||
assertEquals(emptyList<LinkSource>(), linkSourcesFor(SiteCapabilities(setOf("events"), emptySet())))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.data.api.dto.BlockListDto
|
||||
import com.runicgateway.app.data.api.dto.ReportRequest
|
||||
import com.runicgateway.app.data.api.fake.FakeSafetyApi
|
||||
import com.runicgateway.app.data.api.fake.FakeSafetyApi.Companion.httpError
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
/** Report, block and hide over core's routes (USER_SAFETY.md §4.1, §4.3). */
|
||||
class SafetyRepositoryTest {
|
||||
|
||||
private val api = FakeSafetyApi()
|
||||
private val repository = SafetyRepository(api)
|
||||
|
||||
/** The app's own wire settings (NetworkModule). */
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a report names the item and its reason, and a blank detail is left out`() = runTest {
|
||||
assertEquals(SafetyOutcome.Done, repository.report("rust.chat", 42, "spam", " "))
|
||||
|
||||
val sent = api.reports.single()
|
||||
assertEquals(ReportRequest("rust.chat", 42, "spam", null), sent)
|
||||
// The server's validator accepts an absent detail and refuses a null one.
|
||||
assertEquals("""{"targetType":"rust.chat","targetId":42,"reason":"spam"}""", json.encodeToString(sent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a report's detail is trimmed`() = runTest {
|
||||
repository.report("rust.chat", 42, "abuse", " said it twice \n")
|
||||
assertEquals("said it twice", api.reports.single().detail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block names the item, never a person`() = runTest {
|
||||
assertEquals(SafetyOutcome.Done, repository.blockAuthorOf("rust.chat", 7))
|
||||
assertEquals("rust.chat", api.blockRequests.single().targetType)
|
||||
assertEquals(7L, api.blockRequests.single().targetId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 403 on a block is staff, and anywhere else it is forbidden`() = runTest {
|
||||
api.error = httpError(403, """{"code":"staff","message":"You can’t block a staff member."}""")
|
||||
assertEquals(SafetyOutcome.Refused(SafetyRefusal.STAFF), repository.blockAuthorOf("rust.chat", 7))
|
||||
|
||||
api.error = httpError(403)
|
||||
assertEquals(SafetyOutcome.Refused(SafetyRefusal.FORBIDDEN), repository.hide("rust.chat", 7))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the demo's refusal is told apart from a staff refusal`() = runTest {
|
||||
api.error = httpError(403, """{"code":"DEMO_LOCKED","message":"This is turned off on the demo site."}""")
|
||||
assertEquals(SafetyOutcome.Refused(SafetyRefusal.DEMO_LOCKED), repository.blockAuthorOf("rust.chat", 7))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each status the server sends becomes its own refusal`() = runTest {
|
||||
val cases = listOf(
|
||||
409 to SafetyRefusal.ALREADY_DONE,
|
||||
404 to SafetyRefusal.GONE,
|
||||
429 to SafetyRefusal.RATE_LIMITED,
|
||||
503 to SafetyRefusal.UNAVAILABLE,
|
||||
500 to SafetyRefusal.OTHER,
|
||||
)
|
||||
cases.forEach { (status, refusal) ->
|
||||
api.error = httpError(status)
|
||||
assertEquals("status $status", SafetyOutcome.Refused(refusal), repository.report("rust.chat", 1, "spam", null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 400 on a block is not blockable, and on a report it is not`() = runTest {
|
||||
api.error = httpError(400)
|
||||
assertEquals(SafetyOutcome.Refused(SafetyRefusal.NOT_BLOCKABLE), repository.blockAuthorOf("rust.chat", 1))
|
||||
api.error = httpError(400)
|
||||
assertEquals(SafetyOutcome.Refused(SafetyRefusal.OTHER), repository.report("rust.chat", 1, "spam", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no answer at all is a network error`() = runTest {
|
||||
api.error = IOException("offline")
|
||||
assertEquals(SafetyOutcome.NetworkError, repository.hide("rust.chat", 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block list decodes both kinds, and a block that stopped applying says why`() {
|
||||
val body = """
|
||||
{"blocks":[
|
||||
{"id":1,"kind":"account","name":"rowan","identity":null,"applies":true,"reason":null,
|
||||
"createdAt":"2026-10-30T12:00:00.000Z"},
|
||||
{"id":2,"kind":"game","name":"Brannock","applies":true,"reason":null,
|
||||
"identity":{"module":"rust","kind":"steam","label":"Steam","id":"12345678900000001"}},
|
||||
{"id":3,"kind":"account","name":"modsy","applies":false,"reason":"staff"}
|
||||
]}
|
||||
""".trimIndent()
|
||||
|
||||
val blocks = json.decodeFromString<BlockListDto>(body).blocks
|
||||
|
||||
assertNull(blocks[0].identity)
|
||||
assertEquals("Steam", blocks[1].identity?.label)
|
||||
assertEquals("12345678900000001", blocks[1].identity?.id)
|
||||
assertFalse(blocks[2].applies)
|
||||
assertEquals("staff", blocks[2].reason)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.data.api.dto.PageDto
|
||||
import com.runicgateway.app.data.api.dto.PostDto
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.data.api.dto.WikiPageDto
|
||||
import com.runicgateway.app.data.api.dto.WikiSummaryDto
|
||||
@@ -13,6 +15,7 @@ import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.data.repository.ContentRepository
|
||||
import com.runicgateway.app.data.repository.SettingsRepository
|
||||
import com.runicgateway.app.data.repository.WikiRepository
|
||||
import com.runicgateway.app.ui.demo.DemoInfo
|
||||
import com.runicgateway.app.ui.home.HomeViewModel
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.news.NewsViewModel
|
||||
@@ -22,7 +25,10 @@ import com.runicgateway.app.ui.wiki.WikiPageViewModel
|
||||
import com.runicgateway.app.ui.wiki.WikiViewModel
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -128,13 +134,33 @@ class ContentViewModelTest {
|
||||
// ── Home status ─────────────────────────────────────────────────────────
|
||||
@Test fun homeLoadsStatus() {
|
||||
api.status = StatusDto(mode = "maintenance")
|
||||
val vm = HomeViewModel(settings)
|
||||
val vm = homeViewModel()
|
||||
assertTrue((vm.state.value as UiState.Success).data.isMaintenance)
|
||||
}
|
||||
|
||||
@Test fun homeNetworkErrorIsUiError() {
|
||||
api.error = java.io.IOException("offline")
|
||||
val state = HomeViewModel(settings).state.value
|
||||
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
|
||||
val vm = homeViewModel()
|
||||
assertEquals(ErrorKind.NETWORK, (vm.state.value as UiState.Error).kind)
|
||||
assertNull(vm.demo.value)
|
||||
}
|
||||
|
||||
// ── Home: the public demo's strip (DEMO_MODE.md §6) ──
|
||||
@Test fun homeDrawsNoStripOnARealShard() {
|
||||
assertNull(homeViewModel().demo.value)
|
||||
}
|
||||
|
||||
@Test fun homeReadsTheDemoBlock() {
|
||||
api.settings = SettingsDto(
|
||||
demo = Json.parseToJsonElement(
|
||||
"""{"resetsAt":"2026-10-26T14:00:00.000Z","logins":[{"username":"demo-admin","role":"admin"}]}""",
|
||||
),
|
||||
)
|
||||
val vm = homeViewModel(base = "https://demo.runicgateway.com/")
|
||||
assertEquals(DemoInfo("14:00 UTC", listOf("demo-admin")), vm.demo.value)
|
||||
assertEquals("demo.runicgateway.com/account/login", vm.signInPage)
|
||||
}
|
||||
|
||||
private fun homeViewModel(base: String? = null) =
|
||||
HomeViewModel(settings, BaseUrlHolder().apply { set(base?.toHttpUrl()) })
|
||||
}
|
||||
|
||||
81
app/src/test/java/com/runicgateway/app/ui/PollingTest.kt
Normal file
81
app/src/test/java/com/runicgateway/app/ui/PollingTest.kt
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The rule a poll lives on (M14, `docs/modules/rust/PLAN.md` D14).
|
||||
*
|
||||
* **A refresh is invisible when it succeeds and keeps the rows when it fails.**
|
||||
* The site's whole premise is that it renders while the game is off, and an app
|
||||
* that blanked itself the first time a request failed would break that one tier
|
||||
* along from where it was built.
|
||||
*/
|
||||
class PollingTest {
|
||||
|
||||
@Test
|
||||
fun `a successful refresh replaces the data and clears a previous failure`() {
|
||||
val after = refreshInto(UiState.Success(listOf("old")), ApiResult.Ok(listOf("new")))
|
||||
|
||||
assertEquals(UiState.Success(listOf("new")), after.state)
|
||||
assertFalse(after.refreshFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed refresh keeps the rows and reports the failure`() {
|
||||
// The case this whole file exists for. Nothing is blanked, nothing is
|
||||
// retried on the reader's behalf, and the failure is a fact the screen can
|
||||
// render beside rows that are still the best answer anybody has.
|
||||
val before = UiState.Success(listOf("old"))
|
||||
val after = refreshInto(before, ApiResult.NetworkError(RuntimeException("offline")))
|
||||
|
||||
assertEquals(before, after.state)
|
||||
assertTrue(after.refreshFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure with nothing on screen is an ordinary error`() {
|
||||
// There is nothing to protect, so this is a first load that failed — and a
|
||||
// screen that reported "could not refresh" over a blank page would be
|
||||
// hiding the retry the reader needs.
|
||||
val after = refreshInto(UiState.Loading, ApiResult.HttpError(500, "boom"))
|
||||
|
||||
assertTrue(after.state is UiState.Error)
|
||||
assertFalse(after.refreshFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 404 on a first load keeps its own kind`() {
|
||||
val after = refreshInto(UiState.Loading, ApiResult.HttpError(404, "gone"))
|
||||
|
||||
assertEquals(UiState.Error(ErrorKind.NOT_FOUND, 404), after.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a successful refresh over an error recovers`() {
|
||||
val after = refreshInto(
|
||||
UiState.Error(ErrorKind.NETWORK),
|
||||
ApiResult.Ok(listOf("back")),
|
||||
)
|
||||
|
||||
assertEquals(UiState.Success(listOf("back")), after.state)
|
||||
assertFalse(after.refreshFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty answer is a success, not a failure to keep the old rows through`() {
|
||||
// An empty list is an ANSWER — the feed filtered to a wipe nothing happened
|
||||
// in, a fleet with nobody on it. Treating it as "nothing came back" and
|
||||
// keeping stale rows would make an emptied board impossible to observe.
|
||||
val after = refreshInto(UiState.Success(listOf("old")), ApiResult.Ok(emptyList<String>()))
|
||||
|
||||
assertEquals(UiState.Success(emptyList<String>()), after.state)
|
||||
assertFalse(after.refreshFailed)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user