Bump Android Gradle Plugin from 8.11.1 to 8.13.1 and Kotlin from 2.2.20 to 2.3.0 to align with newer plugin version requirements. Kotlin 2.3.0 removed the kotlinOptions DSL free-string assignment. Migrate to the compilerOptions DSL for JVM target configuration. Gradle wrapper remains at 8.14 (compatible with AGP 8.13.1).
639 lines
31 KiB
Kotlin
639 lines
31 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 = 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<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
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|