630 lines
30 KiB
Kotlin
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
|
|
)
|
|
}
|