From 1326b03301954704297b1e48f1a87f037945b48a Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 25 May 2026 01:20:16 +0900 Subject: [PATCH] build: add Android release APK packaging script --- .../android/app/build.gradle.kts | 103 +++++++++++++---- tools/build-android-release-apks.sh | 104 ++++++++++++++++++ 2 files changed, 183 insertions(+), 24 deletions(-) create mode 100755 tools/build-android-release-apks.sh diff --git a/apps/chanora_flutter/android/app/build.gradle.kts b/apps/chanora_flutter/android/app/build.gradle.kts index ab3cfb9..a105572 100644 --- a/apps/chanora_flutter/android/app/build.gradle.kts +++ b/apps/chanora_flutter/android/app/build.gradle.kts @@ -33,6 +33,24 @@ val releaseStorePassword = resolveSigningProp("CHANORA_RELEASE_STORE_PASSWORD") val releaseKeyAlias = resolveSigningProp("CHANORA_RELEASE_KEY_ALIAS") val releaseKeyPassword = resolveSigningProp("CHANORA_RELEASE_KEY_PASSWORD") val hasReleaseSigning = listOf(releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword).all { !it.isNullOrBlank() } +val splitPerAbiRequested = gradle.startParameter.taskNames.any { taskName -> + taskName.contains("split-per-abi", ignoreCase = true) +} +val configuredAbiSet = listOf("arm64-v8a", "x86_64") +val flutterTargetPlatformArg = gradle.startParameter.projectProperties["target-platform"] + ?.split(',') + ?.map { it.trim() } + ?.firstOrNull() +val singleAbiFromFlutterTargetPlatform = when (flutterTargetPlatformArg) { + "android-arm64" -> "arm64-v8a" + "android-x64" -> "x86_64" + else -> null +} +val effectivePackagingAbis: List = when { + singleAbiFromFlutterTargetPlatform != null -> listOf(singleAbiFromFlutterTargetPlatform) + splitPerAbiRequested -> configuredAbiSet + else -> configuredAbiSet +} android { namespace = "app.chanora.chanora_flutter" @@ -77,7 +95,7 @@ android { // ANDROID_PLATFORM as -D variables to the child cmake // invocation). See Cargo.toml [patch.crates-io] block and // docs/governance/product-decision-register.md DEC-032. - abiFilters += listOf("arm64-v8a", "x86_64") + abiFilters += effectivePackagingAbis } } @@ -137,6 +155,16 @@ android { } } + packaging { + jniLibs { + if (singleAbiFromFlutterTargetPlatform != null) { + val excludedAbis = configuredAbiSet.filter { it != singleAbiFromFlutterTargetPlatform } + + listOf("armeabi-v7a", "x86") + excludes += excludedAbis.map { abi -> "lib/$abi/**" } + } + } + } + // SDD-109 item 2: Android App Bundle (.aab) split configuration. // Combined with the SDD-073 abiFilters set, this yields two // native splits (arm64-v8a, x86_64), per-language resource @@ -155,7 +183,7 @@ android { } } -// ONNX Runtime native library for Silero / TEN VAD. +// ONNX Runtime native library for Silero VAD. // The ort crate (Rust) loads libonnxruntime.so via dlopen at runtime // (`load-dynamic` feature). The AAR ships the .so for arm64-v8a, // armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB. @@ -229,7 +257,11 @@ val abiToRustTriple: Map = mapOf( // `android.defaultConfig.ndk.abiFilters` declaration owned by SDD-073 item 4 // so that the build-automation list and the AGP packaging list are guaranteed // in sync. Any unsupported ABI fails fast. -val configuredAbis: List = android.defaultConfig.ndk.abiFilters.toList() +val configuredAbis: List = when { + singleAbiFromFlutterTargetPlatform != null -> listOf(singleAbiFromFlutterTargetPlatform) + splitPerAbiRequested -> configuredAbiSet + else -> android.defaultConfig.ndk.abiFilters.toList() +} configuredAbis.forEach { abi -> require(abiToRustTriple.containsKey(abi)) { "[SDD-118] ABI '$abi' is not supported by SDD-118; see SDD-073 item 4. " + @@ -458,7 +490,7 @@ val abiToNdkSysrootTriple: Map = mapOf( "arm64-v8a" to "aarch64-linux-android", "x86_64" to "x86_64-linux-android", ) -fun registerJniLibsCopyTask(profile: String): TaskProvider { +fun registerJniLibsCopyTask(profile: String): TaskProvider { val capitalized = profile.replaceFirstChar { it.uppercase() } val buildTask = if (profile == "release") buildRustBridgeRelease else buildRustBridgeDebug @@ -468,16 +500,30 @@ fun registerJniLibsCopyTask(profile: String): TaskProvider { .takeUnless { it.isNullOrBlank() } ?: android.ndkDirectory.absolutePath - return tasks.register("copyRustBridgeJniLibs$capitalized") { + return tasks.register("copyRustBridgeJniLibs$capitalized") { group = "chanora_bridge" - description = "SDD-118 item 6 (extended): stage per-ABI libchanora_bridge.so + libc++_shared.so (profile=$profile) into src/main/jniLibs/." + description = "SDD-118 item 6 (extended): strip and stage per-ABI libchanora_bridge.so + libc++_shared.so (profile=$profile) into src/main/jniLibs/." dependsOn(buildTask) - duplicatesStrategy = DuplicatesStrategy.INCLUDE - + val jniLibsDir = layout.projectDirectory.dir("src/main/jniLibs").asFile + val llvmStrip = file( + "$effectiveNdkPath/toolchains/llvm/prebuilt/$ndkHostTag/bin/llvm-strip" + ) + doFirst { + jniLibsDir + .listFiles() + ?.filter { it.isDirectory && !configuredAbis.contains(it.name) } + ?.forEach { staleDir -> + staleDir.deleteRecursively() + } + } configuredAbis.forEach { abi -> val triple = abiToRustTriple.getValue(abi) val sysrootTriple = abiToNdkSysrootTriple.getValue(abi) val bridgeSo = cargoWorkspaceRoot.resolve("target/$triple/$profile/libchanora_bridge.so") + val strippedBridgeSo = layout.buildDirectory + .file("intermediates/stripped_rust_jni/$profile/$abi/libchanora_bridge.so") + .get() + .asFile // SDD-118 item 6 (extended): also stage libc++_shared.so from the // NDK sysroot. libchanora_bridge.so is dynamically linked against // the NDK's shared C++ runtime (via audiopus_sys / opus-cpp and @@ -487,28 +533,37 @@ fun registerJniLibsCopyTask(profile: String): TaskProvider { val cxxSharedSo = file( "$effectiveNdkPath/toolchains/llvm/prebuilt/$ndkHostTag/sysroot/usr/lib/$sysrootTriple/libc++_shared.so" ) - from(bridgeSo) { - into(abi) - } - from(cxxSharedSo) { - into(abi) - } // SDD-118 item 8: input tracking for both staged sources so // Gradle correctly invalidates when either changes (e.g. NDK // version bump replacing libc++_shared.so). inputs.file(bridgeSo).withPropertyName("bridgeSo_$abi") inputs.file(cxxSharedSo).withPropertyName("cxxSharedSo_$abi") - } - into(layout.projectDirectory.dir("src/main/jniLibs")) + inputs.file(llvmStrip).withPropertyName("llvmStrip") + outputs.file(strippedBridgeSo) + outputs.file(File(jniLibsDir, "$abi/libchanora_bridge.so")) + outputs.file(File(jniLibsDir, "$abi/libc++_shared.so")) - // SDD-118 item 8: declare staged outputs so Gradle can track them. - configuredAbis.forEach { abi -> - outputs.file( - layout.projectDirectory.file("src/main/jniLibs/$abi/libchanora_bridge.so") - ) - outputs.file( - layout.projectDirectory.file("src/main/jniLibs/$abi/libc++_shared.so") - ) + doLast { + val abiDir = File(jniLibsDir, abi) + abiDir.mkdirs() + strippedBridgeSo.parentFile.mkdirs() + bridgeSo.copyTo(strippedBridgeSo, overwrite = true) + project.exec { + commandLine( + llvmStrip.absolutePath, + "--strip-debug", + strippedBridgeSo.absolutePath, + ) + } + strippedBridgeSo.copyTo( + File(abiDir, "libchanora_bridge.so"), + overwrite = true, + ) + cxxSharedSo.copyTo( + File(abiDir, "libc++_shared.so"), + overwrite = true, + ) + } } } } diff --git a/tools/build-android-release-apks.sh b/tools/build-android-release-apks.sh new file mode 100755 index 0000000..da78f57 --- /dev/null +++ b/tools/build-android-release-apks.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +APP_DIR="$REPO_ROOT/apps/chanora_flutter" +OUTPUT_DIR="$APP_DIR/build/app/outputs/flutter-apk" +DIST_DIR="$APP_DIR/build/app/outputs/release-dist" +PUBSPEC_PATH="$APP_DIR/pubspec.yaml" +ANDROID_SDK_ROOT_DEFAULT="$HOME/Library/Android/sdk" + +resolve_apksigner() { + local sdk_root="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$ANDROID_SDK_ROOT_DEFAULT}}" + local build_tools_dir="$sdk_root/build-tools" + if [[ ! -d "$build_tools_dir" ]]; then + echo "Android build-tools directory not found: $build_tools_dir" >&2 + exit 1 + fi + + local latest_build_tools + latest_build_tools="$(find "$build_tools_dir" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" + if [[ -z "$latest_build_tools" ]]; then + echo "No Android build-tools versions found under: $build_tools_dir" >&2 + exit 1 + fi + + local apksigner_path="$latest_build_tools/apksigner" + if [[ ! -x "$apksigner_path" ]]; then + echo "apksigner not found or not executable: $apksigner_path" >&2 + exit 1 + fi + + echo "$apksigner_path" +} + +if ! command -v flutter >/dev/null 2>&1; then + echo "flutter not found on PATH" >&2 + exit 1 +fi + +if [[ ! -f "$PUBSPEC_PATH" ]]; then + echo "pubspec.yaml not found at $PUBSPEC_PATH" >&2 + exit 1 +fi + +if [[ ! -f "$APP_DIR/android/key.properties" ]]; then + echo "Missing signing config: $APP_DIR/android/key.properties" >&2 + exit 1 +fi + +APKSIGNER_BIN="$(resolve_apksigner)" + +VERSION_LINE="$(grep '^version:' "$PUBSPEC_PATH" | head -n 1 | awk '{print $2}')" +if [[ -z "${VERSION_LINE:-}" ]]; then + echo "Could not parse version from $PUBSPEC_PATH" >&2 + exit 1 +fi + +VERSION_NAME="${VERSION_LINE%%+*}" +BUILD_NUMBER="${VERSION_LINE##*+}" +APP_NAME="chanora" + +mkdir -p "$DIST_DIR" + +build_one() { + local target_platform="$1" + local abi_label="$2" + local release_path_flutter="$OUTPUT_DIR/app-release.apk" + local release_path_gradle="$APP_DIR/build/app/outputs/apk/release/app-release.apk" + local named_apk="$DIST_DIR/${APP_NAME}-${VERSION_NAME}+${BUILD_NUMBER}-${abi_label}-release.apk" + + rm -f "$release_path_flutter" "$release_path_gradle" + ( + cd "$APP_DIR" + flutter build apk --release --target-platform "$target_platform" --tree-shake-icons >&2 + ) + + local built_apk="" + if [[ -f "$release_path_flutter" ]]; then + built_apk="$release_path_flutter" + elif [[ -f "$release_path_gradle" ]]; then + built_apk="$release_path_gradle" + fi + + if [[ -z "$built_apk" ]]; then + echo "Expected release APK missing: $release_path_flutter" >&2 + exit 1 + fi + + cp "$built_apk" "$named_apk" + "$APKSIGNER_BIN" verify --print-certs "$named_apk" >/dev/null + echo "$named_apk" +} + +ARM64_APK="$(build_one android-arm64 arm64-v8a)" +echo "Built: $ARM64_APK" + +if rustup target list --installed | grep -qx 'x86_64-linux-android'; then + X64_APK="$(build_one android-x64 x86_64)" + echo "Built: $X64_APK" +else + echo "Skipped x86_64 build: rustup target x86_64-linux-android is not installed" +fi