From 7a8231ba9c5154c5e85170f6aef8037d0e7c3914 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 23 Oct 2026 07:03:54 -0500 Subject: [PATCH 1/5] feat(release): target API 36 and upload each tag's bundle to Google Play 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/workflows/pr-checks.yml | 2 +- .gitea/workflows/release.yml | 122 ++++++++++++++++-- .gitea/workflows/sonarqube.yml | 2 +- app/build.gradle.kts | 4 +- .../main/res/xml/network_security_config.xml | 2 +- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 7 files changed, 119 insertions(+), 17 deletions(-) diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 1df823f..374f506 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 7f75623..de3808c 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 : 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" diff --git a/.gitea/workflows/sonarqube.yml b/.gitea/workflows/sonarqube.yml index c7f9b6b..6bd1a87 100644 --- a/.gitea/workflows/sonarqube.yml +++ b/.gitea/workflows/sonarqube.yml @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4594d20..9cc1f0e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 9aac908..9de30f4 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -3,7 +3,7 @@ + This is turned off on the demo site. + + + Demo site + " — anything you change is wiped at %1$s." + " — anything you change is wiped every two hours." + Sign-ins: %1$s — passwords at %2$s. + Sign-ins: %1$s — passwords on the site\'s sign-in page. Connect to your community diff --git a/app/src/test/java/com/runicgateway/app/core/net/DemoLockInterceptorTest.kt b/app/src/test/java/com/runicgateway/app/core/net/DemoLockInterceptorTest.kt new file mode 100644 index 0000000..8ab33e5 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/net/DemoLockInterceptorTest.kt @@ -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() + 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() + backgroundScope.launch { notices.refusals.collect { seen += it } } + + call(clientAnswering(403, """{"message":"Admins only"}""")) + call(clientAnswering(403, "Forbidden")) + call(clientAnswering(500, DEMO_LOCKED_BODY)) + call(clientAnswering(200, DEMO_LOCKED_BODY)) + + assertTrue(seen.isEmpty()) + } +} diff --git a/app/src/test/java/com/runicgateway/app/core/result/DemoLockedTest.kt b/app/src/test/java/com/runicgateway/app/core/result/DemoLockedTest.kt new file mode 100644 index 0000000..62f2b14 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/result/DemoLockedTest.kt @@ -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("403 Forbidden")) + } + + @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(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(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()) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt index 470f222..09badfc 100644 --- a/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt @@ -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()) }) } diff --git a/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt b/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt index 17f101a..0d69008 100644 --- a/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt @@ -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 diff --git a/app/src/test/java/com/runicgateway/app/ui/admin/AdminContentViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/admin/AdminContentViewModelTest.kt index 82d81c8..9d3a013 100644 --- a/app/src/test/java/com/runicgateway/app/ui/admin/AdminContentViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/admin/AdminContentViewModelTest.kt @@ -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) diff --git a/app/src/test/java/com/runicgateway/app/ui/admin/AdminDashboardViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/admin/AdminDashboardViewModelTest.kt index ee8a1fc..20a319c 100644 --- a/app/src/test/java/com/runicgateway/app/ui/admin/AdminDashboardViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/admin/AdminDashboardViewModelTest.kt @@ -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() diff --git a/app/src/test/java/com/runicgateway/app/ui/admin/AdminModerationViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/admin/AdminModerationViewModelTest.kt index b73e79c..1b4f6ed 100644 --- a/app/src/test/java/com/runicgateway/app/ui/admin/AdminModerationViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/admin/AdminModerationViewModelTest.kt @@ -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) diff --git a/app/src/test/java/com/runicgateway/app/ui/admin/AdminSupportViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/admin/AdminSupportViewModelTest.kt index a4dfb86..f88b490 100644 --- a/app/src/test/java/com/runicgateway/app/ui/admin/AdminSupportViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/admin/AdminSupportViewModelTest.kt @@ -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() diff --git a/app/src/test/java/com/runicgateway/app/ui/contact/ContactViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/contact/ContactViewModelTest.kt index ace0ca2..558dcc5 100644 --- a/app/src/test/java/com/runicgateway/app/ui/contact/ContactViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/contact/ContactViewModelTest.kt @@ -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) + } } diff --git a/app/src/test/java/com/runicgateway/app/ui/demo/DemoInfoTest.kt b/app/src/test/java/com/runicgateway/app/ui/demo/DemoInfoTest.kt new file mode 100644 index 0000000..6b5c534 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/demo/DemoInfoTest.kt @@ -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())) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/player/CharactersViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/player/CharactersViewModelTest.kt index 025b75d..024ff4a 100644 --- a/app/src/test/java/com/runicgateway/app/ui/player/CharactersViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/player/CharactersViewModelTest.kt @@ -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) + } } diff --git a/app/src/test/java/com/runicgateway/app/util/FakeApiSupport.kt b/app/src/test/java/com/runicgateway/app/util/FakeApiSupport.kt index ab21960..654b52c 100644 --- a/app/src/test/java/com/runicgateway/app/util/FakeApiSupport.kt +++ b/app/src/test/java/com/runicgateway/app/util/FakeApiSupport.kt @@ -25,3 +25,14 @@ fun okUnit(): Response = Response.success(Unit) /** A non-2xx bodyless [Response] with [code]. */ fun errorUnit(code: Int): Response = 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(403, DEMO_LOCKED_BODY.toResponseBody(JSON))) + +/** The `Response` of a write the public demo refuses. */ +fun demoLockedUnit(): Response = Response.error(403, DEMO_LOCKED_BODY.toResponseBody(JSON)) -- 2.49.1 From 690441a8c359026f29ab0d147e10a497583397b5 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 29 Oct 2026 07:47:43 -0500 Subject: [PATCH 3/5] fix(push): hold the ntfy connection as specialUse and restart it on boot 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- app/src/main/AndroidManifest.xml | 29 +++++++++-- .../app/core/push/PushBootReceiver.kt | 52 +++++++++++++++++++ .../runicgateway/app/core/push/PushService.kt | 10 +++- .../app/core/push/PushBootReceiverTest.kt | 52 +++++++++++++++++++ 4 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/core/push/PushBootReceiver.kt create mode 100644 app/src/test/java/com/runicgateway/app/core/push/PushBootReceiverTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 78778d4..8c09c69 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,10 +8,14 @@ + 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. --> - + + + specialUse foreground type, with the reason Play reviews in the property below; + not exported — started only by PushManager and PushBootReceiver. --> + android:foregroundServiceType="specialUse"> + + + + + + + + + + diff --git a/app/src/main/java/com/runicgateway/app/core/push/PushBootReceiver.kt b/app/src/main/java/com/runicgateway/app/core/push/PushBootReceiver.kt new file mode 100644 index 0000000..5f12582 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/push/PushBootReceiver.kt @@ -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() + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/push/PushService.kt b/app/src/main/java/com/runicgateway/app/core/push/PushService.kt index dc8aa7e..02eae6a 100644 --- a/app/src/main/java/com/runicgateway/app/core/push/PushService.kt +++ b/app/src/main/java/com/runicgateway/app/core/push/PushService.kt @@ -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 } diff --git a/app/src/test/java/com/runicgateway/app/core/push/PushBootReceiverTest.kt b/app/src/test/java/com/runicgateway/app/core/push/PushBootReceiverTest.kt new file mode 100644 index 0000000..6863e75 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/push/PushBootReceiverTest.kt @@ -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) + } +} -- 2.49.1 From 6be83fe70aff885648cecf2f6828f2903a8ef104 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 30 Oct 2026 00:13:34 -0500 Subject: [PATCH 4/5] feat(account): delete my account in the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .../com/runicgateway/app/data/api/MeApi.kt | 9 + .../app/data/api/dto/AccountDto.kt | 16 ++ .../app/data/repository/AccountRepository.kt | 69 ++++++ .../app/data/repository/AuthRepository.kt | 24 ++ .../java/com/runicgateway/app/ui/RunicApp.kt | 26 +++ .../app/ui/auth/AccountDeletion.kt | 39 ++++ .../runicgateway/app/ui/auth/AccountScreen.kt | 34 +++ .../app/ui/auth/DeleteAccountScreen.kt | 218 ++++++++++++++++++ .../app/ui/auth/DeleteAccountViewModel.kt | 87 +++++++ .../runicgateway/app/ui/navigation/Routes.kt | 3 + app/src/main/res/values/strings.xml | 32 +++ .../AccountDeletionRepositoryTest.kt | 122 ++++++++++ .../repository/AccountTrustedDevicesTest.kt | 4 + .../app/ui/auth/AccountDeletionTest.kt | 48 ++++ 14 files changed, 731 insertions(+) create mode 100644 app/src/main/java/com/runicgateway/app/ui/auth/AccountDeletion.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountViewModel.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/repository/AccountDeletionRepositoryTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/auth/AccountDeletionTest.kt diff --git a/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt b/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt index 1114bae..feea7ee 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt @@ -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 } diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt index 507200f..c1431f1 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt @@ -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) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt index c6702cd..437a460 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt @@ -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 = 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 + } } diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt index 5b70045..d2e966a 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt @@ -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 diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index c483850..3ea35a5 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -62,6 +62,8 @@ 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.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 @@ -204,6 +206,7 @@ fun RunicApp( // 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) @@ -417,6 +420,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), ) } @@ -527,6 +539,7 @@ private fun RunicNavHost( session: Session, onSignOut: () -> Unit, onSignOutEverywhere: () -> Unit, + onAccountDeleted: () -> Unit, modifier: Modifier = Modifier, ) { NavHost( @@ -709,6 +722,7 @@ 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) }, ) Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) @@ -728,6 +742,18 @@ private fun RunicNavHost( 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 diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountDeletion.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountDeletion.kt new file mode 100644 index 0000000..168c1ef --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountDeletion.kt @@ -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 +} diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt index be8df3d..ca681c8 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt @@ -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,7 @@ fun AccountScreen( onSignOutEverywhere: () -> Unit, onOpenTrustedDevices: () -> Unit, onOpenRecoveryCodes: () -> Unit, + onOpenDeleteAccount: () -> Unit, modifier: Modifier = Modifier, viewModel: AccountViewModel = hiltViewModel(), ) { @@ -102,9 +105,40 @@ 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)) } +} + @Composable private fun IdentityCard(username: String, roleLabel: String) { ShardCard(Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountScreen.kt new file mode 100644 index 0000000..00f50fc --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountScreen.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountViewModel.kt new file mode 100644 index 0000000..6a41af6 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/DeleteAccountViewModel.kt @@ -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 = 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.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) } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index 68b3f9c..4de6862 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -38,6 +38,9 @@ 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" + /** * The in-app inbox (ENGAGEMENT.md phase 8, signed-in) and its settings. * diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eeb28b1..8784c26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -197,6 +197,38 @@ Trusted devices Recovery codes + + Delete my account + Deletes your account straight away. It cannot be restored. + Delete my account… + Your account is deleted straight away and cannot be restored. These go with it: + your sign-in, password, email address, two-factor settings and recovery codes + every signed-in device, trusted device and push registration + your linked sign-ins, such as Google, Discord or Steam + your game-account links, and anything this site granted in the game through them + your notification settings and inbox + What stays: + What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”. + 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. + Event results stay with the score and the in-game name, with no site account behind them. + A game account stays on the game server. Only its link to this site goes. + You are staff, so your username can never be used again, by anyone, once this account is deleted. + This account has no password yet. Set one in the Password section on My account first; the password is how you confirm a deletion. + Type %1$s to confirm + Recovery code + Use a recovery code instead + Use an authenticator code instead + Delete my account + Deleting… + Your account has been deleted. + Type your username exactly as it is shown. + Your password is incorrect. + Enter a code from your authenticator app, or a recovery code. + That code is not valid. Try again. + You are the only admin. Make someone else an admin before you delete your account. + Too many attempts. Wait a few minutes and try again. + Could not delete your account. Try again. + Trusted devices These devices can skip the authentication code at sign-in for 30 days. diff --git a/app/src/test/java/com/runicgateway/app/data/repository/AccountDeletionRepositoryTest.kt b/app/src/test/java/com/runicgateway/app/data/repository/AccountDeletionRepositoryTest.kt new file mode 100644 index 0000000..66fab49 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/repository/AccountDeletionRepositoryTest.kt @@ -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) : MeApi { + var lastBody: DeleteAccountRequest? = null + + override suspend fun deleteAccount(body: DeleteAccountRequest): Response { + 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 = error("unused") + override suspend fun unlinkIdentity(provider: String, id: Int?): Unit = error("unused") + override suspend fun trustedDevices(): List = error("unused") + override suspend fun trustThisDevice(body: TrustDeviceRequest): Response = 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, "Bad gateway"))) + } + + @Test fun noAnswerIsANetworkError() = runTest { + assertEquals(DeleteOutcome.NetworkError, outcomeOf(FakeMeApi { throw IOException("offline") })) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt b/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt index daa014b..a0fe5b3 100644 --- a/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt +++ b/app/src/test/java/com/runicgateway/app/data/repository/AccountTrustedDevicesTest.kt @@ -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 = emptyList() override suspend fun unlinkIdentity(provider: String, id: Int?) = Unit + override suspend fun deleteAccount(body: DeleteAccountRequest): Response = + Response.success(DeleteAccountResultDto(deleted = true)) } private fun repo(api: MeApi) = AccountRepository(api, json) diff --git a/app/src/test/java/com/runicgateway/app/ui/auth/AccountDeletionTest.kt b/app/src/test/java/com/runicgateway/app/ui/auth/AccountDeletionTest.kt new file mode 100644 index 0000000..4b63b98 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/auth/AccountDeletionTest.kt @@ -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)) + } +} -- 2.49.1 From 5386889ef4999eddd1475c005213890d3afbdcc4 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 30 Oct 2026 09:56:25 -0500 Subject: [PATCH 5/5] feat(safety): report, block and hide chat lines in the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .../com/runicgateway/app/data/api/RustApi.kt | 15 ++ .../runicgateway/app/data/api/SafetyApi.kt | 65 ++++++ .../runicgateway/app/data/api/dto/RustDto.kt | 7 + .../app/data/api/dto/SafetyDto.kt | 103 +++++++++ .../app/data/repository/RustRepository.kt | 8 + .../app/data/repository/SafetyRepository.kt | 110 +++++++++ .../com/runicgateway/app/di/NetworkModule.kt | 6 + .../java/com/runicgateway/app/ui/RunicApp.kt | 10 + .../runicgateway/app/ui/auth/AccountScreen.kt | 24 ++ .../runicgateway/app/ui/auth/BlockedScreen.kt | 156 +++++++++++++ .../app/ui/auth/BlockedViewModel.kt | 116 ++++++++++ .../runicgateway/app/ui/navigation/Routes.kt | 3 + .../runicgateway/app/ui/rust/ChatSafety.kt | 72 ++++++ .../app/ui/rust/ChatSafetySheet.kt | 209 ++++++++++++++++++ .../app/ui/rust/ChatSafetyViewModel.kt | 173 +++++++++++++++ .../app/ui/rust/RustServerScreen.kt | 65 +++++- .../app/ui/rust/RustServerViewModel.kt | 9 + app/src/main/res/values/strings.xml | 23 ++ app/src/main/res/values/strings_rust.xml | 44 ++++ .../app/data/api/fake/FakeRustApi.kt | 13 ++ .../app/data/api/fake/FakeSafetyApi.kt | 94 ++++++++ .../data/repository/SafetyRepositoryTest.kt | 118 ++++++++++ .../app/ui/auth/BlockedViewModelTest.kt | 98 ++++++++ .../app/ui/rust/ChatSafetyTest.kt | 75 +++++++ .../app/ui/rust/ChatSafetyViewModelTest.kt | 159 +++++++++++++ 25 files changed, 1770 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/data/api/SafetyApi.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/dto/SafetyDto.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/repository/SafetyRepository.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/auth/BlockedScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/auth/BlockedViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/rust/ChatSafety.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetySheet.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetyViewModel.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/fake/FakeSafetyApi.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/repository/SafetyRepositoryTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/auth/BlockedViewModelTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyViewModelTest.kt diff --git a/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt b/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt index 987c566..122ce1b 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt @@ -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 } diff --git a/app/src/main/java/com/runicgateway/app/data/api/SafetyApi.kt b/app/src/main/java/com/runicgateway/app/data/api/SafetyApi.kt new file mode 100644 index 0000000..5a3a831 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/SafetyApi.kt @@ -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 +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt index dc63720..d4208f7 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt @@ -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. diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/SafetyDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/SafetyDto.kt new file mode 100644 index 0000000..5f73375 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/SafetyDto.kt @@ -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: + * }`. + * + * 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 = 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 = 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, +) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt index 7bd5779..93cd3c9 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt @@ -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 = 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 = safeApiCall { + if (hidden) api.hideChat(lineId) else api.unhideChat(lineId) + } } diff --git a/app/src/main/java/com/runicgateway/app/data/repository/SafetyRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/SafetyRepository.kt new file mode 100644 index 0000000..fdb2a0c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/SafetyRepository.kt @@ -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> = safeApiCall { api.blocks() }.map { it.blocks } + + suspend fun unblock(id: Long): SafetyOutcome = outcome(blocking = false) { api.unblock(id) } + + suspend fun hidden(): ApiResult> = 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 + }, + ) +} diff --git a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt index 6b721c4..3f0e3db 100644 --- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -24,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 @@ -123,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 diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index 3ea35a5..a391b4f 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -62,6 +62,7 @@ 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 @@ -680,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) { @@ -723,6 +726,7 @@ private fun RunicNavHost( 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) @@ -736,6 +740,12 @@ 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() diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt index ca681c8..90118b0 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt @@ -70,6 +70,7 @@ fun AccountScreen( onOpenTrustedDevices: () -> Unit, onOpenRecoveryCodes: () -> Unit, onOpenDeleteAccount: () -> Unit, + onOpenBlocked: () -> Unit, modifier: Modifier = Modifier, viewModel: AccountViewModel = hiltViewModel(), ) { @@ -94,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()) { @@ -139,6 +142,27 @@ private fun DeleteAccountRow(onOpenDeleteAccount: () -> Unit) { ) { 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)) } + } +} + @Composable private fun IdentityCard(username: String, roleLabel: String) { ShardCard(Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/BlockedScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/BlockedScreen.kt new file mode 100644 index 0000000..0c47afa --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/BlockedScreen.kt @@ -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) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/BlockedViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/auth/BlockedViewModel.kt new file mode 100644 index 0000000..acbe9e6 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/BlockedViewModel.kt @@ -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> = 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.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) + } diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index 4de6862..ae93352 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -41,6 +41,9 @@ object Routes { /** 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. * diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafety.kt b/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafety.kt new file mode 100644 index 0000000..162514c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafety.kt @@ -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 = 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, + ) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetySheet.kt b/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetySheet.kt new file mode 100644 index 0000000..2fc9dfe --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetySheet.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetyViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetyViewModel.kt new file mode 100644 index 0000000..c1ec2ee --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetyViewModel.kt @@ -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 = _menu.asStateFlow() + + private val _changes = Channel(Channel.CONFLATED) + + /** One item per change to what this reader's feed should show. */ + val changes: Flow = _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 +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt index 5552948..9dbd1f1 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt @@ -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 ─────────────────────────────────────────────────────────── /** diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt index 6480d03..a58571c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt @@ -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() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8784c26..a85977c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -376,4 +376,27 @@ Score %1$s Show more Loading… + + + Blocked + Players and members you blocked, and anything you hid for yourself. + Blocked players + 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. + You haven’t blocked anyone. + Unknown player + Account + %1$s %2$s, not linked to an account + %1$s · blocked %2$s + Not applied: this account is now a staff member, and staff can’t be blocked. + Unblock + Unblocked. + %1$s is unblocked. + Could not unblock that. + + You’ve hidden %d item for yourself. + You’ve hidden %d items for yourself. + + Show them again + Everything you hid is shown again. + Could not show those again. diff --git a/app/src/main/res/values/strings_rust.xml b/app/src/main/res/values/strings_rust.xml index 6f92576..d8ee48f 100644 --- a/app/src/main/res/values/strings_rust.xml +++ b/app/src/main/res/values/strings_rust.xml @@ -154,4 +154,48 @@ Page %1$d %1$s · has it %1$s · waiting + + + Report, hide or block + %1$s: %2$s + this player + Sign in to report or block. + Sign in + Report + Hide for me + Block player + Hide for everyone + Show to everyone again + Hidden by staff + Cancel + Close + + This goes to site staff. Reporting does not hide or change anything — it asks a staffer to look. + Anything a staffer should know (optional) + Send report + Reported to site staff. + Abusive or harassing + Spam + Sexual content + Illegal content + Impersonation + Something else + + 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. + Block + + You have already reported this. Staff will look at it. + You have already blocked this player. + That line is no longer there. + You can’t block a staff member. + Your account can’t do that. + This player can’t be blocked. + Too many reports. Please give staff a chance to look at the ones you have raised. + The game module did not answer. Try again in a moment. + This is turned off on the demo site. + Could not do that. Try again. diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt index 65b03df..fc66b87 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt @@ -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> = 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 + } } diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeSafetyApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeSafetyApi.kt new file mode 100644 index 0000000..225f605 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeSafetyApi.kt @@ -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 = mutableListOf() + val blockRequests: MutableList = mutableListOf() + val hides: MutableList = mutableListOf() + val blockList: MutableList = mutableListOf() + val hiddenList: MutableList = 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(status, body.toResponseBody())) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/repository/SafetyRepositoryTest.kt b/app/src/test/java/com/runicgateway/app/data/repository/SafetyRepositoryTest.kt new file mode 100644 index 0000000..f708f30 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/repository/SafetyRepositoryTest.kt @@ -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(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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/auth/BlockedViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/auth/BlockedViewModelTest.kt new file mode 100644 index 0000000..c2b248a --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/auth/BlockedViewModelTest.kt @@ -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")) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyTest.kt new file mode 100644 index 0000000..4c9ba08 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyTest.kt @@ -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( + """{"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("""{"id":5,"kind":"player.chat","frame":{}}""") + assertNull(signedOut.canBlock) + assertFalse(signedOut.hidden) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyViewModelTest.kt new file mode 100644 index 0000000..5a8d308 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyViewModelTest.kt @@ -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) + } +} -- 2.49.1