12 Commits

Author SHA1 Message Date
02fb7d47d5 Merge pull request 'chore(release): cut edge over to main — Google Play, account deletion, report/block/hide (v0.7.0)' (#65) from edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 30s
SonarQube / analysis (push) Failing after 2m16s
Release APK / release (push) Failing after 27m20s
Reviewed-on: #65
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-30 22:26:16 +00:00
6630d05e76 Merge branch 'main' into edge
All checks were successful
PR Checks / android-build (pull_request) Successful in 3m3s
2026-10-30 22:23:03 +00:00
b20abdf4ba Merge pull request 'feat(safety): report, block and hide chat lines in the app' (#64) from feat/user-safety into edge
All checks were successful
PR Checks / android-build (pull_request) Successful in 4m41s
Reviewed-on: #64
2026-10-30 15:30:14 +00:00
5386889ef4 feat(safety): report, block and hide chat lines in the app
All checks were successful
PR Checks / android-build (pull_request) Successful in 24m48s
USER_SAFETY.md step 4 (§4.3): the app half of report, block and hide.

- Long-press a Rust chat line for its menu: Report (the six reasons and an
  optional detail), Hide for me, Block player where the line says canBlock,
  and for admins and moderators Hide for everyone, or Show to everyone again
  on a hidden line. Signed out, it says Sign in to report or block and opens
  the login screen (S6).
- The feed is re-read after a hide, a block or a staff hide, never edited in
  the app: the server filters it per reader (S11). A report changes nothing
  and does not re-read.
- Staff see a line hidden for everyone dimmed and marked Hidden by staff (S10).
- Account -> Blocked lists account and Steam-ID blocks with Unblock, says when
  a block no longer applies because its target is now staff (S8), and offers
  Show them again for hidden items.
- Core's routes on a new SafetyApi; the module's two staff routes on RustApi.
  RustEventDto gains canBlock and hidden.

Two fixes from the emulator walk: Back on the report form or the block
confirmation now steps back to the menu instead of closing the sheet (Back
to put the keyboard away was throwing a half-written report away), and the
sheet opens fully so Send report is on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 09:56:25 -05:00
d0c5bd6ddf Merge pull request 'feat(account): delete my account in the app' (#63) from feat/account-deletion into edge
Reviewed-on: #63
2026-10-30 05:27:57 +00:00
6be83fe70a feat(account): delete my account in the app
All checks were successful
PR Checks / android-build (pull_request) Successful in 14m55s
ACCOUNT_DELETION.md §4.3 (step 4). My account gains a Delete my account
row, last and set apart, which opens its own screen: what goes, what stays,
the staff note, then the typed username, the password and, when two-factor is
on, an authenticator or recovery code. Delete stays off until the name matches.

MeApi.deleteAccount sends DELETE /auth/me/account and reads the raw response,
so each refusal's code becomes its own sentence (the website's words). A
wrong password is a 400 (AD12), so the authenticator never replays it.

On success AuthRepository.forgetDeletedAccount does what a sign-out does
without its server calls: the inbox snapshot, the trust token (which a
sign-out keeps, but the account is gone) and the session, whose teardown
also drops the push registration. It is not cancellable. The route holds the
view model, so it tells a deletion from any other sign-out, and opens sign-in
with "Your account has been deleted." on the shell's snackbar.

Tests: the refusal mapping per code, 429, unknown codes, no answer, the
request body; the confirm rule, the staff rule, each refusal's sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 00:13:34 -05:00
19c2f77a98 Merge pull request 'fix(push): hold the ntfy connection as specialUse and restart it on boot' (#62) from fix/push-special-use into edge
Reviewed-on: #62
2026-10-29 13:10:17 +00:00
690441a8c3 fix(push): hold the ntfy connection as specialUse and restart it on boot
All checks were successful
PR Checks / android-build (pull_request) Successful in 24m52s
PushService was a dataSync foreground service. Android 15 stops dataSync
after six hours a day and expects the service to stop itself on
onTimeout; it never did, so with target 36 the app would crash. A held-open
push connection is not a transfer either, which is what Play reserves
dataSync for.

- The service is now specialUse, with the PROPERTY_SPECIAL_USE_FGS_SUBTYPE
  reason Play reviews (ntfy does the same on Play). No daily limit.
- PushBootReceiver restarts it after BOOT_COMPLETED and MY_PACKAGE_REPLACED
  when push was on and a registration is saved (a sign-out clears it). It
  starts the service from the saved topic instead of re-registering, since
  the network is often not up at boot.

PLAN.md M8, decided by the org lead 2026-10-29.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-29 07:47:43 -05:00
ac60427a3e Merge pull request 'feat(demo): explain the public demo's refusals and show its strip on Home' (#61) from feat/demo-mode into edge
Reviewed-on: #61
2026-10-26 14:18:12 +00:00
1579a1982c Merge pull request 'feat(release): target API 36 and upload each tag's bundle to Google Play (M8 step 1)' (#60) from feat/m8-google-play into edge
Reviewed-on: #60
2026-10-26 14:17:56 +00:00
aca358d72b feat(demo): explain the public demo's refusals and show its strip on Home
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m2s
Step 4 of docs website/DEMO_MODE.md §10 (website#222).

- DemoLockInterceptor on the main client peeks every 403 body. On
  code DEMO_LOCKED it reports the server's sentence to DemoNotices, and the
  shell shows it as one snackbar, like the web's toast (DM19). A newer refusal
  replaces the one showing. Screens added later get it without doing anything.
- ApiResult.HttpError carries the body's code (read only on a 403, peeked so
  the body is not consumed); isDemoLocked(). ErrorKind.DEMO_LOCKED is mapped
  ahead of the status in toUiState and toShardUiState, and is not retryable.
- Screens whose 403 copy blames the role now say the demo sentence instead:
  the four admin view models, Characters' account creation, and the contact
  form's line (the contact form is locked on the demo).
- Home draws the strip from GET /public/settings' demo block: the wipe time in
  UTC, the published usernames, and the site's sign-in page for the passwords.
  The app does not list the passwords (DM20). A real shard sends no block, so
  it draws nothing.

Checked on an emulator against website main (69998ec) with DEMO_MODE=true and
seed:demo: the strip on Home; demo-player's username change refused with the
snackbar under the screen's own line; demo-admin's site-mode switch refused
with the demo sentence in place of "You don't have permission".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 06:29:16 -05:00
7a8231ba9c feat(release): target API 36 and upload each tag's bundle to Google Play
All checks were successful
PR Checks / android-build (pull_request) Successful in 21m22s
Step 1 of M8 (docs/android/PLAN.md item 9).

- compileSdk/targetSdk 36 (Play's floor since 2026-08-31). AGP 8.6.1
  cannot build against 36, so AGP goes to 8.13.2 (the last 8.x) with the
  Gradle 8.14.3 wrapper. Kotlin, KSP and Hilt are unchanged. All three
  workflows install platforms;android-36; AGP 8.13 still defaults to
  build-tools 35.0.0.
- release.yml also runs bundleRelease, signed with the same keystore,
  and attaches the .aab to the Gitea release beside the APK (the first
  bundle has to be uploaded by hand, and this is where it comes from).
- A last step uploads the bundle to Play's internal track through the
  Play Developer API v3 edit flow. The OAuth token comes from a
  service-account JWT signed with openssl; the key is the new
  PLAY_SERVICE_ACCOUNT_JSON secret. While that secret is unset, the
  step warns and skips. The step runs after the Gitea release, so a
  Play failure never holds back the APK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-23 07:03:54 -05:00
76 changed files with 3537 additions and 67 deletions

View File

@@ -63,7 +63,7 @@ jobs:
- name: Install Android SDK packages
run: |
set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
- name: Cache Gradle
uses: actions/cache@v4

View File

@@ -18,6 +18,17 @@
# ANDROID_KEYSTORE_PASSWORD — keystore password
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
# ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore)
# PLAY_SERVICE_ACCOUNT_JSON — Google Cloud service-account key (the whole JSON file)
# with release rights on com.runicgateway.app only. Unset,
# the Play step is skipped with a warning (PLAN.md M8).
#
# Google Play (PLAN.md M8): the same keystore also signs an app bundle (.aab). After
# the Gitea release exists, the bundle is uploaded to Play's *internal* track through
# the Play Developer API. Promotion to closed testing / production stays a click in
# the console, so a tag never reaches testers or review on its own. Google requires
# the app's FIRST bundle to be uploaded by hand: take it from the Gitea release
# assets. The Play step runs last, so a Play failure fails the run but never holds
# back the APK release.
#
# Runner handling matches pr-checks.yml (self-hosted `ubuntu-latest`): the container
# lacks git/curl/unzip and can't reach api.adoptium.net, so we apt-install the base
@@ -56,7 +67,7 @@ jobs:
- name: Install base tools + JDK 17
run: |
apt-get update
apt-get install -y git curl unzip jq openjdk-17-jdk-headless
apt-get install -y git curl unzip jq openssl openjdk-17-jdk-headless
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
- name: Check out the release tag (full history for the changelog)
@@ -103,6 +114,7 @@ jobs:
echo
echo "---"
echo "Signed APK — sideload on Android 10+ (§10). The app self-configures its shard site on first run."
echo "The .aab is the same build as a Google Play bundle; it does not install on a phone."
} > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
@@ -123,7 +135,7 @@ jobs:
- name: Install Android SDK packages
run: |
set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
- name: Decode signing keystore
env:
@@ -148,7 +160,7 @@ jobs:
sed -i -E "s/(toIntOrNull\(\) \?: )[0-9]+/\1${VERSION_CODE}/" "${GRADLE_MODULE}/build.gradle.kts"
grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts"
- name: Unit tests + signed release APK
- name: Unit tests + signed release APK + app bundle
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
@@ -156,15 +168,20 @@ jobs:
run: |
set -euo pipefail
chmod +x ./gradlew
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest \
:${GRADLE_MODULE}:assembleRelease :${GRADLE_MODULE}:bundleRelease
- name: Package APK + SHA256SUMS
- name: Package APK + AAB + SHA256SUMS
run: |
set -euo pipefail
SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
test -f "$SRC" || { echo "::error::release APK not found at $SRC"; exit 1; }
cp "$SRC" "dist/runic-gateway-${{ steps.plan.outputs.version }}.apk"
( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS )
VERSION="${{ steps.plan.outputs.version }}"
APK="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
AAB="${GRADLE_MODULE}/build/outputs/bundle/release/app-release.aab"
test -f "$APK" || { echo "::error::release APK not found at $APK"; exit 1; }
test -f "$AAB" || { echo "::error::release bundle not found at $AAB"; exit 1; }
cp "$APK" "dist/runic-gateway-${VERSION}.apk"
cp "$AAB" "dist/runic-gateway-${VERSION}.aab"
( cd dist && sha256sum "runic-gateway-${VERSION}.apk" "runic-gateway-${VERSION}.aab" > SHA256SUMS )
ls -l dist && cat dist/SHA256SUMS
# ── Create the Gitea release + upload assets (no push to main) ───────
@@ -186,9 +203,94 @@ jobs:
| jq -r '.id')"
echo "Created release ${TAG} (id=${REL_ID})"
for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" SHA256SUMS; do
for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" \
"runic-gateway-${{ steps.plan.outputs.version }}.aab" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
echo " uploaded ${f}"
done
# ── Google Play: bundle → internal track (PLAN.md M8) ────────────────
# Play Developer API v3 edit flow: open an edit, upload the bundle, put its
# versionCode on the internal track, commit. The OAuth token comes from a
# service-account JWT signed with openssl, so no Gradle plugin and no Google
# SDK. The release is "completed" on the internal track, which Play only
# accepts once the app has had a release rolled out; before that, its error
# is "Only releases with status draft may be created on draft app", and the
# fix is the hand upload of the first bundle, not a change here.
- name: Upload the bundle to Google Play (internal track)
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
set -euo pipefail
if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
echo "::warning::PLAY_SERVICE_ACCOUNT_JSON is not set; the bundle was not uploaded to Google Play."
exit 0
fi
PACKAGE="com.runicgateway.app"
TAG="${{ steps.plan.outputs.tag }}"
VERSION_CODE="${{ steps.plan.outputs.versionCode }}"
AAB="dist/runic-gateway-${{ steps.plan.outputs.version }}.aab"
API="https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${PACKAGE}"
UPLOAD_API="https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${PACKAGE}"
# The service-account key only ever touches the runner's temp dir.
umask 077
SA_KEY="${RUNNER_TEMP}/play-sa.pem"
trap 'rm -f "$SA_KEY"' EXIT
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" | jq -r '.private_key' > "$SA_KEY"
CLIENT_EMAIL="$(printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" | jq -r '.client_email')"
# play <curl args...>: prints the response body; on a non-2xx it prints
# Google's error to stderr and fails, so `set -e` stops the step.
play() {
local out code
out="$(mktemp)"
code="$(curl -sS -o "$out" -w '%{http_code}' "$@")"
if [ "${code:0:1}" != "2" ]; then
echo "::error::Google Play API returned HTTP ${code}" >&2
cat "$out" >&2; echo >&2
rm -f "$out"
return 1
fi
cat "$out"
rm -f "$out"
}
# Service-account JWT → OAuth access token (RFC 7523).
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
NOW="$(date +%s)"
JWT_HEAD="$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | b64url)"
JWT_CLAIMS="$(jq -cn --arg iss "$CLIENT_EMAIL" --argjson iat "$NOW" \
'{iss:$iss, scope:"https://www.googleapis.com/auth/androidpublisher",
aud:"https://oauth2.googleapis.com/token", iat:$iat, exp:($iat + 3600)}' | b64url)"
JWT_SIG="$(printf '%s' "${JWT_HEAD}.${JWT_CLAIMS}" | openssl dgst -sha256 -sign "$SA_KEY" -binary | b64url)"
ACCESS_TOKEN="$(play -X POST https://oauth2.googleapis.com/token \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
--data-urlencode "assertion=${JWT_HEAD}.${JWT_CLAIMS}.${JWT_SIG}" \
| jq -r '.access_token')"
echo "::add-mask::${ACCESS_TOKEN}"
AUTH="Authorization: Bearer ${ACCESS_TOKEN}"
EDIT_ID="$(play -X POST "${API}/edits" -H "$AUTH" \
-H 'Content-Type: application/json' -d '{}' | jq -r '.id')"
echo "Opened edit ${EDIT_ID}"
UPLOADED_CODE="$(play -X POST "${UPLOAD_API}/edits/${EDIT_ID}/bundles?uploadType=media" \
-H "$AUTH" -H 'Content-Type: application/octet-stream' \
--data-binary "@${AAB}" | jq -r '.versionCode')"
if [ "$UPLOADED_CODE" != "$VERSION_CODE" ]; then
echo "::error::Play read versionCode ${UPLOADED_CODE} from the bundle, expected ${VERSION_CODE}"
exit 1
fi
echo "Uploaded bundle (versionCode ${UPLOADED_CODE})"
play -X PUT "${API}/edits/${EDIT_ID}/tracks/internal" -H "$AUTH" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg name "$TAG" --arg code "$VERSION_CODE" \
'{track:"internal", releases:[{name:$name, versionCodes:[$code], status:"completed"}]}')" \
>/dev/null
play -X POST "${API}/edits/${EDIT_ID}:commit" -H "$AUTH" -d '' >/dev/null
echo "Released ${TAG} (versionCode ${VERSION_CODE}) to the internal track"

View File

@@ -64,7 +64,7 @@ jobs:
- name: Install Android SDK packages
run: |
set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
- name: Cache Gradle
uses: actions/cache@v4

View File

@@ -38,13 +38,13 @@ val hasReleaseSigning = ksStoreFilePath != null && ksStorePassword != null &&
android {
namespace = "com.runicgateway.app"
compileSdk = 35
compileSdk = 36
defaultConfig {
// Target application id per docs/android/PLAN.md §13 (pending runicgateway.app domain).
applicationId = "com.runicgateway.app"
minSdk = 29
targetSdk = 35
targetSdk = 36
// These committed defaults are the version source of truth (PLAN.md §10).
// release.yml's conventional-commit engine bumps versionName here and commits
// it on release; versionCode is derived from it (major*10000+minor*100+patch)

View File

@@ -8,10 +8,14 @@
<!-- Opt-in push notifications (M7): the runtime notification permission (API 33+)
and a foreground service that holds the persistent ntfy connection open — the
embedded UnifiedPush distributor, so no separate app is needed (PLAN.md §11). -->
embedded UnifiedPush distributor, so no separate app is needed (PLAN.md §11).
specialUse, not dataSync (PLAN.md M8, 2026-10-29): a held-open connection is not a
transfer, and Android 15 stops dataSync after six hours a day. RECEIVE_BOOT_COMPLETED
restarts it after a reboot or an update. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".RunicGatewayApp"
@@ -70,11 +74,28 @@
</activity>
<!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11).
dataSync foreground type; not exported — started only by PushManager. -->
specialUse foreground type, with the reason Play reviews in the property below;
not exported — started only by PushManager and PushBootReceiver. -->
<service
android:name=".core.push.PushService"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Instant push notifications without Google services. When the user turns notifications on, this service holds one connection open to the push relay their community's server names, and shows a notification for each message from it. The ongoing notification shows the service is running; turning notifications off or signing out stops it." />
</service>
<!-- Restarts push after a reboot or an app update, if the member had it on and was
signed in when the phone went down (PLAN.md M8). Not exported: only the system
delivers these broadcasts. -->
<receiver
android:name=".core.push.PushBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -25,7 +25,13 @@ import javax.inject.Inject
* connection to the shard's ntfy open in the background — the price of Google-free,
* self-contained instant delivery — and posts a notification for each tickle. It runs
* under a low-importance ongoing notification and restarts sticky; [PushManager] starts
* and stops it as the user opts in/out or signs out.
* and stops it as the user opts in/out or signs out, and [PushBootReceiver] starts it
* again after a reboot or an update.
*
* Its foreground type is `specialUse` (PLAN.md M8, 2026-10-29). It was `dataSync`, which
* Android 15 stops after six hours a day — and this service never stopped itself on that
* timeout, so the app would have crashed. `specialUse` has no daily limit; the manifest
* carries the reason Google Play reviews.
*/
@AndroidEntryPoint
class PushService : Service() {
@@ -51,7 +57,7 @@ class PushService : Service() {
private fun startAsForeground() {
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
0
}

View File

@@ -14,12 +14,18 @@ import java.io.IOException
* an expected failure, it returns a typed variant the screen can render.
*
* - [Ok] — a 2xx response with a decoded body.
* - [HttpError] — the server answered with a non-2xx status.
* - [HttpError] — the server answered with a non-2xx status. [HttpError.code] is
* the error body's `code` field when it carried one (today only
* the demo's `DEMO_LOCKED` is read, see [isDemoLocked]).
* - [NetworkError] — the request never got an answer (offline, DNS, TLS, timeout).
*/
sealed interface ApiResult<out T> {
data class Ok<T>(val data: T) : ApiResult<T>
data class HttpError(val status: Int, val message: String? = null) : ApiResult<Nothing>
data class HttpError(
val status: Int,
val message: String? = null,
val code: String? = null,
) : ApiResult<Nothing>
data class NetworkError(val cause: Throwable) : ApiResult<Nothing>
}
@@ -54,12 +60,31 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
} catch (e: CancellationException) {
throw e
} catch (e: HttpException) {
ApiResult.HttpError(e.code(), e.message())
ApiResult.HttpError(e.code(), e.message(), errorCodeOf(e))
} catch (e: IOException) {
ApiResult.NetworkError(e)
} catch (e: SerializationException) {
ApiResult.HttpError(MALFORMED_RESPONSE_STATUS, e.message)
}
/**
* The error body's `code`, read only for a status that can carry one the app acts on,
* so an ordinary 404 or 500 body is never looked at. The body is peeked, not
* consumed, so whoever else holds the exception can still read it.
*/
private fun errorCodeOf(e: HttpException): String? {
if (e.code() != DEMO_LOCKED_STATUS) return null
val body = runCatching {
e.response()?.errorBody()?.source()?.let { source ->
source.request(MAX_ERROR_PEEK_BYTES)
source.peek().readUtf8(minOf(source.buffer.size, MAX_ERROR_PEEK_BYTES))
}
}.getOrNull()
return errorCodeOf(body)
}
/** The refusal is one short JSON object; anything longer is not one. */
private const val MAX_ERROR_PEEK_BYTES = 8L * 1024
/** Synthetic status for a 2xx body the app couldn't decode — an invalid upstream response. */
private const val MALFORMED_RESPONSE_STATUS = 502

View File

@@ -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

View File

@@ -5,6 +5,8 @@ package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.DeleteAccountRequest
import com.runicgateway.app.data.api.dto.DeleteAccountResultDto
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
@@ -89,4 +91,11 @@ interface MeApi {
@POST("api/v1/auth/me/account/recovery-codes/generate")
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
// ── Deleting the account (ACCOUNT_DELETION.md §4.1) ──────────────────────
// A DELETE with a body needs @HTTP. Raw [Response] so the caller can read the
// refusal's `code` off a 400 or 409, which a thrown HttpException would not carry.
@HTTP(method = "DELETE", path = "api/v1/auth/me/account", hasBody = true)
suspend fun deleteAccount(@Body body: DeleteAccountRequest): Response<DeleteAccountResultDto>
}

View File

@@ -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.RustWipeListDto
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
@@ -108,4 +109,18 @@ interface RustApi {
*/
@GET("api/v1/public/rust/servers/{id}/map/live")
suspend fun getMapLive(@Path("id") id: String): RustMapLiveDto
// ── Staff's hide for everyone (USER_SAFETY.md §4.2, S10) ─────────────────
/**
* Hide one chat line for everybody but staff, who still get it with `hidden:
* true`. Admins and moderators; anybody else is a 403. [lineId] is the event
* row's id, and an id that is not a chat line is a 404.
*/
@POST("api/v1/admin/rust/chat/{id}/hide")
suspend fun hideChat(@Path("id") lineId: Long): Unit
/** Undo [hideChat] — the only place a hide for everyone is undone. */
@POST("api/v1/admin/rust/chat/{id}/unhide")
suspend fun unhideChat(@Path("id") lineId: Long): Unit
}

View 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
}

View File

@@ -116,6 +116,22 @@ data class RevokedFlagDto(val revoked: Boolean = false)
@Serializable
data class RevokedCountDto(val revoked: Int = 0)
/**
* `DELETE /auth/me/account` body (ACCOUNT_DELETION.md §4.1). The password is always
* sent (AD7); a two-factor [code] or a [recoveryCode] only when two-factor is on.
*/
@Serializable
data class DeleteAccountRequest(
val username: String,
val password: String,
val code: String? = null,
val recoveryCode: String? = null,
)
/** The `{ deleted: true }` a successful deletion answers. */
@Serializable
data class DeleteAccountResultDto(val deleted: Boolean = false)
/** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */
@Serializable
data class RecoveryStatusDto(val remaining: Int = 0)

View File

@@ -146,4 +146,14 @@ data class SettingsDto(
* nav.
*/
@SerialName("nav_public") val navPublic: String? = null,
/**
* The public demo's banner data, `{ resetsAt, logins: [{ username, role, … }] }`
* (`docs/website/DEMO_MODE.md` §6). Sent only while the server runs with
* `DEMO_MODE` on, so its absence is how a real shard draws no banner.
*
* Raw for the same reason as [theme]: the banner is a nicety, and a value of an
* unexpected shape must not fail the decode of the whole payload. Read by
* `DemoInfo.from`.
*/
val demo: JsonElement? = null,
)

View File

@@ -118,6 +118,11 @@ data class RustEventListDto(
* [t] is epoch milliseconds — the stamp the plugin put on the frame, not a
* database column, so it is a number here and an ISO string everywhere else on
* this wire.
*
* [canBlock] and [hidden] are about the row rather than in the frame, and only chat
* lines carry them (`USER_SAFETY.md` §4.2). [canBlock] is sent to a signed-in reader
* only (S13): null means "not said", and the menu offers no Block on it. [hidden]
* reaches staff only — everybody else is never sent a line staff hid (S10).
*/
@Serializable
data class RustEventDto(
@@ -127,6 +132,8 @@ data class RustEventDto(
val wipeId: String? = null,
val steamId: String? = null,
val frame: JsonObject = JsonObject(emptyMap()),
val canBlock: Boolean? = null,
val hidden: Boolean = false,
) {
/**
* One frame field as text, or null.

View 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,
)

View File

@@ -4,10 +4,13 @@
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.DEMO_LOCKED_CODE
import com.runicgateway.app.core.result.errorCodeOf
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.DeleteAccountRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
@@ -117,4 +120,70 @@ class AccountRepository @Inject constructor(
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
// ── Deleting the account (ACCOUNT_DELETION.md §4.1, §4.3) ──────────────
/** Why the server refused a deletion, one per refusal `code` it sends. */
enum class DeleteRefusal {
USERNAME_MISMATCH,
WRONG_PASSWORD,
CODE_REQUIRED,
WRONG_CODE,
/** The account has no password yet; it sets one on Account first (AD7). */
PASSWORD_REQUIRED,
LAST_ADMIN,
/** The public demo's refusal; the shell's snackbar has already said why. */
DEMO_LOCKED,
RATE_LIMITED,
/** Any other answer — a 5xx, or a refusal this app does not know. */
OTHER,
}
sealed interface DeleteOutcome {
/** Gone on the server, with every session; the caller forgets it locally. */
data object Deleted : DeleteOutcome
data class Refused(val reason: DeleteRefusal) : DeleteOutcome
data object NetworkError : DeleteOutcome
}
/**
* Delete the signed-in account. A second factor is a [code] or a [recoveryCode],
* sent only when two-factor is on. Reads the raw response so a refusal's `code`
* survives; a wrong password is a 400, never a 401 (AD12), so the authenticator
* never replays this call.
*/
suspend fun deleteAccount(
username: String,
password: String,
code: String? = null,
recoveryCode: String? = null,
): DeleteOutcome {
val response = try {
api.deleteAccount(DeleteAccountRequest(username, password, code, recoveryCode))
} catch (e: CancellationException) {
throw e
} catch (_: IOException) {
return DeleteOutcome.NetworkError
} catch (_: Exception) {
return DeleteOutcome.Refused(DeleteRefusal.OTHER)
}
if (response.isSuccessful) return DeleteOutcome.Deleted
if (response.code() == 429) return DeleteOutcome.Refused(DeleteRefusal.RATE_LIMITED)
val refusal = runCatching { errorCodeOf(response.errorBody()?.string()) }.getOrNull()
return DeleteOutcome.Refused(deleteRefusalOf(refusal))
}
private fun deleteRefusalOf(code: String?): DeleteRefusal = when (code) {
"username_mismatch" -> DeleteRefusal.USERNAME_MISMATCH
"wrong_password" -> DeleteRefusal.WRONG_PASSWORD
"code_required" -> DeleteRefusal.CODE_REQUIRED
"wrong_code" -> DeleteRefusal.WRONG_CODE
"password_required" -> DeleteRefusal.PASSWORD_REQUIRED
"last_admin" -> DeleteRefusal.LAST_ADMIN
DEMO_LOCKED_CODE -> DeleteRefusal.DEMO_LOCKED
else -> DeleteRefusal.OTHER
}
}

View File

@@ -17,7 +17,9 @@ import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.api.dto.TotpRequiredError
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import retrofit2.Response
import java.io.IOException
@@ -208,6 +210,28 @@ class AuthRepository @Inject constructor(
sessionManager.onSignedOut()
}
/**
* Forget an account the server has just deleted (ACCOUNT_DELETION.md §4.3): what
* [logout] does, without its two calls, because the server dropped the sessions,
* trusted devices and push registrations with the row and would answer 401.
*
* The trust token goes too, unlike a sign-out: it belongs to an account that no
* longer exists. The push registration goes with the session, through
* [PushManager]'s sign-out observer, as on a dead refresh.
*
* Not cancellable: the account is already gone, so a caller leaving the screen
* must not leave its session behind on the phone.
*/
suspend fun forgetDeletedAccount() = withContext(NonCancellable) {
try {
inboxCache.clear()
} catch (_: Exception) {
// Ignore — the snapshot is scoped to (base URL, user id) and is never shown again.
}
trustTokenStore.clear()
sessionManager.onSignedOut()
}
/**
* Re-validate the session against `GET /auth/me` on app resume (§4.3). A
* success refreshes the cached role (roles change server-side); a `401` that

View File

@@ -96,4 +96,12 @@ class RustRepository @Inject constructor(
/** What moves on the map, as this viewer may see it. */
suspend fun mapLive(id: String): ApiResult<RustMapLiveDto> =
safeApiCall { api.getMapLive(id) }
/**
* Staff's hide for everyone on one chat line, or its undo (`USER_SAFETY.md`
* §4.2, S10). The module's own route, not core's: the line and its flag live here.
*/
suspend fun setChatHidden(lineId: Long, hidden: Boolean): ApiResult<Unit> = safeApiCall {
if (hidden) api.hideChat(lineId) else api.unhideChat(lineId)
}
}

View File

@@ -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
},
)
}

View File

@@ -8,6 +8,7 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact
import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.net.AuthInterceptor
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.DemoLockInterceptor
import com.runicgateway.app.core.net.HostSelectionInterceptor
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamClient
@@ -23,6 +24,7 @@ import com.runicgateway.app.data.api.PlayerRustApi
import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.RustApi
import com.runicgateway.app.data.api.SafetyApi
import com.runicgateway.app.data.api.SsoApi
import dagger.Module
import dagger.Provides
@@ -58,7 +60,8 @@ object NetworkModule {
/**
* The main client every API and the SSE stream ride on. Order: identify (UA),
* retarget onto the configured shard host, then attach the bearer; the
* retarget onto the configured shard host, attach the bearer, then spot the
* public demo's refusal on the way back (DEMO_MODE.md §3.1); the
* [TokenAuthenticator] handles silent refresh-on-401 (§4.1). Logging sits last
* so it observes the final, authed request.
*/
@@ -68,12 +71,14 @@ object NetworkModule {
hostSelectionInterceptor: HostSelectionInterceptor,
userAgentInterceptor: UserAgentInterceptor,
authInterceptor: AuthInterceptor,
demoLockInterceptor: DemoLockInterceptor,
tokenAuthenticator: TokenAuthenticator,
): OkHttpClient {
val builder = OkHttpClient.Builder()
.addInterceptor(userAgentInterceptor)
.addInterceptor(hostSelectionInterceptor)
.addInterceptor(authInterceptor)
.addInterceptor(demoLockInterceptor)
.authenticator(tokenAuthenticator)
if (BuildConfig.DEBUG) {
@@ -119,6 +124,11 @@ object NetworkModule {
@Singleton
fun provideMeApi(retrofit: Retrofit): MeApi = retrofit.create(MeApi::class.java)
/** Report, block and hide (USER_SAFETY.md §4.1) — core's routes, bearer-authed. */
@Provides
@Singleton
fun provideSafetyApi(retrofit: Retrofit): SafetyApi = retrofit.create(SafetyApi::class.java)
/** A player's own game data + linking (§6.3) — bearer-authed on the main client. */
@Provides
@Singleton

View File

@@ -26,6 +26,8 @@ import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemColors
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -33,6 +35,7 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -59,12 +62,16 @@ import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.data.repository.Capability
import com.runicgateway.app.ui.auth.AccountScreen
import com.runicgateway.app.ui.auth.BlockedScreen
import com.runicgateway.app.ui.auth.DeleteAccountScreen
import com.runicgateway.app.ui.auth.DeleteAccountViewModel
import com.runicgateway.app.ui.auth.LoginScreen
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
import com.runicgateway.app.ui.auth.roleLabelRes
import com.runicgateway.app.ui.components.BrandLogo
import com.runicgateway.app.ui.contact.ContactScreen
import com.runicgateway.app.ui.demo.DemoNoticeViewModel
import com.runicgateway.app.ui.events.EventScreen
import com.runicgateway.app.ui.events.EventSeriesScreen
import com.runicgateway.app.ui.events.EventsScreen
@@ -110,6 +117,7 @@ import com.runicgateway.app.ui.shard.ShardScreen
import com.runicgateway.app.ui.theme.LocalShardStructure
import com.runicgateway.app.ui.wiki.WikiPageScreen
import com.runicgateway.app.ui.wiki.WikiScreen
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/** Destinations that show the drawer (hamburger); others show a back arrow. */
@@ -151,6 +159,7 @@ fun RunicApp(
sessionViewModel: SessionViewModel = hiltViewModel(),
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
demoNoticeViewModel: DemoNoticeViewModel = hiltViewModel(),
) {
val brand = appearance.brand
val navController = rememberNavController()
@@ -192,6 +201,19 @@ fun RunicApp(
// the previous account's count on the drawer.
LaunchedEffect(session) { inboxBadgeViewModel.refresh() }
// The public demo's refusals (DEMO_MODE.md §3.1, DM19): one snackbar for every
// screen, the app's counterpart of the web's toast. The screen keeps its own
// failure line; this says why, in the server's words. A newer refusal replaces
// the one showing rather than queueing behind it.
val snackbarHostState = remember { SnackbarHostState() }
val demoLockedFallback = stringResource(R.string.error_demo_locked)
val accountDeletedNotice = stringResource(R.string.account_delete_done)
LaunchedEffect(Unit) {
demoNoticeViewModel.refusals.collectLatest { refusal ->
snackbarHostState.showSnackbar(refusal.message ?: demoLockedFallback)
}
}
// A tapped push notification deep-links to its stream's screen (§11, item 7).
LaunchedEffect(deepLinkStream, deepLinkRef) {
val stream = deepLinkStream ?: return@LaunchedEffect
@@ -347,6 +369,7 @@ fun RunicApp(
) {
Scaffold(
modifier = modifier,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
@@ -398,6 +421,15 @@ fun RunicApp(
session = session,
onSignOut = { sessionViewModel.signOut() },
onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) },
onAccountDeleted = {
// To sign-in with the notice, as the web lands home with its own.
// Home stays beneath it, so back leaves sign-in for Home.
navController.navigate(Routes.LOGIN) {
popUpTo(Routes.HOME)
launchSingleTop = true
}
scope.launch { snackbarHostState.showSnackbar(accountDeletedNotice) }
},
modifier = Modifier.padding(innerPadding),
)
}
@@ -508,6 +540,7 @@ private fun RunicNavHost(
session: Session,
onSignOut: () -> Unit,
onSignOutEverywhere: () -> Unit,
onAccountDeleted: () -> Unit,
modifier: Modifier = Modifier,
) {
NavHost(
@@ -648,6 +681,8 @@ private fun RunicNavHost(
onBack = { navController.navigateTopLevel(Routes.RUST) },
// D123: a site-event marker opens the app's own event page on its run.
onOpenEvent = { slug, runId -> navController.navigate(Routes.event(slug, runId)) },
// A chat line's menu signed out (S6): report and block need an account.
onSignIn = { navController.navigate(Routes.LOGIN) },
)
}
composable(Routes.WIKI) {
@@ -690,6 +725,8 @@ private fun RunicNavHost(
onSignOutEverywhere = onSignOutEverywhere,
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
onOpenDeleteAccount = { navController.navigate(Routes.ACCOUNT_DELETE) },
onOpenBlocked = { navController.navigate(Routes.ACCOUNT_BLOCKED) },
)
Session.SignedOut -> LaunchedEffect(Unit) {
navController.navigateTopLevel(Routes.HOME)
@@ -703,12 +740,30 @@ private fun RunicNavHost(
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.ACCOUNT_BLOCKED) {
when (session) {
is Session.SignedIn -> BlockedScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.ACCOUNT_RECOVERY_CODES) {
when (session) {
is Session.SignedIn -> RecoveryCodesScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.ACCOUNT_DELETE) {
// The view model is held here, not in the screen, so this route can tell a
// deletion from any other sign-out: the view model marks it deleted before
// the session drops, and both land in the same frame (ACCOUNT_DELETION.md §4.3).
val viewModel: DeleteAccountViewModel = hiltViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
when {
state.deleted -> LaunchedEffect(Unit) { onAccountDeleted() }
session is Session.SignedIn -> DeleteAccountScreen(viewModel)
else -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.NOTIFICATIONS) {
// Signed-in only; a sign-out (or demotion) sends the user home rather than
// leaving another account's items up. The backend gates every call

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.ui
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
/**
* The load state a screen renders. View models expose a `StateFlow<UiState<T>>`;
@@ -41,6 +42,14 @@ enum class ErrorKind {
*/
FEATURE_UNAVAILABLE,
/**
* The public demo refused this (`403 DEMO_LOCKED`, `docs/website/DEMO_MODE.md`
* §3.1). Checked ahead of the status, because a `403` otherwise reads as "you
* may not", which blames the role of somebody signed in as an admin. Like
* [FEATURE_UNAVAILABLE], retrying cannot change the answer.
*/
DEMO_LOCKED,
/** Any other non-2xx server response. */
SERVER,
}
@@ -50,10 +59,11 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
is ApiResult.Ok -> UiState.Success(data)
is ApiResult.NetworkError -> UiState.Error(ErrorKind.NETWORK)
is ApiResult.HttpError -> UiState.Error(
kind = when (status) {
404 -> ErrorKind.NOT_FOUND
429 -> ErrorKind.RATE_LIMITED
503 -> ErrorKind.SHARD_OFFLINE
kind = when {
isDemoLocked() -> ErrorKind.DEMO_LOCKED
status == 404 -> ErrorKind.NOT_FOUND
status == 429 -> ErrorKind.RATE_LIMITED
status == 503 -> ErrorKind.SHARD_OFFLINE
else -> ErrorKind.SERVER
},
httpStatus = status,
@@ -76,7 +86,7 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
* player or admin route, which is not an admin's visibility setting.
*/
fun <T> ApiResult<T>.toShardUiState(): UiState<T> = when (this) {
is ApiResult.HttpError -> if (status == 403 || status == 404) {
is ApiResult.HttpError -> if ((status == 403 || status == 404) && !isDemoLocked()) {
UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status)
} else {
toUiState()

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
@@ -133,8 +134,14 @@ class AdminContentViewModel @Inject constructor(
/** Map an [ApiResult] to a [Feedback], with role/permission-aware failure copy. */
private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) {
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError ->
Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed)
is ApiResult.HttpError -> Feedback(
false,
when {
isDemoLocked() -> R.string.error_demo_locked
status == 403 -> R.string.admin_forbidden
else -> R.string.admin_action_failed
},
)
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
}
}

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
@@ -79,7 +80,11 @@ class AdminDashboardViewModel @Inject constructor(
switching = false,
feedback = Feedback(
false,
if (result.status == 403) R.string.admin_forbidden else R.string.admin_action_failed,
when {
result.isDemoLocked() -> R.string.error_demo_locked
result.status == 403 -> R.string.admin_forbidden
else -> R.string.admin_action_failed
},
),
)
}

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.data.repository.AdminRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -75,9 +76,10 @@ class AdminModerationViewModel @Inject constructor(
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError -> Feedback(
false,
when (r.status) {
403 -> R.string.admin_forbidden
503 -> R.string.admin_mod_shard_offline
when {
r.isDemoLocked() -> R.string.error_demo_locked
r.status == 403 -> R.string.admin_forbidden
r.status == 503 -> R.string.admin_mod_shard_offline
else -> R.string.admin_action_failed
},
)

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
@@ -71,10 +72,11 @@ class AdminSupportViewModel @Inject constructor(
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError -> Feedback(
false,
when (r.status) {
403 -> R.string.admin_forbidden
404 -> R.string.admin_support_unknown_page
503 -> R.string.admin_mod_shard_offline
when {
r.isDemoLocked() -> R.string.error_demo_locked
r.status == 403 -> R.string.admin_forbidden
r.status == 404 -> R.string.admin_support_unknown_page
r.status == 503 -> R.string.admin_mod_shard_offline
else -> R.string.admin_action_failed
},
)

View File

@@ -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
}

View File

@@ -6,6 +6,7 @@ package com.runicgateway.app.ui.auth
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -67,6 +69,8 @@ fun AccountScreen(
onSignOutEverywhere: () -> Unit,
onOpenTrustedDevices: () -> Unit,
onOpenRecoveryCodes: () -> Unit,
onOpenDeleteAccount: () -> Unit,
onOpenBlocked: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AccountViewModel = hiltViewModel(),
) {
@@ -91,6 +95,8 @@ fun AccountScreen(
is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
}
BlockedRow(onOpenBlocked)
HorizontalDivider(Modifier.padding(vertical = 20.dp))
OutlinedButton(onClick = onSignOut, modifier = Modifier.fillMaxWidth()) {
@@ -102,6 +108,58 @@ fun AccountScreen(
) {
Text(stringResource(R.string.account_sign_out_all))
}
DeleteAccountRow(onOpenDeleteAccount)
}
}
/**
* Last on the screen and set apart (ACCOUNT_DELETION.md §4.3). Always shown, on the
* demo too, where the server refuses: Play's reviewer must be able to find it.
*/
@Composable
private fun DeleteAccountRow(onOpenDeleteAccount: () -> Unit) {
HorizontalDivider(
Modifier.padding(top = 20.dp, bottom = 16.dp),
color = MaterialTheme.colorScheme.error,
)
Text(
stringResource(R.string.account_delete_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
)
Text(
stringResource(R.string.account_delete_row_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
OutlinedButton(
onClick = onOpenDeleteAccount,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) { Text(stringResource(R.string.account_delete_open)) }
}
/**
* Account → Blocked (USER_SAFETY.md §4.3). Outside the loaded sections on purpose:
* the blocks are their own read, and a failed account read is no reason to hide
* the way to undo one.
*/
@Composable
private fun BlockedRow(onOpenBlocked: () -> Unit) {
SectionCard(R.string.blocked_title) {
Text(
stringResource(R.string.blocked_row_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
OutlinedButton(
onClick = onOpenBlocked,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) { Text(stringResource(R.string.blocked_open)) }
}
}

View 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)
}

View File

@@ -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)
}

View File

@@ -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),
)
}
}

View File

@@ -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) }
}
}
}
}

View File

@@ -39,7 +39,9 @@ fun LoadingView(modifier: Modifier = Modifier) {
*
* [ErrorKind.FEATURE_UNAVAILABLE] renders **without** the button: an admin decides
* whether the shard publishes that surface, so retrying cannot change the answer and
* offering it would read as a transient failure the user could wait out (M11).
* offering it would read as a transient failure the user could wait out (M11). The
* public demo's refusal ([ErrorKind.DEMO_LOCKED]) is the same: it is fixed until the
* site stops being a demo.
*/
@Composable
fun ErrorView(
@@ -69,7 +71,8 @@ fun ErrorView(
}
/** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */
fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE
fun isRetryable(kind: ErrorKind): Boolean =
kind != ErrorKind.FEATURE_UNAVAILABLE && kind != ErrorKind.DEMO_LOCKED
/** Centered informational message for an empty list (§7). */
@Composable
@@ -93,5 +96,6 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable
ErrorKind.DEMO_LOCKED -> R.string.error_demo_locked
ErrorKind.SERVER -> R.string.error_server
}

View File

@@ -117,5 +117,6 @@ private fun ResultMessage(result: Result) {
private fun errorText(kind: ErrorKind): String = when (kind) {
ErrorKind.NETWORK -> stringResource(R.string.error_network)
ErrorKind.RATE_LIMITED -> stringResource(R.string.error_rate_limited)
ErrorKind.DEMO_LOCKED -> stringResource(R.string.error_demo_locked)
else -> stringResource(R.string.contact_error)
}

View 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)
}
}

View 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
}

View File

@@ -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
}

View File

@@ -32,6 +32,7 @@ import com.runicgateway.app.ui.components.FeatureCard
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.OnlineDot
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.demo.DemoBanner
/**
* Home / status (PLAN.md §6.1): the shard's name + tagline from branding, and a
@@ -44,11 +45,18 @@ fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val demo by viewModel.demo.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> HomeContent(brand, s.data, modifier)
Column(modifier = modifier.fillMaxSize()) {
// The public demo's strip (DEMO_MODE.md §6), above whatever the status read
// came back with, so it shows even while the status is loading or failed.
demo?.let { DemoBanner(info = it, signInPage = viewModel.signInPage) }
val content = Modifier.weight(1f)
when (val s = state) {
is UiState.Loading -> LoadingView(content)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = content)
is UiState.Success -> HomeContent(brand, s.data, content)
}
}
}

View File

@@ -5,9 +5,13 @@ package com.runicgateway.app.ui.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.demo.DemoInfo
import com.runicgateway.app.ui.demo.signInPageLabel
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -16,15 +20,27 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Loads site status (mode + version) for the Home screen (PLAN.md §6.1). */
/**
* Loads site status (mode + version) for the Home screen (PLAN.md §6.1), and the
* public demo's banner data when the site is one (`docs/website/DEMO_MODE.md` §6).
*/
@HiltViewModel
class HomeViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val baseUrlHolder: BaseUrlHolder,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<StatusDto>>(UiState.Loading)
val state: StateFlow<UiState<StatusDto>> = _state.asStateFlow()
private val _demo = MutableStateFlow<DemoInfo?>(null)
/** The demo strip's data; null on every real shard, and while it is unknown. */
val demo: StateFlow<DemoInfo?> = _demo.asStateFlow()
/** The site's sign-in page, for the strip's logins sentence; null if no site is set. */
val signInPage: String? get() = baseUrlHolder.current?.let(::signInPageLabel)
init { load() }
fun load() {
@@ -32,5 +48,11 @@ class HomeViewModel @Inject constructor(
viewModelScope.launch {
_state.value = settingsRepository.getStatus().toUiState()
}
// A separate read, and silent: the strip is a nicety, so a failed settings
// call keeps whatever was shown rather than surfacing an error of its own.
viewModelScope.launch {
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
_demo.value = DemoInfo.from(settings.demo)
}
}
}

View File

@@ -38,6 +38,12 @@ object Routes {
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
/** Delete my account (ACCOUNT_DELETION.md §4.3), reached from Account. Signed-in only. */
const val ACCOUNT_DELETE = "account/delete"
/** Account → Blocked (USER_SAFETY.md §4.3), reached from Account. Signed-in only. */
const val ACCOUNT_BLOCKED = "account/blocked"
/**
* The in-app inbox (ENGAGEMENT.md phase 8, signed-in) and its settings.
*

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.core.result.map
import com.runicgateway.app.data.api.dto.RosterCharDto
import com.runicgateway.app.data.repository.PlayerShardRepository
@@ -114,7 +115,7 @@ class CharactersViewModel @Inject constructor(
load()
}
is ApiResult.HttpError -> _state.update {
it.copy(busy = false, feedback = Feedback(false, createErrorRes(result.status)))
it.copy(busy = false, feedback = Feedback(false, createErrorRes(result)))
}
is ApiResult.NetworkError -> _state.update {
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
@@ -129,12 +130,15 @@ class CharactersViewModel @Inject constructor(
else -> R.string.player_link_error
}
private fun createErrorRes(status: Int): Int = when (status) {
409 -> R.string.player_create_taken
429 -> R.string.player_create_capped
403 -> R.string.player_create_unavailable
400 -> R.string.player_create_rejected
503 -> R.string.error_shard_offline
// The demo's refusal comes first: its 403 would otherwise read as "signup is off
// on this shard", which is not what refused it (DEMO_MODE.md §3.1).
private fun createErrorRes(result: ApiResult.HttpError): Int = when {
result.isDemoLocked() -> R.string.error_demo_locked
result.status == 409 -> R.string.player_create_taken
result.status == 429 -> R.string.player_create_capped
result.status == 403 -> R.string.player_create_unavailable
result.status == 400 -> R.string.player_create_rejected
result.status == 503 -> R.string.error_shard_offline
else -> R.string.player_create_error
}
}

View 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,
)
}

View File

@@ -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))
}
}
}

View File

@@ -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
}

View File

@@ -3,8 +3,10 @@
*/
package com.runicgateway.app.ui.rust
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -25,6 +27,7 @@ import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -75,13 +78,24 @@ import com.runicgateway.app.ui.components.StatusPill
fun RustServerScreen(
onBack: () -> Unit,
onOpenEvent: (slug: String, runId: String) -> Unit,
onSignIn: () -> Unit,
modifier: Modifier = Modifier,
viewModel: RustServerViewModel = hiltViewModel(),
safety: ChatSafetyViewModel = hiltViewModel(),
) {
val ui by viewModel.state.collectAsStateWithLifecycle()
val menu by safety.menu.collectAsStateWithLifecycle()
PollWhileResumed { viewModel.refresh() }
// The server filters the feed for its reader (S11), so a block or a hide shows
// itself by the feed being read again, never by a line being dropped here.
LaunchedEffect(safety) { safety.changes.collect { viewModel.reloadFeed() } }
menu.line?.let { line ->
chatActions(line, safety.user)?.let { actions -> ChatSafetySheet(menu, actions, safety, onSignIn) }
}
when (val s = ui.server.state) {
is UiState.Loading -> LoadingView(modifier)
@@ -98,7 +112,7 @@ fun RustServerScreen(
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,
viewModel: RustServerViewModel,
onOpenEvent: (slug: String, runId: String) -> Unit,
onLinePressed: (RustEventDto) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier.fillMaxSize()) {
@@ -146,7 +161,13 @@ private fun ServerDetail(
}
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(
ui.leaderboard,
ui.sort,
@@ -279,6 +300,7 @@ private fun FeedPanel(
filterId: String,
onFilter: (String) -> Unit,
onRetry: () -> Unit,
onLinePressed: (RustEventDto) -> Unit,
) {
Column(Modifier.fillMaxSize()) {
Row(
@@ -326,7 +348,7 @@ private fun FeedPanel(
if (feed.refreshFailed) {
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
private fun FeedRow(row: RustEventDto) {
private fun FeedRow(row: RustEventDto, onLinePressed: (RustEventDto) -> Unit) {
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
// 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.
@@ -370,10 +415,20 @@ private fun FeedRow(row: RustEventDto) {
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 ───────────────────────────────────────────────────────────
/**

View File

@@ -258,6 +258,15 @@ class RustServerViewModel @Inject constructor(
viewModelScope.launch { askFeed() }
}
/**
* Ask the feed again after a report, block or hide changed what this reader
* should see (`USER_SAFETY.md` §4.3). The same question, so what is on screen
* stays up until the answer replaces it, as a poll's does.
*/
fun reloadFeed() {
viewModelScope.launch { askFeed() }
}
fun retryLeaderboard() {
_state.update { it.copy(leaderboard = UiState.Loading) }
viewModelScope.launch { askLeaderboard() }

View File

@@ -23,6 +23,15 @@
<string name="error_shard_offline">The game server is offline right now.</string>
<string name="error_feature_unavailable">This site doesn\'t publish this here.</string>
<string name="error_server">Something went wrong on the server. Please try again.</string>
<!-- The public demo's refusal (DEMO_MODE.md §3.1): the server's own words, used when its answer carries none. -->
<string name="error_demo_locked">This is turned off on the demo site.</string>
<!-- The public demo's strip on Home (DEMO_MODE.md §6). The time is said in UTC, like the web's. -->
<string name="demo_banner_title">Demo site</string>
<string name="demo_banner_wipe_at">" — anything you change is wiped at %1$s."</string>
<string name="demo_banner_wipe_soon">" — anything you change is wiped every two hours."</string>
<string name="demo_banner_logins_at">Sign-ins: %1$s — passwords at %2$s.</string>
<string name="demo_banner_logins">Sign-ins: %1$s — passwords on the site\'s sign-in page.</string>
<!-- ── First-run connect (§3) ──────────────────────────────────────── -->
<string name="connect_title">Connect to your community</string>
@@ -188,6 +197,38 @@
<string name="account_security_trusted_devices">Trusted devices</string>
<string name="account_security_recovery_codes">Recovery codes</string>
<!-- Delete my account (ACCOUNT_DELETION.md §4.3). The same words as the website's lib/accountDeletion.js. -->
<string name="account_delete_title">Delete my account</string>
<string name="account_delete_row_hint">Deletes your account straight away. It cannot be restored.</string>
<string name="account_delete_open">Delete my account…</string>
<string name="account_delete_intro">Your account is deleted straight away and cannot be restored. These go with it:</string>
<string name="account_delete_goes_sign_in">your sign-in, password, email address, two-factor settings and recovery codes</string>
<string name="account_delete_goes_devices">every signed-in device, trusted device and push registration</string>
<string name="account_delete_goes_identities">your linked sign-ins, such as Google, Discord or Steam</string>
<string name="account_delete_goes_game_links">your game-account links, and anything this site granted in the game through them</string>
<string name="account_delete_goes_notifications">your notification settings and inbox</string>
<string name="account_delete_stays_title">What stays:</string>
<string name="account_delete_stays_content">What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”.</string>
<string name="account_delete_stays_reports">Reports you filed and appeals you made stay as moderation records. Staff can still see who it was, and the activity log keeps its IP addresses.</string>
<string name="account_delete_stays_events">Event results stay with the score and the in-game name, with no site account behind them.</string>
<string name="account_delete_stays_game_account">A game account stays on the game server. Only its link to this site goes.</string>
<string name="account_delete_staff_note">You are staff, so your username can never be used again, by anyone, once this account is deleted.</string>
<string name="account_delete_no_password">This account has no password yet. Set one in the Password section on My account first; the password is how you confirm a deletion.</string>
<string name="account_delete_type_username">Type %1$s to confirm</string>
<string name="account_delete_recovery_code">Recovery code</string>
<string name="account_delete_use_recovery">Use a recovery code instead</string>
<string name="account_delete_use_authenticator">Use an authenticator code instead</string>
<string name="account_delete_action">Delete my account</string>
<string name="account_delete_busy">Deleting…</string>
<string name="account_delete_done">Your account has been deleted.</string>
<string name="account_delete_error_username">Type your username exactly as it is shown.</string>
<string name="account_delete_error_password">Your password is incorrect.</string>
<string name="account_delete_error_code_required">Enter a code from your authenticator app, or a recovery code.</string>
<string name="account_delete_error_code">That code is not valid. Try again.</string>
<string name="account_delete_error_last_admin">You are the only admin. Make someone else an admin before you delete your account.</string>
<string name="account_delete_error_rate_limited">Too many attempts. Wait a few minutes and try again.</string>
<string name="account_delete_error">Could not delete your account. Try again.</string>
<!-- Trusted devices screen -->
<string name="trusted_devices_title">Trusted devices</string>
<string name="trusted_devices_subtitle">These devices can skip the authentication code at sign-in for 30 days.</string>
@@ -335,4 +376,27 @@
<string name="events_score">Score %1$s</string>
<string name="events_show_more">Show more</string>
<string name="events_loading">Loading…</string>
<!-- ── Account → Blocked (USER_SAFETY.md §4.3; the web's BlockedPanel.jsx) ─ -->
<string name="blocked_title">Blocked</string>
<string name="blocked_row_hint">Players and members you blocked, and anything you hid for yourself.</string>
<string name="blocked_open">Blocked players</string>
<string name="blocked_subtitle">You don’t see anything a blocked member or player writes — forum posts and in-game chat — in the app or on the website. They aren’t told. Staff can’t be blocked.</string>
<string name="blocked_empty">You haven’t blocked anyone.</string>
<string name="blocked_unknown_player">Unknown player</string>
<string name="blocked_kind_account">Account</string>
<string name="blocked_kind_game">%1$s %2$s, not linked to an account</string>
<string name="blocked_subtitle_when">%1$s · blocked %2$s</string>
<string name="blocked_not_applied_staff">Not applied: this account is now a staff member, and staff can’t be blocked.</string>
<string name="blocked_unblock">Unblock</string>
<string name="blocked_unblocked">Unblocked.</string>
<string name="blocked_unblocked_named">%1$s is unblocked.</string>
<string name="blocked_unblock_failed">Could not unblock that.</string>
<plurals name="blocked_hidden_count">
<item quantity="one">You’ve hidden %d item for yourself.</item>
<item quantity="other">You’ve hidden %d items for yourself.</item>
</plurals>
<string name="blocked_show_hidden">Show them again</string>
<string name="blocked_hidden_cleared">Everything you hid is shown again.</string>
<string name="blocked_hidden_failed">Could not show those again.</string>
</resources>

View File

@@ -154,4 +154,48 @@
<string name="rust_backpack_page">Page %1$d</string>
<string name="rust_reach_live">%1$s · has it</string>
<string name="rust_reach_waiting">%1$s · waiting</string>
<!--
A chat line's long-press menu: report, hide, block (docs/website/USER_SAFETY.md
§4.3). The wording follows the website's ChatMenu.jsx, so a member reads one set
of sentences in either place. Account → Blocked is core's, in strings.xml.
-->
<string name="safety_line_actions">Report, hide or block</string>
<string name="safety_line_quote">%1$s: %2$s</string>
<string name="safety_this_player">this player</string>
<string name="safety_sign_in_hint">Sign in to report or block.</string>
<string name="safety_sign_in">Sign in</string>
<string name="safety_report">Report</string>
<string name="safety_hide_for_me">Hide for me</string>
<string name="safety_block_player">Block player</string>
<string name="safety_hide_for_everyone">Hide for everyone</string>
<string name="safety_show_to_everyone">Show to everyone again</string>
<string name="safety_hidden_by_staff">Hidden by staff</string>
<string name="safety_cancel">Cancel</string>
<string name="safety_close">Close</string>
<string name="safety_report_explainer">This goes to site staff. Reporting does not hide or change anything — it asks a staffer to look.</string>
<string name="safety_report_detail">Anything a staffer should know (optional)</string>
<string name="safety_report_send">Send report</string>
<string name="safety_reported">Reported to site staff.</string>
<string name="safety_reason_abuse">Abusive or harassing</string>
<string name="safety_reason_spam">Spam</string>
<string name="safety_reason_sexual">Sexual content</string>
<string name="safety_reason_illegal">Illegal content</string>
<string name="safety_reason_impersonation">Impersonation</string>
<string name="safety_reason_other">Something else</string>
<string name="safety_block_confirm">Block %1$s? You won’t see anything they write, here or on the website. They aren’t told. You can undo it from Account → Blocked.</string>
<string name="safety_block">Block</string>
<string name="safety_error_already_reported">You have already reported this. Staff will look at it.</string>
<string name="safety_error_already_blocked">You have already blocked this player.</string>
<string name="safety_error_gone">That line is no longer there.</string>
<string name="safety_error_staff">You can’t block a staff member.</string>
<string name="safety_error_forbidden">Your account can’t do that.</string>
<string name="safety_error_not_blockable">This player can’t be blocked.</string>
<string name="safety_error_rate_limited">Too many reports. Please give staff a chance to look at the ones you have raised.</string>
<string name="safety_error_unavailable">The game module did not answer. Try again in a moment.</string>
<string name="safety_error_demo">This is turned off on the demo site.</string>
<string name="safety_error_other">Could not do that. Try again.</string>
</resources>

View File

@@ -3,7 +3,7 @@
<!--
The app is purely an HTTPS API client of a shard's website backend, so the base
posture forbids all cleartext (HTTP) traffic. This makes explicit what minSdk 29 /
targetSdk 35 already default to, satisfies the "usesCleartextTraffic implicitly
targetSdk 36 already default to, satisfies the "usesCleartextTraffic implicitly
enabled" scanner finding, and stops any merged library manifest from re-enabling
cleartext. It also mirrors ServerUrl's release-build rule (HTTPS required) at the
platform socket layer — defense in depth.

View File

@@ -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())
}
}

View File

@@ -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)
}
}

View File

@@ -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())
}
}

View File

@@ -120,4 +120,17 @@ class FakeRustApi : RustApi {
error?.let { throw it }
return mapLiveQueue.removeFirstOrNull()?.invoke() ?: mapLive
}
/** Every hide or unhide asked for, in order: `(lineId, hidden)`. */
val chatHides: MutableList<Pair<Long, Boolean>> = mutableListOf()
override suspend fun hideChat(lineId: Long) {
error?.let { throw it }
chatHides += lineId to true
}
override suspend fun unhideChat(lineId: Long) {
error?.let { throw it }
chatHides += lineId to false
}
}

View File

@@ -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()))
}
}

View File

@@ -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") }))
}
}

View File

@@ -7,6 +7,8 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.DeleteAccountRequest
import com.runicgateway.app.data.api.dto.DeleteAccountResultDto
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
@@ -71,6 +73,8 @@ class AccountTrustedDevicesTest {
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
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)

View File

@@ -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)
}
}

View File

@@ -4,8 +4,10 @@
package com.runicgateway.app.ui
import androidx.lifecycle.SavedStateHandle
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.data.api.dto.WikiSummaryDto
@@ -13,6 +15,7 @@ import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.data.repository.WikiRepository
import com.runicgateway.app.ui.demo.DemoInfo
import com.runicgateway.app.ui.home.HomeViewModel
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.news.NewsViewModel
@@ -22,7 +25,10 @@ import com.runicgateway.app.ui.wiki.WikiPageViewModel
import com.runicgateway.app.ui.wiki.WikiViewModel
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -128,13 +134,33 @@ class ContentViewModelTest {
// ── Home status ─────────────────────────────────────────────────────────
@Test fun homeLoadsStatus() {
api.status = StatusDto(mode = "maintenance")
val vm = HomeViewModel(settings)
val vm = homeViewModel()
assertTrue((vm.state.value as UiState.Success).data.isMaintenance)
}
@Test fun homeNetworkErrorIsUiError() {
api.error = java.io.IOException("offline")
val state = HomeViewModel(settings).state.value
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
val vm = homeViewModel()
assertEquals(ErrorKind.NETWORK, (vm.state.value as UiState.Error).kind)
assertNull(vm.demo.value)
}
// ── Home: the public demo's strip (DEMO_MODE.md §6) ──
@Test fun homeDrawsNoStripOnARealShard() {
assertNull(homeViewModel().demo.value)
}
@Test fun homeReadsTheDemoBlock() {
api.settings = SettingsDto(
demo = Json.parseToJsonElement(
"""{"resetsAt":"2026-10-26T14:00:00.000Z","logins":[{"username":"demo-admin","role":"admin"}]}""",
),
)
val vm = homeViewModel(base = "https://demo.runicgateway.com/")
assertEquals(DemoInfo("14:00 UTC", listOf("demo-admin")), vm.demo.value)
assertEquals("demo.runicgateway.com/account/login", vm.signInPage)
}
private fun homeViewModel(base: String? = null) =
HomeViewModel(settings, BaseUrlHolder().apply { set(base?.toHttpUrl()) })
}

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.ui
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.DEMO_LOCKED_CODE
import com.runicgateway.app.ui.components.isRetryable
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -64,13 +65,30 @@ class UiStateTest {
@Test fun anUnavailableFeatureIsNotRetryable() {
// An admin controls this, so a retry button would read as a transient failure
// the user could wait out.
assertFalse(isRetryable(ErrorKind.FEATURE_UNAVAILABLE))
for (kind in ErrorKind.entries.filter { it != ErrorKind.FEATURE_UNAVAILABLE }) {
// the user could wait out. The demo's refusal is fixed the same way.
val fixed = setOf(ErrorKind.FEATURE_UNAVAILABLE, ErrorKind.DEMO_LOCKED)
for (kind in fixed) assertFalse("$kind should not offer a retry", isRetryable(kind))
for (kind in ErrorKind.entries.filter { it !in fixed }) {
assertTrue("$kind should offer a retry", isRetryable(kind))
}
}
// ── The public demo's refusal (DEMO_MODE.md §3.1) ──
@Test fun theDemosRefusalIsItsOwnKind() {
val refused = ApiResult.HttpError(403, code = DEMO_LOCKED_CODE)
assertEquals(ErrorKind.DEMO_LOCKED, (refused.toUiState() as UiState.Error).kind)
assertEquals(403, (refused.toUiState() as UiState.Error).httpStatus)
// On a shard read a 403 would otherwise mean "not published to you".
assertEquals(ErrorKind.DEMO_LOCKED, (refused.toShardUiState() as UiState.Error).kind)
}
@Test fun anotherCodeLeavesTheStatusMapping() {
val other = ApiResult.HttpError(403, code = "FORBIDDEN")
assertEquals(ErrorKind.SERVER, (other.toUiState() as UiState.Error).kind)
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (other.toShardUiState() as UiState.Error).kind)
}
private fun kindOf(status: Int): ErrorKind =
(ApiResult.HttpError(status).toUiState() as UiState.Error).kind

View File

@@ -11,6 +11,8 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.demoLockedError
import com.runicgateway.app.util.demoLockedUnit
import com.runicgateway.app.util.errorUnit
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
@@ -63,6 +65,15 @@ class AdminContentViewModelTest {
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
}
@Test fun theDemosRefusalIsNotBlamedOnTheRole() {
// Somebody signed in as demo-admin must not read "you don't have permission".
api.unitResponse = demoLockedUnit()
api.error = demoLockedError()
val vm = viewModel()
vm.togglePublish(AdminPostDto(id = 5, title = "x", published = 1))
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
}
@Test fun createCategoryRejectsBlankFields() {
val vm = viewModel()
vm.createCategory(slug = "", title = "", description = "", sortOrder = null)

View File

@@ -11,6 +11,7 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.demoLockedError
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -60,6 +61,16 @@ class AdminDashboardViewModelTest {
assertEquals(R.string.admin_forbidden, fb.messageRes)
}
@Test fun setSiteModeOnTheDemoShowsTheDemoLine() {
api.dashboard = AdminDashboardDto()
api.error = demoLockedError()
val vm = viewModel()
vm.setSiteMode("maintenance")
val fb = vm.state.value.feedback!!
assertFalse(fb.ok)
assertEquals(R.string.error_demo_locked, fb.messageRes)
}
@Test fun clearFeedbackResetsBanner() {
api.error = httpError(500)
val vm = viewModel()

View File

@@ -7,6 +7,7 @@ import com.runicgateway.app.R
import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.demoLockedUnit
import com.runicgateway.app.util.errorUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -48,6 +49,13 @@ class AdminModerationViewModelTest {
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
}
@Test fun kickOnTheDemoShowsTheDemoLine() {
api.unitResponse = demoLockedUnit()
val vm = viewModel()
vm.kick(account = "x", serial = "")
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
}
@Test fun broadcastRejectsBlankText() {
val vm = viewModel()
vm.broadcast(text = " ", hue = null)

View File

@@ -9,6 +9,7 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.demoLockedUnit
import com.runicgateway.app.util.errorUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -52,6 +53,13 @@ class AdminSupportViewModelTest {
assertEquals(R.string.admin_support_unknown_page, vm.state.value.feedback!!.messageRes)
}
@Test fun closeOnTheDemoShowsTheDemoLine() {
api.unitResponse = demoLockedUnit()
val vm = viewModel()
vm.close("0x1")
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
}
@Test fun closeShardOfflineMapsTo503Copy() {
api.unitResponse = errorUnit(503)
val vm = viewModel()

View File

@@ -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))
}
}

View File

@@ -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"))
}
}

View File

@@ -8,6 +8,7 @@ import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.ContactRepository
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.demoLockedError
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -64,4 +65,14 @@ class ContactViewModelTest {
assertTrue(result is ContactViewModel.Result.Failed)
assertEquals(ErrorKind.SERVER, (result as ContactViewModel.Result.Failed).kind)
}
@Test fun theDemosLockedContactFormSaysSo() {
// The contact form is locked on the demo (DEMO_MODE.md §3.3).
api.error = demoLockedError()
val vm = viewModel()
vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m")
vm.send()
val result = vm.state.value.result as ContactViewModel.Result.Failed
assertEquals(ErrorKind.DEMO_LOCKED, result.kind)
}
}

View File

@@ -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()))
}
}

View File

@@ -15,6 +15,7 @@ import com.runicgateway.app.data.repository.PlayerShardRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.demoLockedError
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -86,4 +87,11 @@ class CharactersViewModelTest {
vm.createAccount(account = "acct1", password = "longenough")
assertEquals(R.string.player_create_taken, vm.state.value.feedback!!.messageRes)
}
@Test fun createAccountOnTheDemoIsNotSignupBeingOff() {
playerApi.error = demoLockedError()
val vm = viewModel()
vm.createAccount(account = "acct1", password = "longenough")
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -25,3 +25,14 @@ fun okUnit(): Response<Unit> = Response.success(Unit)
/** A non-2xx bodyless [Response] with [code]. */
fun errorUnit(code: Int): Response<Unit> = Response.error(code, "{}".toResponseBody(JSON))
/** The public demo's refusal body (DEMO_MODE.md §3.1), as the server sends it. */
const val DEMO_LOCKED_BODY =
"""{"message":"This is turned off on the demo site.","code":"DEMO_LOCKED"}"""
/** The [HttpException] a fake API throws for a write the public demo refuses. */
fun demoLockedError(): HttpException =
HttpException(Response.error<Any>(403, DEMO_LOCKED_BODY.toResponseBody(JSON)))
/** The `Response<Unit>` of a write the public demo refuses. */
fun demoLockedUnit(): Response<Unit> = Response.error(403, DEMO_LOCKED_BODY.toResponseBody(JSON))

View File

@@ -5,7 +5,7 @@
[versions]
# Build toolchain
agp = "8.6.1"
agp = "8.13.2"
kotlin = "2.0.20"
ksp = "2.0.20-1.0.25"

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
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
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME