65238bd4512a
feat: Publish ABI-aware Android release updates (v2.9.12)
- time
- 2026-07-30 23:53 UTC · 2 months ago
- machine
- koh2
- parent
- 382630e6cd40
↓ peach-android-65238bd4.tar.gzatlas at this save
files changed9 files
VERSION+1 −1app/build.gradle+9 −7app/src/main/java/com/peachpasswords/updates/UpdateManifest.kt+10 −5app/src/main/java/com/peachpasswords/updates/UpdateRepository.kt+3 −1app/src/test/java/com/peachpasswords/updates/UpdateManifestTest.kt+20 −2kepr.build+4 −6scripts/kepr-build-apk.sh+19 −2scripts/kepr-build-helpers.sh+14 −6scripts/test-kepr-build-helpers.sh+13 −8app/build.gradle+9 −7
@@ -32,7 +32,7 @@3232 minSdk 263333 targetSdk 353434 // Keep the Android code in Gradle; the semantic name comes from root VERSION.35- versionCode 24015735+ versionCode 2401583636 versionName semanticVersion3737 3838 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"@@ -64,12 +64,14 @@6464 }6565 }6666 67- // ABI splits were tried and removed: the native libraries are ~2.5 MB of a68- // 24 MB APK, so per-ABI builds saved 8% in exchange for four artifacts and69- // a "which one do I need?" question at every install. The weight is 62 MB of70- // DEX, because `minifyEnabled false` below leaves R8 off entirely — that is71- // the lever worth pulling, and it needs its own pass with real-device72- // verification (kotlinx.serialization, BouncyCastle, Hilt, Compose).67+ splits {68+ abi {69+ enable true70+ reset()71+ include "arm64-v8a", "x86_64"72+ universalApk false73+ }74+ }7375 7476 buildTypes {7577 debug {app/src/main/java/com/peachpasswords/updates/UpdateManifest.kt+10 −5
@@ -61,13 +61,18 @@6161 class DownloadSizeException(message: String) : Exception(message)6262 6363 /**64- * SETTLED — Kepr accepts `android-arm64` as of kepr v1.56.237, matching its65- * `<os>-<arch>` platform vocabulary. This is the key the release manifest is66- * published under, so it is the key the updater must look up. It has to stay67- * identical to `platforms =` in this repo's `kepr.build` and to Kepr's68- * `release_platforms` allowlist; changing any one alone breaks APK publishing.64+ * Kepr platform keys for the two APK splits Peach publishes. `android-x86`65+ * carries the x86_64 APK; the shorter public key matches Kepr's platform66+ * vocabulary while Android's ABI name remains `x86_64` inside the archive.6967 */7068 const val ANDROID_PLATFORM_KEY = "android-arm64"69+const val ANDROID_X86_PLATFORM_KEY = "android-x86"70+71+fun androidPlatformKey(supportedAbis: Array<String>): String? = when {72+ "arm64-v8a" in supportedAbis -> ANDROID_PLATFORM_KEY73+ "x86_64" in supportedAbis -> ANDROID_X86_PLATFORM_KEY74+ else -> null75+}7176 7277 /**7378 * Hard ceiling on a downloaded APK. Kepr publishes ~24 MB today; 50 MB leavesapp/src/main/java/com/peachpasswords/updates/UpdateRepository.kt+3 −1
@@ -113,7 +113,9 @@113113 .build()114114 client.newCall(request).execute().use { response ->115115 if (!response.isSuccessful) throw IOException("HTTP ${response.code}")116- parseReleaseManifest(response.body?.string().orEmpty())116+ val platform = androidPlatformKey(Build.SUPPORTED_ABIS)117+ ?: throw ManifestFormatException("this Android ABI is not supported")118+ parseReleaseManifest(response.body?.string().orEmpty(), platform)117119 }118120 }119121 app/src/test/java/com/peachpasswords/updates/UpdateManifestTest.kt+20 −2
@@ -55,6 +55,25 @@5555 assertEquals("https://x/y", parseReleaseManifest(body).url)5656 }5757 58+ @Test59+ fun `platform selection follows the device ABI`() {60+ assertEquals(ANDROID_PLATFORM_KEY, androidPlatformKey(arrayOf("arm64-v8a", "armeabi-v7a")))61+ assertEquals(ANDROID_X86_PLATFORM_KEY, androidPlatformKey(arrayOf("x86_64", "x86")))62+ assertNull(androidPlatformKey(arrayOf("armeabi-v7a")))63+ }64+65+ @Test66+ fun `x86 device selects the x86 artifact`() {67+ val body = """68+ {"version":"2.3.68","platforms":{"android-x86":69+ {"url":"https://x/x86.apk","checksum":"sha256:${"b".repeat(64)}"}}}70+ """.trimIndent()71+ assertEquals(72+ "https://x/x86.apk",73+ parseReleaseManifest(body, androidPlatformKey(arrayOf("x86_64"))!!).url,74+ )75+ }76+5877 @Test(expected = ManifestFormatException::class)5978 fun `no platforms is a hard failure`() {6079 parseReleaseManifest("""{"version":"2.3.68"}""")@@ -62,8 +81,7 @@6281 6382 @Test(expected = ManifestFormatException::class)6483 fun `non-canonical platform key is rejected, no sole-artifact fallback`() {65- // A Kepr build that only published android-x86 used to be accepted via66- // the sole-artifact fallback; now it is refused outright.84+ // An ARM64 device must not silently accept the x86 artifact.6785 val body = """6886 {"version":"2.3.68","platforms":{"android-x86":6987 {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}}kepr.build+4 −6
@@ -1,11 +1,9 @@11 # Kepr Builds config for peach-android.22 #3-# An APK is a single artifact that runs on every supported ABI, so one platform4-# entry is the honest mapping. `platforms` here must match ANDROID_PLATFORM_KEY5-# in app/src/main/java/com/peachpasswords/updates/UpdateManifest.kt (what the6-# in-app updater looks up) and Kepr's own `release_platforms` allowlist, which7-# accepts android-arm64 as of kepr v1.56.237.3+# Koh runs the command once per platform with KEPR_TARGET set. The script stages4+# the matching signed split APK at the fixed output path. These keys must match5+# UpdateManifest.kt and Kepr's release_platforms allowlist.86 command = ./scripts/kepr-build-apk.sh97 output = build/kepr/peach-android.apk108 trigger = manual11-platforms = android-arm649+platforms = android-arm64, android-x86scripts/kepr-build-apk.sh+19 −2
@@ -78,7 +78,22 @@7878 7979 "$ROOT_DIR/gradlew" --no-configuration-cache :app:assembleRelease8080 81-APK="$ROOT_DIR/app/build/outputs/apk/release/app-release.apk"81+case "${KEPR_TARGET:-}" in82+ android-arm64)83+ APK_NAME="app-arm64-v8a-release.apk"84+ EXPECTED_ABI="arm64-v8a"85+ ;;86+ android-x86)87+ APK_NAME="app-x86_64-release.apk"88+ EXPECTED_ABI="x86_64"89+ ;;90+ *)91+ echo "refusing to publish: unsupported KEPR_TARGET '${KEPR_TARGET:-unset}'" >&292+ exit 193+ ;;94+esac95+96+APK="$ROOT_DIR/app/build/outputs/apk/release/$APK_NAME"8297 METADATA="$ROOT_DIR/app/build/outputs/apk/release/output-metadata.json"8398 if [ ! -f "$APK" ] || [ ! -r "$APK" ]; then8499 echo "refusing to publish: release APK was not produced" >&2@@ -99,7 +114,9 @@99114 "$METADATA" \100115 "$BADGING_FILE" \101116 "$ROOT_VERSION" \102- "com.peachpasswords"; then117+ "com.peachpasswords" \118+ "$APK_NAME" \119+ "$EXPECTED_ABI"; then103120 exit 1104121 fi105122 scripts/kepr-build-helpers.sh+14 −6
@@ -87,19 +87,21 @@8787 # Validate Gradle output-metadata.json, the APK's aapt2 badging, and VERSION as8888 # one release identity. The Python standard library is used only for parsing8989 # generated JSON; duplicate keys and ambiguous element lists are rejected.90-# Arguments: metadata-json badging-file root-version expected-app-id90+# Arguments: metadata-json badging-file root-version expected-app-id expected-output expected-abi9191 validate_release_metadata() {9292 metadata_file=$19393 badging_file=$29494 root_version=$39595 expected_app_id=$496+ expected_output=$597+ expected_abi=$69698 97- python3 - "$metadata_file" "$badging_file" "$root_version" "$expected_app_id" <<'PY'99+ python3 - "$metadata_file" "$badging_file" "$root_version" "$expected_app_id" "$expected_output" "$expected_abi" <<'PY'98100 import json99101 import re100102 import sys101103 102-metadata_path, badging_path, root_version, expected_app_id = sys.argv[1:]104+metadata_path, badging_path, root_version, expected_app_id, expected_output, expected_abi = sys.argv[1:]103105 104106 def reject(message):105107 raise SystemExit("release metadata rejected: " + message)@@ -131,9 +133,12 @@131133 if metadata.get("applicationId") != expected_app_id:132134 reject("Gradle applicationId does not match production package")133135 elements = metadata.get("elements")134-if not isinstance(elements, list) or len(elements) != 1 or not isinstance(elements[0], dict):135- reject("Gradle metadata must contain exactly one APK element")136-element = elements[0]136+if not isinstance(elements, list) or not all(isinstance(item, dict) for item in elements):137+ reject("Gradle metadata elements are malformed")138+matches = [item for item in elements if item.get("outputFile") == expected_output]139+if len(matches) != 1:140+ reject("Gradle metadata must contain exactly one matching APK element")141+element = matches[0]137142 if element.get("versionName") != root_version:138143 reject("Gradle versionName does not match VERSION")139144 expected_code = element.get("versionCode")@@ -164,5 +169,8 @@164169 if apk_version != root_version:165170 reject("APK versionName does not match VERSION")166171 strict_semver(apk_version)172+native_lines = [line for line in badging.splitlines() if line.startswith("native-code:")]173+if native_lines != [f"native-code: '{expected_abi}'"]:174+ reject("APK native ABI does not match requested platform")167175 PY168176 }scripts/test-kepr-build-helpers.sh+13 −8
@@ -52,24 +52,29 @@5252 cat > "$TEMP_ROOT/metadata.json" <<'JSON'5353 {5454 "applicationId": "com.peachpasswords",55- "elements": [{"versionCode": 240146, "versionName": "2.8.4", "outputFile": "app-release.apk"}]55+ "elements": [56+ {"versionCode": 240146, "versionName": "2.8.4", "outputFile": "app-arm64-v8a-release.apk"},57+ {"versionCode": 240146, "versionName": "2.8.4", "outputFile": "app-x86_64-release.apk"}58+ ]5659 }5760 JSON58-printf "package: name='com.peachpasswords' versionCode='240146' versionName='2.8.4'\n" > "$TEMP_ROOT/badging"59-validate_release_metadata "$TEMP_ROOT/metadata.json" "$TEMP_ROOT/badging" 2.8.4 com.peachpasswords61+printf "package: name='com.peachpasswords' versionCode='240146' versionName='2.8.4'\nnative-code: 'arm64-v8a'\n" > "$TEMP_ROOT/badging"62+validate_release_metadata "$TEMP_ROOT/metadata.json" "$TEMP_ROOT/badging" 2.8.4 com.peachpasswords app-arm64-v8a-release.apk arm64-v8a6063 6164 printf '{"applicationId":"com.peachpasswords",' > "$TEMP_ROOT/metadata.malformed"62-expect_failure validate_release_metadata "$TEMP_ROOT/metadata.malformed" "$TEMP_ROOT/badging" 2.8.4 com.peachpasswords65+expect_failure validate_release_metadata "$TEMP_ROOT/metadata.malformed" "$TEMP_ROOT/badging" 2.8.4 com.peachpasswords app-arm64-v8a-release.apk arm64-v8a6366 cat > "$TEMP_ROOT/metadata.duplicate.json" <<'JSON'6467 {"applicationId":"com.peachpasswords","applicationId":"com.peachpasswords","elements":[{"versionCode":240146,"versionName":"2.8.4"}]}6568 JSON66-expect_failure validate_release_metadata "$TEMP_ROOT/metadata.duplicate.json" "$TEMP_ROOT/badging" 2.8.4 com.peachpasswords69+expect_failure validate_release_metadata "$TEMP_ROOT/metadata.duplicate.json" "$TEMP_ROOT/badging" 2.8.4 com.peachpasswords app-arm64-v8a-release.apk arm64-v8a6770 cat > "$TEMP_ROOT/metadata.ambiguous.json" <<'JSON'6871 {"applicationId":"com.peachpasswords","elements":[{"versionCode":240146,"versionName":"2.8.4"},{"versionCode":240147,"versionName":"2.8.4"}]}6972 JSON70-expect_failure validate_release_metadata "$TEMP_ROOT/metadata.ambiguous.json" "$TEMP_ROOT/badging" 2.8.4 com.peachpasswords71-expect_failure validate_release_metadata "$TEMP_ROOT/metadata.json" "$TEMP_ROOT/badging" 2.8.5 com.peachpasswords73+expect_failure validate_release_metadata "$TEMP_ROOT/metadata.ambiguous.json" "$TEMP_ROOT/badging" 2.8.4 com.peachpasswords app-release.apk arm64-v8a74+expect_failure validate_release_metadata "$TEMP_ROOT/metadata.json" "$TEMP_ROOT/badging" 2.8.5 com.peachpasswords app-arm64-v8a-release.apk arm64-v8a7275 printf "package: name='com.peachpasswords' versionCode='240146' versionName='2.8.5'\n" > "$TEMP_ROOT/badging.mismatch"73-expect_failure validate_release_metadata "$TEMP_ROOT/metadata.json" "$TEMP_ROOT/badging.mismatch" 2.8.4 com.peachpasswords76+printf "native-code: 'arm64-v8a'\n" >> "$TEMP_ROOT/badging.mismatch"77+expect_failure validate_release_metadata "$TEMP_ROOT/metadata.json" "$TEMP_ROOT/badging.mismatch" 2.8.4 com.peachpasswords app-arm64-v8a-release.apk arm64-v8a78+expect_failure validate_release_metadata "$TEMP_ROOT/metadata.json" "$TEMP_ROOT/badging" 2.8.4 com.peachpasswords app-arm64-v8a-release.apk x86_647479 7580 printf 'PASS: stable tool selection, malformed/ambiguous metadata, and semantic-version mismatch gates\n'