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 = project.hasProperty("split-per-abi") || 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" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion compileOptions { isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } 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. // When Flutter --split-per-abi is active, skip ndk.abiFilters to avoid // conflicting with the Gradle splits.abi mechanism that Flutter injects. // The per-ABI filtering is still handled by the jniLibs excludes below // and the Rust cargo-ndk per-ABI build tasks. if (!splitPerAbiRequested) { 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") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") } 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//` 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//…`; 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 = 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//…` // for the per-ABI copy and for the inputs/outputs declaration. val abiToRustTriple: Map = 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 = 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 ... -- 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("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 ` 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 { // 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 mergeJniLibFolders 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///libchanora_bridge.so into // src/main/jniLibs//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// // 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 = mapOf( "arm64-v8a" to "aarch64-linux-android", "x86_64" to "x86_64-linux-android", ) fun registerJniLibsCopyTask(profile: String): TaskProvider { 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// // 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 { 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 `mergeJniLibFolders` 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 ) }