build: add Android release APK packaging script

This commit is contained in:
Edison Jwa
2026-05-25 01:20:16 +09:00
parent 5515ff6643
commit 1326b03301
2 changed files with 183 additions and 24 deletions
@@ -33,6 +33,24 @@ val releaseStorePassword = resolveSigningProp("CHANORA_RELEASE_STORE_PASSWORD")
val releaseKeyAlias = resolveSigningProp("CHANORA_RELEASE_KEY_ALIAS") val releaseKeyAlias = resolveSigningProp("CHANORA_RELEASE_KEY_ALIAS")
val releaseKeyPassword = resolveSigningProp("CHANORA_RELEASE_KEY_PASSWORD") val releaseKeyPassword = resolveSigningProp("CHANORA_RELEASE_KEY_PASSWORD")
val hasReleaseSigning = listOf(releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword).all { !it.isNullOrBlank() } 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<String> = when {
singleAbiFromFlutterTargetPlatform != null -> listOf(singleAbiFromFlutterTargetPlatform)
splitPerAbiRequested -> configuredAbiSet
else -> configuredAbiSet
}
android { android {
namespace = "app.chanora.chanora_flutter" namespace = "app.chanora.chanora_flutter"
@@ -77,7 +95,7 @@ android {
// ANDROID_PLATFORM as -D variables to the child cmake // ANDROID_PLATFORM as -D variables to the child cmake
// invocation). See Cargo.toml [patch.crates-io] block and // invocation). See Cargo.toml [patch.crates-io] block and
// docs/governance/product-decision-register.md DEC-032. // 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. // SDD-109 item 2: Android App Bundle (.aab) split configuration.
// Combined with the SDD-073 abiFilters set, this yields two // Combined with the SDD-073 abiFilters set, this yields two
// native splits (arm64-v8a, x86_64), per-language resource // 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 // The ort crate (Rust) loads libonnxruntime.so via dlopen at runtime
// (`load-dynamic` feature). The AAR ships the .so for arm64-v8a, // (`load-dynamic` feature). The AAR ships the .so for arm64-v8a,
// armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB. // armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB.
@@ -229,7 +257,11 @@ val abiToRustTriple: Map<String, String> = mapOf(
// `android.defaultConfig.ndk.abiFilters` declaration owned by SDD-073 item 4 // `android.defaultConfig.ndk.abiFilters` declaration owned by SDD-073 item 4
// so that the build-automation list and the AGP packaging list are guaranteed // so that the build-automation list and the AGP packaging list are guaranteed
// in sync. Any unsupported ABI fails fast. // in sync. Any unsupported ABI fails fast.
val configuredAbis: List<String> = android.defaultConfig.ndk.abiFilters.toList() val configuredAbis: List<String> = when {
singleAbiFromFlutterTargetPlatform != null -> listOf(singleAbiFromFlutterTargetPlatform)
splitPerAbiRequested -> configuredAbiSet
else -> android.defaultConfig.ndk.abiFilters.toList()
}
configuredAbis.forEach { abi -> configuredAbis.forEach { abi ->
require(abiToRustTriple.containsKey(abi)) { require(abiToRustTriple.containsKey(abi)) {
"[SDD-118] ABI '$abi' is not supported by SDD-118; see SDD-073 item 4. " + "[SDD-118] ABI '$abi' is not supported by SDD-118; see SDD-073 item 4. " +
@@ -458,7 +490,7 @@ val abiToNdkSysrootTriple: Map<String, String> = mapOf(
"arm64-v8a" to "aarch64-linux-android", "arm64-v8a" to "aarch64-linux-android",
"x86_64" to "x86_64-linux-android", "x86_64" to "x86_64-linux-android",
) )
fun registerJniLibsCopyTask(profile: String): TaskProvider<Copy> { fun registerJniLibsCopyTask(profile: String): TaskProvider<Task> {
val capitalized = profile.replaceFirstChar { it.uppercase() } val capitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = if (profile == "release") buildRustBridgeRelease else buildRustBridgeDebug val buildTask = if (profile == "release") buildRustBridgeRelease else buildRustBridgeDebug
@@ -468,16 +500,30 @@ fun registerJniLibsCopyTask(profile: String): TaskProvider<Copy> {
.takeUnless { it.isNullOrBlank() } .takeUnless { it.isNullOrBlank() }
?: android.ndkDirectory.absolutePath ?: android.ndkDirectory.absolutePath
return tasks.register<Copy>("copyRustBridgeJniLibs$capitalized") { return tasks.register("copyRustBridgeJniLibs$capitalized") {
group = "chanora_bridge" 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) 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 -> configuredAbis.forEach { abi ->
val triple = abiToRustTriple.getValue(abi) val triple = abiToRustTriple.getValue(abi)
val sysrootTriple = abiToNdkSysrootTriple.getValue(abi) val sysrootTriple = abiToNdkSysrootTriple.getValue(abi)
val bridgeSo = cargoWorkspaceRoot.resolve("target/$triple/$profile/libchanora_bridge.so") 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 // SDD-118 item 6 (extended): also stage libc++_shared.so from the
// NDK sysroot. libchanora_bridge.so is dynamically linked against // NDK sysroot. libchanora_bridge.so is dynamically linked against
// the NDK's shared C++ runtime (via audiopus_sys / opus-cpp and // the NDK's shared C++ runtime (via audiopus_sys / opus-cpp and
@@ -487,28 +533,37 @@ fun registerJniLibsCopyTask(profile: String): TaskProvider<Copy> {
val cxxSharedSo = file( val cxxSharedSo = file(
"$effectiveNdkPath/toolchains/llvm/prebuilt/$ndkHostTag/sysroot/usr/lib/$sysrootTriple/libc++_shared.so" "$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 // SDD-118 item 8: input tracking for both staged sources so
// Gradle correctly invalidates when either changes (e.g. NDK // Gradle correctly invalidates when either changes (e.g. NDK
// version bump replacing libc++_shared.so). // version bump replacing libc++_shared.so).
inputs.file(bridgeSo).withPropertyName("bridgeSo_$abi") inputs.file(bridgeSo).withPropertyName("bridgeSo_$abi")
inputs.file(cxxSharedSo).withPropertyName("cxxSharedSo_$abi") inputs.file(cxxSharedSo).withPropertyName("cxxSharedSo_$abi")
} inputs.file(llvmStrip).withPropertyName("llvmStrip")
into(layout.projectDirectory.dir("src/main/jniLibs")) 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. doLast {
configuredAbis.forEach { abi -> val abiDir = File(jniLibsDir, abi)
outputs.file( abiDir.mkdirs()
layout.projectDirectory.file("src/main/jniLibs/$abi/libchanora_bridge.so") strippedBridgeSo.parentFile.mkdirs()
) bridgeSo.copyTo(strippedBridgeSo, overwrite = true)
outputs.file( project.exec {
layout.projectDirectory.file("src/main/jniLibs/$abi/libc++_shared.so") 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,
)
}
} }
} }
} }
+104
View File
@@ -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