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>
This commit is contained in:
2026-07-20 03:35:06 -05:00
parent e497e6c8a7
commit 0df862a6af
33 changed files with 408 additions and 33 deletions

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)
}
}