diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..9112a9a --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a7d40dd..ee78cb1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") + } } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 178468b..30a708a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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) 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.** + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..15dd55e Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt index 61e719f..804b563 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt @@ -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) + } + } } } diff --git a/app/src/main/java/com/runicgateway/app/ui/connect/ConnectScreen.kt b/app/src/main/java/com/runicgateway/app/ui/connect/ConnectScreen.kt index 75d7e91..d62e8a1 100644 --- a/app/src/main/java/com/runicgateway/app/ui/connect/ConnectScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/connect/ConnectScreen.kt @@ -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) } diff --git a/app/src/main/java/com/runicgateway/app/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/connect/ConnectViewModel.kt index 7645c76..63987dc 100644 --- a/app/src/main/java/com/runicgateway/app/ui/connect/ConnectViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/connect/ConnectViewModel.kt @@ -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)) } diff --git a/app/src/main/res/drawable-anydpi/ic_stat_name.xml b/app/src/main/res/drawable-anydpi/ic_stat_name.xml new file mode 100644 index 0000000..614ca28 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_stat_name.xml @@ -0,0 +1,19 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/ic_stat_name.png b/app/src/main/res/drawable-hdpi/ic_stat_name.png new file mode 100644 index 0000000..a1d0d80 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_name.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_stat_name.png b/app/src/main/res/drawable-mdpi/ic_stat_name.png new file mode 100644 index 0000000..798bb0b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_name.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_name.png b/app/src/main/res/drawable-xhdpi/ic_stat_name.png new file mode 100644 index 0000000..f4239dc Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_name.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_name.png b/app/src/main/res/drawable-xxhdpi/ic_stat_name.png new file mode 100644 index 0000000..89ec99c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_name.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..1a42091 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 65c870b..0000000 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index cba6ee3..e57a4cd 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,7 +1,13 @@ + - - - + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index cba6ee3..a2b11b8 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,7 +1,7 @@ + - - - + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..fca234e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..201b2c2 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..850cb5a Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..6159f16 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..df3ec88 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..d4294a2 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..9bb08ad Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..3be47ec Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..ff6d5dc Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..94ad639 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..8b4315b Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..c323ad7 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..e6d5fd4 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..d6bdcdb Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..be258bf Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f5e95ff..a4d157a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,6 +30,7 @@ Only http and https addresses are supported. A secure https address is required. That site isn\'t a Runic Gateway shard. + This app is out of date for that site (it speaks API %1$s). Update the app and try again. Couldn\'t reach that site. Check the address and your connection. The site responded with an error (%1$d). Try again shortly. diff --git a/app/src/test/java/com/runicgateway/app/data/repository/ConnectionVersionGuardTest.kt b/app/src/test/java/com/runicgateway/app/data/repository/ConnectionVersionGuardTest.kt new file mode 100644 index 0000000..a39d5b2 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/repository/ConnectionVersionGuardTest.kt @@ -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) + } +}