feat(release): m6 release mechanics — signed APK, R8, version guard, icons #11

Merged
whitlocktech merged 1 commits from feat/m6-release into main 2026-07-20 09:17:24 +00:00
33 changed files with 408 additions and 33 deletions

View File

@@ -0,0 +1,150 @@
# Build a SIGNED release APK and attach it to a Gitea release (PLAN.md §10, §12).
#
# Trigger: pushing a semver tag `v*` (e.g. `v1.0.0`). Cutting an APK is a
# deliberate act — we do NOT release on every merge to main — so the tag is the
# source of truth for the version. `workflow_dispatch` builds the signed APK too
# but skips publishing (a dry run to smoke-test signing without cutting a release).
#
# Version: the tag drives versionName (`v1.2.3` -> `1.2.3`); the workflow run
# number is the monotonic versionCode. Both are passed to Gradle as -P overrides.
#
# Signing (Settings -> Actions -> Secrets on RunicGateway/Android-app). The
# keystore never lives in the repo — it is a base64 secret decoded at build time.
# Secret names deliberately avoid the reserved GITEA_/GITHUB_ prefixes:
# ANDROID_KEYSTORE_BASE64 — base64 of the release .jks (single line)
# ANDROID_KEYSTORE_PASSWORD — keystore password
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
# ANDROID_KEY_PASSWORD — key password (equals the store password for a
# PKCS12 keystore)
# The release itself is created with the runner's built-in ${{ github.token }},
# so no extra API token secret is required.
#
# Runner notes are identical to 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 tools + JDK 17 rather than using actions/setup-java, install the exact
# SDK packages, and `chmod +x ./gradlew` in-step (checkout drops the exec bit).
name: Release APK
on:
push:
tags: ['v*']
workflow_dispatch: {}
concurrency:
group: release-apk-${{ github.ref }}
cancel-in-progress: false
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/Android-app
jobs:
release-apk:
runs-on: ubuntu-latest
steps:
- name: Install base tools + JDK 17
run: |
apt-get update
apt-get install -y git curl unzip jq openjdk-17-jdk-headless
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
- uses: actions/checkout@v4
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android SDK packages
run: |
set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
# Derive versionName from the tag (dispatch runs get a 0.0.0-dev placeholder,
# since they don't publish) and a monotonic versionCode from the run number.
- name: Resolve version
id: ver
run: |
set -euo pipefail
if [ "${{ github.ref_type }}" = "tag" ]; then
VN="${GITHUB_REF_NAME#v}"
else
VN="0.0.0-dev"
fi
echo "versionName=${VN}" >> "$GITHUB_OUTPUT"
echo "versionCode=${{ github.run_number }}" >> "$GITHUB_OUTPUT"
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
echo "==> versionName=${VN} versionCode=${{ github.run_number }} ref=${GITHUB_REF_NAME}"
# Decode the keystore secret to a file the build reads via ANDROID_KEYSTORE_FILE.
# `base64 -d` tolerates the trailing newline a pasted secret may carry.
- name: Decode signing keystore
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
set -euo pipefail
if [ -z "${ANDROID_KEYSTORE_BASE64:-}" ]; then
echo "::error::ANDROID_KEYSTORE_BASE64 secret is not set — cannot build a signed release."
exit 1
fi
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks"
echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV"
- name: Build signed release APK
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
set -euo pipefail
chmod +x ./gradlew
./gradlew --no-daemon :app:assembleRelease \
-PversionName="${{ steps.ver.outputs.versionName }}" \
-PversionCode="${{ steps.ver.outputs.versionCode }}"
- name: Stage APK
id: stage
run: |
set -euo pipefail
SRC="app/build/outputs/apk/release/app-release.apk"
test -f "$SRC" || { echo "::error::release APK not found at $SRC"; exit 1; }
mkdir -p dist
OUT="dist/runic-gateway-${{ steps.ver.outputs.versionName }}.apk"
cp "$SRC" "$OUT"
( cd dist && sha256sum "$(basename "$OUT")" > SHA256SUMS )
echo "apk=${OUT}" >> "$GITHUB_OUTPUT"
ls -l dist && cat dist/SHA256SUMS
# Publish only for a real tag push; a manual dispatch stops after the signed
# build above (dry run).
- name: Create Gitea release and upload APK
if: ${{ github.ref_type == 'tag' }}
env:
RELEASE_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
TAG="${{ steps.ver.outputs.tag }}"
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
TOKEN="$(printf '%s' "${RELEASE_TOKEN}" | tr -d '\r\n')"
REL_ID="$(curl -sSf -X POST "${API}/releases" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" \
'{tag_name:$tag, name:$tag, body:("Signed release APK for " + $tag + ". Sideload on Android 10+ (§10); the app self-configures its shard site on first run."), draft:false, prerelease:false}')" \
| jq -r '.id')"
echo "Created release ${TAG} (id=${REL_ID})"
for f in "$(basename "${{ steps.stage.outputs.apk }}")" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
echo " uploaded ${f}"
done

View File

@@ -1,5 +1,8 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import java.io.FileInputStream
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -9,6 +12,25 @@ plugins {
alias(libs.plugins.hilt)
}
// Release signing material (PLAN.md §12) is never committed. It is read from, in
// order of precedence: a local gitignored `keystore.properties` at the repo root,
// then environment variables (how CI injects the decoded keystore + secrets). When
// none is present, the release build is simply left unsigned — `assembleDebug` and
// the PR gate are unaffected, so contributors without the keystore can still build.
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = Properties().apply {
if (keystorePropsFile.exists()) FileInputStream(keystorePropsFile).use { load(it) }
}
fun signingValue(propKey: String, envKey: String): String? =
keystoreProps.getProperty(propKey) ?: System.getenv(envKey)
val ksStoreFilePath = signingValue("storeFile", "ANDROID_KEYSTORE_FILE")
val ksStorePassword = signingValue("storePassword", "ANDROID_KEYSTORE_PASSWORD")
val ksKeyAlias = signingValue("keyAlias", "ANDROID_KEY_ALIAS")
val ksKeyPassword = signingValue("keyPassword", "ANDROID_KEY_PASSWORD")
val hasReleaseSigning = ksStoreFilePath != null && ksStorePassword != null &&
ksKeyAlias != null && ksKeyPassword != null
android {
namespace = "com.runicgateway.app"
compileSdk = 35
@@ -18,20 +40,46 @@ android {
applicationId = "com.runicgateway.app"
minSdk = 29
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
// The release workflow drives these from the git tag (versionName) and a
// monotonic CI run number (versionCode) via -P overrides; local/PR builds
// fall back to the committed defaults. Keep the tag as the release's source
// of truth (PLAN.md §10: semantic versionName + monotonic versionCode).
versionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1
versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = file(ksStoreFilePath!!)
storePassword = ksStorePassword
keyAlias = ksKeyAlias
keyPassword = ksKeyPassword
// Sign with both v1 (JAR) and v2 (APK) schemes for broad compatibility.
enableV1Signing = true
enableV2Signing = true
}
}
}
buildTypes {
release {
// Signing/minification are wired at M6 (release hardening). Debug is auto-signed.
isMinifyEnabled = false
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
// release APK). Keep rules live in proguard-rules.pro.
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Signed only when the keystore material is present (local or CI); an
// unsigned APK is produced otherwise. The direct-APK release (§10) runs
// through release.yml, which supplies the keystore from a Gitea secret.
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
}
}

View File

@@ -1,3 +1,59 @@
# Runic Gateway Android app ProGuard/R8 rules.
# Minification is disabled until M6 (release hardening); real keep rules for
# kotlinx.serialization DTOs and Retrofit models are added there.
# Runic Gateway Android app ProGuard/R8 rules (release minify + resource shrink, M6).
#
# The dependency stack ships its own consumer rules that R8 applies automatically:
# Retrofit 2.11, OkHttp 4.12, kotlinx.serialization 1.7 (core), Hilt/Dagger, Coil 2.7.
# The rules below are defensive belt-and-suspenders for the areas full-mode R8 is
# most likely to over-strip in this app: the kotlinx.serialization generated
# serializers and our own @Serializable wire DTOs.
# ── kotlinx.serialization (canonical keep rules) ────────────────────────────
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
# Keep the Companion of @Serializable classes so `.serializer()` resolves.
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
-if @kotlinx.serialization.Serializable class ** {
static **$Companion Companion;
}
-keepclassmembers class <2>$Companion {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep `INSTANCE.serializer()` of @Serializable objects.
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
# Keep the synthesized $$serializer classes and their descriptor field.
-keepclassmembers class **$$serializer {
*** descriptor;
}
# ── Our wire DTOs ───────────────────────────────────────────────────────────
# All request/response models decoded by kotlinx.serialization. Keeping them
# (and their generated serializers) guarantees additive backend fields and
# @SerialName mappings survive minification. DTOs are small, so keeping them
# whole is cheap insurance against a full-mode strip.
-keep @kotlinx.serialization.Serializable class com.runicgateway.app.** { *; }
-keepclassmembers class com.runicgateway.app.data.api.dto.** { *; }
# ── Retrofit service interfaces ─────────────────────────────────────────────
# Retrofit reads method + parameter annotations reflectively; keep our API
# interfaces' generic signatures so return types (suspend .../Call<T>) resolve.
-keep,allowobfuscation interface com.runicgateway.app.data.api.*Api
-keepattributes Signature, Exceptions
# Kotlin metadata is needed for reflection over Kotlin types (serialization/Retrofit).
-keep class kotlin.Metadata { *; }
# ── Tink / EncryptedSharedPreferences (androidx.security-crypto) ─────────────
# Tink references Error Prone compile-only annotations that are absent at runtime;
# they are safe to ignore (they carry no runtime behaviour). Suppresses the R8
# "Missing class com.google.errorprone.annotations.*" errors.
-dontwarn com.google.errorprone.annotations.**

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View File

@@ -36,6 +36,13 @@ class ConnectionRepository @Inject constructor(
/** Reachable and 2xx, but not a Runic Gateway backend (wrong version identity). */
data object NotRunicGateway : ProbeResult
/**
* A Runic Gateway backend, but speaking an API version this app build does
* not support (§3 version guard) — refuse rather than mis-render. [serverApi]
* is what the site reported; [supportedApi] is what this app speaks.
*/
data class VersionMismatch(val serverApi: String, val supportedApi: String) : ProbeResult
data class ServerError(val status: Int) : ProbeResult
data class Unreachable(val cause: Throwable) : ProbeResult
}
@@ -68,14 +75,15 @@ class ConnectionRepository @Inject constructor(
?: return ProbeResult.InvalidUrl(ServerUrl.Reason.MALFORMED)
return when (val result = safeApiCall { api.probeStatus(statusUrl) }) {
is ApiResult.Ok -> {
if (!result.data.version.service.equals(RUNIC_SERVICE_ID, ignoreCase = true)) {
ProbeResult.NotRunicGateway
} else {
is ApiResult.Ok -> when (val verdict = evaluateVersion(result.data.version)) {
is VersionVerdict.Ok -> {
prefs.setBaseUrl(normalized.toString())
baseUrlHolder.set(normalized)
ProbeResult.Success(result.data)
}
is VersionVerdict.NotRunicGateway -> ProbeResult.NotRunicGateway
is VersionVerdict.Mismatch ->
ProbeResult.VersionMismatch(serverApi = verdict.serverApi, supportedApi = SUPPORTED_API)
}
is ApiResult.HttpError -> ProbeResult.ServerError(result.status)
is ApiResult.NetworkError -> ProbeResult.Unreachable(result.cause)
@@ -93,7 +101,38 @@ class ConnectionRepository @Inject constructor(
baseUrlHolder.set(null)
}
private companion object {
/** The pure outcome of inspecting a probed site's [VersionDto] (§3 version guard). */
internal sealed interface VersionVerdict {
data object Ok : VersionVerdict
data object NotRunicGateway : VersionVerdict
data class Mismatch(val serverApi: String) : VersionVerdict
}
internal companion object {
const val RUNIC_SERVICE_ID = "runic-gateway"
/** The backend API major version this app build speaks (matches `/public/version` `api`). */
const val SUPPORTED_API = "v1"
/**
* Decide whether a probed site is a Runic Gateway backend this app can talk
* to. Pure (no I/O) so it is unit-testable without a live site. Lenient on a
* blank `api` (an older backend that predates version surfacing); refuses only
* an API version we positively know we can't parse (e.g. a future `v2`).
*/
fun evaluateVersion(
version: com.runicgateway.app.data.api.dto.VersionDto,
supportedApi: String = SUPPORTED_API,
): VersionVerdict {
if (!version.service.trim().equals(RUNIC_SERVICE_ID, ignoreCase = true)) {
return VersionVerdict.NotRunicGateway
}
val serverApi = version.api.trim()
return when {
serverApi.isEmpty() -> VersionVerdict.Ok
serverApi.equals(supportedApi, ignoreCase = true) -> VersionVerdict.Ok
else -> VersionVerdict.Mismatch(serverApi)
}
}
}
}

View File

@@ -107,6 +107,7 @@ private fun errorMessage(error: ConnectError): String = when (error) {
ConnectError.UnsupportedScheme -> stringResource(R.string.connect_error_scheme)
ConnectError.Insecure -> stringResource(R.string.connect_error_insecure)
ConnectError.NotRunicGateway -> stringResource(R.string.connect_error_not_runic)
is ConnectError.VersionMismatch -> stringResource(R.string.connect_error_version, error.serverApi)
ConnectError.Unreachable -> stringResource(R.string.connect_error_unreachable)
is ConnectError.Server -> stringResource(R.string.connect_error_server, error.status)
}

View File

@@ -32,6 +32,7 @@ class ConnectViewModel @Inject constructor(
data object UnsupportedScheme : ConnectError
data object Insecure : ConnectError
data object NotRunicGateway : ConnectError
data class VersionMismatch(val serverApi: String) : ConnectError
data object Unreachable : ConnectError
data class Server(val status: Int) : ConnectError
}
@@ -61,6 +62,7 @@ class ConnectViewModel @Inject constructor(
}
is ProbeResult.InvalidUrl -> fail(result.reason.toError())
ProbeResult.NotRunicGateway -> fail(ConnectError.NotRunicGateway)
is ProbeResult.VersionMismatch -> fail(ConnectError.VersionMismatch(result.serverApi))
is ProbeResult.Unreachable -> fail(ConnectError.Unreachable)
is ProbeResult.ServerError -> fail(ConnectError.Server(result.status))
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="172"
android:viewportHeight="218.95209"
android:tint="#FFFFFF">
<group android:scaleX="0.7227152"
android:scaleY="0.92"
android:translateX="23.846495"
android:translateY="8.758083">
<group android:translateY="154.51205">
<path android:pathData="M9.28125,-0L9.28125,-103.109375L42.265625,-103.109375Q53.140625,-103.109375,60.984375,-99.25Q68.828125,-95.40625,73.109375,-88.203125Q77.40625,-81,77.40625,-71.203125L77.40625,-70.0625Q77.40625,-60.1875,73.109375,-53.015625Q68.828125,-45.859375,60.9375,-41.96875Q53.0625,-38.09375,42.265625,-38.09375L20.296875,-38.09375L20.296875,-54.5L41.828125,-54.5Q49.46875,-54.5,53.75,-58.390625Q58.03125,-62.28125,58.03125,-69.125L58.03125,-70.984375Q58.03125,-77.828125,53.75,-81.71875Q49.46875,-85.609375,41.828125,-85.609375L28.4375,-85.609375L28.4375,-0L9.28125,-0ZM61.78125,-0L35.5625,-45.359375L56.65625,-45.359375L83.453125,-0L61.78125,-0Z"
android:fillColor="#000000"/>
<path android:pathData="M132.65625,2.375Q119.765625,2.375,110.40625,-3.921875Q101.046875,-10.21875,96.046875,-21.953125Q91.046875,-33.703125,91.046875,-49.96875L91.046875,-52.625Q91.046875,-69.046875,96.046875,-80.890625Q101.046875,-92.734375,110.296875,-99.109375Q119.546875,-105.484375,132.375,-105.484375Q145.25,-105.484375,153.96875,-99.109375Q162.6875,-92.734375,166.5625,-80.5L149.21875,-74.09375Q147.125,-81,143.04688,-84.234375Q138.98438,-87.484375,132.57812,-87.484375Q121.859375,-87.484375,116.125,-78.546875Q110.40625,-69.625,110.40625,-52.984375L110.40625,-49.609375Q110.40625,-32.96875,116.15625,-24.21875Q121.921875,-15.484375,132.79688,-15.484375Q141.57812,-15.484375,146.35938,-21.453125Q151.15625,-27.4375,151.15625,-38.453125L151.15625,-41.828125L129.84375,-41.828125L129.84375,-57.890625L169.09375,-57.890625L169.09375,-41.90625Q169.09375,-20.734375,159.57812,-9.171875Q150.07812,2.375,132.65625,2.375Z"
android:fillColor="#000000"/>
</group>
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 B

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
Adaptive-icon background layer (M6): a solid deep-indigo fill behind the
transparent gateway-medallion foreground. Replaces the Image Asset wizard's
default green grid. Sourced from @color/ic_launcher_background so the launcher
background stays a single source of truth.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="@color/ic_launcher_background"
android:pathData="M0,0h108v108h-108z" />
</vector>

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!-- Placeholder launcher glyph: a stylized gateway arch. Replaced in the M5 design pass. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#D0BCFF"
android:pathData="M38,74 L38,50 A16,16 0 0,1 70,50 L70,74 L62,74 L62,50 A8,8 0 0,0 46,50 L46,74 Z" />
<path
android:fillColor="#EFB8C8"
android:pathData="M52,34 L56,34 L56,40 L52,40 Z M50,40 L58,40 L58,44 L50,44 Z" />
</vector>

View File

@@ -1,7 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
Adaptive launcher icon (API 26+, which is every target device at minSdk 29):
the gateway-medallion foreground (transparent PNG, all densities) over the
deep-indigo background. No <monochrome> layer: the medallion is a full-colour
mark that does not reduce to a clean single-tone silhouette, so themed-icon
mode falls back to this standard icon rather than a tinted blob.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!-- Round adaptive launcher icon — same layers as ic_launcher.xml. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -30,6 +30,7 @@
<string name="connect_error_scheme">Only http and https addresses are supported.</string>
<string name="connect_error_insecure">A secure https address is required.</string>
<string name="connect_error_not_runic">That site isn\'t a Runic Gateway shard.</string>
<string name="connect_error_version">This app is out of date for that site (it speaks API %1$s). Update the app and try again.</string>
<string name="connect_error_unreachable">Couldn\'t reach that site. Check the address and your connection.</string>
<string name="connect_error_server">The site responded with an error (%1$d). Try again shortly.</string>

View File

@@ -0,0 +1,51 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.data.api.dto.VersionDto
import com.runicgateway.app.data.repository.ConnectionRepository.Companion.evaluateVersion
import com.runicgateway.app.data.repository.ConnectionRepository.VersionVerdict
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The connect-probe version guard (§3): a Runic Gateway backend on the app's
* supported API is accepted; a wrong service identity or an unsupported API
* version is refused clearly rather than mis-rendered.
*/
class ConnectionVersionGuardTest {
@Test
fun `matching service and api is Ok`() {
val v = VersionDto(service = "runic-gateway", api = "v1", server = "1.2.3")
assertEquals(VersionVerdict.Ok, evaluateVersion(v))
}
@Test
fun `service id is case-insensitive and trimmed`() {
val v = VersionDto(service = " Runic-Gateway ", api = "V1", server = "x")
assertEquals(VersionVerdict.Ok, evaluateVersion(v))
}
@Test
fun `wrong service is NotRunicGateway`() {
val v = VersionDto(service = "some-other-app", api = "v1", server = "x")
assertEquals(VersionVerdict.NotRunicGateway, evaluateVersion(v))
}
@Test
fun `blank api is lenient (older backend) and accepted`() {
val v = VersionDto(service = "runic-gateway", api = "", server = "x")
assertEquals(VersionVerdict.Ok, evaluateVersion(v))
}
@Test
fun `future api version is a mismatch carrying the server value`() {
val v = VersionDto(service = "runic-gateway", api = "v2", server = "x")
val verdict = evaluateVersion(v)
assertTrue(verdict is VersionVerdict.Mismatch)
assertEquals("v2", (verdict as VersionVerdict.Mismatch).serverApi)
}
}