9 Commits

Author SHA1 Message Date
d0fb6bbdfc Merge pull request 'ci(release): make the release tag-driven (stop pushing to protected main)' (#14) from ci/tag-driven-release into main
All checks were successful
Release APK / release (push) Successful in 8m44s
Reviewed-on: #14
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 19:11:40 +00:00
9268579c5f ci(release): make the release tag-driven (no push to protected main)
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m23s
The push-to-main release model kept failing: the job builds the signed APK
fine, but the final `git push origin HEAD:main` (version-bump commit) is
rejected by main's branch protection — "pre-receive hook declined / Internal
Server Error" — across runs #187, #194. main is deliberately protected
(allowlist push, required approvals, required status checks), which is
fundamentally incompatible with a CI job pushing a fresh commit to it.

Flip the trigger: the workflow now runs on pushing a `v*` tag (or via
workflow_dispatch with a tag input). The tag *is* the release input, so:

- version/versionCode are derived from the tag name (no version-planning engine);
- app/build.gradle.kts is set for the build only, never committed back;
- no `git push` to main, no tag creation, no REGISTRY_USER needed —
  only REGISTRY_TOKEN, to create the Gitea release + upload the APK/SHA256SUMS.

To cut a release now: `git tag v0.1.0 && git push origin v0.1.0`.

Keeps the speed fixes from #13 (trimmed setup-android, no Gradle cache,
timeout-minutes). Changelog is still generated from conventional-commit
subjects since the previous tag.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 19:00:04 +00:00
0c395b2527 Merge pull request 'ci(release): trim setup-android, drop broken Gradle cache, add job timeout' (#13) from ci/release-workflow-speedup into main
Some checks failed
Release APK / release (push) Failing after 9m23s
Reviewed-on: #13
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 17:50:25 +00:00
2d84a930e5 ci(release): fix workflow file mangled by prior base64 double-encode
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m19s
Restore proper YAML content (the previous commit on this branch accidentally
stored the base64 string as the literal file body).
2026-07-20 17:39:29 +00:00
b6a0fa1f5d ci(release): trim setup-android, drop broken Gradle cache, add job timeout
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m43s
The release workflow was slow and sometimes appeared to hang mid-build:

- android-actions/setup-android@v3 pulled the entire Android emulator and the
  legacy `tools` package (hundreds of MB, network-bound on the self-hosted
  runner) that a headless APK build never uses. Pin `packages: ''` so it only
  puts cmdline-tools on PATH; the next step installs exactly what we need.
- The `actions/cache@v4` Gradle step timed out every run against the Gitea
  artifact-cache backend (`getCacheEntry failed: Request timeout`), adding
  latency with no benefit. Dropped it.
- The job had no timeout, and with `concurrency.cancel-in-progress: false` a
  genuinely wedged run would hang forever and block every later release behind
  it. Added `timeout-minutes: 30` so a hang fails fast.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 17:37:15 +00:00
bc09593550 Merge pull request 'ci(release): conventional-commit auto-release engine for the APK' (#12) from ci/android-release-engine into main
Some checks failed
Release APK / release (push) Has been cancelled
Reviewed-on: #12
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 09:35:19 +00:00
9514172b71 ci(release): auto-release engine (conventional commits) for the APK
Replace the tag-triggered release.yml with link/'s language-agnostic release
engine, adapted for Android. On every push to main it derives the next version
from conventional-commit subjects since the last v* tag (feat!/BREAKING -> major,
feat -> minor, fix|perf -> patch; nothing releasable -> no release), generates a
grouped changelog, bumps versionName in build.gradle.kts (versionCode derived
major*10000+minor*100+patch, monotonic), builds the SIGNED release APK, then
commits the bump [skip ci], tags vX.Y.Z, and creates the Gitea release with the
notes + APK + SHA256SUMS.

Uses REGISTRY_USER/REGISTRY_TOKEN (write:repository) to push the bump + create
the release, matching link/. main must allow that account to push (bump lands on
main; the [skip ci] + head-commit guard prevent a re-trigger loop). Signing
secrets (ANDROID_KEYSTORE_BASE64/_PASSWORD, ANDROID_KEY_ALIAS/_PASSWORD) unchanged.
Same self-hosted-runner handling as pr-checks.yml (apt JDK 17, sdkmanager, chmod).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 04:31:05 -05:00
de79bf547c Merge pull request 'feat(release): m6 release mechanics — signed APK, R8, version guard, icons' (#11) from feat/m6-release into main
Reviewed-on: #11
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 09:17:23 +00:00
0df862a6af 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>
2026-07-20 03:35:06 -05:00
33 changed files with 452 additions and 33 deletions

View File

@@ -0,0 +1,194 @@
# Automated release for the Runic Gateway Android app.
#
# Trigger: pushing a version tag `v*` (e.g. `v0.1.0`). Tag-driven on purpose — the
# build never has to push to protected `main`; the tag *is* the release input.
#
# To cut a release:
# git tag v0.1.0 && git push origin v0.1.0
# (or create the tag from the Gitea UI). Re-build/re-release an existing tag via
# the workflow_dispatch input below.
#
# versionName = the tag without its leading `v`; versionCode = major*10000 +
# minor*100 + patch (deterministic + monotonic, PLAN.md §10). Both are injected
# into app/build.gradle.kts for the build only — nothing is committed back to main.
#
# Prerequisites (Settings -> Actions -> Secrets on RunicGateway/Android-app):
# REGISTRY_TOKEN — Gitea access token with `write:repository` (create the release)
# 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 (== store password for a PKCS12 keystore)
#
# 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
# tools + JDK 17 (not 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:
inputs:
tag:
description: 'Existing v* tag to (re)build and release'
required: true
concurrency:
group: release-apk-${{ github.event.inputs.tag || github.ref_name }}
cancel-in-progress: false
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/Android-app
GRADLE_MODULE: app
jobs:
release:
runs-on: ubuntu-latest
# Fail fast on a genuinely wedged run (e.g. a stalled SDK/network download on
# the self-hosted runner) instead of hanging forever and — because concurrency
# is `cancel-in-progress: false` — blocking every later release behind it.
timeout-minutes: 30
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"
- name: Check out the release tag (full history for the changelog)
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref_name }}
fetch-depth: 0
# ── Derive version + changelog straight from the tag ─────────────────
- name: Plan the release (version + changelog from the tag)
id: plan
run: |
set -euo pipefail
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
TAG="${{ github.event.inputs.tag || github.ref_name }}"
case "$TAG" in
v[0-9]*) : ;;
*) echo "::error::expected a v* version tag, got '$TAG'"; exit 1 ;;
esac
VERSION="${TAG#v}"
# versionCode: deterministic + monotonic from the semver (PLAN.md §10).
IFS=. read -r MA MI PA <<< "$VERSION"
: "${MA:=0}"; : "${MI:=0}"; : "${PA:=0}"
VERSION_CODE=$(( MA*10000 + MI*100 + PA ))
# Changelog: conventional-commit subjects since the previous v* tag.
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "${TAG}^" 2>/dev/null || true)"
if [ -n "$PREV_TAG" ]; then RANGE="${PREV_TAG}..${TAG}"; else RANGE="${TAG}"; fi
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
{
echo "## Runic Gateway Android ${TAG}"
echo
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes"
if [ -n "$PREV_TAG" ]; then echo "Since ${PREV_TAG}:"; fi
echo "$SUBJECTS" | sed 's/^/- /'
echo
echo "---"
echo "Signed APK — sideload on Android 10+ (§10). The app self-configures its shard site on first run."
} > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "versionCode=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "==> tag=${TAG} version=${VERSION} code=${VERSION_CODE} prev_tag=${PREV_TAG:-<none>}"
# ── SDK + signing keystore ───────────────────────────────────────────
- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
# Only put cmdline-tools on PATH. The action's default package set drags in
# the whole emulator + the legacy `tools` package (hundreds of MB, network-
# bound on this runner) that a headless APK build never uses. The next step
# installs exactly the packages we need.
packages: ''
- name: Install Android SDK packages
run: |
set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
- 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"
# ── Set the version, build the signed APK ────────────────────────────
- name: Set the app version to match the tag
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
VERSION_CODE="${{ steps.plan.outputs.versionCode }}"
# Replace only the version defaults (the `?: "x.y.z"` / `?: N` fallbacks).
sed -i -E "s/(\?: )\"[0-9]+\.[0-9]+\.[0-9]+\"/\1\"${VERSION}\"/" "${GRADLE_MODULE}/build.gradle.kts"
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
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 :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease
- name: Package APK + SHA256SUMS
run: |
set -euo pipefail
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) ───────
- name: Create Gitea release and upload assets
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
BODY="$(cat dist/CHANGELOG.md)"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
REL_ID="$(curl -sSf -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
| jq -r '.id')"
echo "Created release ${TAG} (id=${REL_ID})"
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

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"
// 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)
// so it stays monotonic. Both remain overridable via -P for local/manual builds.
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)
}
}