Compare commits
2 Commits
19c2f77a98
...
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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -68,7 +65,6 @@ 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
|
||||
@@ -114,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. */
|
||||
@@ -156,7 +151,6 @@ fun RunicApp(
|
||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
|
||||
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
|
||||
demoNoticeViewModel: DemoNoticeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val brand = appearance.brand
|
||||
val navController = rememberNavController()
|
||||
@@ -198,18 +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)
|
||||
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
|
||||
@@ -365,7 +347,6 @@ fun RunicApp(
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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