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/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/net/DemoLockInterceptor.kt b/app/src/main/java/com/runicgateway/app/core/net/DemoLockInterceptor.kt new file mode 100644 index 0000000..a87b708 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/net/DemoLockInterceptor.kt @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.net + +import com.runicgateway.app.core.result.DEMO_LOCKED_STATUS +import com.runicgateway.app.core.result.demoRefusalOf +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Spots the public demo's refusal on any response of the main client and reports it + * to [DemoNotices] (`docs/website/DEMO_MODE.md` §3.1, DM19). Every API rides this + * client, so a screen added later is covered without doing anything. + * + * It only reads: the body is peeked, not consumed, and the response goes on to its + * caller unchanged. Only a `403` is looked at, so an ordinary answer is never + * buffered. + */ +@Singleton +class DemoLockInterceptor @Inject constructor( + private val notices: DemoNotices, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + if (response.code != DEMO_LOCKED_STATUS) return response + val body = runCatching { response.peekBody(MAX_PEEK_BYTES).string() }.getOrNull() + demoRefusalOf(body)?.let(notices::report) + return response + } + + private companion object { + /** The refusal is one short JSON object; anything longer is not one. */ + const val MAX_PEEK_BYTES = 8L * 1024 + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/net/DemoNotices.kt b/app/src/main/java/com/runicgateway/app/core/net/DemoNotices.kt new file mode 100644 index 0000000..b1a054c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/net/DemoNotices.kt @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.net + +import com.runicgateway.app.core.result.DemoRefusal +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The app's one channel for the public demo's refusals (`docs/website/DEMO_MODE.md` + * §3.1, DM19) — the counterpart of the web client's `demoNotice.js`. + * [DemoLockInterceptor] reports every `DEMO_LOCKED` answer here and the shell shows + * it as one snackbar, wherever the action was, so no screen has to learn the code. + * The screen still gets its error as usual. + * + * A refusal nobody is collecting (the app in the background) is dropped rather than + * queued: a snackbar about a tap from minutes ago would explain nothing. + */ +@Singleton +class DemoNotices @Inject constructor() { + private val _refusals = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val refusals: SharedFlow = _refusals.asSharedFlow() + + fun report(refusal: DemoRefusal) { + _refusals.tryEmit(refusal) + } +} 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/main/java/com/runicgateway/app/core/result/ApiResult.kt b/app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt index 6f500a8..6a2fd93 100644 --- a/app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt +++ b/app/src/main/java/com/runicgateway/app/core/result/ApiResult.kt @@ -14,12 +14,18 @@ import java.io.IOException * an expected failure, it returns a typed variant the screen can render. * * - [Ok] — a 2xx response with a decoded body. - * - [HttpError] — the server answered with a non-2xx status. + * - [HttpError] — the server answered with a non-2xx status. [HttpError.code] is + * the error body's `code` field when it carried one (today only + * the demo's `DEMO_LOCKED` is read, see [isDemoLocked]). * - [NetworkError] — the request never got an answer (offline, DNS, TLS, timeout). */ sealed interface ApiResult { data class Ok(val data: T) : ApiResult - data class HttpError(val status: Int, val message: String? = null) : ApiResult + data class HttpError( + val status: Int, + val message: String? = null, + val code: String? = null, + ) : ApiResult data class NetworkError(val cause: Throwable) : ApiResult } @@ -54,12 +60,31 @@ suspend fun safeApiCall(block: suspend () -> T): ApiResult = try { } catch (e: CancellationException) { throw e } catch (e: HttpException) { - ApiResult.HttpError(e.code(), e.message()) + ApiResult.HttpError(e.code(), e.message(), errorCodeOf(e)) } catch (e: IOException) { ApiResult.NetworkError(e) } catch (e: SerializationException) { ApiResult.HttpError(MALFORMED_RESPONSE_STATUS, e.message) } +/** + * The error body's `code`, read only for a status that can carry one the app acts on, + * so an ordinary 404 or 500 body is never looked at. The body is peeked, not + * consumed, so whoever else holds the exception can still read it. + */ +private fun errorCodeOf(e: HttpException): String? { + if (e.code() != DEMO_LOCKED_STATUS) return null + val body = runCatching { + e.response()?.errorBody()?.source()?.let { source -> + source.request(MAX_ERROR_PEEK_BYTES) + source.peek().readUtf8(minOf(source.buffer.size, MAX_ERROR_PEEK_BYTES)) + } + }.getOrNull() + return errorCodeOf(body) +} + +/** The refusal is one short JSON object; anything longer is not one. */ +private const val MAX_ERROR_PEEK_BYTES = 8L * 1024 + /** Synthetic status for a 2xx body the app couldn't decode — an invalid upstream response. */ private const val MALFORMED_RESPONSE_STATUS = 502 diff --git a/app/src/main/java/com/runicgateway/app/core/result/DemoLocked.kt b/app/src/main/java/com/runicgateway/app/core/result/DemoLocked.kt new file mode 100644 index 0000000..7e7f87f --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/result/DemoLocked.kt @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.result + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * The public demo's refusal (`docs/website/DEMO_MODE.md` §3.1). With `DEMO_MODE` on, + * the server answers a locked write + * `403 { "message": "This is turned off on the demo site.", "code": "DEMO_LOCKED" }`. + * A module may word its own sentence (a refused console command names the command), + * so the message is carried as sent. + * + * The lock is the server's (DM10). Nothing in the app decides what is allowed; it + * only explains what was refused. + */ +const val DEMO_LOCKED_CODE = "DEMO_LOCKED" + +/** Status the server answers a demo refusal with. */ +const val DEMO_LOCKED_STATUS = 403 + +/** A refusal read off a response body. [message] is null when the body carried none. */ +data class DemoRefusal(val message: String?) + +private val lenient = Json { ignoreUnknownKeys = true } + +/** + * The `code` field of a JSON error body, or null for any body that is not a JSON + * object carrying a string `code`. Never throws: an error body is whatever the + * server or a proxy in front of it sent. + */ +fun errorCodeOf(body: String?): String? = errorObject(body)?.stringField("code") + +/** The refusal in this error body, or null when it is not one. */ +fun demoRefusalOf(body: String?): DemoRefusal? { + val obj = errorObject(body) ?: return null + if (obj.stringField("code") != DEMO_LOCKED_CODE) return null + return DemoRefusal(obj.stringField("message")?.takeIf { it.isNotBlank() }) +} + +/** True when this result is the demo's refusal. */ +fun ApiResult<*>.isDemoLocked(): Boolean = + this is ApiResult.HttpError && code == DEMO_LOCKED_CODE + +private fun errorObject(body: String?): JsonObject? { + if (body.isNullOrBlank()) return null + return runCatching { lenient.parseToJsonElement(body) as? JsonObject }.getOrNull() +} + +private fun JsonObject.stringField(name: String): String? = + (get(name) as? JsonPrimitive)?.takeIf { it.isString }?.content 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/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/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/api/dto/PublicDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt index cdcde5c..47fc1fe 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt @@ -146,4 +146,14 @@ data class SettingsDto( * nav. */ @SerialName("nav_public") val navPublic: String? = null, + /** + * The public demo's banner data, `{ resetsAt, logins: [{ username, role, … }] }` + * (`docs/website/DEMO_MODE.md` §6). Sent only while the server runs with + * `DEMO_MODE` on, so its absence is how a real shard draws no banner. + * + * Raw for the same reason as [theme]: the banner is a nicety, and a value of an + * unexpected shape must not fail the decode of the whole payload. Read by + * `DemoInfo.from`. + */ + val demo: JsonElement? = null, ) 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/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/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 ed3e7df..3f0e3db 100644 --- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -8,6 +8,7 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact import com.runicgateway.app.BuildConfig import com.runicgateway.app.core.net.AuthInterceptor import com.runicgateway.app.core.net.BaseUrlHolder +import com.runicgateway.app.core.net.DemoLockInterceptor import com.runicgateway.app.core.net.HostSelectionInterceptor import com.runicgateway.app.core.net.ShardStream import com.runicgateway.app.core.net.ShardStreamClient @@ -23,6 +24,7 @@ import com.runicgateway.app.data.api.PlayerRustApi import com.runicgateway.app.data.api.PlayerShardApi import com.runicgateway.app.data.api.PublicApi import com.runicgateway.app.data.api.RustApi +import com.runicgateway.app.data.api.SafetyApi import com.runicgateway.app.data.api.SsoApi import dagger.Module import dagger.Provides @@ -58,7 +60,8 @@ object NetworkModule { /** * The main client every API and the SSE stream ride on. Order: identify (UA), - * retarget onto the configured shard host, then attach the bearer; the + * retarget onto the configured shard host, attach the bearer, then spot the + * public demo's refusal on the way back (DEMO_MODE.md §3.1); the * [TokenAuthenticator] handles silent refresh-on-401 (§4.1). Logging sits last * so it observes the final, authed request. */ @@ -68,12 +71,14 @@ object NetworkModule { hostSelectionInterceptor: HostSelectionInterceptor, userAgentInterceptor: UserAgentInterceptor, authInterceptor: AuthInterceptor, + demoLockInterceptor: DemoLockInterceptor, tokenAuthenticator: TokenAuthenticator, ): OkHttpClient { val builder = OkHttpClient.Builder() .addInterceptor(userAgentInterceptor) .addInterceptor(hostSelectionInterceptor) .addInterceptor(authInterceptor) + .addInterceptor(demoLockInterceptor) .authenticator(tokenAuthenticator) if (BuildConfig.DEBUG) { @@ -119,6 +124,11 @@ object NetworkModule { @Singleton fun provideMeApi(retrofit: Retrofit): MeApi = retrofit.create(MeApi::class.java) + /** Report, block and hide (USER_SAFETY.md §4.1) — core's routes, bearer-authed. */ + @Provides + @Singleton + fun provideSafetyApi(retrofit: Retrofit): SafetyApi = retrofit.create(SafetyApi::class.java) + /** A player's own game data + linking (§6.3) — bearer-authed on the main client. */ @Provides @Singleton 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 b517a47..a391b4f 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -26,6 +26,8 @@ import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItemColors import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -33,6 +35,7 @@ import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -59,12 +62,16 @@ import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.data.appearance.SiteAppearance import com.runicgateway.app.data.repository.Capability import com.runicgateway.app.ui.auth.AccountScreen +import com.runicgateway.app.ui.auth.BlockedScreen +import com.runicgateway.app.ui.auth.DeleteAccountScreen +import com.runicgateway.app.ui.auth.DeleteAccountViewModel import com.runicgateway.app.ui.auth.LoginScreen import com.runicgateway.app.ui.auth.RecoveryCodesScreen import com.runicgateway.app.ui.auth.TrustedDevicesScreen import com.runicgateway.app.ui.auth.roleLabelRes import com.runicgateway.app.ui.components.BrandLogo import com.runicgateway.app.ui.contact.ContactScreen +import com.runicgateway.app.ui.demo.DemoNoticeViewModel import com.runicgateway.app.ui.events.EventScreen import com.runicgateway.app.ui.events.EventSeriesScreen import com.runicgateway.app.ui.events.EventsScreen @@ -110,6 +117,7 @@ import com.runicgateway.app.ui.shard.ShardScreen import com.runicgateway.app.ui.theme.LocalShardStructure import com.runicgateway.app.ui.wiki.WikiPageScreen import com.runicgateway.app.ui.wiki.WikiScreen +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch /** Destinations that show the drawer (hamburger); others show a back arrow. */ @@ -151,6 +159,7 @@ fun RunicApp( sessionViewModel: SessionViewModel = hiltViewModel(), inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(), rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(), + demoNoticeViewModel: DemoNoticeViewModel = hiltViewModel(), ) { val brand = appearance.brand val navController = rememberNavController() @@ -192,6 +201,19 @@ fun RunicApp( // the previous account's count on the drawer. LaunchedEffect(session) { inboxBadgeViewModel.refresh() } + // The public demo's refusals (DEMO_MODE.md §3.1, DM19): one snackbar for every + // screen, the app's counterpart of the web's toast. The screen keeps its own + // failure line; this says why, in the server's words. A newer refusal replaces + // the one showing rather than queueing behind it. + val snackbarHostState = remember { SnackbarHostState() } + val demoLockedFallback = stringResource(R.string.error_demo_locked) + val accountDeletedNotice = stringResource(R.string.account_delete_done) + LaunchedEffect(Unit) { + demoNoticeViewModel.refusals.collectLatest { refusal -> + snackbarHostState.showSnackbar(refusal.message ?: demoLockedFallback) + } + } + // A tapped push notification deep-links to its stream's screen (§11, item 7). LaunchedEffect(deepLinkStream, deepLinkRef) { val stream = deepLinkStream ?: return@LaunchedEffect @@ -347,6 +369,7 @@ fun RunicApp( ) { Scaffold( modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( colors = TopAppBarDefaults.topAppBarColors( @@ -398,6 +421,15 @@ fun RunicApp( session = session, onSignOut = { sessionViewModel.signOut() }, onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) }, + onAccountDeleted = { + // To sign-in with the notice, as the web lands home with its own. + // Home stays beneath it, so back leaves sign-in for Home. + navController.navigate(Routes.LOGIN) { + popUpTo(Routes.HOME) + launchSingleTop = true + } + scope.launch { snackbarHostState.showSnackbar(accountDeletedNotice) } + }, modifier = Modifier.padding(innerPadding), ) } @@ -508,6 +540,7 @@ private fun RunicNavHost( session: Session, onSignOut: () -> Unit, onSignOutEverywhere: () -> Unit, + onAccountDeleted: () -> Unit, modifier: Modifier = Modifier, ) { NavHost( @@ -648,6 +681,8 @@ private fun RunicNavHost( onBack = { navController.navigateTopLevel(Routes.RUST) }, // D123: a site-event marker opens the app's own event page on its run. onOpenEvent = { slug, runId -> navController.navigate(Routes.event(slug, runId)) }, + // A chat line's menu signed out (S6): report and block need an account. + onSignIn = { navController.navigate(Routes.LOGIN) }, ) } composable(Routes.WIKI) { @@ -690,6 +725,8 @@ private fun RunicNavHost( onSignOutEverywhere = onSignOutEverywhere, onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) }, onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) }, + onOpenDeleteAccount = { navController.navigate(Routes.ACCOUNT_DELETE) }, + onOpenBlocked = { navController.navigate(Routes.ACCOUNT_BLOCKED) }, ) Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) @@ -703,12 +740,30 @@ private fun RunicNavHost( Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } } } + composable(Routes.ACCOUNT_BLOCKED) { + when (session) { + is Session.SignedIn -> BlockedScreen() + Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } + } + } composable(Routes.ACCOUNT_RECOVERY_CODES) { when (session) { is Session.SignedIn -> RecoveryCodesScreen() Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } } } + composable(Routes.ACCOUNT_DELETE) { + // The view model is held here, not in the screen, so this route can tell a + // deletion from any other sign-out: the view model marks it deleted before + // the session drops, and both land in the same frame (ACCOUNT_DELETION.md §4.3). + val viewModel: DeleteAccountViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + when { + state.deleted -> LaunchedEffect(Unit) { onAccountDeleted() } + session is Session.SignedIn -> DeleteAccountScreen(viewModel) + else -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } + } + } composable(Routes.NOTIFICATIONS) { // Signed-in only; a sign-out (or demotion) sends the user home rather than // leaving another account's items up. The backend gates every call diff --git a/app/src/main/java/com/runicgateway/app/ui/UiState.kt b/app/src/main/java/com/runicgateway/app/ui/UiState.kt index c0efa1a..f5ca21c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/UiState.kt +++ b/app/src/main/java/com/runicgateway/app/ui/UiState.kt @@ -4,6 +4,7 @@ package com.runicgateway.app.ui import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.isDemoLocked /** * The load state a screen renders. View models expose a `StateFlow>`; @@ -41,6 +42,14 @@ enum class ErrorKind { */ FEATURE_UNAVAILABLE, + /** + * The public demo refused this (`403 DEMO_LOCKED`, `docs/website/DEMO_MODE.md` + * §3.1). Checked ahead of the status, because a `403` otherwise reads as "you + * may not", which blames the role of somebody signed in as an admin. Like + * [FEATURE_UNAVAILABLE], retrying cannot change the answer. + */ + DEMO_LOCKED, + /** Any other non-2xx server response. */ SERVER, } @@ -50,10 +59,11 @@ fun ApiResult.toUiState(): UiState = when (this) { is ApiResult.Ok -> UiState.Success(data) is ApiResult.NetworkError -> UiState.Error(ErrorKind.NETWORK) is ApiResult.HttpError -> UiState.Error( - kind = when (status) { - 404 -> ErrorKind.NOT_FOUND - 429 -> ErrorKind.RATE_LIMITED - 503 -> ErrorKind.SHARD_OFFLINE + kind = when { + isDemoLocked() -> ErrorKind.DEMO_LOCKED + status == 404 -> ErrorKind.NOT_FOUND + status == 429 -> ErrorKind.RATE_LIMITED + status == 503 -> ErrorKind.SHARD_OFFLINE else -> ErrorKind.SERVER }, httpStatus = status, @@ -76,7 +86,7 @@ fun ApiResult.toUiState(): UiState = when (this) { * player or admin route, which is not an admin's visibility setting. */ fun ApiResult.toShardUiState(): UiState = when (this) { - is ApiResult.HttpError -> if (status == 403 || status == 404) { + is ApiResult.HttpError -> if ((status == 403 || status == 404) && !isDemoLocked()) { UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status) } else { toUiState() diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt index dbc967c..3710083 100644 --- a/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.runicgateway.app.R import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.isDemoLocked import com.runicgateway.app.data.api.dto.AdminPostDto import com.runicgateway.app.data.api.dto.PostCreateRequest import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto @@ -133,8 +134,14 @@ class AdminContentViewModel @Inject constructor( /** Map an [ApiResult] to a [Feedback], with role/permission-aware failure copy. */ private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) { is ApiResult.Ok -> Feedback(true, okRes) - is ApiResult.HttpError -> - Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed) + is ApiResult.HttpError -> Feedback( + false, + when { + isDemoLocked() -> R.string.error_demo_locked + status == 403 -> R.string.admin_forbidden + else -> R.string.admin_action_failed + }, + ) is ApiResult.NetworkError -> Feedback(false, R.string.error_network) } } diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt index 2f19f53..c17459f 100644 --- a/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.runicgateway.app.R import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.isDemoLocked import com.runicgateway.app.data.api.dto.AdminDashboardDto import com.runicgateway.app.data.repository.AdminRepository import com.runicgateway.app.ui.UiState @@ -79,7 +80,11 @@ class AdminDashboardViewModel @Inject constructor( switching = false, feedback = Feedback( false, - if (result.status == 403) R.string.admin_forbidden else R.string.admin_action_failed, + when { + result.isDemoLocked() -> R.string.error_demo_locked + result.status == 403 -> R.string.admin_forbidden + else -> R.string.admin_action_failed + }, ), ) } diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationViewModel.kt index 4ff7180..1e01b6c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.runicgateway.app.R import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.isDemoLocked import com.runicgateway.app.data.repository.AdminRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -75,9 +76,10 @@ class AdminModerationViewModel @Inject constructor( is ApiResult.Ok -> Feedback(true, okRes) is ApiResult.HttpError -> Feedback( false, - when (r.status) { - 403 -> R.string.admin_forbidden - 503 -> R.string.admin_mod_shard_offline + when { + r.isDemoLocked() -> R.string.error_demo_locked + r.status == 403 -> R.string.admin_forbidden + r.status == 503 -> R.string.admin_mod_shard_offline else -> R.string.admin_action_failed }, ) diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportViewModel.kt index 64648ef..981529e 100644 --- a/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.runicgateway.app.R import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.isDemoLocked import com.runicgateway.app.data.api.dto.SupportPageDto import com.runicgateway.app.data.repository.AdminRepository import com.runicgateway.app.ui.UiState @@ -71,10 +72,11 @@ class AdminSupportViewModel @Inject constructor( is ApiResult.Ok -> Feedback(true, okRes) is ApiResult.HttpError -> Feedback( false, - when (r.status) { - 403 -> R.string.admin_forbidden - 404 -> R.string.admin_support_unknown_page - 503 -> R.string.admin_mod_shard_offline + when { + r.isDemoLocked() -> R.string.error_demo_locked + r.status == 403 -> R.string.admin_forbidden + r.status == 404 -> R.string.admin_support_unknown_page + r.status == 503 -> R.string.admin_mod_shard_offline else -> R.string.admin_action_failed }, ) 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..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 @@ -6,6 +6,7 @@ package com.runicgateway.app.ui.auth import android.graphics.BitmapFactory import android.util.Base64 import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -67,6 +69,8 @@ fun AccountScreen( onSignOutEverywhere: () -> Unit, onOpenTrustedDevices: () -> Unit, onOpenRecoveryCodes: () -> Unit, + onOpenDeleteAccount: () -> Unit, + onOpenBlocked: () -> Unit, modifier: Modifier = Modifier, viewModel: AccountViewModel = hiltViewModel(), ) { @@ -91,6 +95,8 @@ fun AccountScreen( is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes) } + BlockedRow(onOpenBlocked) + HorizontalDivider(Modifier.padding(vertical = 20.dp)) OutlinedButton(onClick = onSignOut, modifier = Modifier.fillMaxWidth()) { @@ -102,6 +108,58 @@ fun AccountScreen( ) { Text(stringResource(R.string.account_sign_out_all)) } + + DeleteAccountRow(onOpenDeleteAccount) + } +} + +/** + * Last on the screen and set apart (ACCOUNT_DELETION.md §4.3). Always shown, on the + * demo too, where the server refuses: Play's reviewer must be able to find it. + */ +@Composable +private fun DeleteAccountRow(onOpenDeleteAccount: () -> Unit) { + HorizontalDivider( + Modifier.padding(top = 20.dp, bottom = 16.dp), + color = MaterialTheme.colorScheme.error, + ) + Text( + stringResource(R.string.account_delete_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Text( + stringResource(R.string.account_delete_row_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + OutlinedButton( + onClick = onOpenDeleteAccount, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) { Text(stringResource(R.string.account_delete_open)) } +} + +/** + * Account → Blocked (USER_SAFETY.md §4.3). Outside the loaded sections on purpose: + * the blocks are their own read, and a failed account read is no reason to hide + * the way to undo one. + */ +@Composable +private fun BlockedRow(onOpenBlocked: () -> Unit) { + SectionCard(R.string.blocked_title) { + Text( + stringResource(R.string.blocked_row_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + OutlinedButton( + onClick = onOpenBlocked, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) { Text(stringResource(R.string.blocked_open)) } } } 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/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/components/StateViews.kt b/app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt index eedb479..cc12d7a 100644 --- a/app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt +++ b/app/src/main/java/com/runicgateway/app/ui/components/StateViews.kt @@ -39,7 +39,9 @@ fun LoadingView(modifier: Modifier = Modifier) { * * [ErrorKind.FEATURE_UNAVAILABLE] renders **without** the button: an admin decides * whether the shard publishes that surface, so retrying cannot change the answer and - * offering it would read as a transient failure the user could wait out (M11). + * offering it would read as a transient failure the user could wait out (M11). The + * public demo's refusal ([ErrorKind.DEMO_LOCKED]) is the same: it is fixed until the + * site stops being a demo. */ @Composable fun ErrorView( @@ -69,7 +71,8 @@ fun ErrorView( } /** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */ -fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE +fun isRetryable(kind: ErrorKind): Boolean = + kind != ErrorKind.FEATURE_UNAVAILABLE && kind != ErrorKind.DEMO_LOCKED /** Centered informational message for an empty list (§7). */ @Composable @@ -93,5 +96,6 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) { ErrorKind.RATE_LIMITED -> R.string.error_rate_limited ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable + ErrorKind.DEMO_LOCKED -> R.string.error_demo_locked ErrorKind.SERVER -> R.string.error_server } diff --git a/app/src/main/java/com/runicgateway/app/ui/contact/ContactScreen.kt b/app/src/main/java/com/runicgateway/app/ui/contact/ContactScreen.kt index 35e085f..fc0a774 100644 --- a/app/src/main/java/com/runicgateway/app/ui/contact/ContactScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/contact/ContactScreen.kt @@ -117,5 +117,6 @@ private fun ResultMessage(result: Result) { private fun errorText(kind: ErrorKind): String = when (kind) { ErrorKind.NETWORK -> stringResource(R.string.error_network) ErrorKind.RATE_LIMITED -> stringResource(R.string.error_rate_limited) + ErrorKind.DEMO_LOCKED -> stringResource(R.string.error_demo_locked) else -> stringResource(R.string.contact_error) } diff --git a/app/src/main/java/com/runicgateway/app/ui/demo/DemoBanner.kt b/app/src/main/java/com/runicgateway/app/ui/demo/DemoBanner.kt new file mode 100644 index 0000000..1daa5e5 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/demo/DemoBanner.kt @@ -0,0 +1,65 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.demo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.runicgateway.app.R + +/** + * The public demo's strip on Home (`docs/website/DEMO_MODE.md` §6), worded like the + * web's: "**Demo site** — anything you change is wiped at 14:00 UTC. Sign-ins: + * demo-admin / demo-player — passwords at demo.runicgateway.com/account/login." + * + * Drawn only from a [DemoInfo] the server sent, so a real shard never shows it and + * the app needs no schedule of its own. [signInPage] is null when the site address + * is unknown, and the logins sentence then names the sign-in page without it. + */ +@Composable +fun DemoBanner(info: DemoInfo, signInPage: String?, modifier: Modifier = Modifier) { + val wipe = info.resetsAt + ?.let { stringResource(R.string.demo_banner_wipe_at, it) } + ?: stringResource(R.string.demo_banner_wipe_soon) + val logins = info.usernames.takeIf { it.isNotEmpty() }?.joinToString(" / ")?.let { names -> + if (signInPage != null) { + stringResource(R.string.demo_banner_logins_at, names, signInPage) + } else { + stringResource(R.string.demo_banner_logins, names) + } + } + val title = stringResource(R.string.demo_banner_title) + val accent = MaterialTheme.colorScheme.primary + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = accent, fontWeight = FontWeight.Bold)) { append(title) } + append(wipe) + logins?.let { append(" $it") } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + HorizontalDivider(thickness = 1.dp, color = accent) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/demo/DemoInfo.kt b/app/src/main/java/com/runicgateway/app/ui/demo/DemoInfo.kt new file mode 100644 index 0000000..20a27bb --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/demo/DemoInfo.kt @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.demo + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.HttpUrl +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +/** + * What the Home strip says on the public demo (`docs/website/DEMO_MODE.md` §6). + * + * @property resetsAt when the next wipe runs, already worded ("14:00 UTC"), or null + * when the server sent no readable time. + * @property usernames the published logins, in the server's order. Their passwords + * are on the site's sign-in page, not in the app (DM20). + */ +data class DemoInfo( + val resetsAt: String?, + val usernames: List, +) { + companion object { + /** + * The banner data from `GET /public/settings`' raw `demo` value, or null when + * there is none, which is every real shard. Forgiving field by field: a + * missing time or a malformed login drops that part, never the banner. + */ + fun from(demo: JsonElement?): DemoInfo? { + val obj = demo as? JsonObject ?: return null + val resetsAt = (obj["resetsAt"] as? JsonPrimitive) + ?.takeIf { it.isString } + ?.let { formatResetTime(it.content) } + val usernames = (obj["logins"] as? JsonArray).orEmpty().mapNotNull { login -> + ((login as? JsonObject)?.get("username") as? JsonPrimitive) + ?.takeIf { it.isString } + ?.content + ?.takeIf { it.isNotBlank() } + } + return DemoInfo(resetsAt, usernames) + } + } +} + +private val RESET_TIME: DateTimeFormatter = + DateTimeFormatter.ofPattern("HH:mm 'UTC'").withZone(ZoneOffset.UTC) + +/** + * "14:00 UTC" for an ISO-8601 instant, or null when it does not parse. The wipe runs + * on the hour in UTC (DM7), so the strip says UTC rather than converting, the same as + * the web's: a time in the viewer's own zone reads as a different schedule from the + * one published on `/beta`. + */ +fun formatResetTime(iso: String): String? = try { + RESET_TIME.format(Instant.parse(iso)) +} catch (_: DateTimeParseException) { + null +} + +/** + * The site's sign-in page as the strip prints it, `demo.runicgateway.com/account/login`: + * no scheme, and the port only when it is not the scheme's default. + */ +fun signInPageLabel(base: HttpUrl): String? { + val page = base.resolve("account/login") ?: return null + val port = if (page.port == HttpUrl.defaultPort(page.scheme)) "" else ":${page.port}" + return page.host + port + page.encodedPath +} diff --git a/app/src/main/java/com/runicgateway/app/ui/demo/DemoNoticeViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/demo/DemoNoticeViewModel.kt new file mode 100644 index 0000000..119e481 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/demo/DemoNoticeViewModel.kt @@ -0,0 +1,22 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.demo + +import androidx.lifecycle.ViewModel +import com.runicgateway.app.core.net.DemoNotices +import com.runicgateway.app.core.result.DemoRefusal +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharedFlow +import javax.inject.Inject + +/** + * Hands the shell the public demo's refusals (DM19), for its one snackbar. Only a + * window onto [DemoNotices]: the shell collects while it is on screen. + */ +@HiltViewModel +class DemoNoticeViewModel @Inject constructor( + notices: DemoNotices, +) : ViewModel() { + val refusals: SharedFlow = notices.refusals +} diff --git a/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt b/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt index 3fecd9e..402270d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt @@ -32,6 +32,7 @@ import com.runicgateway.app.ui.components.FeatureCard import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.OnlineDot import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.demo.DemoBanner /** * Home / status (PLAN.md §6.1): the shard's name + tagline from branding, and a @@ -44,11 +45,18 @@ fun HomeScreen( viewModel: HomeViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val demo by viewModel.demo.collectAsStateWithLifecycle() - when (val s = state) { - is UiState.Loading -> LoadingView(modifier) - is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier) - is UiState.Success -> HomeContent(brand, s.data, modifier) + Column(modifier = modifier.fillMaxSize()) { + // The public demo's strip (DEMO_MODE.md §6), above whatever the status read + // came back with, so it shows even while the status is loading or failed. + demo?.let { DemoBanner(info = it, signInPage = viewModel.signInPage) } + val content = Modifier.weight(1f) + when (val s = state) { + is UiState.Loading -> LoadingView(content) + is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = content) + is UiState.Success -> HomeContent(brand, s.data, content) + } } } diff --git a/app/src/main/java/com/runicgateway/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/home/HomeViewModel.kt index bb6f122..026518a 100644 --- a/app/src/main/java/com/runicgateway/app/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/home/HomeViewModel.kt @@ -5,9 +5,13 @@ package com.runicgateway.app.ui.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.runicgateway.app.core.net.BaseUrlHolder +import com.runicgateway.app.core.result.ApiResult import com.runicgateway.app.data.api.dto.StatusDto import com.runicgateway.app.data.repository.SettingsRepository import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.demo.DemoInfo +import com.runicgateway.app.ui.demo.signInPageLabel import com.runicgateway.app.ui.toUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -16,15 +20,27 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject -/** Loads site status (mode + version) for the Home screen (PLAN.md §6.1). */ +/** + * Loads site status (mode + version) for the Home screen (PLAN.md §6.1), and the + * public demo's banner data when the site is one (`docs/website/DEMO_MODE.md` §6). + */ @HiltViewModel class HomeViewModel @Inject constructor( private val settingsRepository: SettingsRepository, + private val baseUrlHolder: BaseUrlHolder, ) : ViewModel() { private val _state = MutableStateFlow>(UiState.Loading) val state: StateFlow> = _state.asStateFlow() + private val _demo = MutableStateFlow(null) + + /** The demo strip's data; null on every real shard, and while it is unknown. */ + val demo: StateFlow = _demo.asStateFlow() + + /** The site's sign-in page, for the strip's logins sentence; null if no site is set. */ + val signInPage: String? get() = baseUrlHolder.current?.let(::signInPageLabel) + init { load() } fun load() { @@ -32,5 +48,11 @@ class HomeViewModel @Inject constructor( viewModelScope.launch { _state.value = settingsRepository.getStatus().toUiState() } + // A separate read, and silent: the strip is a nicety, so a failed settings + // call keeps whatever was shown rather than surfacing an error of its own. + viewModelScope.launch { + val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch + _demo.value = DemoInfo.from(settings.demo) + } } } 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..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 @@ -38,6 +38,12 @@ object Routes { const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices" const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes" + /** Delete my account (ACCOUNT_DELETION.md §4.3), reached from Account. Signed-in only. */ + const val ACCOUNT_DELETE = "account/delete" + + /** Account → Blocked (USER_SAFETY.md §4.3), reached from Account. Signed-in only. */ + const val ACCOUNT_BLOCKED = "account/blocked" + /** * The in-app inbox (ENGAGEMENT.md phase 8, signed-in) and its settings. * diff --git a/app/src/main/java/com/runicgateway/app/ui/player/CharactersViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/player/CharactersViewModel.kt index 04f7089..c8e8e69 100644 --- a/app/src/main/java/com/runicgateway/app/ui/player/CharactersViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/player/CharactersViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.runicgateway.app.R import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.isDemoLocked import com.runicgateway.app.core.result.map import com.runicgateway.app.data.api.dto.RosterCharDto import com.runicgateway.app.data.repository.PlayerShardRepository @@ -114,7 +115,7 @@ class CharactersViewModel @Inject constructor( load() } is ApiResult.HttpError -> _state.update { - it.copy(busy = false, feedback = Feedback(false, createErrorRes(result.status))) + it.copy(busy = false, feedback = Feedback(false, createErrorRes(result))) } is ApiResult.NetworkError -> _state.update { it.copy(busy = false, feedback = Feedback(false, R.string.error_network)) @@ -129,12 +130,15 @@ class CharactersViewModel @Inject constructor( else -> R.string.player_link_error } - private fun createErrorRes(status: Int): Int = when (status) { - 409 -> R.string.player_create_taken - 429 -> R.string.player_create_capped - 403 -> R.string.player_create_unavailable - 400 -> R.string.player_create_rejected - 503 -> R.string.error_shard_offline + // The demo's refusal comes first: its 403 would otherwise read as "signup is off + // on this shard", which is not what refused it (DEMO_MODE.md §3.1). + private fun createErrorRes(result: ApiResult.HttpError): Int = when { + result.isDemoLocked() -> R.string.error_demo_locked + result.status == 409 -> R.string.player_create_taken + result.status == 429 -> R.string.player_create_capped + result.status == 403 -> R.string.player_create_unavailable + result.status == 400 -> R.string.player_create_rejected + result.status == 503 -> R.string.error_shard_offline else -> R.string.player_create_error } } 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 bd9cafd..a85977c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,15 @@ The game server is offline right now. This site doesn\'t publish this here. Something went wrong on the server. Please try again. + + 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 @@ -188,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. @@ -335,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/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 @@