Files
chanora/apps/chanora_flutter/android/app/build.gradle.kts
T

630 lines
30 KiB
Kotlin

import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
// SDD-073 / SDD-109 trace: load release-signing material from a
// developer-local gradle.properties file (preferred) or from environment
// variables (CI). Key custody is governed by SDD-073 item 5 and the
// CI signing assertion hook in SDD-109 item 4. NO key material is ever
// committed to the repository; the four properties below are looked up
// at configure time and may be absent on developer machines.
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { load(it) }
}
}
fun resolveSigningProp(name: String): String? {
// Precedence: gradle property -> key.properties file -> environment variable.
val fromProject = if (project.hasProperty(name)) project.property(name)?.toString() else null
val fromFile = keystoreProperties.getProperty(name)
val fromEnv = System.getenv(name)
return fromProject ?: fromFile ?: fromEnv
}
val releaseStoreFile = resolveSigningProp("CHANORA_RELEASE_STORE_FILE")
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<String> = when {
singleAbiFromFlutterTargetPlatform != null -> listOf(singleAbiFromFlutterTargetPlatform)
splitPerAbiRequested -> configuredAbiSet
else -> configuredAbiSet
}
android {
namespace = "app.chanora.chanora_flutter"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
applicationId = "app.chanora.chanora_flutter"
// DEC-004 (register v0.9.5) / SDD-073 item 1: Android minimum API
// 28 (Android 9.0). Raised from the original recommendation of
// API 24 by owner ruling on 2026-05-14 for a simpler audio path
// (AAudio stable from API 28) and a narrower compatibility /
// scoped-storage surface. Do NOT lower without re-opening DEC-004.
minSdk = 28
// DEC-005 / SDD-073 item 2 / SRS-188: target the Google
// Play-required API level on upload date. Flutter's default is
// kept; release-time CI must verify this still satisfies the
// current Play policy (SDD-109 release-inspection hook).
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
// SDD-073 item 4: NDK ABIs pinned to arm64-v8a, armeabi-v7a,
// and x86_64. Other ABIs (x86, mips, …) shall not be packaged.
// Per-ABI delivery is handled by the AAB bundle splits below
// (SDD-109 item 2) rather than a fat APK.
ndk {
// DEC-032 RESOLVED (2026-05-18): canonical two-ABI set
// (arm64-v8a + x86_64) restored after the audiopus_sys
// ANDROID_ABI propagation gap was closed by the workspace
// [patch.crates-io] override pinning `cmake` to the fork
// carrying cmake-rs PR #257 (forwards ANDROID_ABI /
// 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 += effectivePackagingAbis
}
}
// SDD-073 item 5 / SDD-109 item 4: release signing is sourced from
// CI-provided credentials. If any of the four properties is absent,
// the signingConfig is intentionally NOT registered so the release
// build fails fast at task-execution time with a clear message,
// rather than silently falling back to the debug keystore.
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = file(releaseStoreFile!!)
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
buildTypes {
release {
// SDD-073 item 5: release builds MUST use a non-debug
// signing config. When the four CHANORA_RELEASE_* inputs
// are present (CI), wire the dedicated release signingConfig.
// When they are absent (developer machine without keys),
// leave signingConfig unset so any attempt to assemble a
// release artefact fails with the canonical Gradle error
// "No signing config provided for variant release" — this
// matches the SDD-109 CI signing assertion stance and
// prevents accidental debug-key-signed release outputs.
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
} else {
logger.warn(
"[SDD-073] Release signing credentials are not configured " +
"(CHANORA_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / " +
"_KEY_PASSWORD). Release tasks will fail; debug fallback " +
"is intentionally disabled per SDD-073 item 5."
)
}
// SDD-073 item 6: enable R8 + resource shrinking for release.
// Keep rules live in proguard-rules.pro; see that file for
// the JNI / FRB / native-method retention stance.
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
// SDD-073 item 6: debug builds run unminified for developer
// ergonomics and stack-trace readability.
isMinifyEnabled = false
}
}
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
// delivery, and per-density resource delivery. Each device
// receives only its matching ABI .so — no cross-ABI bundling.
bundle {
language {
enableSplit = true
}
density {
enableSplit = true
}
abi {
enableSplit = true
}
}
}
// 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.
dependencies {
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.26.0")
}
flutter {
source = "../.."
}
// ---------------------------------------------------------------------------
// SDD-118: AndroidBridgeBuildAutomation
//
// Auto-build the `chanora_bridge` Rust cdylib via `cargo-ndk` and stage the
// resulting per-ABI `.so` files into `src/main/jniLibs/<abi>/` so AGP picks
// them up during the normal JNI-lib merge step. No manual `cargo ndk`
// invocations and no parent-shell environment mutation are required.
//
// Cross-trace: SDD-073 (Android build config — minSdk, ABI filters),
// SDD-105 (JNI bootstrap — consumer of the produced .so),
// SDD-109 (AAB pipeline — downstream packaging consumer of staged jniLibs).
//
// SDD-118 item 12 (caching across clean builds): cargo's `target/` directory
// lives at the repository root (outside `apps/chanora_flutter/android/`) and
// is intentionally OUTSIDE Gradle's `clean` scope. Do NOT register `target/`
// (or any subpath) as a Gradle output; doing so would make `./gradlew clean`
// destroy cargo's incremental cache.
//
// SDD-118 item 11 (idempotency): Gradle inputs/outputs gate whether the task
// runs at all; cargo's own incremental cache decides whether the run actually
// relinks. No manual timestamp guards.
// ---------------------------------------------------------------------------
// SDD-118 item 2: cargo workspace root (repository root). From the :app
// project (apps/chanora_flutter/android/app) we ascend three levels to reach
// the cargo workspace at the repository root where Cargo.toml / Cargo.lock /
// crates/ live. rootProject.projectDir is the Flutter Android root project
// (apps/chanora_flutter/android), so three levels up reaches the repo root.
val cargoWorkspaceRoot: File = rootProject.projectDir.resolve("../../..").canonicalFile
// SDD-118 item 6 (extended): NDK prebuilt host tag. The NDK ships
// host-specific prebuilt toolchains under
// `$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/<host-tag>/…`; the tag is
// the only path component that varies across developer/CI hosts. Detect
// at configure time so the same Gradle file builds on Linux, macOS, and
// Windows CI runners without manual editing.
val ndkHostTag: String = when {
org.gradle.internal.os.OperatingSystem.current().isMacOsX -> "darwin-x86_64"
org.gradle.internal.os.OperatingSystem.current().isWindows -> "windows-x86_64"
else -> "linux-x86_64"
}
// SDD-118 item 2: minimum API level source of truth — the
// `chanora.android.minSdk` Gradle property mandated by SDD-073 item 1,
// defaulting to 28 per DEC-004. Overridable via `-Pchanora.android.minSdk=…`
// or via `gradle.properties`. Do NOT hard-code a numeric API level elsewhere.
val chanoraAndroidMinSdk: Provider<String> =
providers.gradleProperty("chanora.android.minSdk").orElse("28")
// SDD-118 item 6: explicit Rust-triple ↔ Android-ABI mapping. cargo-ndk
// performs the triple mapping internally; we only pass the Android ABI name
// to its `-t` flag, but we need the Rust triple to locate `target/<triple>/…`
// for the per-ABI copy and for the inputs/outputs declaration.
val abiToRustTriple: Map<String, String> = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"x86_64" to "x86_64-linux-android",
)
// SDD-118 item 3: ABI set derived from the existing
// `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<String> = 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. " +
"Supported ABIs: ${abiToRustTriple.keys.joinToString(", ")}."
}
}
// SDD-118 item 9: preflight check task. Fails loudly with actionable
// remediation commands if cargo / cargo-ndk / required rustup targets are
// missing. Does NOT auto-install and does NOT mutate user state.
val checkRustBridgeToolchain = tasks.register("checkRustBridgeToolchain") {
group = "chanora_bridge"
description = "SDD-118 item 9: verify cargo, cargo-ndk, and Android rustup targets are installed."
doLast {
// cargo presence
val cargoResult = providers.exec {
commandLine("cargo", "--version")
isIgnoreExitValue = true
}.result.get()
if (cargoResult.exitValue != 0) {
throw GradleException(
"[SDD-118] `cargo` is not on PATH. Install Rust via https://rustup.rs " +
"and ensure ~/.cargo/bin is on PATH."
)
}
// cargo-ndk presence
val cargoNdkResult = providers.exec {
commandLine("cargo", "ndk", "--version")
isIgnoreExitValue = true
}.result.get()
if (cargoNdkResult.exitValue != 0) {
throw GradleException(
"[SDD-118] `cargo-ndk` is required. Install: cargo install cargo-ndk"
)
}
// rustup targets
val rustupOutput = providers.exec {
commandLine("rustup", "target", "list", "--installed")
isIgnoreExitValue = true
}.standardOutput.asText.get()
val requiredTriples = abiToRustTriple.values.toSet()
val installed = rustupOutput.lines().map { it.trim() }.filter { it.isNotEmpty() }.toSet()
val missing = requiredTriples - installed
if (missing.isNotEmpty()) {
throw GradleException(
"[SDD-118] Missing Android Rust target(s): ${missing.joinToString(", ")}. " +
"Install: rustup target add aarch64-linux-android x86_64-linux-android"
)
}
}
}
// SDD-118 item 13 (corrected): one cargo-ndk invocation per ABI rather
// than a single multi-`-t` invocation. The single-shot form is shorter
// but leaks ANDROID_ABI state across iterations, causing audiopus_sys's
// child CMake invocation for x86_64-linux-android to collide with the
// NDK toolchain file's armv7 default. Per-ABI invocation gives
// cargo-ndk a clean ANDROID_ABI per call and isolates failure scope to
// a single ABI.
//
// SDD-118 item 4: profile mapping — Gradle `debug` invokes `cargo build`
// (no `--release`); Gradle `release` invokes `cargo build --release`. We
// register one aggregate task per profile that fans out to one Exec
// sub-task per (profile, ABI) tuple.
fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
val capitalized = profile.replaceFirstChar { it.uppercase() }
// PascalCase ABI suffix for sub-task names (e.g. arm64-v8a -> Arm64V8a).
fun abiToTaskSuffix(abi: String): String =
abi.split('-', '_').joinToString("") { part ->
part.replaceFirstChar { it.uppercase() }
}
// SDD-118 item 5: shared env-var setup. Resolve NDK / CMake toolchain
// path once at configuration time; applied identically to every per-ABI
// sub-task below.
val effectiveNdkPath = System.getenv("ANDROID_NDK_HOME")
.takeUnless { it.isNullOrBlank() }
?: android.ndkDirectory.absolutePath
val cmakeToolchainFile = "$effectiveNdkPath/build/cmake/android.toolchain.cmake"
val homeDir = System.getenv("HOME") ?: System.getProperty("user.home")
val rustupBinDir = listOf(
"$homeDir/.cargo/bin",
"/opt/homebrew/opt/rustup/bin",
"/usr/local/opt/rustup/bin"
).firstOrNull { File(it).resolve("cargo").exists() }
val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup"
val cargoHome = System.getenv("CARGO_HOME") ?: "$homeDir/.cargo"
val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: "stable-aarch64-apple-darwin"
// SDD-118 item 13 (corrected): per-ABI Exec sub-tasks. Each runs an
// isolated `cargo ndk -t <abi> ... -- build ...` so ANDROID_ABI is set
// cleanly for both the Rust compile and any child CMake invocation.
val perAbiTaskProviders = configuredAbis.map { abi ->
val triple = abiToRustTriple.getValue(abi)
val abiSuffix = abiToTaskSuffix(abi)
tasks.register<Exec>("buildRustBridge$capitalized$abiSuffix") {
group = "chanora_bridge"
description = "SDD-118 items 1/4/13 (corrected): build chanora_bridge cdylib (profile=$profile, abi=$abi) via cargo-ndk."
dependsOn(checkRustBridgeToolchain)
// SDD-118 item 2: cargo workspace root is the repo root.
workingDir = cargoWorkspaceRoot
// SDD-118 item 13 (corrected): single `-t <abi>` per invocation.
val profileArgs = if (profile == "release") listOf("--release") else emptyList()
val cmd = mutableListOf("cargo", "ndk", "-t", abi)
cmd.add("--platform")
cmd.add(chanoraAndroidMinSdk.get())
cmd.add("--")
cmd.add("build")
cmd.addAll(profileArgs)
cmd.add("-p")
cmd.add("chanora_bridge")
commandLine(cmd)
// SDD-118 item 5 (corrected v3): completely replace the inherited
// environment with a deliberately-minimized map. The Gradle daemon
// accumulates env state from prior per-ABI cargo-ndk invocations
// (CFLAGS, CC, AR, CMAKE_*, RUSTFLAGS); audiopus_sys's cmake crate
// reads these before falling through to the NDK toolchain file's
// per-target settings, causing cross-ABI flag contamination (notably
// armv7's --target=armv7-none-linux-androideabi21 + -march=armv7-a
// leaks into the x86_64 task's clang command line). Resetting the
// env per task isolates each ABI from the rest.
//
// Deliberately NOT propagated (cross-ABI contamination vectors):
// CFLAGS, CXXFLAGS, CPPFLAGS, LDFLAGS, ASFLAGS
// CC, CXX, AR, AS, LD, NM, RANLIB, STRIP, OBJCOPY, OBJDUMP
// CMAKE_C_FLAGS, CMAKE_CXX_FLAGS, CMAKE_C_COMPILER,
// CMAKE_CXX_COMPILER, CMAKE_AR, CMAKE_LINKER, CMAKE_* (other)
// RUSTFLAGS
// Any per-target *_aarch64_linux_android,
// *_armv7_linux_androideabi, *_x86_64_linux_android,
// *_i686_linux_android variants of the above
// Any CARGO_TARGET_* env vars set by an outer build wrapper
val cleanEnv = buildMap<String, String> {
// Pass-through from the calling environment, only the safe set.
System.getenv("PATH")?.let { path ->
put("PATH", listOfNotNull(rustupBinDir, path).joinToString(":"))
}
put("HOME", homeDir)
System.getenv("USER")?.let { put("USER", it) }
System.getenv("TMPDIR")?.let { put("TMPDIR", it) }
System.getenv("TEMP")?.let { put("TEMP", it) }
System.getenv("LANG")?.let { put("LANG", it) }
System.getenv("LC_ALL")?.let { put("LC_ALL", it) }
put("CARGO_HOME", cargoHome)
put("RUSTUP_HOME", rustupHome)
put("RUSTUP_TOOLCHAIN", rustupToolchain)
System.getenv("JAVA_HOME")?.let { put("JAVA_HOME", it) }
// Per-task explicit settings (override any pass-through).
// SDD-118 item 5: audiopus_sys needs libopus built from source
// statically because the NDK sysroot ships none.
put("LIBOPUS_STATIC", "1")
put("LIBOPUS_NO_PKG", "1")
put("CMAKE_POLICY_VERSION_MINIMUM", "3.5")
put("ANDROID_NDK_HOME", effectiveNdkPath)
put("ANDROID_NDK_ROOT", effectiveNdkPath)
// SDD-118 item 5 (extended): pin the CMake toolchain file so
// child CMake invocations spawned by build scripts (notably
// audiopus_sys via the `cmake` crate) can locate the Android NDK
// toolchain file at $NDK_HOME/build/cmake/android.toolchain.cmake.
put("CMAKE_TOOLCHAIN_FILE", cmakeToolchainFile)
// SDD-118 item 13 (corrected) — explicitly pin ANDROID_ABI and
// ANDROID_PLATFORM for this invocation. cargo-ndk already sets
// ANDROID_ABI per `-t` flag, but setting it ourselves forecloses
// any chance of a stale value leaking into audiopus_sys's child
// CMake process (the original single-shot bug).
put("ANDROID_ABI", abi)
put("ANDROID_PLATFORM", "android-${chanoraAndroidMinSdk.get()}")
}
// REPLACES the inherited environment (does not augment it).
environment = cleanEnv
// SDD-118 item 8: Gradle up-to-date semantics. Rust source tree,
// workspace manifest, and lockfile are inputs. The single per-ABI
// target/.so is the output of this Exec; the jniLibs copy is a
// separate task with its own outputs (item 7 below).
inputs.dir(cargoWorkspaceRoot.resolve("crates"))
inputs.file(cargoWorkspaceRoot.resolve("Cargo.toml"))
inputs.file(cargoWorkspaceRoot.resolve("Cargo.lock"))
inputs.property("profile", profile)
inputs.property("minSdk", chanoraAndroidMinSdk)
inputs.property("abi", abi)
outputs.file(cargoWorkspaceRoot.resolve("target/$triple/$profile/libchanora_bridge.so"))
}
}
// SDD-118 item 13 (corrected): aggregate no-op task. Downstream tasks
// (the copy task, the merge<Variant>JniLibFolders hook) continue to
// depend on `buildRustBridge${Profile}` as before; Gradle's dependsOn
// graph transitively pulls in every per-ABI sub-task. Per-ABI tasks are
// independent and can fail in isolation; Gradle may also choose to run
// them in parallel via its worker pool.
return tasks.register("buildRustBridge$capitalized") {
group = "chanora_bridge"
description = "SDD-118 items 1/4/13 (corrected): aggregate chanora_bridge build (profile=$profile) — fans out to per-ABI sub-tasks."
dependsOn(perAbiTaskProviders)
}
}
val buildRustBridgeDebug = registerCargoNdkBuildTask("debug")
val buildRustBridgeRelease = registerCargoNdkBuildTask("release")
// SDD-118 item 6: per-ABI plain copy (no symlinks, no fat binary) from
// target/<triple>/<profile>/libchanora_bridge.so into
// src/main/jniLibs/<abi>/libchanora_bridge.so. Overwrites prior staging.
//
// 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
// oboe-sys); without libc++_shared.so co-located in jniLibs/<abi>/
// the Android dynamic loader fails at first library load with
// UnsatisfiedLinkError: cannot locate symbol "__cxa_pure_virtual".
//
// SDD-118 item 6 (extended): Android-ABI ↔ NDK-sysroot-triple map.
// Note this is NOT the same as `abiToRustTriple`: the NDK sysroot
// uses `arm-linux-androideabi` for 32-bit ARM whereas Rust uses
// `armv7-linux-androideabi`. The sysroot directory names are the
// authoritative source; verified by listing
// $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/.
val abiToNdkSysrootTriple: Map<String, String> = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"x86_64" to "x86_64-linux-android",
)
fun registerJniLibsCopyTask(profile: String): TaskProvider<Task> {
val capitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = if (profile == "release") buildRustBridgeRelease else buildRustBridgeDebug
// SDD-118 item 6 (extended): resolve NDK path the same way the
// cargo-ndk task does (env override, fall back to AGP default).
val effectiveNdkPath = System.getenv("ANDROID_NDK_HOME")
.takeUnless { it.isNullOrBlank() }
?: android.ndkDirectory.absolutePath
return tasks.register("copyRustBridgeJniLibs$capitalized") {
group = "chanora_bridge"
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)
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
// oboe-sys); without libc++_shared.so co-located in jniLibs/<abi>/
// the Android dynamic loader fails at first library load with
// UnsatisfiedLinkError: cannot locate symbol "__cxa_pure_virtual".
val cxxSharedSo = file(
"$effectiveNdkPath/toolchains/llvm/prebuilt/$ndkHostTag/sysroot/usr/lib/$sysrootTriple/libc++_shared.so"
)
// 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")
inputs.file(llvmStrip).withPropertyName("llvmStrip_$abi")
outputs.file(strippedBridgeSo)
outputs.file(File(jniLibsDir, "$abi/libchanora_bridge.so"))
outputs.file(File(jniLibsDir, "$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,
)
}
}
}
}
val copyRustBridgeJniLibsDebug = registerJniLibsCopyTask("debug")
val copyRustBridgeJniLibsRelease = registerJniLibsCopyTask("release")
// SDD-118 item 10: release-inspection assertion. After the copy step and
// before the AGP merge-JniLibFolders node, verify each expected .so exists
// at its destination and is larger than 1 KiB. Catches silent cargo-ndk
// task skips and empty-link-output regressions before they ship into an AAB.
fun registerAssertStagedTask(profile: String): TaskProvider<Task> {
val capitalized = profile.replaceFirstChar { it.uppercase() }
val copyTask = if (profile == "release") copyRustBridgeJniLibsRelease else copyRustBridgeJniLibsDebug
return tasks.register("assertRustBridgeStaged$capitalized") {
group = "chanora_bridge"
description = "SDD-118 item 10 (extended): assert per-ABI libchanora_bridge.so + libc++_shared.so (profile=$profile) are staged and >1 KiB."
dependsOn(copyTask)
doLast {
configuredAbis.forEach { abi ->
// SDD-118 item 10 (extended): assert both the bridge .so and
// the co-staged libc++_shared.so. Missing C++ runtime is the
// exact failure mode that produced the
// `cannot locate symbol "__cxa_pure_virtual"` cold-launch crash.
listOf("libchanora_bridge.so", "libc++_shared.so").forEach { soName ->
val staged = layout.projectDirectory
.file("src/main/jniLibs/$abi/$soName").asFile
if (!staged.exists()) {
throw GradleException(
"[SDD-118 item 10] Missing staged native library '$soName' for ABI '$abi' " +
"(profile=$profile): expected at ${staged.absolutePath}. " +
"Did cargo-ndk fail or silently skip, or is the NDK sysroot missing libc++_shared.so?"
)
}
if (staged.length() <= 1024) {
throw GradleException(
"[SDD-118 item 10] Staged native library '$soName' for ABI '$abi' " +
"(profile=$profile) is trivially small (${staged.length()} bytes) " +
"at ${staged.absolutePath}. Suspected empty-link-output regression."
)
}
}
}
}
}
}
val assertRustBridgeStagedDebug = registerAssertStagedTask("debug")
val assertRustBridgeStagedRelease = registerAssertStagedTask("release")
// SDD-118 item 7: task-graph wiring. Attach the build+copy+assert chain to
// AGP's per-variant `merge<Variant>JniLibFolders` task. This is the correct
// graph node — earlier than preBuild would over-trigger (e.g. IDE sync),
// later would race AGP's jniLibs packaging.
tasks.matching {
it.name.startsWith("merge") && it.name.endsWith("JniLibFolders")
}.configureEach {
val lowerName = name.lowercase()
val isRelease = lowerName.contains("release")
dependsOn(
if (isRelease) assertRustBridgeStagedRelease else assertRustBridgeStagedDebug
)
}