Compare commits
2 Commits
d0c5bd6ddf
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d01871c72 | |||
| 5e61ee2bbb |
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
set +o pipefail
|
||||
yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
|
||||
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -18,17 +18,6 @@
|
||||
# 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
|
||||
@@ -67,7 +56,7 @@ jobs:
|
||||
- name: Install base tools + JDK 17
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y git curl unzip jq openssl openjdk-17-jdk-headless
|
||||
apt-get install -y git curl unzip jq 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)
|
||||
@@ -114,7 +103,6 @@ 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"
|
||||
@@ -135,7 +123,7 @@ jobs:
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
set +o pipefail
|
||||
yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
|
||||
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||
|
||||
- name: Decode signing keystore
|
||||
env:
|
||||
@@ -160,7 +148,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 + app bundle
|
||||
- name: Unit tests + signed release APK
|
||||
env:
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
@@ -168,20 +156,15 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x ./gradlew
|
||||
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest \
|
||||
:${GRADLE_MODULE}:assembleRelease :${GRADLE_MODULE}:bundleRelease
|
||||
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease
|
||||
|
||||
- name: Package APK + AAB + SHA256SUMS
|
||||
- name: Package APK + SHA256SUMS
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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 )
|
||||
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 )
|
||||
ls -l dist && cat dist/SHA256SUMS
|
||||
|
||||
# ── Create the Gitea release + upload assets (no push to main) ───────
|
||||
@@ -203,94 +186,9 @@ jobs:
|
||||
| jq -r '.id')"
|
||||
echo "Created release ${TAG} (id=${REL_ID})"
|
||||
|
||||
for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" \
|
||||
"runic-gateway-${{ steps.plan.outputs.version }}.aab" SHA256SUMS; do
|
||||
for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" SHA256SUMS; do
|
||||
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@dist/${f}" >/dev/null
|
||||
echo " uploaded ${f}"
|
||||
done
|
||||
|
||||
# ── Google Play: bundle → internal track (PLAN.md M8) ────────────────
|
||||
# Play Developer API v3 edit flow: open an edit, upload the bundle, put its
|
||||
# versionCode on the internal track, commit. The OAuth token comes from a
|
||||
# service-account JWT signed with openssl, so no Gradle plugin and no Google
|
||||
# SDK. The release is "completed" on the internal track, which Play only
|
||||
# accepts once the app has had a release rolled out; before that, its error
|
||||
# is "Only releases with status draft may be created on draft app", and the
|
||||
# fix is the hand upload of the first bundle, not a change here.
|
||||
- name: Upload the bundle to Google Play (internal track)
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
|
||||
echo "::warning::PLAY_SERVICE_ACCOUNT_JSON is not set; the bundle was not uploaded to Google Play."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PACKAGE="com.runicgateway.app"
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
VERSION_CODE="${{ steps.plan.outputs.versionCode }}"
|
||||
AAB="dist/runic-gateway-${{ steps.plan.outputs.version }}.aab"
|
||||
API="https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${PACKAGE}"
|
||||
UPLOAD_API="https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${PACKAGE}"
|
||||
|
||||
# The service-account key only ever touches the runner's temp dir.
|
||||
umask 077
|
||||
SA_KEY="${RUNNER_TEMP}/play-sa.pem"
|
||||
trap 'rm -f "$SA_KEY"' EXIT
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" | jq -r '.private_key' > "$SA_KEY"
|
||||
CLIENT_EMAIL="$(printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" | jq -r '.client_email')"
|
||||
|
||||
# play <curl args...>: prints the response body; on a non-2xx it prints
|
||||
# Google's error to stderr and fails, so `set -e` stops the step.
|
||||
play() {
|
||||
local out code
|
||||
out="$(mktemp)"
|
||||
code="$(curl -sS -o "$out" -w '%{http_code}' "$@")"
|
||||
if [ "${code:0:1}" != "2" ]; then
|
||||
echo "::error::Google Play API returned HTTP ${code}" >&2
|
||||
cat "$out" >&2; echo >&2
|
||||
rm -f "$out"
|
||||
return 1
|
||||
fi
|
||||
cat "$out"
|
||||
rm -f "$out"
|
||||
}
|
||||
|
||||
# Service-account JWT → OAuth access token (RFC 7523).
|
||||
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
|
||||
NOW="$(date +%s)"
|
||||
JWT_HEAD="$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | b64url)"
|
||||
JWT_CLAIMS="$(jq -cn --arg iss "$CLIENT_EMAIL" --argjson iat "$NOW" \
|
||||
'{iss:$iss, scope:"https://www.googleapis.com/auth/androidpublisher",
|
||||
aud:"https://oauth2.googleapis.com/token", iat:$iat, exp:($iat + 3600)}' | b64url)"
|
||||
JWT_SIG="$(printf '%s' "${JWT_HEAD}.${JWT_CLAIMS}" | openssl dgst -sha256 -sign "$SA_KEY" -binary | b64url)"
|
||||
ACCESS_TOKEN="$(play -X POST https://oauth2.googleapis.com/token \
|
||||
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
|
||||
--data-urlencode "assertion=${JWT_HEAD}.${JWT_CLAIMS}.${JWT_SIG}" \
|
||||
| jq -r '.access_token')"
|
||||
echo "::add-mask::${ACCESS_TOKEN}"
|
||||
AUTH="Authorization: Bearer ${ACCESS_TOKEN}"
|
||||
|
||||
EDIT_ID="$(play -X POST "${API}/edits" -H "$AUTH" \
|
||||
-H 'Content-Type: application/json' -d '{}' | jq -r '.id')"
|
||||
echo "Opened edit ${EDIT_ID}"
|
||||
|
||||
UPLOADED_CODE="$(play -X POST "${UPLOAD_API}/edits/${EDIT_ID}/bundles?uploadType=media" \
|
||||
-H "$AUTH" -H 'Content-Type: application/octet-stream' \
|
||||
--data-binary "@${AAB}" | jq -r '.versionCode')"
|
||||
if [ "$UPLOADED_CODE" != "$VERSION_CODE" ]; then
|
||||
echo "::error::Play read versionCode ${UPLOADED_CODE} from the bundle, expected ${VERSION_CODE}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Uploaded bundle (versionCode ${UPLOADED_CODE})"
|
||||
|
||||
play -X PUT "${API}/edits/${EDIT_ID}/tracks/internal" -H "$AUTH" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg name "$TAG" --arg code "$VERSION_CODE" \
|
||||
'{track:"internal", releases:[{name:$name, versionCodes:[$code], status:"completed"}]}')" \
|
||||
>/dev/null
|
||||
play -X POST "${API}/edits/${EDIT_ID}:commit" -H "$AUTH" -d '' >/dev/null
|
||||
echo "Released ${TAG} (versionCode ${VERSION_CODE}) to the internal track"
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
set +o pipefail
|
||||
yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
|
||||
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -38,13 +38,13 @@ val hasReleaseSigning = ksStoreFilePath != null && ksStorePassword != null &&
|
||||
|
||||
android {
|
||||
namespace = "com.runicgateway.app"
|
||||
compileSdk = 36
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
// Target application id per docs/android/PLAN.md §13 (pending runicgateway.app domain).
|
||||
applicationId = "com.runicgateway.app"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
targetSdk = 35
|
||||
// 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)
|
||||
|
||||
@@ -8,14 +8,10 @@
|
||||
|
||||
<!-- Opt-in push notifications (M7): the runtime notification permission (API 33+)
|
||||
and a foreground service that holds the persistent ntfy connection open — the
|
||||
embedded UnifiedPush distributor, so no separate app is needed (PLAN.md §11).
|
||||
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. -->
|
||||
embedded UnifiedPush distributor, so no separate app is needed (PLAN.md §11). -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<application
|
||||
android:name=".RunicGatewayApp"
|
||||
@@ -74,28 +70,11 @@
|
||||
</activity>
|
||||
|
||||
<!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11).
|
||||
specialUse foreground type, with the reason Play reviews in the property below;
|
||||
not exported — started only by PushManager and PushBootReceiver. -->
|
||||
dataSync foreground type; not exported — started only by PushManager. -->
|
||||
<service
|
||||
android:name=".core.push.PushService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Instant push notifications without Google services. When the user turns notifications on, this service holds one connection open to the push relay their community's server names, and shows a notification for each message from it. The ongoing notification shows the service is running; turning notifications off or signing out stops it." />
|
||||
</service>
|
||||
|
||||
<!-- Restarts push after a reboot or an app update, if the member had it on and was
|
||||
signed in when the phone went down (PLAN.md M8). Not exported: only the system
|
||||
delivers these broadcasts. -->
|
||||
<receiver
|
||||
android:name=".core.push.PushBootReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import com.runicgateway.app.core.result.DemoRefusal
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The app's one channel for the public demo's refusals (`docs/website/DEMO_MODE.md`
|
||||
* §3.1, DM19) — the counterpart of the web client's `demoNotice.js`.
|
||||
* [DemoLockInterceptor] reports every `DEMO_LOCKED` answer here and the shell shows
|
||||
* it as one snackbar, wherever the action was, so no screen has to learn the code.
|
||||
* The screen still gets its error as usual.
|
||||
*
|
||||
* A refusal nobody is collecting (the app in the background) is dropped rather than
|
||||
* queued: a snackbar about a tap from minutes ago would explain nothing.
|
||||
*/
|
||||
@Singleton
|
||||
class DemoNotices @Inject constructor() {
|
||||
private val _refusals = MutableSharedFlow<DemoRefusal>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
val refusals: SharedFlow<DemoRefusal> = _refusals.asSharedFlow()
|
||||
|
||||
fun report(refusal: DemoRefusal) {
|
||||
_refusals.tryEmit(refusal)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.push
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Restarts [PushService] after a reboot or an app update (PLAN.md M8, 2026-10-29). Before
|
||||
* this, push stayed off after either until the member next opened the app.
|
||||
*
|
||||
* It starts the service straight from the saved registration rather than through
|
||||
* [PushManager], which re-registers with the backend first: at boot the network is often
|
||||
* not up yet, and a failed registration would leave push off. The registration survives a
|
||||
* reboot, the topic on the relay does not change, and the service's stream client already
|
||||
* retries with backoff until the network arrives. A sign-out clears the registration, so a
|
||||
* saved one also means the member was signed in.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class PushBootReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var prefs: PushPreferences
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action !in RESTART_ACTIONS) return
|
||||
val pending = goAsync()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
if (shouldRestart(prefs.snapshot())) {
|
||||
runCatching { PushService.start(context.applicationContext) }
|
||||
}
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
val RESTART_ACTIONS = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)
|
||||
|
||||
/** Push was on, and a registration (so a signed-in member) is still saved. */
|
||||
fun shouldRestart(snapshot: PushPreferences.Snapshot): Boolean =
|
||||
snapshot.enabled && !snapshot.topic.isNullOrBlank() && !snapshot.ntfyUrl.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,7 @@ 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 [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.
|
||||
* and stops it as the user opts in/out or signs out.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class PushService : Service() {
|
||||
@@ -57,7 +51,7 @@ class PushService : Service() {
|
||||
|
||||
private fun startAsForeground() {
|
||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
@@ -14,18 +14,12 @@ 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.code] is
|
||||
* the error body's `code` field when it carried one (today only
|
||||
* the demo's `DEMO_LOCKED` is read, see [isDemoLocked]).
|
||||
* - [HttpError] — the server answered with a non-2xx status.
|
||||
* - [NetworkError] — the request never got an answer (offline, DNS, TLS, timeout).
|
||||
*/
|
||||
sealed interface ApiResult<out T> {
|
||||
data class Ok<T>(val data: T) : ApiResult<T>
|
||||
data class HttpError(
|
||||
val status: Int,
|
||||
val message: String? = null,
|
||||
val code: String? = null,
|
||||
) : ApiResult<Nothing>
|
||||
data class HttpError(val status: Int, val message: String? = null) : ApiResult<Nothing>
|
||||
data class NetworkError(val cause: Throwable) : ApiResult<Nothing>
|
||||
}
|
||||
|
||||
@@ -60,31 +54,12 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: HttpException) {
|
||||
ApiResult.HttpError(e.code(), e.message(), errorCodeOf(e))
|
||||
ApiResult.HttpError(e.code(), e.message())
|
||||
} 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
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.result
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* The public demo's refusal (`docs/website/DEMO_MODE.md` §3.1). With `DEMO_MODE` on,
|
||||
* the server answers a locked write
|
||||
* `403 { "message": "This is turned off on the demo site.", "code": "DEMO_LOCKED" }`.
|
||||
* A module may word its own sentence (a refused console command names the command),
|
||||
* so the message is carried as sent.
|
||||
*
|
||||
* The lock is the server's (DM10). Nothing in the app decides what is allowed; it
|
||||
* only explains what was refused.
|
||||
*/
|
||||
const val DEMO_LOCKED_CODE = "DEMO_LOCKED"
|
||||
|
||||
/** Status the server answers a demo refusal with. */
|
||||
const val DEMO_LOCKED_STATUS = 403
|
||||
|
||||
/** A refusal read off a response body. [message] is null when the body carried none. */
|
||||
data class DemoRefusal(val message: String?)
|
||||
|
||||
private val lenient = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* The `code` field of a JSON error body, or null for any body that is not a JSON
|
||||
* object carrying a string `code`. Never throws: an error body is whatever the
|
||||
* server or a proxy in front of it sent.
|
||||
*/
|
||||
fun errorCodeOf(body: String?): String? = errorObject(body)?.stringField("code")
|
||||
|
||||
/** The refusal in this error body, or null when it is not one. */
|
||||
fun demoRefusalOf(body: String?): DemoRefusal? {
|
||||
val obj = errorObject(body) ?: return null
|
||||
if (obj.stringField("code") != DEMO_LOCKED_CODE) return null
|
||||
return DemoRefusal(obj.stringField("message")?.takeIf { it.isNotBlank() })
|
||||
}
|
||||
|
||||
/** True when this result is the demo's refusal. */
|
||||
fun ApiResult<*>.isDemoLocked(): Boolean =
|
||||
this is ApiResult.HttpError && code == DEMO_LOCKED_CODE
|
||||
|
||||
private fun errorObject(body: String?): JsonObject? {
|
||||
if (body.isNullOrBlank()) return null
|
||||
return runCatching { lenient.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.stringField(name: String): String? =
|
||||
(get(name) as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
@@ -5,8 +5,6 @@ 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
|
||||
@@ -91,11 +89,4 @@ interface MeApi {
|
||||
|
||||
@POST("api/v1/auth/me/account/recovery-codes/generate")
|
||||
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
|
||||
|
||||
// ── Deleting the account (ACCOUNT_DELETION.md §4.1) ──────────────────────
|
||||
|
||||
// A DELETE with a body needs @HTTP. Raw [Response] so the caller can read the
|
||||
// refusal's `code` off a 400 or 409, which a thrown HttpException would not carry.
|
||||
@HTTP(method = "DELETE", path = "api/v1/auth/me/account", hasBody = true)
|
||||
suspend fun deleteAccount(@Body body: DeleteAccountRequest): Response<DeleteAccountResultDto>
|
||||
}
|
||||
|
||||
@@ -116,22 +116,6 @@ 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)
|
||||
|
||||
@@ -146,14 +146,4 @@ 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,
|
||||
)
|
||||
|
||||
@@ -4,13 +4,10 @@
|
||||
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
|
||||
@@ -120,70 +117,4 @@ class AccountRepository @Inject constructor(
|
||||
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
|
||||
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
|
||||
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
|
||||
|
||||
// ── Deleting the account (ACCOUNT_DELETION.md §4.1, §4.3) ──────────────
|
||||
|
||||
/** Why the server refused a deletion, one per refusal `code` it sends. */
|
||||
enum class DeleteRefusal {
|
||||
USERNAME_MISMATCH,
|
||||
WRONG_PASSWORD,
|
||||
CODE_REQUIRED,
|
||||
WRONG_CODE,
|
||||
|
||||
/** The account has no password yet; it sets one on Account first (AD7). */
|
||||
PASSWORD_REQUIRED,
|
||||
LAST_ADMIN,
|
||||
|
||||
/** The public demo's refusal; the shell's snackbar has already said why. */
|
||||
DEMO_LOCKED,
|
||||
RATE_LIMITED,
|
||||
|
||||
/** Any other answer — a 5xx, or a refusal this app does not know. */
|
||||
OTHER,
|
||||
}
|
||||
|
||||
sealed interface DeleteOutcome {
|
||||
/** Gone on the server, with every session; the caller forgets it locally. */
|
||||
data object Deleted : DeleteOutcome
|
||||
data class Refused(val reason: DeleteRefusal) : DeleteOutcome
|
||||
data object NetworkError : DeleteOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the signed-in account. A second factor is a [code] or a [recoveryCode],
|
||||
* sent only when two-factor is on. Reads the raw response so a refusal's `code`
|
||||
* survives; a wrong password is a 400, never a 401 (AD12), so the authenticator
|
||||
* never replays this call.
|
||||
*/
|
||||
suspend fun deleteAccount(
|
||||
username: String,
|
||||
password: String,
|
||||
code: String? = null,
|
||||
recoveryCode: String? = null,
|
||||
): DeleteOutcome {
|
||||
val response = try {
|
||||
api.deleteAccount(DeleteAccountRequest(username, password, code, recoveryCode))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IOException) {
|
||||
return DeleteOutcome.NetworkError
|
||||
} catch (_: Exception) {
|
||||
return DeleteOutcome.Refused(DeleteRefusal.OTHER)
|
||||
}
|
||||
if (response.isSuccessful) return DeleteOutcome.Deleted
|
||||
if (response.code() == 429) return DeleteOutcome.Refused(DeleteRefusal.RATE_LIMITED)
|
||||
val refusal = runCatching { errorCodeOf(response.errorBody()?.string()) }.getOrNull()
|
||||
return DeleteOutcome.Refused(deleteRefusalOf(refusal))
|
||||
}
|
||||
|
||||
private fun deleteRefusalOf(code: String?): DeleteRefusal = when (code) {
|
||||
"username_mismatch" -> DeleteRefusal.USERNAME_MISMATCH
|
||||
"wrong_password" -> DeleteRefusal.WRONG_PASSWORD
|
||||
"code_required" -> DeleteRefusal.CODE_REQUIRED
|
||||
"wrong_code" -> DeleteRefusal.WRONG_CODE
|
||||
"password_required" -> DeleteRefusal.PASSWORD_REQUIRED
|
||||
"last_admin" -> DeleteRefusal.LAST_ADMIN
|
||||
DEMO_LOCKED_CODE -> DeleteRefusal.DEMO_LOCKED
|
||||
else -> DeleteRefusal.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,7 @@ 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
|
||||
@@ -210,28 +208,6 @@ 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
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -59,8 +58,7 @@ object NetworkModule {
|
||||
|
||||
/**
|
||||
* The main client every API and the SSE stream ride on. Order: identify (UA),
|
||||
* 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
|
||||
* retarget onto the configured shard host, then attach the bearer; the
|
||||
* [TokenAuthenticator] handles silent refresh-on-401 (§4.1). Logging sits last
|
||||
* so it observes the final, authed request.
|
||||
*/
|
||||
@@ -70,14 +68,12 @@ 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) {
|
||||
|
||||
@@ -26,8 +26,6 @@ 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
|
||||
@@ -35,7 +33,6 @@ 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
|
||||
@@ -62,15 +59,12 @@ import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
import com.runicgateway.app.data.repository.Capability
|
||||
import com.runicgateway.app.ui.auth.AccountScreen
|
||||
import com.runicgateway.app.ui.auth.DeleteAccountScreen
|
||||
import com.runicgateway.app.ui.auth.DeleteAccountViewModel
|
||||
import com.runicgateway.app.ui.auth.LoginScreen
|
||||
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
||||
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
|
||||
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
|
||||
@@ -116,7 +110,6 @@ 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. */
|
||||
@@ -158,7 +151,6 @@ fun RunicApp(
|
||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
|
||||
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
|
||||
demoNoticeViewModel: DemoNoticeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val brand = appearance.brand
|
||||
val navController = rememberNavController()
|
||||
@@ -200,19 +192,6 @@ 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
|
||||
@@ -368,7 +347,6 @@ fun RunicApp(
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
@@ -420,15 +398,6 @@ 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),
|
||||
)
|
||||
}
|
||||
@@ -539,7 +508,6 @@ private fun RunicNavHost(
|
||||
session: Session,
|
||||
onSignOut: () -> Unit,
|
||||
onSignOutEverywhere: () -> Unit,
|
||||
onAccountDeleted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
NavHost(
|
||||
@@ -722,7 +690,6 @@ private fun RunicNavHost(
|
||||
onSignOutEverywhere = onSignOutEverywhere,
|
||||
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
|
||||
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
|
||||
onOpenDeleteAccount = { navController.navigate(Routes.ACCOUNT_DELETE) },
|
||||
)
|
||||
Session.SignedOut -> LaunchedEffect(Unit) {
|
||||
navController.navigateTopLevel(Routes.HOME)
|
||||
@@ -742,18 +709,6 @@ private fun RunicNavHost(
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
composable(Routes.ACCOUNT_DELETE) {
|
||||
// The view model is held here, not in the screen, so this route can tell a
|
||||
// deletion from any other sign-out: the view model marks it deleted before
|
||||
// the session drops, and both land in the same frame (ACCOUNT_DELETION.md §4.3).
|
||||
val viewModel: DeleteAccountViewModel = hiltViewModel()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
when {
|
||||
state.deleted -> LaunchedEffect(Unit) { onAccountDeleted() }
|
||||
session is Session.SignedIn -> DeleteAccountScreen(viewModel)
|
||||
else -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
composable(Routes.NOTIFICATIONS) {
|
||||
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
||||
// leaving another account's items up. The backend gates every call
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.isDemoLocked
|
||||
|
||||
/**
|
||||
* The load state a screen renders. View models expose a `StateFlow<UiState<T>>`;
|
||||
@@ -42,14 +41,6 @@ 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,
|
||||
}
|
||||
@@ -59,11 +50,10 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
|
||||
is ApiResult.Ok -> UiState.Success(data)
|
||||
is ApiResult.NetworkError -> UiState.Error(ErrorKind.NETWORK)
|
||||
is ApiResult.HttpError -> UiState.Error(
|
||||
kind = when {
|
||||
isDemoLocked() -> ErrorKind.DEMO_LOCKED
|
||||
status == 404 -> ErrorKind.NOT_FOUND
|
||||
status == 429 -> ErrorKind.RATE_LIMITED
|
||||
status == 503 -> ErrorKind.SHARD_OFFLINE
|
||||
kind = when (status) {
|
||||
404 -> ErrorKind.NOT_FOUND
|
||||
429 -> ErrorKind.RATE_LIMITED
|
||||
503 -> ErrorKind.SHARD_OFFLINE
|
||||
else -> ErrorKind.SERVER
|
||||
},
|
||||
httpStatus = status,
|
||||
@@ -86,7 +76,7 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
|
||||
* player or admin route, which is not an admin's visibility setting.
|
||||
*/
|
||||
fun <T> ApiResult<T>.toShardUiState(): UiState<T> = when (this) {
|
||||
is ApiResult.HttpError -> if ((status == 403 || status == 404) && !isDemoLocked()) {
|
||||
is ApiResult.HttpError -> if (status == 403 || status == 404) {
|
||||
UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status)
|
||||
} else {
|
||||
toUiState()
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -134,14 +133,8 @@ 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,
|
||||
when {
|
||||
isDemoLocked() -> R.string.error_demo_locked
|
||||
status == 403 -> R.string.admin_forbidden
|
||||
else -> R.string.admin_action_failed
|
||||
},
|
||||
)
|
||||
is ApiResult.HttpError ->
|
||||
Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed)
|
||||
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -80,11 +79,7 @@ class AdminDashboardViewModel @Inject constructor(
|
||||
switching = false,
|
||||
feedback = Feedback(
|
||||
false,
|
||||
when {
|
||||
result.isDemoLocked() -> R.string.error_demo_locked
|
||||
result.status == 403 -> R.string.admin_forbidden
|
||||
else -> R.string.admin_action_failed
|
||||
},
|
||||
if (result.status == 403) R.string.admin_forbidden else R.string.admin_action_failed,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -76,10 +75,9 @@ class AdminModerationViewModel @Inject constructor(
|
||||
is ApiResult.Ok -> Feedback(true, okRes)
|
||||
is ApiResult.HttpError -> Feedback(
|
||||
false,
|
||||
when {
|
||||
r.isDemoLocked() -> R.string.error_demo_locked
|
||||
r.status == 403 -> R.string.admin_forbidden
|
||||
r.status == 503 -> R.string.admin_mod_shard_offline
|
||||
when (r.status) {
|
||||
403 -> R.string.admin_forbidden
|
||||
503 -> R.string.admin_mod_shard_offline
|
||||
else -> R.string.admin_action_failed
|
||||
},
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -72,11 +71,10 @@ class AdminSupportViewModel @Inject constructor(
|
||||
is ApiResult.Ok -> Feedback(true, okRes)
|
||||
is ApiResult.HttpError -> Feedback(
|
||||
false,
|
||||
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
|
||||
when (r.status) {
|
||||
403 -> R.string.admin_forbidden
|
||||
404 -> R.string.admin_support_unknown_page
|
||||
503 -> R.string.admin_mod_shard_offline
|
||||
else -> R.string.admin_action_failed
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.repository.AccountRepository.DeleteRefusal
|
||||
|
||||
/*
|
||||
* What the Delete my account screen decides (ACCOUNT_DELETION.md §4.3), kept out
|
||||
* of the composable so it can be tested. The website's `lib/accountDeletion.js`
|
||||
* holds the same rules and words.
|
||||
*
|
||||
* The server is the guard: it checks the username, the password and the code again
|
||||
* whatever this file decides. What is here only keeps a mis-tap from reaching it
|
||||
* and turns its refusals into sentences.
|
||||
*/
|
||||
|
||||
/** Delete is live once the typed name matches the account's, ignoring case. */
|
||||
fun deleteUsernameMatches(typed: String, username: String): Boolean =
|
||||
typed.isNotBlank() && typed.trim().equals(username, ignoreCase = true)
|
||||
|
||||
/** Every role but player is staff, whose username is never free again (AD9). */
|
||||
fun isStaffRole(role: String): Boolean = role.isNotBlank() && !role.equals("player", ignoreCase = true)
|
||||
|
||||
/** The sentence for a refusal. */
|
||||
@StringRes
|
||||
fun deleteRefusalRes(reason: DeleteRefusal): Int = when (reason) {
|
||||
DeleteRefusal.USERNAME_MISMATCH -> R.string.account_delete_error_username
|
||||
DeleteRefusal.WRONG_PASSWORD -> R.string.account_delete_error_password
|
||||
DeleteRefusal.CODE_REQUIRED -> R.string.account_delete_error_code_required
|
||||
DeleteRefusal.WRONG_CODE -> R.string.account_delete_error_code
|
||||
DeleteRefusal.PASSWORD_REQUIRED -> R.string.account_delete_no_password
|
||||
DeleteRefusal.LAST_ADMIN -> R.string.account_delete_error_last_admin
|
||||
DeleteRefusal.DEMO_LOCKED -> R.string.error_demo_locked
|
||||
DeleteRefusal.RATE_LIMITED -> R.string.account_delete_error_rate_limited
|
||||
DeleteRefusal.OTHER -> R.string.account_delete_error
|
||||
}
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -18,7 +17,6 @@ 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
|
||||
@@ -69,7 +67,6 @@ fun AccountScreen(
|
||||
onSignOutEverywhere: () -> Unit,
|
||||
onOpenTrustedDevices: () -> Unit,
|
||||
onOpenRecoveryCodes: () -> Unit,
|
||||
onOpenDeleteAccount: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -105,40 +102,9 @@ fun AccountScreen(
|
||||
) {
|
||||
Text(stringResource(R.string.account_sign_out_all))
|
||||
}
|
||||
|
||||
DeleteAccountRow(onOpenDeleteAccount)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Last on the screen and set apart (ACCOUNT_DELETION.md §4.3). Always shown, on the
|
||||
* demo too, where the server refuses: Play's reviewer must be able to find it.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeleteAccountRow(onOpenDeleteAccount: () -> Unit) {
|
||||
HorizontalDivider(
|
||||
Modifier.padding(top = 20.dp, bottom = 16.dp),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.account_delete_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.account_delete_row_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = onOpenDeleteAccount,
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.account_delete_open)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdentityCard(username: String, roleLabel: String) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
/*
|
||||
* 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.repository.AccountRepository
|
||||
import com.runicgateway.app.data.repository.AccountRepository.DeleteOutcome
|
||||
import com.runicgateway.app.data.repository.AuthRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives Account → Delete my account (ACCOUNT_DELETION.md §4.3). It loads the
|
||||
* account to know whether a code is asked for and whether there is a password at
|
||||
* all, sends `DELETE /auth/me/account`, and on success forgets the account on the
|
||||
* phone. There is no grace period (AD4): the confirm step is the safeguard.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class DeleteAccountViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
data class State(
|
||||
val account: UiState<PlayerAccountDto> = UiState.Loading,
|
||||
val busy: Boolean = false,
|
||||
@param:StringRes val error: Int? = null,
|
||||
/**
|
||||
* Set before the session is dropped, so the route sees it in the same frame as
|
||||
* the sign-out and goes to sign-in with the notice rather than home.
|
||||
*/
|
||||
val deleted: Boolean = false,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(account = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
val account = accountRepository.getAccount()
|
||||
_state.update { it.copy(account = account.toUiState()) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete the account. [useRecovery] sends [code] as a recovery code instead. */
|
||||
fun delete(typed: String, password: String, code: String, useRecovery: Boolean) {
|
||||
val account = (_state.value.account as? UiState.Success)?.data ?: return
|
||||
if (_state.value.busy || _state.value.deleted) return
|
||||
_state.update { it.copy(busy = true, error = null) }
|
||||
val secondFactor = code.trim().takeIf { account.totp_enabled && it.isNotEmpty() }
|
||||
viewModelScope.launch {
|
||||
val outcome = accountRepository.deleteAccount(
|
||||
username = typed.trim(),
|
||||
password = password,
|
||||
code = secondFactor.takeUnless { useRecovery },
|
||||
recoveryCode = secondFactor.takeIf { useRecovery },
|
||||
)
|
||||
when (outcome) {
|
||||
DeleteOutcome.Deleted -> {
|
||||
_state.update { it.copy(busy = false, deleted = true) }
|
||||
authRepository.forgetDeletedAccount()
|
||||
}
|
||||
is DeleteOutcome.Refused ->
|
||||
_state.update { it.copy(busy = false, error = deleteRefusalRes(outcome.reason)) }
|
||||
DeleteOutcome.NetworkError ->
|
||||
_state.update { it.copy(busy = false, error = R.string.error_network) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,7 @@ 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). The
|
||||
* public demo's refusal ([ErrorKind.DEMO_LOCKED]) is the same: it is fixed until the
|
||||
* site stops being a demo.
|
||||
* offering it would read as a transient failure the user could wait out (M11).
|
||||
*/
|
||||
@Composable
|
||||
fun ErrorView(
|
||||
@@ -71,8 +69,7 @@ fun ErrorView(
|
||||
}
|
||||
|
||||
/** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */
|
||||
fun isRetryable(kind: ErrorKind): Boolean =
|
||||
kind != ErrorKind.FEATURE_UNAVAILABLE && kind != ErrorKind.DEMO_LOCKED
|
||||
fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE
|
||||
|
||||
/** Centered informational message for an empty list (§7). */
|
||||
@Composable
|
||||
@@ -96,6 +93,5 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
|
||||
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
|
||||
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
|
||||
ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable
|
||||
ErrorKind.DEMO_LOCKED -> R.string.error_demo_locked
|
||||
ErrorKind.SERVER -> R.string.error_server
|
||||
}
|
||||
|
||||
@@ -117,6 +117,5 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.demo
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import okhttp3.HttpUrl
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
|
||||
/**
|
||||
* What the Home strip says on the public demo (`docs/website/DEMO_MODE.md` §6).
|
||||
*
|
||||
* @property resetsAt when the next wipe runs, already worded ("14:00 UTC"), or null
|
||||
* when the server sent no readable time.
|
||||
* @property usernames the published logins, in the server's order. Their passwords
|
||||
* are on the site's sign-in page, not in the app (DM20).
|
||||
*/
|
||||
data class DemoInfo(
|
||||
val resetsAt: String?,
|
||||
val usernames: List<String>,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* The banner data from `GET /public/settings`' raw `demo` value, or null when
|
||||
* there is none, which is every real shard. Forgiving field by field: a
|
||||
* missing time or a malformed login drops that part, never the banner.
|
||||
*/
|
||||
fun from(demo: JsonElement?): DemoInfo? {
|
||||
val obj = demo as? JsonObject ?: return null
|
||||
val resetsAt = (obj["resetsAt"] as? JsonPrimitive)
|
||||
?.takeIf { it.isString }
|
||||
?.let { formatResetTime(it.content) }
|
||||
val usernames = (obj["logins"] as? JsonArray).orEmpty().mapNotNull { login ->
|
||||
((login as? JsonObject)?.get("username") as? JsonPrimitive)
|
||||
?.takeIf { it.isString }
|
||||
?.content
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
return DemoInfo(resetsAt, usernames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val RESET_TIME: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("HH:mm 'UTC'").withZone(ZoneOffset.UTC)
|
||||
|
||||
/**
|
||||
* "14:00 UTC" for an ISO-8601 instant, or null when it does not parse. The wipe runs
|
||||
* on the hour in UTC (DM7), so the strip says UTC rather than converting, the same as
|
||||
* the web's: a time in the viewer's own zone reads as a different schedule from the
|
||||
* one published on `/beta`.
|
||||
*/
|
||||
fun formatResetTime(iso: String): String? = try {
|
||||
RESET_TIME.format(Instant.parse(iso))
|
||||
} catch (_: DateTimeParseException) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* The site's sign-in page as the strip prints it, `demo.runicgateway.com/account/login`:
|
||||
* no scheme, and the port only when it is not the scheme's default.
|
||||
*/
|
||||
fun signInPageLabel(base: HttpUrl): String? {
|
||||
val page = base.resolve("account/login") ?: return null
|
||||
val port = if (page.port == HttpUrl.defaultPort(page.scheme)) "" else ":${page.port}"
|
||||
return page.host + port + page.encodedPath
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.demo
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.runicgateway.app.core.net.DemoNotices
|
||||
import com.runicgateway.app.core.result.DemoRefusal
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Hands the shell the public demo's refusals (DM19), for its one snackbar. Only a
|
||||
* window onto [DemoNotices]: the shell collects while it is on screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class DemoNoticeViewModel @Inject constructor(
|
||||
notices: DemoNotices,
|
||||
) : ViewModel() {
|
||||
val refusals: SharedFlow<DemoRefusal> = notices.refusals
|
||||
}
|
||||
@@ -32,7 +32,6 @@ 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
|
||||
@@ -45,18 +44,11 @@ fun HomeScreen(
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val demo by viewModel.demo.collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
// The public demo's strip (DEMO_MODE.md §6), above whatever the status read
|
||||
// came back with, so it shows even while the status is loading or failed.
|
||||
demo?.let { DemoBanner(info = it, signInPage = viewModel.signInPage) }
|
||||
val content = Modifier.weight(1f)
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView(content)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = content)
|
||||
is UiState.Success -> HomeContent(brand, s.data, content)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,9 @@ 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
|
||||
@@ -20,27 +16,15 @@ 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), and the
|
||||
* public demo's banner data when the site is one (`docs/website/DEMO_MODE.md` §6).
|
||||
*/
|
||||
/** Loads site status (mode + version) for the Home screen (PLAN.md §6.1). */
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<UiState<StatusDto>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<StatusDto>> = _state.asStateFlow()
|
||||
|
||||
private val _demo = MutableStateFlow<DemoInfo?>(null)
|
||||
|
||||
/** The demo strip's data; null on every real shard, and while it is unknown. */
|
||||
val demo: StateFlow<DemoInfo?> = _demo.asStateFlow()
|
||||
|
||||
/** The site's sign-in page, for the strip's logins sentence; null if no site is set. */
|
||||
val signInPage: String? get() = baseUrlHolder.current?.let(::signInPageLabel)
|
||||
|
||||
init { load() }
|
||||
|
||||
fun load() {
|
||||
@@ -48,11 +32,5 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,9 +38,6 @@ object Routes {
|
||||
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
|
||||
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
|
||||
|
||||
/** Delete my account (ACCOUNT_DELETION.md §4.3), reached from Account. Signed-in only. */
|
||||
const val ACCOUNT_DELETE = "account/delete"
|
||||
|
||||
/**
|
||||
* The in-app inbox (ENGAGEMENT.md phase 8, signed-in) and its settings.
|
||||
*
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -115,7 +114,7 @@ class CharactersViewModel @Inject constructor(
|
||||
load()
|
||||
}
|
||||
is ApiResult.HttpError -> _state.update {
|
||||
it.copy(busy = false, feedback = Feedback(false, createErrorRes(result)))
|
||||
it.copy(busy = false, feedback = Feedback(false, createErrorRes(result.status)))
|
||||
}
|
||||
is ApiResult.NetworkError -> _state.update {
|
||||
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
|
||||
@@ -130,15 +129,12 @@ class CharactersViewModel @Inject constructor(
|
||||
else -> R.string.player_link_error
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
else -> R.string.player_create_error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,15 +23,6 @@
|
||||
<string name="error_shard_offline">The game server is offline right now.</string>
|
||||
<string name="error_feature_unavailable">This site doesn\'t publish this here.</string>
|
||||
<string name="error_server">Something went wrong on the server. Please try again.</string>
|
||||
<!-- The public demo's refusal (DEMO_MODE.md §3.1): the server's own words, used when its answer carries none. -->
|
||||
<string name="error_demo_locked">This is turned off on the demo site.</string>
|
||||
|
||||
<!-- The public demo's strip on Home (DEMO_MODE.md §6). The time is said in UTC, like the web's. -->
|
||||
<string name="demo_banner_title">Demo site</string>
|
||||
<string name="demo_banner_wipe_at">" — anything you change is wiped at %1$s."</string>
|
||||
<string name="demo_banner_wipe_soon">" — anything you change is wiped every two hours."</string>
|
||||
<string name="demo_banner_logins_at">Sign-ins: %1$s — passwords at %2$s.</string>
|
||||
<string name="demo_banner_logins">Sign-ins: %1$s — passwords on the site\'s sign-in page.</string>
|
||||
|
||||
<!-- ── First-run connect (§3) ──────────────────────────────────────── -->
|
||||
<string name="connect_title">Connect to your community</string>
|
||||
@@ -197,38 +188,6 @@
|
||||
<string name="account_security_trusted_devices">Trusted devices</string>
|
||||
<string name="account_security_recovery_codes">Recovery codes</string>
|
||||
|
||||
<!-- Delete my account (ACCOUNT_DELETION.md §4.3). The same words as the website's lib/accountDeletion.js. -->
|
||||
<string name="account_delete_title">Delete my account</string>
|
||||
<string name="account_delete_row_hint">Deletes your account straight away. It cannot be restored.</string>
|
||||
<string name="account_delete_open">Delete my account…</string>
|
||||
<string name="account_delete_intro">Your account is deleted straight away and cannot be restored. These go with it:</string>
|
||||
<string name="account_delete_goes_sign_in">your sign-in, password, email address, two-factor settings and recovery codes</string>
|
||||
<string name="account_delete_goes_devices">every signed-in device, trusted device and push registration</string>
|
||||
<string name="account_delete_goes_identities">your linked sign-ins, such as Google, Discord or Steam</string>
|
||||
<string name="account_delete_goes_game_links">your game-account links, and anything this site granted in the game through them</string>
|
||||
<string name="account_delete_goes_notifications">your notification settings and inbox</string>
|
||||
<string name="account_delete_stays_title">What stays:</string>
|
||||
<string name="account_delete_stays_content">What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”.</string>
|
||||
<string name="account_delete_stays_reports">Reports you filed and appeals you made stay as moderation records. Staff can still see who it was, and the activity log keeps its IP addresses.</string>
|
||||
<string name="account_delete_stays_events">Event results stay with the score and the in-game name, with no site account behind them.</string>
|
||||
<string name="account_delete_stays_game_account">A game account stays on the game server. Only its link to this site goes.</string>
|
||||
<string name="account_delete_staff_note">You are staff, so your username can never be used again, by anyone, once this account is deleted.</string>
|
||||
<string name="account_delete_no_password">This account has no password yet. Set one in the Password section on My account first; the password is how you confirm a deletion.</string>
|
||||
<string name="account_delete_type_username">Type %1$s to confirm</string>
|
||||
<string name="account_delete_recovery_code">Recovery code</string>
|
||||
<string name="account_delete_use_recovery">Use a recovery code instead</string>
|
||||
<string name="account_delete_use_authenticator">Use an authenticator code instead</string>
|
||||
<string name="account_delete_action">Delete my account</string>
|
||||
<string name="account_delete_busy">Deleting…</string>
|
||||
<string name="account_delete_done">Your account has been deleted.</string>
|
||||
<string name="account_delete_error_username">Type your username exactly as it is shown.</string>
|
||||
<string name="account_delete_error_password">Your password is incorrect.</string>
|
||||
<string name="account_delete_error_code_required">Enter a code from your authenticator app, or a recovery code.</string>
|
||||
<string name="account_delete_error_code">That code is not valid. Try again.</string>
|
||||
<string name="account_delete_error_last_admin">You are the only admin. Make someone else an admin before you delete your account.</string>
|
||||
<string name="account_delete_error_rate_limited">Too many attempts. Wait a few minutes and try again.</string>
|
||||
<string name="account_delete_error">Could not delete your account. Try again.</string>
|
||||
|
||||
<!-- Trusted devices screen -->
|
||||
<string name="trusted_devices_title">Trusted devices</string>
|
||||
<string name="trusted_devices_subtitle">These devices can skip the authentication code at sign-in for 30 days.</string>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!--
|
||||
The app is purely an HTTPS API client of a shard's website backend, so the base
|
||||
posture forbids all cleartext (HTTP) traffic. This makes explicit what minSdk 29 /
|
||||
targetSdk 36 already default to, satisfies the "usesCleartextTraffic implicitly
|
||||
targetSdk 35 already default to, satisfies the "usesCleartextTraffic implicitly
|
||||
enabled" scanner finding, and stops any merged library manifest from re-enabling
|
||||
cleartext. It also mirrors ServerUrl's release-build rule (HTTPS required) at the
|
||||
platform socket layer — defense in depth.
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import com.runicgateway.app.core.result.DemoRefusal
|
||||
import com.runicgateway.app.util.DEMO_LOCKED_BODY
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The main client's refusal spotter (DEMO_MODE.md §3.1, DM19). Runs a real OkHttp
|
||||
* client whose last interceptor answers in place of the network.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DemoLockInterceptorTest {
|
||||
|
||||
private val notices = DemoNotices()
|
||||
|
||||
private fun clientAnswering(code: Int, body: String): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(DemoLockInterceptor(notices))
|
||||
.addInterceptor(Interceptor { chain ->
|
||||
Response.Builder()
|
||||
.request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(code)
|
||||
.message("")
|
||||
.body(body.toResponseBody("application/json".toMediaType()))
|
||||
.build()
|
||||
})
|
||||
.build()
|
||||
|
||||
private fun call(client: OkHttpClient): String =
|
||||
client.newCall(Request.Builder().url("https://demo.example/api/v1/x").build())
|
||||
.execute()
|
||||
.use { it.body!!.string() }
|
||||
|
||||
@Test fun aRefusalIsReportedAndTheBodyStillReachesTheCaller() = runTest(UnconfinedTestDispatcher()) {
|
||||
val seen = mutableListOf<DemoRefusal>()
|
||||
backgroundScope.launch { notices.refusals.collect { seen += it } }
|
||||
|
||||
val body = call(clientAnswering(403, DEMO_LOCKED_BODY))
|
||||
|
||||
assertEquals(listOf(DemoRefusal("This is turned off on the demo site.")), seen)
|
||||
// Peeked, not consumed: Retrofit still reads the same body into its error.
|
||||
assertEquals(DEMO_LOCKED_BODY, body)
|
||||
}
|
||||
|
||||
@Test fun anythingElseIsNotReported() = runTest(UnconfinedTestDispatcher()) {
|
||||
val seen = mutableListOf<DemoRefusal>()
|
||||
backgroundScope.launch { notices.refusals.collect { seen += it } }
|
||||
|
||||
call(clientAnswering(403, """{"message":"Admins only"}"""))
|
||||
call(clientAnswering(403, "<html>Forbidden</html>"))
|
||||
call(clientAnswering(500, DEMO_LOCKED_BODY))
|
||||
call(clientAnswering(200, DEMO_LOCKED_BODY))
|
||||
|
||||
assertTrue(seen.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.push
|
||||
|
||||
import android.content.Intent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** When push comes back by itself after a reboot or an update (PLAN.md M8). */
|
||||
class PushBootReceiverTest {
|
||||
|
||||
private fun snapshot(
|
||||
enabled: Boolean = true,
|
||||
ntfyUrl: String? = "https://ntfy.tld",
|
||||
topic: String? = "upAbc123",
|
||||
) = PushPreferences.Snapshot(
|
||||
enabled = enabled,
|
||||
ntfyUrl = ntfyUrl,
|
||||
topic = topic,
|
||||
endpoint = topic?.let { "https://ntfy.tld/$it" },
|
||||
deviceId = topic?.let { 7L },
|
||||
)
|
||||
|
||||
@Test fun restartsWhenPushWasOnAndARegistrationIsSaved() {
|
||||
assertTrue(PushBootReceiver.shouldRestart(snapshot()))
|
||||
}
|
||||
|
||||
@Test fun staysOffWhenTheMemberTurnedPushOff() {
|
||||
assertFalse(PushBootReceiver.shouldRestart(snapshot(enabled = false)))
|
||||
}
|
||||
|
||||
@Test fun staysOffAfterASignOutClearedTheRegistration() {
|
||||
// A sign-out keeps the opt-in but forgets the topic: no signed-in member to push to.
|
||||
assertFalse(PushBootReceiver.shouldRestart(snapshot(topic = null)))
|
||||
assertFalse(PushBootReceiver.shouldRestart(snapshot(topic = " ")))
|
||||
}
|
||||
|
||||
@Test fun staysOffWhenTheServerNamesNoRelay() {
|
||||
assertFalse(PushBootReceiver.shouldRestart(snapshot(ntfyUrl = null)))
|
||||
}
|
||||
|
||||
@Test fun listensForBootAndForItsOwnUpdate() {
|
||||
assertEquals(
|
||||
setOf("android.intent.action.BOOT_COMPLETED", "android.intent.action.MY_PACKAGE_REPLACED"),
|
||||
PushBootReceiver.RESTART_ACTIONS,
|
||||
)
|
||||
assertTrue(Intent.ACTION_BOOT_COMPLETED in PushBootReceiver.RESTART_ACTIONS)
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.result
|
||||
|
||||
import com.runicgateway.app.util.DEMO_LOCKED_BODY
|
||||
import com.runicgateway.app.util.demoLockedError
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
|
||||
/** Reading the public demo's refusal off an error body (DEMO_MODE.md §3.1). */
|
||||
class DemoLockedTest {
|
||||
|
||||
@Test fun theServersBodyIsARefusalWithItsMessage() {
|
||||
assertEquals(DemoRefusal("This is turned off on the demo site."), demoRefusalOf(DEMO_LOCKED_BODY))
|
||||
}
|
||||
|
||||
@Test fun aModulesOwnSentenceIsKept() {
|
||||
// A refused console command names the command (§3.4); the app must not
|
||||
// swap that for the generic line.
|
||||
val body = """{"message":"The command quit is turned off on the demo site.","code":"DEMO_LOCKED"}"""
|
||||
assertEquals("The command quit is turned off on the demo site.", demoRefusalOf(body)!!.message)
|
||||
}
|
||||
|
||||
@Test fun aRefusalWithNoMessageStillCounts() {
|
||||
assertEquals(DemoRefusal(null), demoRefusalOf("""{"code":"DEMO_LOCKED"}"""))
|
||||
assertEquals(DemoRefusal(null), demoRefusalOf("""{"code":"DEMO_LOCKED","message":" "}"""))
|
||||
}
|
||||
|
||||
@Test fun anythingElseIsNotARefusal() {
|
||||
assertNull(demoRefusalOf(null))
|
||||
assertNull(demoRefusalOf(""))
|
||||
assertNull(demoRefusalOf("{}"))
|
||||
assertNull(demoRefusalOf("""{"message":"Forbidden"}"""))
|
||||
assertNull(demoRefusalOf("""{"code":"FORBIDDEN"}"""))
|
||||
assertNull(demoRefusalOf("""{"code":7}"""))
|
||||
assertNull(demoRefusalOf("""["DEMO_LOCKED"]"""))
|
||||
assertNull(demoRefusalOf("<html>403 Forbidden</html>"))
|
||||
}
|
||||
|
||||
@Test fun safeApiCallCarriesTheCode() = runTest {
|
||||
val result = safeApiCall { throw demoLockedError() }
|
||||
assertEquals(DEMO_LOCKED_CODE, (result as ApiResult.HttpError).code)
|
||||
assertTrue(result.isDemoLocked())
|
||||
}
|
||||
|
||||
@Test fun anOrdinary403IsNotARefusal() = runTest {
|
||||
val result = safeApiCall {
|
||||
throw HttpException(Response.error<Any>(403, """{"message":"Admins only"}""".toResponseBody(null)))
|
||||
}
|
||||
assertEquals(403, (result as ApiResult.HttpError).status)
|
||||
assertNull(result.code)
|
||||
assertFalse(result.isDemoLocked())
|
||||
}
|
||||
|
||||
@Test fun onlyA403sBodyIsRead() = runTest {
|
||||
// The server answers the refusal with 403 and nothing else, so another
|
||||
// status's body is never buffered, even if it happens to carry the code.
|
||||
val result = safeApiCall {
|
||||
throw HttpException(Response.error<Any>(500, DEMO_LOCKED_BODY.toResponseBody(null)))
|
||||
}
|
||||
assertNull((result as ApiResult.HttpError).code)
|
||||
assertFalse(result.isDemoLocked())
|
||||
}
|
||||
|
||||
@Test fun otherResultsAreNeverRefusals() {
|
||||
assertFalse(ApiResult.Ok(1).isDemoLocked())
|
||||
assertFalse(ApiResult.NetworkError(java.io.IOException()).isDemoLocked())
|
||||
assertFalse(ApiResult.HttpError(403).isDemoLocked())
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.data.api.MeApi
|
||||
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||
import com.runicgateway.app.data.api.dto.DeleteAccountRequest
|
||||
import com.runicgateway.app.data.api.dto.DeleteAccountResultDto
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
|
||||
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
|
||||
import com.runicgateway.app.data.api.dto.RevokedCountDto
|
||||
import com.runicgateway.app.data.api.dto.RevokedFlagDto
|
||||
import com.runicgateway.app.data.api.dto.TotpCodeRequest
|
||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||
import com.runicgateway.app.data.repository.AccountRepository.DeleteOutcome
|
||||
import com.runicgateway.app.data.repository.AccountRepository.DeleteRefusal
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* [AccountRepository.deleteAccount] (ACCOUNT_DELETION.md §4.1, §4.3) over a fake
|
||||
* [MeApi]: each refusal the server sends becomes its own [DeleteRefusal], read off
|
||||
* the body's `code`, so the screen can say which field is wrong.
|
||||
*/
|
||||
class AccountDeletionRepositoryTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
/** Only [deleteAccount] is wired; it answers [answer] and records the body. */
|
||||
private class FakeMeApi(val answer: () -> Response<DeleteAccountResultDto>) : MeApi {
|
||||
var lastBody: DeleteAccountRequest? = null
|
||||
|
||||
override suspend fun deleteAccount(body: DeleteAccountRequest): Response<DeleteAccountResultDto> {
|
||||
lastBody = body
|
||||
return answer()
|
||||
}
|
||||
|
||||
override suspend fun getAccount(): PlayerAccountDto = error("unused")
|
||||
override suspend fun changeUsername(body: ChangeUsernameRequest): UsernameResponse = error("unused")
|
||||
override suspend fun changePassword(body: ChangePasswordRequest): Unit = error("unused")
|
||||
override suspend fun totpSetup(): TotpSetupDto = error("unused")
|
||||
override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = error("unused")
|
||||
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = error("unused")
|
||||
override suspend fun identities(): List<LinkedIdentityDto> = error("unused")
|
||||
override suspend fun unlinkIdentity(provider: String, id: Int?): Unit = error("unused")
|
||||
override suspend fun trustedDevices(): List<TrustedDeviceDto> = error("unused")
|
||||
override suspend fun trustThisDevice(body: TrustDeviceRequest): Response<TrustDeviceResultDto> = error("unused")
|
||||
override suspend fun revokeTrustedDevice(id: Long): RevokedFlagDto = error("unused")
|
||||
override suspend fun revokeAllTrustedDevices(): RevokedCountDto = error("unused")
|
||||
override suspend fun recoveryCodesStatus(): RecoveryStatusDto = error("unused")
|
||||
override suspend fun generateRecoveryCodes(body: RecoveryGenerateRequest): RecoveryCodesDto = error("unused")
|
||||
}
|
||||
|
||||
private fun refusal(status: Int, body: String) = FakeMeApi {
|
||||
Response.error(status, body.toResponseBody("application/json".toMediaTypeOrNull()))
|
||||
}
|
||||
|
||||
private suspend fun outcomeOf(api: MeApi) = AccountRepository(api, json).deleteAccount("Ravenna", "pw")
|
||||
|
||||
@Test fun aDeletionIsDeletedAndSendsWhatItWasGiven() = runTest {
|
||||
val api = FakeMeApi { Response.success(DeleteAccountResultDto(deleted = true)) }
|
||||
val outcome = AccountRepository(api, json).deleteAccount("Ravenna", "pw", code = "123456")
|
||||
assertEquals(DeleteOutcome.Deleted, outcome)
|
||||
assertEquals(DeleteAccountRequest("Ravenna", "pw", "123456", null), api.lastBody)
|
||||
}
|
||||
|
||||
@Test fun aRecoveryCodeGoesInItsOwnField() = runTest {
|
||||
val api = FakeMeApi { Response.success(DeleteAccountResultDto(deleted = true)) }
|
||||
AccountRepository(api, json).deleteAccount("Ravenna", "pw", recoveryCode = "abcd-efgh")
|
||||
assertNull(api.lastBody?.code)
|
||||
assertEquals("abcd-efgh", api.lastBody?.recoveryCode)
|
||||
}
|
||||
|
||||
@Test fun eachRefusalCodeIsItsOwnReason() = runTest {
|
||||
val cases = mapOf(
|
||||
refusal(400, """{"code":"username_mismatch","message":"x"}""") to DeleteRefusal.USERNAME_MISMATCH,
|
||||
refusal(400, """{"code":"wrong_password","message":"x"}""") to DeleteRefusal.WRONG_PASSWORD,
|
||||
refusal(400, """{"code":"code_required","message":"x"}""") to DeleteRefusal.CODE_REQUIRED,
|
||||
refusal(400, """{"code":"wrong_code","message":"x"}""") to DeleteRefusal.WRONG_CODE,
|
||||
refusal(409, """{"code":"password_required","message":"x"}""") to DeleteRefusal.PASSWORD_REQUIRED,
|
||||
refusal(409, """{"code":"last_admin","message":"x"}""") to DeleteRefusal.LAST_ADMIN,
|
||||
refusal(403, """{"code":"DEMO_LOCKED","message":"This is turned off on the demo site."}""") to
|
||||
DeleteRefusal.DEMO_LOCKED,
|
||||
)
|
||||
cases.forEach { (api, reason) -> assertEquals(DeleteOutcome.Refused(reason), outcomeOf(api)) }
|
||||
}
|
||||
|
||||
@Test fun tooManyAttemptsIsRateLimitedWhateverTheBody() = runTest {
|
||||
assertEquals(DeleteOutcome.Refused(DeleteRefusal.RATE_LIMITED), outcomeOf(refusal(429, "Too many requests")))
|
||||
}
|
||||
|
||||
@Test fun anUnknownOrMissingCodeIsOther() = runTest {
|
||||
assertEquals(DeleteOutcome.Refused(DeleteRefusal.OTHER), outcomeOf(refusal(400, """{"code":"new_rule"}""")))
|
||||
assertEquals(DeleteOutcome.Refused(DeleteRefusal.OTHER), outcomeOf(refusal(500, """{"message":"boom"}""")))
|
||||
assertEquals(DeleteOutcome.Refused(DeleteRefusal.OTHER), outcomeOf(refusal(502, "<html>Bad gateway</html>")))
|
||||
}
|
||||
|
||||
@Test fun noAnswerIsANetworkError() = runTest {
|
||||
assertEquals(DeleteOutcome.NetworkError, outcomeOf(FakeMeApi { throw IOException("offline") }))
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.MeApi
|
||||
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||
import com.runicgateway.app.data.api.dto.DeleteAccountRequest
|
||||
import com.runicgateway.app.data.api.dto.DeleteAccountResultDto
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||
@@ -73,8 +71,6 @@ class AccountTrustedDevicesTest {
|
||||
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
|
||||
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
|
||||
override suspend fun unlinkIdentity(provider: String, id: Int?) = Unit
|
||||
override suspend fun deleteAccount(body: DeleteAccountRequest): Response<DeleteAccountResultDto> =
|
||||
Response.success(DeleteAccountResultDto(deleted = true))
|
||||
}
|
||||
|
||||
private fun repo(api: MeApi) = AccountRepository(api, json)
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.data.api.dto.PageDto
|
||||
import com.runicgateway.app.data.api.dto.PostDto
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.data.api.dto.WikiPageDto
|
||||
import com.runicgateway.app.data.api.dto.WikiSummaryDto
|
||||
@@ -15,7 +13,6 @@ import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.data.repository.ContentRepository
|
||||
import com.runicgateway.app.data.repository.SettingsRepository
|
||||
import com.runicgateway.app.data.repository.WikiRepository
|
||||
import com.runicgateway.app.ui.demo.DemoInfo
|
||||
import com.runicgateway.app.ui.home.HomeViewModel
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.news.NewsViewModel
|
||||
@@ -25,10 +22,7 @@ import com.runicgateway.app.ui.wiki.WikiPageViewModel
|
||||
import com.runicgateway.app.ui.wiki.WikiViewModel
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -134,33 +128,13 @@ class ContentViewModelTest {
|
||||
// ── Home status ─────────────────────────────────────────────────────────
|
||||
@Test fun homeLoadsStatus() {
|
||||
api.status = StatusDto(mode = "maintenance")
|
||||
val vm = homeViewModel()
|
||||
val vm = HomeViewModel(settings)
|
||||
assertTrue((vm.state.value as UiState.Success).data.isMaintenance)
|
||||
}
|
||||
|
||||
@Test fun homeNetworkErrorIsUiError() {
|
||||
api.error = java.io.IOException("offline")
|
||||
val vm = homeViewModel()
|
||||
assertEquals(ErrorKind.NETWORK, (vm.state.value as UiState.Error).kind)
|
||||
assertNull(vm.demo.value)
|
||||
val state = HomeViewModel(settings).state.value
|
||||
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
|
||||
}
|
||||
|
||||
// ── Home: the public demo's strip (DEMO_MODE.md §6) ──
|
||||
@Test fun homeDrawsNoStripOnARealShard() {
|
||||
assertNull(homeViewModel().demo.value)
|
||||
}
|
||||
|
||||
@Test fun homeReadsTheDemoBlock() {
|
||||
api.settings = SettingsDto(
|
||||
demo = Json.parseToJsonElement(
|
||||
"""{"resetsAt":"2026-10-26T14:00:00.000Z","logins":[{"username":"demo-admin","role":"admin"}]}""",
|
||||
),
|
||||
)
|
||||
val vm = homeViewModel(base = "https://demo.runicgateway.com/")
|
||||
assertEquals(DemoInfo("14:00 UTC", listOf("demo-admin")), vm.demo.value)
|
||||
assertEquals("demo.runicgateway.com/account/login", vm.signInPage)
|
||||
}
|
||||
|
||||
private fun homeViewModel(base: String? = null) =
|
||||
HomeViewModel(settings, BaseUrlHolder().apply { set(base?.toHttpUrl()) })
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.DEMO_LOCKED_CODE
|
||||
import com.runicgateway.app.ui.components.isRetryable
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -65,30 +64,13 @@ class UiStateTest {
|
||||
|
||||
@Test fun anUnavailableFeatureIsNotRetryable() {
|
||||
// An admin controls this, so a retry button would read as a transient failure
|
||||
// the user could wait out. The demo's refusal is fixed the same way.
|
||||
val fixed = setOf(ErrorKind.FEATURE_UNAVAILABLE, ErrorKind.DEMO_LOCKED)
|
||||
for (kind in fixed) assertFalse("$kind should not offer a retry", isRetryable(kind))
|
||||
for (kind in ErrorKind.entries.filter { it !in fixed }) {
|
||||
// the user could wait out.
|
||||
assertFalse(isRetryable(ErrorKind.FEATURE_UNAVAILABLE))
|
||||
for (kind in ErrorKind.entries.filter { it != ErrorKind.FEATURE_UNAVAILABLE }) {
|
||||
assertTrue("$kind should offer a retry", isRetryable(kind))
|
||||
}
|
||||
}
|
||||
|
||||
// ── The public demo's refusal (DEMO_MODE.md §3.1) ──
|
||||
|
||||
@Test fun theDemosRefusalIsItsOwnKind() {
|
||||
val refused = ApiResult.HttpError(403, code = DEMO_LOCKED_CODE)
|
||||
assertEquals(ErrorKind.DEMO_LOCKED, (refused.toUiState() as UiState.Error).kind)
|
||||
assertEquals(403, (refused.toUiState() as UiState.Error).httpStatus)
|
||||
// On a shard read a 403 would otherwise mean "not published to you".
|
||||
assertEquals(ErrorKind.DEMO_LOCKED, (refused.toShardUiState() as UiState.Error).kind)
|
||||
}
|
||||
|
||||
@Test fun anotherCodeLeavesTheStatusMapping() {
|
||||
val other = ApiResult.HttpError(403, code = "FORBIDDEN")
|
||||
assertEquals(ErrorKind.SERVER, (other.toUiState() as UiState.Error).kind)
|
||||
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (other.toShardUiState() as UiState.Error).kind)
|
||||
}
|
||||
|
||||
private fun kindOf(status: Int): ErrorKind =
|
||||
(ApiResult.HttpError(status).toUiState() as UiState.Error).kind
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.demoLockedError
|
||||
import com.runicgateway.app.util.demoLockedUnit
|
||||
import com.runicgateway.app.util.errorUnit
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -65,15 +63,6 @@ class AdminContentViewModelTest {
|
||||
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun theDemosRefusalIsNotBlamedOnTheRole() {
|
||||
// Somebody signed in as demo-admin must not read "you don't have permission".
|
||||
api.unitResponse = demoLockedUnit()
|
||||
api.error = demoLockedError()
|
||||
val vm = viewModel()
|
||||
vm.togglePublish(AdminPostDto(id = 5, title = "x", published = 1))
|
||||
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun createCategoryRejectsBlankFields() {
|
||||
val vm = viewModel()
|
||||
vm.createCategory(slug = "", title = "", description = "", sortOrder = null)
|
||||
|
||||
@@ -11,7 +11,6 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.demoLockedError
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -61,16 +60,6 @@ class AdminDashboardViewModelTest {
|
||||
assertEquals(R.string.admin_forbidden, fb.messageRes)
|
||||
}
|
||||
|
||||
@Test fun setSiteModeOnTheDemoShowsTheDemoLine() {
|
||||
api.dashboard = AdminDashboardDto()
|
||||
api.error = demoLockedError()
|
||||
val vm = viewModel()
|
||||
vm.setSiteMode("maintenance")
|
||||
val fb = vm.state.value.feedback!!
|
||||
assertFalse(fb.ok)
|
||||
assertEquals(R.string.error_demo_locked, fb.messageRes)
|
||||
}
|
||||
|
||||
@Test fun clearFeedbackResetsBanner() {
|
||||
api.error = httpError(500)
|
||||
val vm = viewModel()
|
||||
|
||||
@@ -7,7 +7,6 @@ import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.fake.FakeAdminApi
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.demoLockedUnit
|
||||
import com.runicgateway.app.util.errorUnit
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -49,13 +48,6 @@ class AdminModerationViewModelTest {
|
||||
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun kickOnTheDemoShowsTheDemoLine() {
|
||||
api.unitResponse = demoLockedUnit()
|
||||
val vm = viewModel()
|
||||
vm.kick(account = "x", serial = "")
|
||||
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun broadcastRejectsBlankText() {
|
||||
val vm = viewModel()
|
||||
vm.broadcast(text = " ", hue = null)
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.demoLockedUnit
|
||||
import com.runicgateway.app.util.errorUnit
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -53,13 +52,6 @@ class AdminSupportViewModelTest {
|
||||
assertEquals(R.string.admin_support_unknown_page, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun closeOnTheDemoShowsTheDemoLine() {
|
||||
api.unitResponse = demoLockedUnit()
|
||||
val vm = viewModel()
|
||||
vm.close("0x1")
|
||||
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun closeShardOfflineMapsTo503Copy() {
|
||||
api.unitResponse = errorUnit(503)
|
||||
val vm = viewModel()
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.repository.AccountRepository.DeleteRefusal
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** The Delete my account screen's rules (ACCOUNT_DELETION.md §4.3). */
|
||||
class AccountDeletionTest {
|
||||
|
||||
@Test fun theTypedNameMatchesIgnoringCaseAndSpaces() {
|
||||
assertTrue(deleteUsernameMatches("Ravenna", "Ravenna"))
|
||||
assertTrue(deleteUsernameMatches("ravenna", "Ravenna"))
|
||||
assertTrue(deleteUsernameMatches(" RAVENNA ", "Ravenna"))
|
||||
}
|
||||
|
||||
@Test fun anythingElseDoesNotMatch() {
|
||||
assertFalse(deleteUsernameMatches("", "Ravenna"))
|
||||
assertFalse(deleteUsernameMatches(" ", "Ravenna"))
|
||||
assertFalse(deleteUsernameMatches("Raven", "Ravenna"))
|
||||
assertFalse(deleteUsernameMatches("Ravenna2", "Ravenna"))
|
||||
}
|
||||
|
||||
@Test fun everyRoleButPlayerIsStaff() {
|
||||
assertFalse(isStaffRole("player"))
|
||||
assertFalse(isStaffRole(""))
|
||||
assertTrue(isStaffRole("moderator"))
|
||||
assertTrue(isStaffRole("editor"))
|
||||
assertTrue(isStaffRole("admin"))
|
||||
}
|
||||
|
||||
@Test fun eachRefusalHasItsSentence() {
|
||||
assertEquals(R.string.account_delete_error_username, deleteRefusalRes(DeleteRefusal.USERNAME_MISMATCH))
|
||||
assertEquals(R.string.account_delete_error_password, deleteRefusalRes(DeleteRefusal.WRONG_PASSWORD))
|
||||
assertEquals(R.string.account_delete_error_code_required, deleteRefusalRes(DeleteRefusal.CODE_REQUIRED))
|
||||
assertEquals(R.string.account_delete_error_code, deleteRefusalRes(DeleteRefusal.WRONG_CODE))
|
||||
assertEquals(R.string.account_delete_no_password, deleteRefusalRes(DeleteRefusal.PASSWORD_REQUIRED))
|
||||
assertEquals(R.string.account_delete_error_last_admin, deleteRefusalRes(DeleteRefusal.LAST_ADMIN))
|
||||
assertEquals(R.string.error_demo_locked, deleteRefusalRes(DeleteRefusal.DEMO_LOCKED))
|
||||
assertEquals(R.string.account_delete_error_rate_limited, deleteRefusalRes(DeleteRefusal.RATE_LIMITED))
|
||||
assertEquals(R.string.account_delete_error, deleteRefusalRes(DeleteRefusal.OTHER))
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.data.repository.ContactRepository
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.demoLockedError
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -65,14 +64,4 @@ class ContactViewModelTest {
|
||||
assertTrue(result is ContactViewModel.Result.Failed)
|
||||
assertEquals(ErrorKind.SERVER, (result as ContactViewModel.Result.Failed).kind)
|
||||
}
|
||||
|
||||
@Test fun theDemosLockedContactFormSaysSo() {
|
||||
// The contact form is locked on the demo (DEMO_MODE.md §3.3).
|
||||
api.error = demoLockedError()
|
||||
val vm = viewModel()
|
||||
vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m")
|
||||
vm.send()
|
||||
val result = vm.state.value.result as ContactViewModel.Result.Failed
|
||||
assertEquals(ErrorKind.DEMO_LOCKED, result.kind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.demo
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** The Home strip's data (DEMO_MODE.md §6). */
|
||||
class DemoInfoTest {
|
||||
|
||||
private fun parse(json: String) = DemoInfo.from(Json.parseToJsonElement(json))
|
||||
|
||||
@Test fun readsTheServersShape() {
|
||||
val info = parse(
|
||||
"""{"resetsAt":"2026-10-26T14:00:00.000Z","logins":[
|
||||
{"username":"demo-admin","password":"x","role":"admin"},
|
||||
{"username":"demo-player","password":"y","role":"player"}]}""",
|
||||
)
|
||||
assertEquals(DemoInfo("14:00 UTC", listOf("demo-admin", "demo-player")), info)
|
||||
}
|
||||
|
||||
@Test fun noBlockMeansNoBanner() {
|
||||
assertNull(DemoInfo.from(null))
|
||||
assertNull(DemoInfo.from(JsonNull))
|
||||
assertNull(parse("""["demo"]"""))
|
||||
}
|
||||
|
||||
@Test fun aBadPartDropsThatPartOnly() {
|
||||
assertEquals(
|
||||
DemoInfo(null, listOf("demo-admin")),
|
||||
parse("""{"resetsAt":"soon","logins":[{"username":"demo-admin"},{"username":""},{"name":"x"},7]}"""),
|
||||
)
|
||||
assertEquals(DemoInfo(null, emptyList()), parse("""{"resetsAt":42,"logins":"demo-admin"}"""))
|
||||
assertEquals(DemoInfo(null, emptyList()), parse("{}"))
|
||||
}
|
||||
|
||||
@Test fun theTimeIsSaidInUtc() {
|
||||
// Not converted to the phone's zone: it must match the schedule on /beta.
|
||||
assertEquals("14:00 UTC", formatResetTime("2026-10-26T14:00:00Z"))
|
||||
assertEquals("00:05 UTC", formatResetTime("2026-10-26T00:05:59.999Z"))
|
||||
assertNull(formatResetTime("14:00"))
|
||||
assertNull(formatResetTime(""))
|
||||
}
|
||||
|
||||
@Test fun theSignInPageIsPrintedWithoutTheScheme() {
|
||||
assertEquals(
|
||||
"demo.runicgateway.com/account/login",
|
||||
signInPageLabel("https://demo.runicgateway.com/".toHttpUrl()),
|
||||
)
|
||||
assertEquals("10.0.2.2:3000/account/login", signInPageLabel("http://10.0.2.2:3000/".toHttpUrl()))
|
||||
assertEquals("example.com/site/account/login", signInPageLabel("https://example.com/site/".toHttpUrl()))
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
import com.runicgateway.app.data.repository.SettingsRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.demoLockedError
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -87,11 +86,4 @@ class CharactersViewModelTest {
|
||||
vm.createAccount(account = "acct1", password = "longenough")
|
||||
assertEquals(R.string.player_create_taken, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun createAccountOnTheDemoIsNotSignupBeingOff() {
|
||||
playerApi.error = demoLockedError()
|
||||
val vm = viewModel()
|
||||
vm.createAccount(account = "acct1", password = "longenough")
|
||||
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,14 +25,3 @@ fun okUnit(): Response<Unit> = Response.success(Unit)
|
||||
|
||||
/** A non-2xx bodyless [Response] with [code]. */
|
||||
fun errorUnit(code: Int): Response<Unit> = Response.error(code, "{}".toResponseBody(JSON))
|
||||
|
||||
/** The public demo's refusal body (DEMO_MODE.md §3.1), as the server sends it. */
|
||||
const val DEMO_LOCKED_BODY =
|
||||
"""{"message":"This is turned off on the demo site.","code":"DEMO_LOCKED"}"""
|
||||
|
||||
/** The [HttpException] a fake API throws for a write the public demo refuses. */
|
||||
fun demoLockedError(): HttpException =
|
||||
HttpException(Response.error<Any>(403, DEMO_LOCKED_BODY.toResponseBody(JSON)))
|
||||
|
||||
/** The `Response<Unit>` of a write the public demo refuses. */
|
||||
fun demoLockedUnit(): Response<Unit> = Response.error(403, DEMO_LOCKED_BODY.toResponseBody(JSON))
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
[versions]
|
||||
# Build toolchain
|
||||
agp = "8.13.2"
|
||||
agp = "8.6.1"
|
||||
kotlin = "2.0.20"
|
||||
ksp = "2.0.20-1.0.25"
|
||||
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Reference in New Issue
Block a user