feat(release): m6 release mechanics — signed APK, R8, version guard, icons
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m52s
Release-hardening pass (PLAN.md §9 M6, §10, §12). No architecture, data-flow, or endpoint changes; the app remains a pure API client. App icons (default brand assets): - New gateway-medallion launcher icon set (all densities, adaptive fg/bg, round, Play Store icon) + an RG notification icon staged for M7 push. - Replace the Image Asset wizard's default green-grid adaptive background with the deep-indigo brand fill (@color/ic_launcher_background #1B1033); recomposite the legacy square/round webps and the 512 Play icon over indigo so the whole set is coherent (the green never shipped). Restore the SPDX headers the wizard stripped; drop the orphaned placeholder foreground vector. No <monochrome> layer — the full-colour medallion has no clean silhouette, so themed mode falls back to the standard icon rather than a tinted blob. Version-mismatch guard (§3): - The connect probe now refuses a Runic Gateway backend whose API version this build can't speak (e.g. a future v2) with a clear "app out of date" message, instead of mis-rendering; lenient on a blank api (older backend). Decision logic extracted to a pure ConnectionRepository.evaluateVersion() with unit tests. Release build hardening (§7, §12): - Enable R8 full-mode minify + resource shrink for release (~31 MB debug -> 4.2 MB signed release). ProGuard keep-rules for kotlinx.serialization serializers + our wire DTOs, Retrofit service interfaces, and a -dontwarn for Tink's compile-only Error Prone annotations (EncryptedSharedPreferences). - Release signingConfig reads keystore material from a gitignored keystore.properties or env vars; absent -> unsigned (debug + PR gate unaffected). Keystore never in repo. - versionName/versionCode overridable via -P so the release tag + CI run number drive them (§10). CI: - release.yml: on a `v*` tag, build a SIGNED release APK (keystore from a base64 Gitea secret) and attach it + SHA256SUMS to a Gitea release; workflow_dispatch is a signing dry run. Mirrors pr-checks.yml's self-hosted-runner handling (apt JDK 17, explicit sdkmanager, in-step chmod +x gradlew). Co-Authored-By: Claude <noreply@anthropic.com>
150
.gitea/workflows/release.yml
Normal 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
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
@@ -9,6 +12,25 @@ plugins {
|
|||||||
alias(libs.plugins.hilt)
|
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 {
|
android {
|
||||||
namespace = "com.runicgateway.app"
|
namespace = "com.runicgateway.app"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -18,20 +40,46 @@ android {
|
|||||||
applicationId = "com.runicgateway.app"
|
applicationId = "com.runicgateway.app"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
// The release workflow drives these from the git tag (versionName) and a
|
||||||
versionName = "0.1.0"
|
// 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"
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// Signing/minification are wired at M6 (release hardening). Debug is auto-signed.
|
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
|
||||||
isMinifyEnabled = false
|
// release APK). Keep rules live in proguard-rules.pro.
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
62
app/proguard-rules.pro
vendored
@@ -1,3 +1,59 @@
|
|||||||
# Runic Gateway Android app — ProGuard/R8 rules.
|
# Runic Gateway Android app — ProGuard/R8 rules (release minify + resource shrink, M6).
|
||||||
# Minification is disabled until M6 (release hardening); real keep rules for
|
#
|
||||||
# kotlinx.serialization DTOs and Retrofit models are added there.
|
# 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.**
|
||||||
|
|
||||||
|
|||||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
@@ -36,6 +36,13 @@ class ConnectionRepository @Inject constructor(
|
|||||||
|
|
||||||
/** Reachable and 2xx, but not a Runic Gateway backend (wrong version identity). */
|
/** Reachable and 2xx, but not a Runic Gateway backend (wrong version identity). */
|
||||||
data object NotRunicGateway : ProbeResult
|
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 ServerError(val status: Int) : ProbeResult
|
||||||
data class Unreachable(val cause: Throwable) : ProbeResult
|
data class Unreachable(val cause: Throwable) : ProbeResult
|
||||||
}
|
}
|
||||||
@@ -68,14 +75,15 @@ class ConnectionRepository @Inject constructor(
|
|||||||
?: return ProbeResult.InvalidUrl(ServerUrl.Reason.MALFORMED)
|
?: return ProbeResult.InvalidUrl(ServerUrl.Reason.MALFORMED)
|
||||||
|
|
||||||
return when (val result = safeApiCall { api.probeStatus(statusUrl) }) {
|
return when (val result = safeApiCall { api.probeStatus(statusUrl) }) {
|
||||||
is ApiResult.Ok -> {
|
is ApiResult.Ok -> when (val verdict = evaluateVersion(result.data.version)) {
|
||||||
if (!result.data.version.service.equals(RUNIC_SERVICE_ID, ignoreCase = true)) {
|
is VersionVerdict.Ok -> {
|
||||||
ProbeResult.NotRunicGateway
|
|
||||||
} else {
|
|
||||||
prefs.setBaseUrl(normalized.toString())
|
prefs.setBaseUrl(normalized.toString())
|
||||||
baseUrlHolder.set(normalized)
|
baseUrlHolder.set(normalized)
|
||||||
ProbeResult.Success(result.data)
|
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.HttpError -> ProbeResult.ServerError(result.status)
|
||||||
is ApiResult.NetworkError -> ProbeResult.Unreachable(result.cause)
|
is ApiResult.NetworkError -> ProbeResult.Unreachable(result.cause)
|
||||||
@@ -93,7 +101,38 @@ class ConnectionRepository @Inject constructor(
|
|||||||
baseUrlHolder.set(null)
|
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"
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ private fun errorMessage(error: ConnectError): String = when (error) {
|
|||||||
ConnectError.UnsupportedScheme -> stringResource(R.string.connect_error_scheme)
|
ConnectError.UnsupportedScheme -> stringResource(R.string.connect_error_scheme)
|
||||||
ConnectError.Insecure -> stringResource(R.string.connect_error_insecure)
|
ConnectError.Insecure -> stringResource(R.string.connect_error_insecure)
|
||||||
ConnectError.NotRunicGateway -> stringResource(R.string.connect_error_not_runic)
|
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)
|
ConnectError.Unreachable -> stringResource(R.string.connect_error_unreachable)
|
||||||
is ConnectError.Server -> stringResource(R.string.connect_error_server, error.status)
|
is ConnectError.Server -> stringResource(R.string.connect_error_server, error.status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class ConnectViewModel @Inject constructor(
|
|||||||
data object UnsupportedScheme : ConnectError
|
data object UnsupportedScheme : ConnectError
|
||||||
data object Insecure : ConnectError
|
data object Insecure : ConnectError
|
||||||
data object NotRunicGateway : ConnectError
|
data object NotRunicGateway : ConnectError
|
||||||
|
data class VersionMismatch(val serverApi: String) : ConnectError
|
||||||
data object Unreachable : ConnectError
|
data object Unreachable : ConnectError
|
||||||
data class Server(val status: Int) : ConnectError
|
data class Server(val status: Int) : ConnectError
|
||||||
}
|
}
|
||||||
@@ -61,6 +62,7 @@ class ConnectViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
is ProbeResult.InvalidUrl -> fail(result.reason.toError())
|
is ProbeResult.InvalidUrl -> fail(result.reason.toError())
|
||||||
ProbeResult.NotRunicGateway -> fail(ConnectError.NotRunicGateway)
|
ProbeResult.NotRunicGateway -> fail(ConnectError.NotRunicGateway)
|
||||||
|
is ProbeResult.VersionMismatch -> fail(ConnectError.VersionMismatch(result.serverApi))
|
||||||
is ProbeResult.Unreachable -> fail(ConnectError.Unreachable)
|
is ProbeResult.Unreachable -> fail(ConnectError.Unreachable)
|
||||||
is ProbeResult.ServerError -> fail(ConnectError.Server(result.status))
|
is ProbeResult.ServerError -> fail(ConnectError.Server(result.status))
|
||||||
}
|
}
|
||||||
|
|||||||
19
app/src/main/res/drawable-anydpi/ic_stat_name.xml
Normal 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>
|
||||||
BIN
app/src/main/res/drawable-hdpi/ic_stat_name.png
Normal file
|
After Width: | Height: | Size: 493 B |
BIN
app/src/main/res/drawable-mdpi/ic_stat_name.png
Normal file
|
After Width: | Height: | Size: 352 B |
BIN
app/src/main/res/drawable-xhdpi/ic_stat_name.png
Normal file
|
After Width: | Height: | Size: 647 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_name.png
Normal file
|
After Width: | Height: | Size: 975 B |
17
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
<!-- 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">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
<!-- 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">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
@@ -30,6 +30,7 @@
|
|||||||
<string name="connect_error_scheme">Only http and https addresses are supported.</string>
|
<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_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_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_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>
|
<string name="connect_error_server">The site responded with an error (%1$d). Try again shortly.</string>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||