Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6630d05e76 | |||
| b20abdf4ba | |||
| 5386889ef4 | |||
| d0c5bd6ddf | |||
| 6be83fe70a | |||
| 19c2f77a98 | |||
| 690441a8c3 | |||
| ac60427a3e | |||
| 1579a1982c | |||
| aca358d72b | |||
| 7a8231ba9c |
@@ -63,7 +63,7 @@ jobs:
|
|||||||
- name: Install Android SDK packages
|
- name: Install Android SDK packages
|
||||||
run: |
|
run: |
|
||||||
set +o pipefail
|
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
|
- name: Cache Gradle
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
|
|||||||
@@ -18,6 +18,17 @@
|
|||||||
# ANDROID_KEYSTORE_PASSWORD — keystore password
|
# ANDROID_KEYSTORE_PASSWORD — keystore password
|
||||||
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
|
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
|
||||||
# ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore)
|
# 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
|
# 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
|
# 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
|
- name: Install base tools + JDK 17
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
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"
|
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Check out the release tag (full history for the changelog)
|
- name: Check out the release tag (full history for the changelog)
|
||||||
@@ -103,6 +114,7 @@ jobs:
|
|||||||
echo
|
echo
|
||||||
echo "---"
|
echo "---"
|
||||||
echo "Signed APK — sideload on Android 10+ (§10). The app self-configures its shard site on first run."
|
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
|
} > dist/CHANGELOG.md
|
||||||
|
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
@@ -123,7 +135,7 @@ jobs:
|
|||||||
- name: Install Android SDK packages
|
- name: Install Android SDK packages
|
||||||
run: |
|
run: |
|
||||||
set +o pipefail
|
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
|
- name: Decode signing keystore
|
||||||
env:
|
env:
|
||||||
@@ -148,7 +160,7 @@ jobs:
|
|||||||
sed -i -E "s/(toIntOrNull\(\) \?: )[0-9]+/\1${VERSION_CODE}/" "${GRADLE_MODULE}/build.gradle.kts"
|
sed -i -E "s/(toIntOrNull\(\) \?: )[0-9]+/\1${VERSION_CODE}/" "${GRADLE_MODULE}/build.gradle.kts"
|
||||||
grep -nE "versionCode = |versionName = " "${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:
|
env:
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
@@ -156,15 +168,20 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x ./gradlew
|
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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
test -f "$SRC" || { echo "::error::release APK not found at $SRC"; exit 1; }
|
APK="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
|
||||||
cp "$SRC" "dist/runic-gateway-${{ steps.plan.outputs.version }}.apk"
|
AAB="${GRADLE_MODULE}/build/outputs/bundle/release/app-release.aab"
|
||||||
( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS )
|
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
|
ls -l dist && cat dist/SHA256SUMS
|
||||||
|
|
||||||
# ── Create the Gitea release + upload assets (no push to main) ───────
|
# ── Create the Gitea release + upload assets (no push to main) ───────
|
||||||
@@ -186,9 +203,94 @@ jobs:
|
|||||||
| jq -r '.id')"
|
| jq -r '.id')"
|
||||||
echo "Created release ${TAG} (id=${REL_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}" \
|
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||||
-H "Authorization: token ${CI_TOKEN}" \
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
-F "attachment=@dist/${f}" >/dev/null
|
-F "attachment=@dist/${f}" >/dev/null
|
||||||
echo " uploaded ${f}"
|
echo " uploaded ${f}"
|
||||||
done
|
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
|
- name: Install Android SDK packages
|
||||||
run: |
|
run: |
|
||||||
set +o pipefail
|
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
|
- name: Cache Gradle
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
|
|||||||
@@ -38,13 +38,13 @@ val hasReleaseSigning = ksStoreFilePath != null && ksStorePassword != null &&
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.runicgateway.app"
|
namespace = "com.runicgateway.app"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// Target application id per docs/android/PLAN.md §13 (pending runicgateway.app domain).
|
// Target application id per docs/android/PLAN.md §13 (pending runicgateway.app domain).
|
||||||
applicationId = "com.runicgateway.app"
|
applicationId = "com.runicgateway.app"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 35
|
targetSdk = 36
|
||||||
// These committed defaults are the version source of truth (PLAN.md §10).
|
// These committed defaults are the version source of truth (PLAN.md §10).
|
||||||
// release.yml's conventional-commit engine bumps versionName here and commits
|
// release.yml's conventional-commit engine bumps versionName here and commits
|
||||||
// it on release; versionCode is derived from it (major*10000+minor*100+patch)
|
// it on release; versionCode is derived from it (major*10000+minor*100+patch)
|
||||||
|
|||||||
@@ -8,10 +8,14 @@
|
|||||||
|
|
||||||
<!-- Opt-in push notifications (M7): the runtime notification permission (API 33+)
|
<!-- Opt-in push notifications (M7): the runtime notification permission (API 33+)
|
||||||
and a foreground service that holds the persistent ntfy connection open — the
|
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.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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
|
<application
|
||||||
android:name=".RunicGatewayApp"
|
android:name=".RunicGatewayApp"
|
||||||
@@ -70,11 +74,28 @@
|
|||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11).
|
<!-- 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
|
<service
|
||||||
android:name=".core.push.PushService"
|
android:name=".core.push.PushService"
|
||||||
android:exported="false"
|
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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,13 @@ import javax.inject.Inject
|
|||||||
* connection to the shard's ntfy open in the background — the price of Google-free,
|
* 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
|
* self-contained instant delivery — and posts a notification for each tickle. It runs
|
||||||
* under a low-importance ongoing notification and restarts sticky; [PushManager] starts
|
* 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
|
@AndroidEntryPoint
|
||||||
class PushService : Service() {
|
class PushService : Service() {
|
||||||
@@ -51,7 +57,7 @@ class PushService : Service() {
|
|||||||
|
|
||||||
private fun startAsForeground() {
|
private fun startAsForeground() {
|
||||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
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 {
|
} else {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,18 @@ import java.io.IOException
|
|||||||
* an expected failure, it returns a typed variant the screen can render.
|
* an expected failure, it returns a typed variant the screen can render.
|
||||||
*
|
*
|
||||||
* - [Ok] — a 2xx response with a decoded body.
|
* - [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).
|
* - [NetworkError] — the request never got an answer (offline, DNS, TLS, timeout).
|
||||||
*/
|
*/
|
||||||
sealed interface ApiResult<out T> {
|
sealed interface ApiResult<out T> {
|
||||||
data class Ok<T>(val data: T) : ApiResult<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>
|
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) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
ApiResult.HttpError(e.code(), e.message())
|
ApiResult.HttpError(e.code(), e.message(), errorCodeOf(e))
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
ApiResult.NetworkError(e)
|
ApiResult.NetworkError(e)
|
||||||
} catch (e: SerializationException) {
|
} catch (e: SerializationException) {
|
||||||
ApiResult.HttpError(MALFORMED_RESPONSE_STATUS, e.message)
|
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. */
|
/** Synthetic status for a 2xx body the app couldn't decode — an invalid upstream response. */
|
||||||
private const val MALFORMED_RESPONSE_STATUS = 502
|
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.ChangePasswordRequest
|
||||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
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.LinkedIdentityDto
|
||||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||||
@@ -89,4 +91,11 @@ interface MeApi {
|
|||||||
|
|
||||||
@POST("api/v1/auth/me/account/recovery-codes/generate")
|
@POST("api/v1/auth/me/account/recovery-codes/generate")
|
||||||
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
|
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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.runicgateway.app.data.api.dto.RustServerListDto
|
|||||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||||
import com.runicgateway.app.data.api.dto.RustWipeListDto
|
import com.runicgateway.app.data.api.dto.RustWipeListDto
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
@@ -108,4 +109,18 @@ interface RustApi {
|
|||||||
*/
|
*/
|
||||||
@GET("api/v1/public/rust/servers/{id}/map/live")
|
@GET("api/v1/public/rust/servers/{id}/map/live")
|
||||||
suspend fun getMapLive(@Path("id") id: String): RustMapLiveDto
|
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
|
@Serializable
|
||||||
data class RevokedCountDto(val revoked: Int = 0)
|
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. */
|
/** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RecoveryStatusDto(val remaining: Int = 0)
|
data class RecoveryStatusDto(val remaining: Int = 0)
|
||||||
|
|||||||
@@ -146,4 +146,14 @@ data class SettingsDto(
|
|||||||
* nav.
|
* nav.
|
||||||
*/
|
*/
|
||||||
@SerialName("nav_public") val navPublic: String? = null,
|
@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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ data class RustEventListDto(
|
|||||||
* [t] is epoch milliseconds — the stamp the plugin put on the frame, not a
|
* [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
|
* database column, so it is a number here and an ISO string everywhere else on
|
||||||
* this wire.
|
* 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
|
@Serializable
|
||||||
data class RustEventDto(
|
data class RustEventDto(
|
||||||
@@ -127,6 +132,8 @@ data class RustEventDto(
|
|||||||
val wipeId: String? = null,
|
val wipeId: String? = null,
|
||||||
val steamId: String? = null,
|
val steamId: String? = null,
|
||||||
val frame: JsonObject = JsonObject(emptyMap()),
|
val frame: JsonObject = JsonObject(emptyMap()),
|
||||||
|
val canBlock: Boolean? = null,
|
||||||
|
val hidden: Boolean = false,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* One frame field as text, or null.
|
* One frame field as text, or null.
|
||||||
|
|||||||
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,
|
||||||
|
)
|
||||||
@@ -4,10 +4,13 @@
|
|||||||
package com.runicgateway.app.data.repository
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
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.core.result.safeApiCall
|
||||||
import com.runicgateway.app.data.api.MeApi
|
import com.runicgateway.app.data.api.MeApi
|
||||||
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
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.LinkedIdentityDto
|
||||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||||
@@ -117,4 +120,70 @@ class AccountRepository @Inject constructor(
|
|||||||
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
|
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
|
||||||
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
|
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
|
||||||
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
|
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.TotpRequiredError
|
||||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -208,6 +210,28 @@ class AuthRepository @Inject constructor(
|
|||||||
sessionManager.onSignedOut()
|
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
|
* 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
|
* success refreshes the cached role (roles change server-side); a `401` that
|
||||||
|
|||||||
@@ -96,4 +96,12 @@ class RustRepository @Inject constructor(
|
|||||||
/** What moves on the map, as this viewer may see it. */
|
/** What moves on the map, as this viewer may see it. */
|
||||||
suspend fun mapLive(id: String): ApiResult<RustMapLiveDto> =
|
suspend fun mapLive(id: String): ApiResult<RustMapLiveDto> =
|
||||||
safeApiCall { api.getMapLive(id) }
|
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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact
|
|||||||
import com.runicgateway.app.BuildConfig
|
import com.runicgateway.app.BuildConfig
|
||||||
import com.runicgateway.app.core.net.AuthInterceptor
|
import com.runicgateway.app.core.net.AuthInterceptor
|
||||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
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.HostSelectionInterceptor
|
||||||
import com.runicgateway.app.core.net.ShardStream
|
import com.runicgateway.app.core.net.ShardStream
|
||||||
import com.runicgateway.app.core.net.ShardStreamClient
|
import com.runicgateway.app.core.net.ShardStreamClient
|
||||||
@@ -23,6 +24,7 @@ import com.runicgateway.app.data.api.PlayerRustApi
|
|||||||
import com.runicgateway.app.data.api.PlayerShardApi
|
import com.runicgateway.app.data.api.PlayerShardApi
|
||||||
import com.runicgateway.app.data.api.PublicApi
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
import com.runicgateway.app.data.api.RustApi
|
import com.runicgateway.app.data.api.RustApi
|
||||||
|
import com.runicgateway.app.data.api.SafetyApi
|
||||||
import com.runicgateway.app.data.api.SsoApi
|
import com.runicgateway.app.data.api.SsoApi
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -58,7 +60,8 @@ object NetworkModule {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The main client every API and the SSE stream ride on. Order: identify (UA),
|
* 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
|
* [TokenAuthenticator] handles silent refresh-on-401 (§4.1). Logging sits last
|
||||||
* so it observes the final, authed request.
|
* so it observes the final, authed request.
|
||||||
*/
|
*/
|
||||||
@@ -68,12 +71,14 @@ object NetworkModule {
|
|||||||
hostSelectionInterceptor: HostSelectionInterceptor,
|
hostSelectionInterceptor: HostSelectionInterceptor,
|
||||||
userAgentInterceptor: UserAgentInterceptor,
|
userAgentInterceptor: UserAgentInterceptor,
|
||||||
authInterceptor: AuthInterceptor,
|
authInterceptor: AuthInterceptor,
|
||||||
|
demoLockInterceptor: DemoLockInterceptor,
|
||||||
tokenAuthenticator: TokenAuthenticator,
|
tokenAuthenticator: TokenAuthenticator,
|
||||||
): OkHttpClient {
|
): OkHttpClient {
|
||||||
val builder = OkHttpClient.Builder()
|
val builder = OkHttpClient.Builder()
|
||||||
.addInterceptor(userAgentInterceptor)
|
.addInterceptor(userAgentInterceptor)
|
||||||
.addInterceptor(hostSelectionInterceptor)
|
.addInterceptor(hostSelectionInterceptor)
|
||||||
.addInterceptor(authInterceptor)
|
.addInterceptor(authInterceptor)
|
||||||
|
.addInterceptor(demoLockInterceptor)
|
||||||
.authenticator(tokenAuthenticator)
|
.authenticator(tokenAuthenticator)
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
@@ -119,6 +124,11 @@ object NetworkModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideMeApi(retrofit: Retrofit): MeApi = retrofit.create(MeApi::class.java)
|
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. */
|
/** A player's own game data + linking (§6.3) — bearer-authed on the main client. */
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import androidx.compose.material3.NavigationDrawerItem
|
|||||||
import androidx.compose.material3.NavigationDrawerItemColors
|
import androidx.compose.material3.NavigationDrawerItemColors
|
||||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@@ -33,6 +35,7 @@ import androidx.compose.material3.rememberDrawerState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -59,12 +62,16 @@ import com.runicgateway.app.data.api.dto.BrandDto
|
|||||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||||
import com.runicgateway.app.data.repository.Capability
|
import com.runicgateway.app.data.repository.Capability
|
||||||
import com.runicgateway.app.ui.auth.AccountScreen
|
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.LoginScreen
|
||||||
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
||||||
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
|
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
|
||||||
import com.runicgateway.app.ui.auth.roleLabelRes
|
import com.runicgateway.app.ui.auth.roleLabelRes
|
||||||
import com.runicgateway.app.ui.components.BrandLogo
|
import com.runicgateway.app.ui.components.BrandLogo
|
||||||
import com.runicgateway.app.ui.contact.ContactScreen
|
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.EventScreen
|
||||||
import com.runicgateway.app.ui.events.EventSeriesScreen
|
import com.runicgateway.app.ui.events.EventSeriesScreen
|
||||||
import com.runicgateway.app.ui.events.EventsScreen
|
import com.runicgateway.app.ui.events.EventsScreen
|
||||||
@@ -110,6 +117,7 @@ import com.runicgateway.app.ui.shard.ShardScreen
|
|||||||
import com.runicgateway.app.ui.theme.LocalShardStructure
|
import com.runicgateway.app.ui.theme.LocalShardStructure
|
||||||
import com.runicgateway.app.ui.wiki.WikiPageScreen
|
import com.runicgateway.app.ui.wiki.WikiPageScreen
|
||||||
import com.runicgateway.app.ui.wiki.WikiScreen
|
import com.runicgateway.app.ui.wiki.WikiScreen
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
||||||
@@ -151,6 +159,7 @@ fun RunicApp(
|
|||||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||||
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
|
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
|
||||||
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
|
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
|
||||||
|
demoNoticeViewModel: DemoNoticeViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val brand = appearance.brand
|
val brand = appearance.brand
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
@@ -192,6 +201,19 @@ fun RunicApp(
|
|||||||
// the previous account's count on the drawer.
|
// the previous account's count on the drawer.
|
||||||
LaunchedEffect(session) { inboxBadgeViewModel.refresh() }
|
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).
|
// A tapped push notification deep-links to its stream's screen (§11, item 7).
|
||||||
LaunchedEffect(deepLinkStream, deepLinkRef) {
|
LaunchedEffect(deepLinkStream, deepLinkRef) {
|
||||||
val stream = deepLinkStream ?: return@LaunchedEffect
|
val stream = deepLinkStream ?: return@LaunchedEffect
|
||||||
@@ -347,6 +369,7 @@ fun RunicApp(
|
|||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
@@ -398,6 +421,15 @@ fun RunicApp(
|
|||||||
session = session,
|
session = session,
|
||||||
onSignOut = { sessionViewModel.signOut() },
|
onSignOut = { sessionViewModel.signOut() },
|
||||||
onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) },
|
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),
|
modifier = Modifier.padding(innerPadding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -508,6 +540,7 @@ private fun RunicNavHost(
|
|||||||
session: Session,
|
session: Session,
|
||||||
onSignOut: () -> Unit,
|
onSignOut: () -> Unit,
|
||||||
onSignOutEverywhere: () -> Unit,
|
onSignOutEverywhere: () -> Unit,
|
||||||
|
onAccountDeleted: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
@@ -648,6 +681,8 @@ private fun RunicNavHost(
|
|||||||
onBack = { navController.navigateTopLevel(Routes.RUST) },
|
onBack = { navController.navigateTopLevel(Routes.RUST) },
|
||||||
// D123: a site-event marker opens the app's own event page on its run.
|
// D123: a site-event marker opens the app's own event page on its run.
|
||||||
onOpenEvent = { slug, runId -> navController.navigate(Routes.event(slug, runId)) },
|
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) {
|
composable(Routes.WIKI) {
|
||||||
@@ -690,6 +725,8 @@ private fun RunicNavHost(
|
|||||||
onSignOutEverywhere = onSignOutEverywhere,
|
onSignOutEverywhere = onSignOutEverywhere,
|
||||||
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
|
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
|
||||||
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
|
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
|
||||||
|
onOpenDeleteAccount = { navController.navigate(Routes.ACCOUNT_DELETE) },
|
||||||
|
onOpenBlocked = { navController.navigate(Routes.ACCOUNT_BLOCKED) },
|
||||||
)
|
)
|
||||||
Session.SignedOut -> LaunchedEffect(Unit) {
|
Session.SignedOut -> LaunchedEffect(Unit) {
|
||||||
navController.navigateTopLevel(Routes.HOME)
|
navController.navigateTopLevel(Routes.HOME)
|
||||||
@@ -703,12 +740,30 @@ private fun RunicNavHost(
|
|||||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
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) {
|
composable(Routes.ACCOUNT_RECOVERY_CODES) {
|
||||||
when (session) {
|
when (session) {
|
||||||
is Session.SignedIn -> RecoveryCodesScreen()
|
is Session.SignedIn -> RecoveryCodesScreen()
|
||||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
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) {
|
composable(Routes.NOTIFICATIONS) {
|
||||||
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
// 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
|
// leaving another account's items up. The backend gates every call
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package com.runicgateway.app.ui
|
package com.runicgateway.app.ui
|
||||||
|
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
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>>`;
|
* The load state a screen renders. View models expose a `StateFlow<UiState<T>>`;
|
||||||
@@ -41,6 +42,14 @@ enum class ErrorKind {
|
|||||||
*/
|
*/
|
||||||
FEATURE_UNAVAILABLE,
|
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. */
|
/** Any other non-2xx server response. */
|
||||||
SERVER,
|
SERVER,
|
||||||
}
|
}
|
||||||
@@ -50,10 +59,11 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
|
|||||||
is ApiResult.Ok -> UiState.Success(data)
|
is ApiResult.Ok -> UiState.Success(data)
|
||||||
is ApiResult.NetworkError -> UiState.Error(ErrorKind.NETWORK)
|
is ApiResult.NetworkError -> UiState.Error(ErrorKind.NETWORK)
|
||||||
is ApiResult.HttpError -> UiState.Error(
|
is ApiResult.HttpError -> UiState.Error(
|
||||||
kind = when (status) {
|
kind = when {
|
||||||
404 -> ErrorKind.NOT_FOUND
|
isDemoLocked() -> ErrorKind.DEMO_LOCKED
|
||||||
429 -> ErrorKind.RATE_LIMITED
|
status == 404 -> ErrorKind.NOT_FOUND
|
||||||
503 -> ErrorKind.SHARD_OFFLINE
|
status == 429 -> ErrorKind.RATE_LIMITED
|
||||||
|
status == 503 -> ErrorKind.SHARD_OFFLINE
|
||||||
else -> ErrorKind.SERVER
|
else -> ErrorKind.SERVER
|
||||||
},
|
},
|
||||||
httpStatus = status,
|
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.
|
* player or admin route, which is not an admin's visibility setting.
|
||||||
*/
|
*/
|
||||||
fun <T> ApiResult<T>.toShardUiState(): UiState<T> = when (this) {
|
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)
|
UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status)
|
||||||
} else {
|
} else {
|
||||||
toUiState()
|
toUiState()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
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.AdminPostDto
|
||||||
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
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. */
|
/** Map an [ApiResult] to a [Feedback], with role/permission-aware failure copy. */
|
||||||
private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) {
|
private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) {
|
||||||
is ApiResult.Ok -> Feedback(true, okRes)
|
is ApiResult.Ok -> Feedback(true, okRes)
|
||||||
is ApiResult.HttpError ->
|
is ApiResult.HttpError -> Feedback(
|
||||||
Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed)
|
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)
|
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
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.api.dto.AdminDashboardDto
|
||||||
import com.runicgateway.app.data.repository.AdminRepository
|
import com.runicgateway.app.data.repository.AdminRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
@@ -79,7 +80,11 @@ class AdminDashboardViewModel @Inject constructor(
|
|||||||
switching = false,
|
switching = false,
|
||||||
feedback = Feedback(
|
feedback = Feedback(
|
||||||
false,
|
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 androidx.lifecycle.viewModelScope
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.isDemoLocked
|
||||||
import com.runicgateway.app.data.repository.AdminRepository
|
import com.runicgateway.app.data.repository.AdminRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -75,9 +76,10 @@ class AdminModerationViewModel @Inject constructor(
|
|||||||
is ApiResult.Ok -> Feedback(true, okRes)
|
is ApiResult.Ok -> Feedback(true, okRes)
|
||||||
is ApiResult.HttpError -> Feedback(
|
is ApiResult.HttpError -> Feedback(
|
||||||
false,
|
false,
|
||||||
when (r.status) {
|
when {
|
||||||
403 -> R.string.admin_forbidden
|
r.isDemoLocked() -> R.string.error_demo_locked
|
||||||
503 -> R.string.admin_mod_shard_offline
|
r.status == 403 -> R.string.admin_forbidden
|
||||||
|
r.status == 503 -> R.string.admin_mod_shard_offline
|
||||||
else -> R.string.admin_action_failed
|
else -> R.string.admin_action_failed
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
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.api.dto.SupportPageDto
|
||||||
import com.runicgateway.app.data.repository.AdminRepository
|
import com.runicgateway.app.data.repository.AdminRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
@@ -71,10 +72,11 @@ class AdminSupportViewModel @Inject constructor(
|
|||||||
is ApiResult.Ok -> Feedback(true, okRes)
|
is ApiResult.Ok -> Feedback(true, okRes)
|
||||||
is ApiResult.HttpError -> Feedback(
|
is ApiResult.HttpError -> Feedback(
|
||||||
false,
|
false,
|
||||||
when (r.status) {
|
when {
|
||||||
403 -> R.string.admin_forbidden
|
r.isDemoLocked() -> R.string.error_demo_locked
|
||||||
404 -> R.string.admin_support_unknown_page
|
r.status == 403 -> R.string.admin_forbidden
|
||||||
503 -> R.string.admin_mod_shard_offline
|
r.status == 404 -> R.string.admin_support_unknown_page
|
||||||
|
r.status == 503 -> R.string.admin_mod_shard_offline
|
||||||
else -> R.string.admin_action_failed
|
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.graphics.BitmapFactory
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
@@ -67,6 +69,8 @@ fun AccountScreen(
|
|||||||
onSignOutEverywhere: () -> Unit,
|
onSignOutEverywhere: () -> Unit,
|
||||||
onOpenTrustedDevices: () -> Unit,
|
onOpenTrustedDevices: () -> Unit,
|
||||||
onOpenRecoveryCodes: () -> Unit,
|
onOpenRecoveryCodes: () -> Unit,
|
||||||
|
onOpenDeleteAccount: () -> Unit,
|
||||||
|
onOpenBlocked: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AccountViewModel = hiltViewModel(),
|
viewModel: AccountViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -91,6 +95,8 @@ fun AccountScreen(
|
|||||||
is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
|
is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BlockedRow(onOpenBlocked)
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||||
|
|
||||||
OutlinedButton(onClick = onSignOut, modifier = Modifier.fillMaxWidth()) {
|
OutlinedButton(onClick = onSignOut, modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -102,6 +108,58 @@ fun AccountScreen(
|
|||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.account_sign_out_all))
|
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)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
* [ErrorKind.FEATURE_UNAVAILABLE] renders **without** the button: an admin decides
|
||||||
* whether the shard publishes that surface, so retrying cannot change the answer and
|
* 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
|
@Composable
|
||||||
fun ErrorView(
|
fun ErrorView(
|
||||||
@@ -69,7 +71,8 @@ fun ErrorView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */
|
/** 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). */
|
/** Centered informational message for an empty list (§7). */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -93,5 +96,6 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
|
|||||||
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
|
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
|
||||||
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
|
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
|
||||||
ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable
|
ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable
|
||||||
|
ErrorKind.DEMO_LOCKED -> R.string.error_demo_locked
|
||||||
ErrorKind.SERVER -> R.string.error_server
|
ErrorKind.SERVER -> R.string.error_server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,5 +117,6 @@ private fun ResultMessage(result: Result) {
|
|||||||
private fun errorText(kind: ErrorKind): String = when (kind) {
|
private fun errorText(kind: ErrorKind): String = when (kind) {
|
||||||
ErrorKind.NETWORK -> stringResource(R.string.error_network)
|
ErrorKind.NETWORK -> stringResource(R.string.error_network)
|
||||||
ErrorKind.RATE_LIMITED -> stringResource(R.string.error_rate_limited)
|
ErrorKind.RATE_LIMITED -> stringResource(R.string.error_rate_limited)
|
||||||
|
ErrorKind.DEMO_LOCKED -> stringResource(R.string.error_demo_locked)
|
||||||
else -> stringResource(R.string.contact_error)
|
else -> stringResource(R.string.contact_error)
|
||||||
}
|
}
|
||||||
|
|||||||
65
app/src/main/java/com/runicgateway/app/ui/demo/DemoBanner.kt
Normal file
65
app/src/main/java/com/runicgateway/app/ui/demo/DemoBanner.kt
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.demo
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public demo's strip on Home (`docs/website/DEMO_MODE.md` §6), worded like the
|
||||||
|
* web's: "**Demo site** — anything you change is wiped at 14:00 UTC. Sign-ins:
|
||||||
|
* demo-admin / demo-player — passwords at demo.runicgateway.com/account/login."
|
||||||
|
*
|
||||||
|
* Drawn only from a [DemoInfo] the server sent, so a real shard never shows it and
|
||||||
|
* the app needs no schedule of its own. [signInPage] is null when the site address
|
||||||
|
* is unknown, and the logins sentence then names the sign-in page without it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DemoBanner(info: DemoInfo, signInPage: String?, modifier: Modifier = Modifier) {
|
||||||
|
val wipe = info.resetsAt
|
||||||
|
?.let { stringResource(R.string.demo_banner_wipe_at, it) }
|
||||||
|
?: stringResource(R.string.demo_banner_wipe_soon)
|
||||||
|
val logins = info.usernames.takeIf { it.isNotEmpty() }?.joinToString(" / ")?.let { names ->
|
||||||
|
if (signInPage != null) {
|
||||||
|
stringResource(R.string.demo_banner_logins_at, names, signInPage)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.demo_banner_logins, names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val title = stringResource(R.string.demo_banner_title)
|
||||||
|
val accent = MaterialTheme.colorScheme.primary
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = buildAnnotatedString {
|
||||||
|
withStyle(SpanStyle(color = accent, fontWeight = FontWeight.Bold)) { append(title) }
|
||||||
|
append(wipe)
|
||||||
|
logins?.let { append(" $it") }
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 1.dp, color = accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/src/main/java/com/runicgateway/app/ui/demo/DemoInfo.kt
Normal file
73
app/src/main/java/com/runicgateway/app/ui/demo/DemoInfo.kt
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.demo
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the Home strip says on the public demo (`docs/website/DEMO_MODE.md` §6).
|
||||||
|
*
|
||||||
|
* @property resetsAt when the next wipe runs, already worded ("14:00 UTC"), or null
|
||||||
|
* when the server sent no readable time.
|
||||||
|
* @property usernames the published logins, in the server's order. Their passwords
|
||||||
|
* are on the site's sign-in page, not in the app (DM20).
|
||||||
|
*/
|
||||||
|
data class DemoInfo(
|
||||||
|
val resetsAt: String?,
|
||||||
|
val usernames: List<String>,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* The banner data from `GET /public/settings`' raw `demo` value, or null when
|
||||||
|
* there is none, which is every real shard. Forgiving field by field: a
|
||||||
|
* missing time or a malformed login drops that part, never the banner.
|
||||||
|
*/
|
||||||
|
fun from(demo: JsonElement?): DemoInfo? {
|
||||||
|
val obj = demo as? JsonObject ?: return null
|
||||||
|
val resetsAt = (obj["resetsAt"] as? JsonPrimitive)
|
||||||
|
?.takeIf { it.isString }
|
||||||
|
?.let { formatResetTime(it.content) }
|
||||||
|
val usernames = (obj["logins"] as? JsonArray).orEmpty().mapNotNull { login ->
|
||||||
|
((login as? JsonObject)?.get("username") as? JsonPrimitive)
|
||||||
|
?.takeIf { it.isString }
|
||||||
|
?.content
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
return DemoInfo(resetsAt, usernames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val RESET_TIME: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern("HH:mm 'UTC'").withZone(ZoneOffset.UTC)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "14:00 UTC" for an ISO-8601 instant, or null when it does not parse. The wipe runs
|
||||||
|
* on the hour in UTC (DM7), so the strip says UTC rather than converting, the same as
|
||||||
|
* the web's: a time in the viewer's own zone reads as a different schedule from the
|
||||||
|
* one published on `/beta`.
|
||||||
|
*/
|
||||||
|
fun formatResetTime(iso: String): String? = try {
|
||||||
|
RESET_TIME.format(Instant.parse(iso))
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The site's sign-in page as the strip prints it, `demo.runicgateway.com/account/login`:
|
||||||
|
* no scheme, and the port only when it is not the scheme's default.
|
||||||
|
*/
|
||||||
|
fun signInPageLabel(base: HttpUrl): String? {
|
||||||
|
val page = base.resolve("account/login") ?: return null
|
||||||
|
val port = if (page.port == HttpUrl.defaultPort(page.scheme)) "" else ":${page.port}"
|
||||||
|
return page.host + port + page.encodedPath
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.demo
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.runicgateway.app.core.net.DemoNotices
|
||||||
|
import com.runicgateway.app.core.result.DemoRefusal
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands the shell the public demo's refusals (DM19), for its one snackbar. Only a
|
||||||
|
* window onto [DemoNotices]: the shell collects while it is on screen.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class DemoNoticeViewModel @Inject constructor(
|
||||||
|
notices: DemoNotices,
|
||||||
|
) : ViewModel() {
|
||||||
|
val refusals: SharedFlow<DemoRefusal> = notices.refusals
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import com.runicgateway.app.ui.components.FeatureCard
|
|||||||
import com.runicgateway.app.ui.components.LoadingView
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
import com.runicgateway.app.ui.components.OnlineDot
|
import com.runicgateway.app.ui.components.OnlineDot
|
||||||
import com.runicgateway.app.ui.components.PillTone
|
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
|
* Home / status (PLAN.md §6.1): the shard's name + tagline from branding, and a
|
||||||
@@ -44,11 +45,18 @@ fun HomeScreen(
|
|||||||
viewModel: HomeViewModel = hiltViewModel(),
|
viewModel: HomeViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val demo by viewModel.demo.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
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) {
|
when (val s = state) {
|
||||||
is UiState.Loading -> LoadingView(modifier)
|
is UiState.Loading -> LoadingView(content)
|
||||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = content)
|
||||||
is UiState.Success -> HomeContent(brand, s.data, modifier)
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.api.dto.StatusDto
|
||||||
import com.runicgateway.app.data.repository.SettingsRepository
|
import com.runicgateway.app.data.repository.SettingsRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
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 com.runicgateway.app.ui.toUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -16,15 +20,27 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
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
|
@HiltViewModel
|
||||||
class HomeViewModel @Inject constructor(
|
class HomeViewModel @Inject constructor(
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow<UiState<StatusDto>>(UiState.Loading)
|
private val _state = MutableStateFlow<UiState<StatusDto>>(UiState.Loading)
|
||||||
val state: StateFlow<UiState<StatusDto>> = _state.asStateFlow()
|
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() }
|
init { load() }
|
||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
@@ -32,5 +48,11 @@ class HomeViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = settingsRepository.getStatus().toUiState()
|
_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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ object Routes {
|
|||||||
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
|
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
|
||||||
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
|
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.
|
* The in-app inbox (ENGAGEMENT.md phase 8, signed-in) and its settings.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
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.map
|
||||||
import com.runicgateway.app.data.api.dto.RosterCharDto
|
import com.runicgateway.app.data.api.dto.RosterCharDto
|
||||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||||
@@ -114,7 +115,7 @@ class CharactersViewModel @Inject constructor(
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
is ApiResult.HttpError -> _state.update {
|
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 {
|
is ApiResult.NetworkError -> _state.update {
|
||||||
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
|
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
|
else -> R.string.player_link_error
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createErrorRes(status: Int): Int = when (status) {
|
// The demo's refusal comes first: its 403 would otherwise read as "signup is off
|
||||||
409 -> R.string.player_create_taken
|
// on this shard", which is not what refused it (DEMO_MODE.md §3.1).
|
||||||
429 -> R.string.player_create_capped
|
private fun createErrorRes(result: ApiResult.HttpError): Int = when {
|
||||||
403 -> R.string.player_create_unavailable
|
result.isDemoLocked() -> R.string.error_demo_locked
|
||||||
400 -> R.string.player_create_rejected
|
result.status == 409 -> R.string.player_create_taken
|
||||||
503 -> R.string.error_shard_offline
|
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
|
else -> R.string.player_create_error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.rust
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -25,6 +27,7 @@ import androidx.compose.material3.ScrollableTabRow
|
|||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -75,13 +78,24 @@ import com.runicgateway.app.ui.components.StatusPill
|
|||||||
fun RustServerScreen(
|
fun RustServerScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenEvent: (slug: String, runId: String) -> Unit,
|
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||||
|
onSignIn: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: RustServerViewModel = hiltViewModel(),
|
viewModel: RustServerViewModel = hiltViewModel(),
|
||||||
|
safety: ChatSafetyViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val ui by viewModel.state.collectAsStateWithLifecycle()
|
val ui by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val menu by safety.menu.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
PollWhileResumed { viewModel.refresh() }
|
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) {
|
when (val s = ui.server.state) {
|
||||||
is UiState.Loading -> LoadingView(modifier)
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
|
||||||
@@ -98,7 +112,7 @@ fun RustServerScreen(
|
|||||||
ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
is UiState.Success -> ServerDetail(s.data, ui, viewModel, onOpenEvent, modifier)
|
is UiState.Success -> ServerDetail(s.data, ui, viewModel, onOpenEvent, safety::open, modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +143,7 @@ private fun ServerDetail(
|
|||||||
ui: RustServerUi,
|
ui: RustServerUi,
|
||||||
viewModel: RustServerViewModel,
|
viewModel: RustServerViewModel,
|
||||||
onOpenEvent: (slug: String, runId: String) -> Unit,
|
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||||
|
onLinePressed: (RustEventDto) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(modifier.fillMaxSize()) {
|
Column(modifier.fillMaxSize()) {
|
||||||
@@ -146,7 +161,13 @@ private fun ServerDetail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
when (ui.tab) {
|
when (ui.tab) {
|
||||||
RustTab.FEED -> FeedPanel(ui.feed, ui.filterId, viewModel::selectFilter, viewModel::retryFeed)
|
RustTab.FEED -> FeedPanel(
|
||||||
|
ui.feed,
|
||||||
|
ui.filterId,
|
||||||
|
viewModel::selectFilter,
|
||||||
|
viewModel::retryFeed,
|
||||||
|
onLinePressed,
|
||||||
|
)
|
||||||
RustTab.LEADERBOARD -> LeaderboardPanel(
|
RustTab.LEADERBOARD -> LeaderboardPanel(
|
||||||
ui.leaderboard,
|
ui.leaderboard,
|
||||||
ui.sort,
|
ui.sort,
|
||||||
@@ -279,6 +300,7 @@ private fun FeedPanel(
|
|||||||
filterId: String,
|
filterId: String,
|
||||||
onFilter: (String) -> Unit,
|
onFilter: (String) -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
|
onLinePressed: (RustEventDto) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Row(
|
Row(
|
||||||
@@ -326,7 +348,7 @@ private fun FeedPanel(
|
|||||||
if (feed.refreshFailed) {
|
if (feed.refreshFailed) {
|
||||||
item { RefreshFailedLine() }
|
item { RefreshFailedLine() }
|
||||||
}
|
}
|
||||||
items(s.data.events, key = { it.id }) { FeedRow(it) }
|
items(s.data.events, key = { it.id }) { FeedRow(it, onLinePressed) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,11 +356,34 @@ private fun FeedPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun FeedRow(row: RustEventDto) {
|
private fun FeedRow(row: RustEventDto, onLinePressed: (RustEventDto) -> Unit) {
|
||||||
val line = describe(row)
|
val line = describe(row)
|
||||||
|
val menuLabel = stringResource(R.string.safety_line_actions)
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.Top) {
|
// 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
|
// 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
|
// 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.
|
// exactly what happens the moment the feed is filtered to a past wipe.
|
||||||
@@ -370,9 +415,19 @@ private fun FeedRow(row: RustEventDto) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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 ───────────────────────────────────────────────────────────
|
// ── Leaderboard ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -258,6 +258,15 @@ class RustServerViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { askFeed() }
|
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() {
|
fun retryLeaderboard() {
|
||||||
_state.update { it.copy(leaderboard = UiState.Loading) }
|
_state.update { it.copy(leaderboard = UiState.Loading) }
|
||||||
viewModelScope.launch { askLeaderboard() }
|
viewModelScope.launch { askLeaderboard() }
|
||||||
|
|||||||
@@ -23,6 +23,15 @@
|
|||||||
<string name="error_shard_offline">The game server is offline right now.</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_feature_unavailable">This site doesn\'t publish this here.</string>
|
||||||
<string name="error_server">Something went wrong on the server. Please try again.</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) ──────────────────────────────────────── -->
|
<!-- ── First-run connect (§3) ──────────────────────────────────────── -->
|
||||||
<string name="connect_title">Connect to your community</string>
|
<string name="connect_title">Connect to your community</string>
|
||||||
@@ -188,6 +197,38 @@
|
|||||||
<string name="account_security_trusted_devices">Trusted devices</string>
|
<string name="account_security_trusted_devices">Trusted devices</string>
|
||||||
<string name="account_security_recovery_codes">Recovery codes</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 -->
|
<!-- Trusted devices screen -->
|
||||||
<string name="trusted_devices_title">Trusted devices</string>
|
<string name="trusted_devices_title">Trusted devices</string>
|
||||||
<string name="trusted_devices_subtitle">These devices can skip the authentication code at sign-in for 30 days.</string>
|
<string name="trusted_devices_subtitle">These devices can skip the authentication code at sign-in for 30 days.</string>
|
||||||
@@ -335,4 +376,27 @@
|
|||||||
<string name="events_score">Score %1$s</string>
|
<string name="events_score">Score %1$s</string>
|
||||||
<string name="events_show_more">Show more</string>
|
<string name="events_show_more">Show more</string>
|
||||||
<string name="events_loading">Loading…</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>
|
</resources>
|
||||||
|
|||||||
@@ -154,4 +154,48 @@
|
|||||||
<string name="rust_backpack_page">Page %1$d</string>
|
<string name="rust_backpack_page">Page %1$d</string>
|
||||||
<string name="rust_reach_live">%1$s · has it</string>
|
<string name="rust_reach_live">%1$s · has it</string>
|
||||||
<string name="rust_reach_waiting">%1$s · waiting</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>
|
</resources>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!--
|
<!--
|
||||||
The app is purely an HTTPS API client of a shard's website backend, so the base
|
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 /
|
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
|
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
|
cleartext. It also mirrors ServerUrl's release-build rule (HTTPS required) at the
|
||||||
platform socket layer — defense in depth.
|
platform socket layer — defense in depth.
|
||||||
|
|||||||
@@ -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,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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,4 +120,17 @@ class FakeRustApi : RustApi {
|
|||||||
error?.let { throw it }
|
error?.let { throw it }
|
||||||
return mapLiveQueue.removeFirstOrNull()?.invoke() ?: mapLive
|
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.MeApi
|
||||||
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
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.LinkedIdentityDto
|
||||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||||
@@ -71,6 +73,8 @@ class AccountTrustedDevicesTest {
|
|||||||
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
|
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
|
||||||
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
|
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
|
||||||
override suspend fun unlinkIdentity(provider: String, id: Int?) = 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)
|
private fun repo(api: MeApi) = AccountRepository(api, json)
|
||||||
|
|||||||
@@ -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
|
package com.runicgateway.app.ui
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
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.PageDto
|
||||||
import com.runicgateway.app.data.api.dto.PostDto
|
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.StatusDto
|
||||||
import com.runicgateway.app.data.api.dto.WikiPageDto
|
import com.runicgateway.app.data.api.dto.WikiPageDto
|
||||||
import com.runicgateway.app.data.api.dto.WikiSummaryDto
|
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.ContentRepository
|
||||||
import com.runicgateway.app.data.repository.SettingsRepository
|
import com.runicgateway.app.data.repository.SettingsRepository
|
||||||
import com.runicgateway.app.data.repository.WikiRepository
|
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.home.HomeViewModel
|
||||||
import com.runicgateway.app.ui.navigation.Routes
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
import com.runicgateway.app.ui.news.NewsViewModel
|
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.ui.wiki.WikiViewModel
|
||||||
import com.runicgateway.app.util.MainDispatcherRule
|
import com.runicgateway.app.util.MainDispatcherRule
|
||||||
import com.runicgateway.app.util.httpError
|
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.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -128,13 +134,33 @@ class ContentViewModelTest {
|
|||||||
// ── Home status ─────────────────────────────────────────────────────────
|
// ── Home status ─────────────────────────────────────────────────────────
|
||||||
@Test fun homeLoadsStatus() {
|
@Test fun homeLoadsStatus() {
|
||||||
api.status = StatusDto(mode = "maintenance")
|
api.status = StatusDto(mode = "maintenance")
|
||||||
val vm = HomeViewModel(settings)
|
val vm = homeViewModel()
|
||||||
assertTrue((vm.state.value as UiState.Success).data.isMaintenance)
|
assertTrue((vm.state.value as UiState.Success).data.isMaintenance)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun homeNetworkErrorIsUiError() {
|
@Test fun homeNetworkErrorIsUiError() {
|
||||||
api.error = java.io.IOException("offline")
|
api.error = java.io.IOException("offline")
|
||||||
val state = HomeViewModel(settings).state.value
|
val vm = homeViewModel()
|
||||||
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
|
assertEquals(ErrorKind.NETWORK, (vm.state.value as UiState.Error).kind)
|
||||||
|
assertNull(vm.demo.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Home: the public demo's strip (DEMO_MODE.md §6) ──
|
||||||
|
@Test fun homeDrawsNoStripOnARealShard() {
|
||||||
|
assertNull(homeViewModel().demo.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun homeReadsTheDemoBlock() {
|
||||||
|
api.settings = SettingsDto(
|
||||||
|
demo = Json.parseToJsonElement(
|
||||||
|
"""{"resetsAt":"2026-10-26T14:00:00.000Z","logins":[{"username":"demo-admin","role":"admin"}]}""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val vm = homeViewModel(base = "https://demo.runicgateway.com/")
|
||||||
|
assertEquals(DemoInfo("14:00 UTC", listOf("demo-admin")), vm.demo.value)
|
||||||
|
assertEquals("demo.runicgateway.com/account/login", vm.signInPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun homeViewModel(base: String? = null) =
|
||||||
|
HomeViewModel(settings, BaseUrlHolder().apply { set(base?.toHttpUrl()) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package com.runicgateway.app.ui
|
package com.runicgateway.app.ui
|
||||||
|
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.DEMO_LOCKED_CODE
|
||||||
import com.runicgateway.app.ui.components.isRetryable
|
import com.runicgateway.app.ui.components.isRetryable
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -64,13 +65,30 @@ class UiStateTest {
|
|||||||
|
|
||||||
@Test fun anUnavailableFeatureIsNotRetryable() {
|
@Test fun anUnavailableFeatureIsNotRetryable() {
|
||||||
// An admin controls this, so a retry button would read as a transient failure
|
// An admin controls this, so a retry button would read as a transient failure
|
||||||
// the user could wait out.
|
// the user could wait out. The demo's refusal is fixed the same way.
|
||||||
assertFalse(isRetryable(ErrorKind.FEATURE_UNAVAILABLE))
|
val fixed = setOf(ErrorKind.FEATURE_UNAVAILABLE, ErrorKind.DEMO_LOCKED)
|
||||||
for (kind in ErrorKind.entries.filter { it != ErrorKind.FEATURE_UNAVAILABLE }) {
|
for (kind in fixed) assertFalse("$kind should not offer a retry", isRetryable(kind))
|
||||||
|
for (kind in ErrorKind.entries.filter { it !in fixed }) {
|
||||||
assertTrue("$kind should offer a retry", isRetryable(kind))
|
assertTrue("$kind should offer a retry", isRetryable(kind))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── The public demo's refusal (DEMO_MODE.md §3.1) ──
|
||||||
|
|
||||||
|
@Test fun theDemosRefusalIsItsOwnKind() {
|
||||||
|
val refused = ApiResult.HttpError(403, code = DEMO_LOCKED_CODE)
|
||||||
|
assertEquals(ErrorKind.DEMO_LOCKED, (refused.toUiState() as UiState.Error).kind)
|
||||||
|
assertEquals(403, (refused.toUiState() as UiState.Error).httpStatus)
|
||||||
|
// On a shard read a 403 would otherwise mean "not published to you".
|
||||||
|
assertEquals(ErrorKind.DEMO_LOCKED, (refused.toShardUiState() as UiState.Error).kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anotherCodeLeavesTheStatusMapping() {
|
||||||
|
val other = ApiResult.HttpError(403, code = "FORBIDDEN")
|
||||||
|
assertEquals(ErrorKind.SERVER, (other.toUiState() as UiState.Error).kind)
|
||||||
|
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (other.toShardUiState() as UiState.Error).kind)
|
||||||
|
}
|
||||||
|
|
||||||
private fun kindOf(status: Int): ErrorKind =
|
private fun kindOf(status: Int): ErrorKind =
|
||||||
(ApiResult.HttpError(status).toUiState() as UiState.Error).kind
|
(ApiResult.HttpError(status).toUiState() as UiState.Error).kind
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi
|
|||||||
import com.runicgateway.app.data.repository.AdminRepository
|
import com.runicgateway.app.data.repository.AdminRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.util.MainDispatcherRule
|
import com.runicgateway.app.util.MainDispatcherRule
|
||||||
|
import com.runicgateway.app.util.demoLockedError
|
||||||
|
import com.runicgateway.app.util.demoLockedUnit
|
||||||
import com.runicgateway.app.util.errorUnit
|
import com.runicgateway.app.util.errorUnit
|
||||||
import com.runicgateway.app.util.httpError
|
import com.runicgateway.app.util.httpError
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -63,6 +65,15 @@ class AdminContentViewModelTest {
|
|||||||
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
|
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun theDemosRefusalIsNotBlamedOnTheRole() {
|
||||||
|
// Somebody signed in as demo-admin must not read "you don't have permission".
|
||||||
|
api.unitResponse = demoLockedUnit()
|
||||||
|
api.error = demoLockedError()
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.togglePublish(AdminPostDto(id = 5, title = "x", published = 1))
|
||||||
|
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun createCategoryRejectsBlankFields() {
|
@Test fun createCategoryRejectsBlankFields() {
|
||||||
val vm = viewModel()
|
val vm = viewModel()
|
||||||
vm.createCategory(slug = "", title = "", description = "", sortOrder = null)
|
vm.createCategory(slug = "", title = "", description = "", sortOrder = null)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi
|
|||||||
import com.runicgateway.app.data.repository.AdminRepository
|
import com.runicgateway.app.data.repository.AdminRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.util.MainDispatcherRule
|
import com.runicgateway.app.util.MainDispatcherRule
|
||||||
|
import com.runicgateway.app.util.demoLockedError
|
||||||
import com.runicgateway.app.util.httpError
|
import com.runicgateway.app.util.httpError
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -60,6 +61,16 @@ class AdminDashboardViewModelTest {
|
|||||||
assertEquals(R.string.admin_forbidden, fb.messageRes)
|
assertEquals(R.string.admin_forbidden, fb.messageRes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun setSiteModeOnTheDemoShowsTheDemoLine() {
|
||||||
|
api.dashboard = AdminDashboardDto()
|
||||||
|
api.error = demoLockedError()
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.setSiteMode("maintenance")
|
||||||
|
val fb = vm.state.value.feedback!!
|
||||||
|
assertFalse(fb.ok)
|
||||||
|
assertEquals(R.string.error_demo_locked, fb.messageRes)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun clearFeedbackResetsBanner() {
|
@Test fun clearFeedbackResetsBanner() {
|
||||||
api.error = httpError(500)
|
api.error = httpError(500)
|
||||||
val vm = viewModel()
|
val vm = viewModel()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.runicgateway.app.R
|
|||||||
import com.runicgateway.app.data.api.fake.FakeAdminApi
|
import com.runicgateway.app.data.api.fake.FakeAdminApi
|
||||||
import com.runicgateway.app.data.repository.AdminRepository
|
import com.runicgateway.app.data.repository.AdminRepository
|
||||||
import com.runicgateway.app.util.MainDispatcherRule
|
import com.runicgateway.app.util.MainDispatcherRule
|
||||||
|
import com.runicgateway.app.util.demoLockedUnit
|
||||||
import com.runicgateway.app.util.errorUnit
|
import com.runicgateway.app.util.errorUnit
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -48,6 +49,13 @@ class AdminModerationViewModelTest {
|
|||||||
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
|
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun kickOnTheDemoShowsTheDemoLine() {
|
||||||
|
api.unitResponse = demoLockedUnit()
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.kick(account = "x", serial = "")
|
||||||
|
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun broadcastRejectsBlankText() {
|
@Test fun broadcastRejectsBlankText() {
|
||||||
val vm = viewModel()
|
val vm = viewModel()
|
||||||
vm.broadcast(text = " ", hue = null)
|
vm.broadcast(text = " ", hue = null)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi
|
|||||||
import com.runicgateway.app.data.repository.AdminRepository
|
import com.runicgateway.app.data.repository.AdminRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.util.MainDispatcherRule
|
import com.runicgateway.app.util.MainDispatcherRule
|
||||||
|
import com.runicgateway.app.util.demoLockedUnit
|
||||||
import com.runicgateway.app.util.errorUnit
|
import com.runicgateway.app.util.errorUnit
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -52,6 +53,13 @@ class AdminSupportViewModelTest {
|
|||||||
assertEquals(R.string.admin_support_unknown_page, vm.state.value.feedback!!.messageRes)
|
assertEquals(R.string.admin_support_unknown_page, vm.state.value.feedback!!.messageRes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun closeOnTheDemoShowsTheDemoLine() {
|
||||||
|
api.unitResponse = demoLockedUnit()
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.close("0x1")
|
||||||
|
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun closeShardOfflineMapsTo503Copy() {
|
@Test fun closeShardOfflineMapsTo503Copy() {
|
||||||
api.unitResponse = errorUnit(503)
|
api.unitResponse = errorUnit(503)
|
||||||
val vm = viewModel()
|
val vm = viewModel()
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.auth
|
||||||
|
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.repository.AccountRepository.DeleteRefusal
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** The Delete my account screen's rules (ACCOUNT_DELETION.md §4.3). */
|
||||||
|
class AccountDeletionTest {
|
||||||
|
|
||||||
|
@Test fun theTypedNameMatchesIgnoringCaseAndSpaces() {
|
||||||
|
assertTrue(deleteUsernameMatches("Ravenna", "Ravenna"))
|
||||||
|
assertTrue(deleteUsernameMatches("ravenna", "Ravenna"))
|
||||||
|
assertTrue(deleteUsernameMatches(" RAVENNA ", "Ravenna"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anythingElseDoesNotMatch() {
|
||||||
|
assertFalse(deleteUsernameMatches("", "Ravenna"))
|
||||||
|
assertFalse(deleteUsernameMatches(" ", "Ravenna"))
|
||||||
|
assertFalse(deleteUsernameMatches("Raven", "Ravenna"))
|
||||||
|
assertFalse(deleteUsernameMatches("Ravenna2", "Ravenna"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun everyRoleButPlayerIsStaff() {
|
||||||
|
assertFalse(isStaffRole("player"))
|
||||||
|
assertFalse(isStaffRole(""))
|
||||||
|
assertTrue(isStaffRole("moderator"))
|
||||||
|
assertTrue(isStaffRole("editor"))
|
||||||
|
assertTrue(isStaffRole("admin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun eachRefusalHasItsSentence() {
|
||||||
|
assertEquals(R.string.account_delete_error_username, deleteRefusalRes(DeleteRefusal.USERNAME_MISMATCH))
|
||||||
|
assertEquals(R.string.account_delete_error_password, deleteRefusalRes(DeleteRefusal.WRONG_PASSWORD))
|
||||||
|
assertEquals(R.string.account_delete_error_code_required, deleteRefusalRes(DeleteRefusal.CODE_REQUIRED))
|
||||||
|
assertEquals(R.string.account_delete_error_code, deleteRefusalRes(DeleteRefusal.WRONG_CODE))
|
||||||
|
assertEquals(R.string.account_delete_no_password, deleteRefusalRes(DeleteRefusal.PASSWORD_REQUIRED))
|
||||||
|
assertEquals(R.string.account_delete_error_last_admin, deleteRefusalRes(DeleteRefusal.LAST_ADMIN))
|
||||||
|
assertEquals(R.string.error_demo_locked, deleteRefusalRes(DeleteRefusal.DEMO_LOCKED))
|
||||||
|
assertEquals(R.string.account_delete_error_rate_limited, deleteRefusalRes(DeleteRefusal.RATE_LIMITED))
|
||||||
|
assertEquals(R.string.account_delete_error, deleteRefusalRes(DeleteRefusal.OTHER))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.auth
|
||||||
|
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.HiddenItemDto
|
||||||
|
import com.runicgateway.app.data.api.dto.UserBlockDto
|
||||||
|
import com.runicgateway.app.data.api.fake.FakeSafetyApi
|
||||||
|
import com.runicgateway.app.data.repository.SafetyRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.util.MainDispatcherRule
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.IOException
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/** Account → Blocked (USER_SAFETY.md §4.3). */
|
||||||
|
class BlockedViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val dispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
private val api = FakeSafetyApi()
|
||||||
|
|
||||||
|
private fun viewModel() = BlockedViewModel(SafetyRepository(api))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `it lists the blocks and counts the hidden items`() {
|
||||||
|
api.blockList += UserBlockDto(id = 1, name = "rowan")
|
||||||
|
api.hiddenList += HiddenItemDto("rust.chat", 4)
|
||||||
|
api.hiddenList += HiddenItemDto("team_forum_post", 9)
|
||||||
|
|
||||||
|
val state = viewModel().state.value
|
||||||
|
|
||||||
|
assertEquals(listOf("rowan"), (state.blocks as UiState.Success).data.map { it.name })
|
||||||
|
assertEquals(2, state.hiddenCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unblocking takes the row away and names who`() {
|
||||||
|
api.blockList += UserBlockDto(id = 1, name = "rowan")
|
||||||
|
val vm = viewModel()
|
||||||
|
|
||||||
|
vm.unblock(UserBlockDto(id = 1, name = "rowan"))
|
||||||
|
|
||||||
|
assertTrue((vm.state.value.blocks as UiState.Success).data.isEmpty())
|
||||||
|
val fb = vm.state.value.feedback!!
|
||||||
|
assertTrue(fb.ok)
|
||||||
|
assertEquals(R.string.blocked_unblocked_named, fb.messageRes)
|
||||||
|
assertEquals("rowan", fb.arg)
|
||||||
|
assertFalse(vm.state.value.busy)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a block that is already gone says it could not unblock`() {
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.unblock(UserBlockDto(id = 99, name = "ghost"))
|
||||||
|
|
||||||
|
assertEquals(R.string.blocked_unblock_failed, vm.state.value.feedback!!.messageRes)
|
||||||
|
assertFalse(vm.state.value.feedback!!.ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `show them again clears every hide`() {
|
||||||
|
api.hiddenList += HiddenItemDto("rust.chat", 4)
|
||||||
|
val vm = viewModel()
|
||||||
|
|
||||||
|
vm.showHidden()
|
||||||
|
|
||||||
|
assertEquals(0, vm.state.value.hiddenCount)
|
||||||
|
assertEquals(R.string.blocked_hidden_cleared, vm.state.value.feedback!!.messageRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failed read of the blocks is an error to retry`() {
|
||||||
|
api.error = IOException("offline")
|
||||||
|
val vm = viewModel()
|
||||||
|
assertTrue(vm.state.value.blocks is UiState.Error)
|
||||||
|
|
||||||
|
vm.load()
|
||||||
|
assertTrue(vm.state.value.blocks is UiState.Success)
|
||||||
|
assertNull(vm.state.value.feedback)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a block's date is read in the reader's zone, and a missing one is none`() {
|
||||||
|
assertEquals(blockedOn("2026-10-30 23:30:00", ZoneId.of("UTC")), blockedOn("2026-10-30T23:30:00Z", ZoneId.of("UTC")))
|
||||||
|
assertTrue(blockedOn("2026-10-30T23:30:00Z", ZoneId.of("UTC"))!!.contains("30"))
|
||||||
|
assertTrue(blockedOn("2026-10-30T23:30:00Z", ZoneId.of("Asia/Tokyo"))!!.contains("31"))
|
||||||
|
assertNull(blockedOn(null))
|
||||||
|
assertNull(blockedOn("not a date"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import com.runicgateway.app.data.api.fake.FakePublicApi
|
|||||||
import com.runicgateway.app.data.repository.ContactRepository
|
import com.runicgateway.app.data.repository.ContactRepository
|
||||||
import com.runicgateway.app.ui.ErrorKind
|
import com.runicgateway.app.ui.ErrorKind
|
||||||
import com.runicgateway.app.util.MainDispatcherRule
|
import com.runicgateway.app.util.MainDispatcherRule
|
||||||
|
import com.runicgateway.app.util.demoLockedError
|
||||||
import com.runicgateway.app.util.httpError
|
import com.runicgateway.app.util.httpError
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -64,4 +65,14 @@ class ContactViewModelTest {
|
|||||||
assertTrue(result is ContactViewModel.Result.Failed)
|
assertTrue(result is ContactViewModel.Result.Failed)
|
||||||
assertEquals(ErrorKind.SERVER, (result as ContactViewModel.Result.Failed).kind)
|
assertEquals(ErrorKind.SERVER, (result as ContactViewModel.Result.Failed).kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun theDemosLockedContactFormSaysSo() {
|
||||||
|
// The contact form is locked on the demo (DEMO_MODE.md §3.3).
|
||||||
|
api.error = demoLockedError()
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m")
|
||||||
|
vm.send()
|
||||||
|
val result = vm.state.value.result as ContactViewModel.Result.Failed
|
||||||
|
assertEquals(ErrorKind.DEMO_LOCKED, result.kind)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.demo
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** The Home strip's data (DEMO_MODE.md §6). */
|
||||||
|
class DemoInfoTest {
|
||||||
|
|
||||||
|
private fun parse(json: String) = DemoInfo.from(Json.parseToJsonElement(json))
|
||||||
|
|
||||||
|
@Test fun readsTheServersShape() {
|
||||||
|
val info = parse(
|
||||||
|
"""{"resetsAt":"2026-10-26T14:00:00.000Z","logins":[
|
||||||
|
{"username":"demo-admin","password":"x","role":"admin"},
|
||||||
|
{"username":"demo-player","password":"y","role":"player"}]}""",
|
||||||
|
)
|
||||||
|
assertEquals(DemoInfo("14:00 UTC", listOf("demo-admin", "demo-player")), info)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun noBlockMeansNoBanner() {
|
||||||
|
assertNull(DemoInfo.from(null))
|
||||||
|
assertNull(DemoInfo.from(JsonNull))
|
||||||
|
assertNull(parse("""["demo"]"""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aBadPartDropsThatPartOnly() {
|
||||||
|
assertEquals(
|
||||||
|
DemoInfo(null, listOf("demo-admin")),
|
||||||
|
parse("""{"resetsAt":"soon","logins":[{"username":"demo-admin"},{"username":""},{"name":"x"},7]}"""),
|
||||||
|
)
|
||||||
|
assertEquals(DemoInfo(null, emptyList()), parse("""{"resetsAt":42,"logins":"demo-admin"}"""))
|
||||||
|
assertEquals(DemoInfo(null, emptyList()), parse("{}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theTimeIsSaidInUtc() {
|
||||||
|
// Not converted to the phone's zone: it must match the schedule on /beta.
|
||||||
|
assertEquals("14:00 UTC", formatResetTime("2026-10-26T14:00:00Z"))
|
||||||
|
assertEquals("00:05 UTC", formatResetTime("2026-10-26T00:05:59.999Z"))
|
||||||
|
assertNull(formatResetTime("14:00"))
|
||||||
|
assertNull(formatResetTime(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theSignInPageIsPrintedWithoutTheScheme() {
|
||||||
|
assertEquals(
|
||||||
|
"demo.runicgateway.com/account/login",
|
||||||
|
signInPageLabel("https://demo.runicgateway.com/".toHttpUrl()),
|
||||||
|
)
|
||||||
|
assertEquals("10.0.2.2:3000/account/login", signInPageLabel("http://10.0.2.2:3000/".toHttpUrl()))
|
||||||
|
assertEquals("example.com/site/account/login", signInPageLabel("https://example.com/site/".toHttpUrl()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import com.runicgateway.app.data.repository.PlayerShardRepository
|
|||||||
import com.runicgateway.app.data.repository.SettingsRepository
|
import com.runicgateway.app.data.repository.SettingsRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.util.MainDispatcherRule
|
import com.runicgateway.app.util.MainDispatcherRule
|
||||||
|
import com.runicgateway.app.util.demoLockedError
|
||||||
import com.runicgateway.app.util.httpError
|
import com.runicgateway.app.util.httpError
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -86,4 +87,11 @@ class CharactersViewModelTest {
|
|||||||
vm.createAccount(account = "acct1", password = "longenough")
|
vm.createAccount(account = "acct1", password = "longenough")
|
||||||
assertEquals(R.string.player_create_taken, vm.state.value.feedback!!.messageRes)
|
assertEquals(R.string.player_create_taken, vm.state.value.feedback!!.messageRes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun createAccountOnTheDemoIsNotSignupBeingOff() {
|
||||||
|
playerApi.error = demoLockedError()
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.createAccount(account = "acct1", password = "longenough")
|
||||||
|
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.auth.Role
|
||||||
|
import com.runicgateway.app.core.auth.SessionUser
|
||||||
|
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||||
|
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
|
||||||
|
|
||||||
|
/** What a chat line's menu offers (USER_SAFETY.md §4.3, S6, S8, S10, S13). */
|
||||||
|
class ChatSafetyTest {
|
||||||
|
|
||||||
|
private fun chat(canBlock: Boolean? = null, hidden: Boolean = false) =
|
||||||
|
RustEventDto(id = 1, kind = "player.chat", canBlock = canBlock, hidden = hidden)
|
||||||
|
|
||||||
|
private fun user(role: Role) = SessionUser(id = 9, username = "reader", role = role)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a row that is not a chat line has no menu`() {
|
||||||
|
assertNull(chatActions(RustEventDto(kind = "player.death"), user(Role.PLAYER)))
|
||||||
|
assertNull(chatActions(RustEventDto(kind = "player.death"), null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `signed out, the menu only says to sign in`() {
|
||||||
|
val actions = chatActions(chat(canBlock = true), null)!!
|
||||||
|
assertTrue(actions.signIn)
|
||||||
|
assertFalse(actions.report || actions.hide || actions.block)
|
||||||
|
assertNull(actions.staff)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a member reports and hides any line, and blocks only where the server said so`() {
|
||||||
|
val yes = chatActions(chat(canBlock = true), user(Role.PLAYER))!!
|
||||||
|
assertTrue(yes.report && yes.hide && yes.block)
|
||||||
|
assertNull(yes.staff)
|
||||||
|
|
||||||
|
assertFalse(chatActions(chat(canBlock = false), user(Role.PLAYER))!!.block)
|
||||||
|
// Absent is "not said", and not saying is not permission.
|
||||||
|
assertFalse(chatActions(chat(canBlock = null), user(Role.PLAYER))!!.block)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `admins and moderators hide for everyone, and an editor does not`() {
|
||||||
|
assertEquals(StaffHide.HIDE, chatActions(chat(), user(Role.MODERATOR))!!.staff)
|
||||||
|
assertEquals(StaffHide.HIDE, chatActions(chat(), user(Role.ADMIN))!!.staff)
|
||||||
|
assertNull(chatActions(chat(), user(Role.EDITOR))!!.staff)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on a line staff hid, staff are offered to show it again`() {
|
||||||
|
assertEquals(StaffHide.UNHIDE, chatActions(chat(hidden = true), user(Role.MODERATOR))!!.staff)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a feed row carries canBlock and hidden beside its frame`() {
|
||||||
|
val json = Json { ignoreUnknownKeys = true }
|
||||||
|
val row = json.decodeFromString<RustEventDto>(
|
||||||
|
"""{"id":5,"kind":"player.chat","t":1,"steamId":"76561198000000001",
|
||||||
|
"frame":{"name":"Brannock","message":"hi"},"canBlock":true,"hidden":true}""",
|
||||||
|
)
|
||||||
|
assertEquals(true, row.canBlock)
|
||||||
|
assertTrue(row.hidden)
|
||||||
|
|
||||||
|
val signedOut = json.decodeFromString<RustEventDto>("""{"id":5,"kind":"player.chat","frame":{}}""")
|
||||||
|
assertNull(signedOut.canBlock)
|
||||||
|
assertFalse(signedOut.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.rust
|
||||||
|
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.core.auth.StoredSession
|
||||||
|
import com.runicgateway.app.core.auth.TokenStore
|
||||||
|
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||||
|
import com.runicgateway.app.data.api.fake.FakeRustApi
|
||||||
|
import com.runicgateway.app.data.api.fake.FakeSafetyApi
|
||||||
|
import com.runicgateway.app.data.api.fake.FakeSafetyApi.Companion.httpError
|
||||||
|
import com.runicgateway.app.data.repository.RustRepository
|
||||||
|
import com.runicgateway.app.data.repository.SafetyRepository
|
||||||
|
import com.runicgateway.app.util.MainDispatcherRule
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chat line's menu (USER_SAFETY.md §4.3): what each action calls, and that the
|
||||||
|
* feed is re-read after anything that changes it — and not after a report.
|
||||||
|
*/
|
||||||
|
class ChatSafetyViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val dispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
private class Store(var stored: StoredSession?) : TokenStore {
|
||||||
|
override fun load(): StoredSession? = stored
|
||||||
|
override fun save(session: StoredSession) { stored = session }
|
||||||
|
override fun clear() { stored = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val safetyApi = FakeSafetyApi()
|
||||||
|
private val rustApi = FakeRustApi()
|
||||||
|
|
||||||
|
private fun viewModel(role: String? = "player") = ChatSafetyViewModel(
|
||||||
|
SafetyRepository(safetyApi),
|
||||||
|
RustRepository(rustApi),
|
||||||
|
SessionManager(Store(role?.let { StoredSession("a", "r", 9, "reader", it) })),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val line = RustEventDto(
|
||||||
|
id = 42,
|
||||||
|
kind = "player.chat",
|
||||||
|
canBlock = true,
|
||||||
|
frame = buildJsonObject {
|
||||||
|
put("name", JsonPrimitive("Brannock"))
|
||||||
|
put("message", JsonPrimitive("see you in september"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Whether the view model asked for the feed to be re-read (without waiting forever). */
|
||||||
|
private suspend fun ChatSafetyViewModel.askedForFeed(): Boolean =
|
||||||
|
withTimeoutOrNull(1_000) { changes.first() } != null
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hide for me hides that line, closes the menu and re-reads the feed`() = runTest {
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.open(line)
|
||||||
|
vm.hideForMe()
|
||||||
|
|
||||||
|
assertEquals("rust.chat", safetyApi.hides.single().targetType)
|
||||||
|
assertEquals(42L, safetyApi.hides.single().targetId)
|
||||||
|
assertNull(vm.menu.value.line)
|
||||||
|
assertTrue(vm.askedForFeed())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a report says it was sent and does not re-read the feed`() = runTest {
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.open(line)
|
||||||
|
vm.go(ChatMenuStep.REPORT)
|
||||||
|
vm.setReason("abuse")
|
||||||
|
vm.setDetail("again")
|
||||||
|
vm.report()
|
||||||
|
|
||||||
|
val sent = safetyApi.reports.single()
|
||||||
|
assertEquals("abuse", sent.reason)
|
||||||
|
assertEquals("again", sent.detail)
|
||||||
|
assertEquals(ChatMenuStep.DONE, vm.menu.value.step)
|
||||||
|
assertEquals(R.string.safety_reported, vm.menu.value.doneRes)
|
||||||
|
assertTrue(!vm.askedForFeed())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a refused report stays on the form with words for why`() = runTest {
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.open(line)
|
||||||
|
vm.go(ChatMenuStep.REPORT)
|
||||||
|
safetyApi.error = httpError(409)
|
||||||
|
vm.report()
|
||||||
|
|
||||||
|
assertEquals(ChatMenuStep.REPORT, vm.menu.value.step)
|
||||||
|
assertEquals(R.string.safety_error_already_reported, vm.menu.value.errorRes)
|
||||||
|
|
||||||
|
// Moving between steps drops the old refusal.
|
||||||
|
vm.go(ChatMenuStep.MENU)
|
||||||
|
assertNull(vm.menu.value.errorRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blocking staff is refused in the block's own words`() = runTest {
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.open(line)
|
||||||
|
vm.go(ChatMenuStep.BLOCK)
|
||||||
|
safetyApi.error = httpError(403)
|
||||||
|
vm.block()
|
||||||
|
|
||||||
|
assertEquals(R.string.safety_error_staff, vm.menu.value.errorRes)
|
||||||
|
assertNotNull(vm.menu.value.line)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a block re-reads the feed`() = runTest {
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.open(line)
|
||||||
|
vm.block()
|
||||||
|
|
||||||
|
assertEquals(42L, safetyApi.blockRequests.single().targetId)
|
||||||
|
assertTrue(vm.askedForFeed())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `staff hide and unhide through the module's own route`() = runTest {
|
||||||
|
val vm = viewModel(role = "moderator")
|
||||||
|
vm.open(line)
|
||||||
|
vm.staffHide(true)
|
||||||
|
vm.open(line.copy(hidden = true))
|
||||||
|
vm.staffHide(false)
|
||||||
|
|
||||||
|
assertEquals(listOf(42L to true, 42L to false), rustApi.chatHides)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a row that is not a chat line opens nothing`() = runTest {
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.open(RustEventDto(id = 3, kind = "player.death"))
|
||||||
|
assertNull(vm.menu.value.line)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `signed out, the menu opens to say sign in and nothing else`() = runTest {
|
||||||
|
val vm = viewModel(role = null)
|
||||||
|
vm.open(line)
|
||||||
|
assertNotNull(vm.menu.value.line)
|
||||||
|
assertTrue(chatActions(line, vm.user)!!.signIn)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,3 +25,14 @@ fun okUnit(): Response<Unit> = Response.success(Unit)
|
|||||||
|
|
||||||
/** A non-2xx bodyless [Response] with [code]. */
|
/** A non-2xx bodyless [Response] with [code]. */
|
||||||
fun errorUnit(code: Int): Response<Unit> = Response.error(code, "{}".toResponseBody(JSON))
|
fun errorUnit(code: Int): Response<Unit> = Response.error(code, "{}".toResponseBody(JSON))
|
||||||
|
|
||||||
|
/** The public demo's refusal body (DEMO_MODE.md §3.1), as the server sends it. */
|
||||||
|
const val DEMO_LOCKED_BODY =
|
||||||
|
"""{"message":"This is turned off on the demo site.","code":"DEMO_LOCKED"}"""
|
||||||
|
|
||||||
|
/** The [HttpException] a fake API throws for a write the public demo refuses. */
|
||||||
|
fun demoLockedError(): HttpException =
|
||||||
|
HttpException(Response.error<Any>(403, DEMO_LOCKED_BODY.toResponseBody(JSON)))
|
||||||
|
|
||||||
|
/** The `Response<Unit>` of a write the public demo refuses. */
|
||||||
|
fun demoLockedUnit(): Response<Unit> = Response.error(403, DEMO_LOCKED_BODY.toResponseBody(JSON))
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
[versions]
|
[versions]
|
||||||
# Build toolchain
|
# Build toolchain
|
||||||
agp = "8.6.1"
|
agp = "8.13.2"
|
||||||
kotlin = "2.0.20"
|
kotlin = "2.0.20"
|
||||||
ksp = "2.0.20-1.0.25"
|
ksp = "2.0.20-1.0.25"
|
||||||
|
|
||||||
|
|||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
Reference in New Issue
Block a user