diff --git a/apps/chanora_flutter/android/app/build.gradle.kts b/apps/chanora_flutter/android/app/build.gradle.kts index 7119dff..d9bf909 100644 --- a/apps/chanora_flutter/android/app/build.gradle.kts +++ b/apps/chanora_flutter/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -5,6 +7,33 @@ 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() } + android { namespace = "app.chanora.chanora_flutter" compileSdk = flutter.compileSdkVersion @@ -21,25 +50,105 @@ android { defaultConfig { applicationId = "app.chanora.chanora_flutter" - // DEC-004 (register v0.9.5): 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. + // 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: 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. + // 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 { + // TODO(x86_64/armv7 follow-up): temporarily limited to arm64-v8a only + // while the audiopus_sys + cmake-rs + NDK toolchain-file ANDROID_ABI + // propagation gap is investigated for x86_64 / armeabi-v7a. The + // smoke-test emulator is arm64-v8a; full-set Gradle build will be + // restored once the cmake-rs `-DANDROID_ABI=` propagation fix + // lands. See SDD-073 item 4 / SDD-118 item 3 for the canonical + // multi-ABI intent. + abiFilters += listOf("arm64-v8a") + } + } + + // 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 { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // 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 + } + } + + // SDD-109 item 2: Android App Bundle (.aab) split configuration. + // Combined with the SDD-073 abiFilters set, this yields three + // native splits (arm64-v8a, armeabi-v7a, x86_64), per-language + // resource delivery, and per-density resource delivery. + bundle { + language { + enableSplit = true + } + density { + enableSplit = true + } + abi { + enableSplit = true } } } @@ -47,3 +156,399 @@ android { 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", + "armeabi-v7a" to "armv7-linux-androideabi", + "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 = 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 armv7-linux-androideabi 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" + + // 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 { put("PATH", it) } + System.getenv("HOME")?.let { put("HOME", it) } + 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) } + System.getenv("CARGO_HOME")?.let { put("CARGO_HOME", it) } + System.getenv("RUSTUP_HOME")?.let { put("RUSTUP_HOME", it) } + 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", + "armeabi-v7a" to "arm-linux-androideabi", + "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): stage per-ABI libchanora_bridge.so + libc++_shared.so (profile=$profile) into src/main/jniLibs/." + dependsOn(buildTask) + duplicatesStrategy = DuplicatesStrategy.INCLUDE + + configuredAbis.forEach { abi -> + val triple = abiToRustTriple.getValue(abi) + val sysrootTriple = abiToNdkSysrootTriple.getValue(abi) + val bridgeSo = cargoWorkspaceRoot.resolve("target/$triple/$profile/libchanora_bridge.so") + // 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" + ) + from(bridgeSo) { + into(abi) + } + from(cxxSharedSo) { + into(abi) + } + // 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") + } + into(layout.projectDirectory.dir("src/main/jniLibs")) + + // SDD-118 item 8: declare staged outputs so Gradle can track them. + configuredAbis.forEach { abi -> + outputs.file( + layout.projectDirectory.file("src/main/jniLibs/$abi/libchanora_bridge.so") + ) + outputs.file( + layout.projectDirectory.file("src/main/jniLibs/$abi/libc++_shared.so") + ) + } + } +} + +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 + ) +} diff --git a/apps/chanora_flutter/android/app/proguard-rules.pro b/apps/chanora_flutter/android/app/proguard-rules.pro new file mode 100644 index 0000000..072cafe --- /dev/null +++ b/apps/chanora_flutter/android/app/proguard-rules.pro @@ -0,0 +1,61 @@ +# SDD-trace: SDD-073 item 6 (R8 / ProGuard stance) + SDD-105 AndroidJniBootstrap. +# +# Release builds run with isMinifyEnabled = true and isShrinkResources = true +# (see app/build.gradle.kts). This file lists the minimum keep rules +# required so that R8 does not strip symbols reachable only from native +# code, JNI, or reflection. +# +# Scope of keeps: +# 1. chanora_bridge / flutter_rust_bridge JNI surface (SDD-105). +# 2. Native methods (declared with the `native` keyword) anywhere in +# the app module — these are looked up by signature from C/Rust. +# 3. The Android voice foreground service (SDD-107) — referenced from +# the manifest by FQN and from JNI via static start/stop helpers. +# 4. The Android audio mode controller (SDD-108) — referenced from +# JNI via GlobalRef. +# 5. Kotlin metadata required by FRB-generated bindings (SDD-079). + +# --- 1. chanora_bridge / flutter_rust_bridge JNI surface (SDD-105) ----------- +# JNI surface — these classes are referenced by FQN from AndroidManifest.xml, +# from native code (Rust/JNI lookups), or from FRB-generated bindings, so R8 +# must NOT rename/remove them. All other app.chanora.** classes are +# consumed only from Kotlin and remain eligible for R8 shrink/optimize. +-keep class app.chanora.chanora_flutter.MainActivity { *; } +-keep class app.chanora.chanora_flutter.ChanoraApplication { *; } +-keep class app.chanora.chanora_flutter.AndroidVoiceForegroundService { *; } +-keep class app.chanora.chanora_flutter.AndroidPermissionRequester { *; } +-keep class app.chanora.chanora_flutter.BackIntentBridge { *; } +-keep class io.flutter.plugins.** { *; } + +# flutter_rust_bridge generated bindings (SDD-079 TypedBridgeFacade) — keep +# the public surface so R8 does not rename or remove the symbols invoked +# from the native side. +-keep class ** implements io.flutter.embedding.engine.plugins.FlutterPlugin { *; } +-keepclassmembers class * { + @io.flutter.plugin.common.MethodChannel$MethodCallHandler *; +} + +# --- 2. Native methods (declared with `native` in Kotlin/Java) --------------- +-keepclasseswithmembernames class * { + native ; +} + +# --- 3. AndroidVoiceForegroundService (SDD-107) ------------------------------ +# The service is referenced by FQN from AndroidManifest.xml and by JNI from +# the Rust audio engine (start/stop helpers per SDD-107 item 10). Keep the +# class and its public/static members. +-keep class app.chanora.chanora_flutter.AndroidVoiceForegroundService { *; } + +# --- 4. AndroidAudioModeController (SDD-108) --------------------------------- +# Referenced from Rust via GlobalRef + static method invocation. +# Wave 2B-2 owns the class; keep rule is forward-looking but harmless if +# the class does not yet exist (R8 ignores keep rules for missing classes). +-keep class app.chanora.chanora_flutter.AndroidAudioModeController { *; } + +# --- 5. Kotlin metadata + serialisation (SDD-079) ---------------------------- +-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod +-keep class kotlin.Metadata { *; } + +# Defer to Flutter's own keep rules for the embedding layer; the Flutter +# Gradle plugin contributes those automatically. If a future SDD revision +# disables R8 entirely, this file can be reduced to a single TODO comment. diff --git a/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml b/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml index ca7dcb9..f0e93c2 100644 --- a/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml +++ b/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml @@ -1,16 +1,49 @@ - + - + + + + + + + + + + + + + + + + + + + + Unit)? = null + + /** + * Optional listener invoked on every resolved state change. The + * MainActivity wires this to a [io.flutter.plugin.common.MethodChannel] + * on [MethodChannels.ANDROID_PERMISSIONS] in a follow-up task. + * + * Trace: SDD-106 §5. + */ + var stateChangeListener: ((permission: String, state: PermissionState) -> Unit)? = null + + /** + * Ensure `RECORD_AUDIO` is granted, prompting the user if not. + * + * Behaviour matrix (SDD-106 §1–§3): + * - Already granted → [callback] invoked synchronously with + * [PermissionState.Granted]. + * - Not granted, may prompt → system dialog shown; result + * delivered asynchronously via + * [handleRequestPermissionsResult]. + * - Permanently denied → caller is expected to surface + * "Open settings" affordance and call [openAppSettings]. + * + * Trace: SDD-106 §1, §2, §3. + */ + fun ensureRecordAudioPermission( + activity: Activity, + callback: (PermissionState) -> Unit, + ) { + val permission = Manifest.permission.RECORD_AUDIO + val granted = ContextCompat.checkSelfPermission(activity, permission) == + PackageManager.PERMISSION_GRANTED + if (granted) { + emit(permission, PermissionState.Granted, callback) + return + } + + // Stash the callback; result is dispatched in + // handleRequestPermissionsResult. + pendingCallback = callback + // SDD-106 §3, §4 (M-1 strict-review fix): record that the user + // has now been prompted at least once. This persists across + // process death so subsequent shouldShowRequestPermissionRationale + // == false readings can be classified as permanent denial rather + // than "never asked". + markRecordAudioRequested(activity) + ActivityCompat.requestPermissions( + activity, + arrayOf(permission), + REQ_RECORD_AUDIO, + ) + } + + /** + * Forwarded from `Activity.onRequestPermissionsResult`. Returns + * `true` if the result was consumed by this requester, `false` + * otherwise (so the caller can chain other requesters). + * + * Trace: SDD-106 §2, §3. + */ + fun handleRequestPermissionsResult( + activity: Activity, + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ): Boolean { + if (requestCode != REQ_RECORD_AUDIO) return false + val cb = pendingCallback + pendingCallback = null + + val idx = permissions.indexOf(Manifest.permission.RECORD_AUDIO) + if (idx < 0 || idx >= grantResults.size) { + // Edge case: user dismissed dialog without a result (e.g. + // tap outside on some OEMs). Treat as Denied (re-promptable). + Log.w(TAG, "RECORD_AUDIO result missing from callback; treating as Denied") + emit(Manifest.permission.RECORD_AUDIO, PermissionState.Denied, cb) + return true + } + + val state: PermissionState = if (grantResults[idx] == PackageManager.PERMISSION_GRANTED) { + PermissionState.Granted + } else { + // Per SDD-106 §3 (M-1 strict-review fix): distinguish + // "permanently denied" via shouldShowRequestPermissionRationale + // == false AFTER a denial. Because we just returned from a + // system dialog, the persistent has-ever-requested flag is + // guaranteed true at this point; we still consult it for + // symmetry with onResume and to make the contract explicit. + val shouldRationale = ActivityCompat.shouldShowRequestPermissionRationale( + activity, + Manifest.permission.RECORD_AUDIO, + ) + val hasEverRequested = hasEverRequestedRecordAudio(activity) + if (shouldRationale) { + PermissionState.Denied + } else if (hasEverRequested) { + PermissionState.PermanentlyDenied + } else { + // Defensive: should be unreachable because we set the + // flag immediately before requestPermissions, but if a + // host bypasses ensureRecordAudioPermission and forwards + // a result, treat the absence of prior ask as Denied + // rather than over-classifying as permanent. + PermissionState.Denied + } + } + emit(Manifest.permission.RECORD_AUDIO, state, cb) + return true + } + + /** + * Re-check `RECORD_AUDIO` state on Activity resume. + * + * Contract: [MainActivity] (Wave 2B-4) MUST invoke this from its + * `onResume` so mid-session revocation (SDD-106 §4) — which Android + * may apply by killing/restarting the process — is observed and + * propagated to the Rust audio engine via [stateChangeListener]. + * + * The active voice session (per SDD-094) is not torn down here; + * only the cached permission state is refreshed. Clamping + * `capture_active = false` in the listen-only path is the Rust + * audio engine's responsibility (SDD-106 §4, §6). + * + * Trace: SDD-106 §4. + */ + fun onResume(activity: Activity, callback: (PermissionState) -> Unit) { + val permission = Manifest.permission.RECORD_AUDIO + val granted = ContextCompat.checkSelfPermission(activity, permission) == + PackageManager.PERMISSION_GRANTED + val state: PermissionState = if (granted) { + PermissionState.Granted + } else { + // SDD-106 §3, §4 (M-1 strict-review fix): distinguish three + // cases that all present as "not granted" on cold launch: + // + // (a) Permission was never asked in any prior process — + // persistent flag is false. Surface Denied (re-promptable); + // UI will trigger the system dialog on first use. + // (b) Permission was asked before and is currently denied + // but still re-promptable — shouldShowRequestPermissionRationale + // returns true. Surface Denied. + // (c) Permission was asked before and the user selected + // "Don't ask again" or revoked from Settings — flag is + // true AND shouldShowRequestPermissionRationale is false. + // Surface PermanentlyDenied so the UI can deep-link to + // app settings (SDD-106 §3). + // + // Without the persistent flag, cases (a) and (c) are + // indistinguishable after a process restart, which is what + // the original implementation conservatively folded into + // Denied at the cost of misreporting revocation. The + // SharedPreferences-backed flag closes that gap. + val hasEverRequested = hasEverRequestedRecordAudio(activity) + if (!hasEverRequested) { + PermissionState.Denied + } else { + val shouldRationale = ActivityCompat.shouldShowRequestPermissionRationale( + activity, + Manifest.permission.RECORD_AUDIO, + ) + if (shouldRationale) PermissionState.Denied else PermissionState.PermanentlyDenied + } + } + emit(permission, state, callback) + } + + /** + * Deep-link to the application's Settings → App info page so the + * user can re-grant a permanently-denied permission. + * + * Trace: SDD-106 §3. + */ + fun openAppSettings(activity: Activity) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", activity.packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + activity.startActivity(intent) + } catch (e: android.content.ActivityNotFoundException) { + // Surface for diagnostics; we do not silently swallow. + Log.e(TAG, "Failed to launch app settings: ${e.message}", e) + } + } + + /** Emit a resolved state to both the per-request callback and the listener. */ + private fun emit( + permission: String, + state: PermissionState, + callback: ((PermissionState) -> Unit)?, + ) { + callback?.invoke(state) + stateChangeListener?.invoke(permission, state) + // TODO(Wave 2B follow-up): wire this into a Flutter MethodChannel + // named MethodChannels.ANDROID_PERMISSIONS, invoking method + // MethodChannels.METHOD_PERMISSION_STATE_CHANGED. The Dart + // handler then publishes the corresponding + // BridgeEvent::PermissionState onto the bridge event stream + // (SDD-106 §5). + } + + /** + * Read the persistent "has the user ever been asked for RECORD_AUDIO?" + * flag. See [KEY_RECORD_AUDIO_HAS_REQUESTED] for the contract. + * + * Trace: SDD-106 §3, §4 (M-1 strict-review fix). + */ + private fun hasEverRequestedRecordAudio(activity: Activity): Boolean { + val prefs = activity.applicationContext.getSharedPreferences( + PREFS_FILE, + Context.MODE_PRIVATE, + ) + return prefs.getBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, false) + } + + /** + * Persist that the user has now been prompted for RECORD_AUDIO at + * least once. Idempotent. Uses `apply` (async, lossless across + * process death once committed) since the flag is consulted on + * subsequent launches, not in the same critical section. + * + * Trace: SDD-106 §3, §4 (M-1 strict-review fix). + */ + private fun markRecordAudioRequested(activity: Activity) { + val prefs = activity.applicationContext.getSharedPreferences( + PREFS_FILE, + Context.MODE_PRIVATE, + ) + if (!prefs.getBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, false)) { + prefs.edit().putBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, true).apply() + } + } +} diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidVoiceForegroundService.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidVoiceForegroundService.kt new file mode 100644 index 0000000..a39540a --- /dev/null +++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidVoiceForegroundService.kt @@ -0,0 +1,362 @@ +package app.chanora.chanora_flutter + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat + +/** + * Android foreground service that hosts the lifecycle of an active + * Chanora voice session. + * + * Trace: + * - SDD-107 `AndroidVoiceForegroundService` (foreground service class, + * notification channel id `chanora.voice.session`, + * `foregroundServiceType=microphone` on API 30+, POST_NOTIFICATIONS + * on API 33+, START_NOT_STICKY, ongoing-notification re-post on + * dismissal, lifecycle bound to voice_join / voice_leave / + * shutdown_if_idle). + * - SDD-105 (`AndroidJniBootstrap` — `JavaVM*` capture; the Rust side + * invokes [start] / [stop] via JNI per SDD-107 §10). + * - SDD-106 (`AndroidPermissionRequester` — service may start in + * listen-only mode; type=microphone is still declared so capture can + * resume on grant without a service restart, per SDD-107 §8). + * - SDD-108 (`AndroidAudioModeController` — owns + * `AudioManager.setMode`; this service does NOT touch the audio + * mode, per SDD-107 §8 and SDD-108 §1). + * - SAD-086 (source SAD). + * + * ## Naming + * + * The SDD-107 implementation text references the simple name + * `ChanoraVoiceForegroundService` in a `voice/` subpackage. This file + * uses the FQN `app.chanora.chanora_flutter.AndroidVoiceForegroundService` + * as coordinated for Wave 2B (the AndroidManifest entry declared by + * Wave 2B-1 matches this FQN). The behaviour and lifecycle contract + * are unchanged. + * + * ## Responsibilities + * + * This service is purely the foreground-lifecycle host: it keeps the + * process foregrounded so the Rust audio engine (`crates/chanora_audio`) + * can keep capture / playback streams open while the UI is backgrounded. + * + * It does NOT: + * - call `AudioManager.setMode` (owned by SDD-108 / + * `AndroidAudioModeController`), + * - open or drive audio streams (owned by the Rust audio engine + * via existing JNI on `crates/chanora_audio`), + * - perform any networking. + */ +class AndroidVoiceForegroundService : Service() { + + companion object { + private const val TAG = "ChanoraVoiceFGS" + + /** + * Notification channel id. + * + * Trace: SDD-107 §4. + */ + private const val CHANNEL_ID = "chanora.voice.session" + + /** + * Notification channel user-visible name. + * + * TODO(localization): SDD-107 §4 requires this to be sourced + * from a product string resource. Hard-coded here for the + * Wave 2B implementation slice; localised strings land + * alongside the broader Android string-resource pass. + */ + private const val CHANNEL_NAME = "Voice session" + + /** + * Notification channel description (product copy only, no + * server-supplied content per SDD-107 §5 privacy note). + * + * TODO(localization): see [CHANNEL_NAME]. + */ + private const val CHANNEL_DESCRIPTION = + "Shown while a Chanora voice session is active." + + /** + * Stable notification id ("CHAN") per SDD-107 §5. Must remain + * stable so that re-posts after user dismissal land on the + * same notification slot. + */ + private const val NOTIFICATION_ID = 0x4348414E + + /** Intent action: begin / refresh the foreground session. */ + const val ACTION_START_VOICE_SESSION: String = + "app.chanora.action.START_VOICE_SESSION" + + /** Intent action: terminate the foreground session. */ + const val ACTION_STOP_VOICE_SESSION: String = + "app.chanora.action.STOP_VOICE_SESSION" + + /** + * Start the service in voice-session mode. + * + * Intended call sites: + * - Kotlin: [MainActivity] or platform glue. + * - Rust: invoked from the `voice_join` bridge handler via + * JNI per SDD-107 §10 (the `JavaVM*` captured by SDD-105 + * is used to call this static method). + * + * Trace: SDD-107 §7 (lifecycle — start). + */ + @JvmStatic + fun start(context: Context) { + val intent = Intent(context, AndroidVoiceForegroundService::class.java).apply { + action = ACTION_START_VOICE_SESSION + } + ContextCompat.startForegroundService(context, intent) + } + + /** + * Stop the service. Idempotent per SDD-107 §7. + * + * Trace: SDD-107 §7 (lifecycle — stop). + */ + @JvmStatic + fun stop(context: Context) { + val intent = Intent(context, AndroidVoiceForegroundService::class.java).apply { + action = ACTION_STOP_VOICE_SESSION + } + // We deliberately route through startService so the running + // service receives ACTION_STOP_VOICE_SESSION via + // onStartCommand and can perform an orderly stopForeground + + // stopSelf. If the service is already stopped this is a + // no-op aside from a brief onCreate/onDestroy cycle, which + // satisfies the idempotent-stop contract. + try { + context.startService(intent) + } catch (e: IllegalStateException) { + // Background-start restrictions: if the app is in a + // state that disallows starting services (e.g., + // process being torn down), there is nothing left to + // stop. Log and continue. + Log.w(TAG, "stop() could not deliver intent: ${e.message}") + } + } + } + + /** + * Service is start-only — no clients bind. Per SDD-107 §1. + * + * Trace: SDD-107 §1. + */ + override fun onBind(intent: Intent?): IBinder? = null + + /** + * Lifecycle entry. Promotes the service to foreground within the + * 5-second platform deadline per SDD-107 §5, dispatches on the + * incoming action, and returns [START_NOT_STICKY] per SDD-107 §7 + * (process death must NOT auto-restart; state is rebuilt from the + * audio engine on the next `voice_join`). + * + * Trace: SDD-107 §5, §7. + */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP_VOICE_SESSION -> { + stopForegroundCompat() + stopSelf() + } + // Treat null action (e.g., service re-creation by the + // system before we return START_NOT_STICKY takes effect) + // and any unknown action the same as START — we must call + // startForeground within 5 seconds of onStartCommand or the + // platform will kill us with a ForegroundServiceDidNotStart + // exception (Android 12+). + ACTION_START_VOICE_SESSION, null -> promoteToForeground() + else -> { + Log.w(TAG, "Unknown action: ${intent.action}; treating as START") + promoteToForeground() + } + } + // SDD-107 §7: do not auto-restart on process death; the next + // voice_join re-starts the service explicitly. + return START_NOT_STICKY + } + + /** + * Tear-down. Removes the ongoing notification and clears the + * channel slot per SDD-107 §7 (stop path). + * + * If the user manually dismissed the notification while the + * service was still running (API 34+ allows this per SDD-107 §7), + * the dismissal does NOT stop the session — the audio engine is + * the sole authority for when the service goes away. The service + * re-posts the notification on the next state transition; in + * practice "next state transition" means the next [start] call + * arriving with ACTION_START_VOICE_SESSION, which calls + * [startForeground] again. We do NOT schedule a JobScheduler / + * AlarmManager re-post (per the task scope) — re-posting is + * driven by the Rust audio engine emitting a state tick. + * + * Trace: SDD-107 §7. + */ + override fun onDestroy() { + stopForegroundCompat() + try { + val nm = getSystemService(NOTIFICATION_SERVICE) as? NotificationManager + nm?.cancel(NOTIFICATION_ID) + } catch (e: SecurityException) { + // Unlikely on cancel(), but POST_NOTIFICATIONS-related + // SecurityException surfaces have been reported on some + // OEM builds. Do not crash teardown. + Log.w(TAG, "cancel() raised SecurityException: ${e.message}") + } + super.onDestroy() + } + + /** + * Build (or refresh) the channel and call [startForeground]. + * + * Trace: SDD-107 §4 (channel), §5 (notification), §6 + * (POST_NOTIFICATIONS handling). + */ + private fun promoteToForeground() { + ensureChannel() + val notification = buildNotification() + + // POST_NOTIFICATIONS (API 33+): per SDD-107 §6, denial must NOT + // block the service. We still call startForeground; the + // platform will silently suppress the notification if the + // permission is missing. We log a warning so this case is + // observable. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val granted = ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + if (!granted) { + Log.w( + TAG, + "POST_NOTIFICATIONS not granted; service will run without a " + + "visible notification (SDD-107 §6).", + ) + } + } + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Typed overload required on API 29+ when the manifest + // declares foregroundServiceType=microphone (SDD-107 + // §2). On API 34+ this is enforced; declaring it on + // 29+ is forward-safe. + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ) + } else { + // API 28 (minSdk per build.gradle.kts): un-typed + // startForeground is the only available overload. + @Suppress("DEPRECATION") + startForeground(NOTIFICATION_ID, notification) + } + } catch (e: SecurityException) { + // FOREGROUND_SERVICE_MICROPHONE missing on API 34+, or + // RECORD_AUDIO missing while type=microphone is declared. + // Per SDD-107 §6 / §7 we do not crash; the audio engine + // will observe the absence via the BridgeEvent stream and + // clamp to listen-only. + Log.e( + TAG, + "startForeground(type=MICROPHONE) failed: ${e.message}. " + + "Service may not have promoted; SDD-107 §6 listen-only path applies.", + e, + ) + } + } + + /** + * Create the notification channel on first use. Repeated creates + * are no-ops per Android contract (SDD-107 §4). + */ + private fun ensureChannel() { + // minSdk = 28 (Android 9), so NotificationChannel APIs (O+) are + // unconditionally available. + val nm = getSystemService(NOTIFICATION_SERVICE) as? NotificationManager ?: run { + Log.e(TAG, "NotificationManager unavailable; cannot create channel") + return + } + val existing = nm.getNotificationChannel(CHANNEL_ID) + if (existing != null) return + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = CHANNEL_DESCRIPTION + setShowBadge(false) + } + nm.createNotificationChannel(channel) + } + + /** + * Build the ongoing notification. + * + * Privacy: per SDD-107 §5, the notification carries ONLY product + * copy — no server-supplied channel names, user names, or message + * content. + * + * Trace: SDD-107 §5. + */ + private fun buildNotification(): Notification { + val contentIntent: PendingIntent? = packageManager + .getLaunchIntentForPackage(packageName) + ?.let { launch -> + PendingIntent.getActivity( + this, + 0, + launch, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_speakerphone) + // TODO(SDD-107 §5): replace stat_sys_speakerphone with the + // product icon `ic_chanora_voice` once the drawable lands + // in res/drawable. Using a platform-provided icon as the + // interim placeholder keeps the build green without + // touching res/* (out of this slice's file set). + .setContentTitle("Chanora — Voice session active") + .setContentText("Microphone may be in use.") + .setOngoing(true) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setShowWhen(false) + + if (contentIntent != null) { + builder.setContentIntent(contentIntent) + } + return builder.build() + } + + /** + * Version-portable `stopForeground` that removes the notification. + */ + private fun stopForegroundCompat() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + } +} diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/BackIntentBridge.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/BackIntentBridge.kt new file mode 100644 index 0000000..5ffe074 --- /dev/null +++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/BackIntentBridge.kt @@ -0,0 +1,174 @@ +package app.chanora.chanora_flutter + +import android.os.Build +import android.util.Log +import android.window.OnBackInvokedCallback +import android.window.OnBackInvokedDispatcher +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import io.flutter.embedding.android.FlutterActivity +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel + +/** + * BackIntentBridge — Kotlin half of SDD-028 (`BackIntentService`). + * + * Trace: SDD-028 (Android back-intent registration paths + deterministic + * route-pop ordering) / SAD-018. + * + * Responsibility (Kotlin side): + * * On API 33+ (`Build.VERSION.SDK_INT >= TIRAMISU`) register an + * `OnBackInvokedCallback` against `activity.onBackInvokedDispatcher` + * at `PRIORITY_DEFAULT`. + * * On API < 33, register an `OnBackPressedCallback` (enabled = true) + * against `activity.onBackPressedDispatcher`. + * * Both callbacks consume the system back event (no super / no + * re-dispatch) and forward a single `backIntent` MethodChannel call + * to Dart with payload `{"kind": "system_back"}`. The Dart side + * (`BackIntentService`) owns the deterministic route-pop policy + * (PTT-active → ignore, modal → close, non-root → pop, root → + * `exitCandidate`). + * * When Dart concludes the event is unhandled at the root route, it + * calls back via `popToSystem`, which invokes `activity.finish()` + * exactly once (M-2 strict-review fix: guarded by `isFinishing` / + * `isDestroyed` so a duplicate Dart-side ExitApp decision cannot + * re-enter `finish()`). + * + * Threading: all callbacks are dispatched on the Android main thread, + * matching SDD-028 §4 ("dispatch runs on the platform main thread"). + * + * ## Lifecycle / ownership (M-3 strict-review fix) + * + * Previously this type was a Kotlin `object` (process-wide singleton) + * that retained a strong reference to a `FlutterActivity`. Even though + * [detach] cleared the reference, the singleton pattern is an + * Activity-leak footgun: any caller forgetting `detach()` would pin + * the Activity for the lifetime of the process. + * + * The bridge is now a plain `class`. `MainActivity` constructs one + * instance in `configureFlutterEngine`, holds it in a private field, + * and clears the field in `onDestroy` after calling [detach]. The + * Activity is therefore reachable only via the bridge instance, and + * the bridge instance is reachable only via `MainActivity` — when + * `MainActivity` is destroyed, both become eligible for collection. + */ +class BackIntentBridge { + + private companion object { + private const val TAG = "BackIntentBridge" + private const val KEY_KIND = "kind" + private const val VALUE_SYSTEM_BACK = "system_back" + } + + private var channel: MethodChannel? = null + private var attachedActivity: FlutterActivity? = null + + // API 33+ path. + private var onBackInvokedCallback: OnBackInvokedCallback? = null + + // Pre-33 path. + private var onBackPressedCallback: OnBackPressedCallback? = null + + /** + * Attach the back-intent bridge to [activity] using [messenger] for + * the Dart `MethodChannel`. Idempotent: a second call detaches the + * prior attachment first. + * + * The [activity] reference is retained until [detach] is called. + * The owning `MainActivity` MUST invoke [detach] from its + * `onDestroy` (see class KDoc on lifecycle / ownership). + */ + fun attach(activity: FlutterActivity, messenger: BinaryMessenger) { + // Guard against double-attach (e.g. re-creation under config changes). + detach() + + // X-2 strict-review fix: channel + method names sourced from the + // central MethodChannels registry rather than file-local literals. + val ch = MethodChannel(messenger, MethodChannels.BACK_INTENT) + channel = ch + attachedActivity = activity + + // SDD-028: Dart -> Kotlin "popToSystem" closes the activity at root. + ch.setMethodCallHandler { call, result -> + when (call.method) { + MethodChannels.METHOD_POP_TO_SYSTEM -> { + // M-2 strict-review fix (SDD-028): guarantee exactly-once + // finish(). If Dart issues two ExitApp decisions in rapid + // succession we must not re-enter Activity teardown. + if (activity.isFinishing || activity.isDestroyed) { + Log.i( + TAG, + "popToSystem received but activity already finishing/destroyed; ignoring duplicate", + ) + result.success(null) + return@setMethodCallHandler + } + activity.finish() + result.success(null) + } + else -> result.notImplemented() + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerApi33(activity, ch) + } else { + registerPre33(activity, ch) + } + } + + /** + * Detach from the previously-attached activity and tear down all + * registrations. Safe to call repeatedly. + */ + fun detach() { + val activity = attachedActivity + if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + onBackInvokedCallback?.let { cb -> + activity.onBackInvokedDispatcher.unregisterOnBackInvokedCallback(cb) + } + } + onBackInvokedCallback = null + + onBackPressedCallback?.remove() + onBackPressedCallback = null + + channel?.setMethodCallHandler(null) + channel = null + attachedActivity = null + } + + private fun registerApi33(activity: FlutterActivity, channel: MethodChannel) { + val cb = OnBackInvokedCallback { + // SDD-028 §1: callback delegates to BackIntentService.dispatch() + // on the Dart side and does NOT call any system fallback. + channel.invokeMethod( + MethodChannels.METHOD_BACK_INTENT, + mapOf(KEY_KIND to VALUE_SYSTEM_BACK), + ) + } + activity.onBackInvokedDispatcher.registerOnBackInvokedCallback( + OnBackInvokedDispatcher.PRIORITY_DEFAULT, + cb, + ) + onBackInvokedCallback = cb + } + + private fun registerPre33(activity: FlutterActivity, channel: MethodChannel) { + val cb = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + // SDD-028 §1: forward to Dart; do NOT re-invoke the + // system fallback here. If Dart determines the event is + // unhandled (root route), it calls back via + // `popToSystem` which executes `activity.finish()`. + channel.invokeMethod( + MethodChannels.METHOD_BACK_INTENT, + mapOf(KEY_KIND to VALUE_SYSTEM_BACK), + ) + } + } + // Cast resolves onBackPressedDispatcher via ComponentActivity (FlutterActivity → FragmentActivity → ComponentActivity). + (activity as ComponentActivity).onBackPressedDispatcher.addCallback(activity, cb) + onBackPressedCallback = cb + } +} diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/ChanoraApplication.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/ChanoraApplication.kt new file mode 100644 index 0000000..eb2a0af --- /dev/null +++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/ChanoraApplication.kt @@ -0,0 +1,62 @@ +package app.chanora.chanora_flutter + +import android.app.Application +import android.util.Log + +/** + * Application subclass that owns the earliest-possible load of the + * `chanora_bridge` native cdylib. + * + * Trace: SDD-105 (AndroidJniBootstrap), DEC-004 (single JavaVM* capture point). + * + * Rationale (SDD-105): + * `JNI_OnLoad` in `crates/chanora_bridge/src/android_init.rs` captures the + * process-wide `JavaVM*` the first time the library is loaded. By performing + * the `System.loadLibrary("chanora_bridge")` call here in `Application.onCreate` + * we guarantee that the VM pointer is available *before* any Flutter plugin, + * background isolate, or FRB-generated stub attempts to call into Rust. This + * also ensures the load happens on the main thread, satisfying the threading + * guarantee documented in SDD-105. + * + * Manifest contract (Wave 2B-1): + * AndroidManifest.xml must reference this class via + * android:name="app.chanora.chanora_flutter.ChanoraApplication" + * in the tag. Wave 2B-1 owns that edit; do not duplicate it + * here. + */ +class ChanoraApplication : Application() { + + override fun onCreate() { + super.onCreate() + // SDD-105: load the native bridge as early as possible so JNI_OnLoad + // runs before any FRB call site is reached. + try { + // SDD-105 implementation detail: the Android NDK C++ runtime + // (libc++_shared.so) must be loaded BEFORE chanora_bridge so that + // chanora_bridge's undefined C++ symbols (notably + // __cxa_pure_virtual, __cxa_atexit) resolve via the global + // symbol namespace. libchanora_bridge.so does not carry a + // DT_NEEDED libc++_shared.so entry today (the Rust cdylib build + // does not emit one), so the loader will not auto-pull it just + // because it is co-located in jniLibs//. This explicit + // ordered loadLibrary pair is the canonical NDK pattern. + // Follow-up: a cleaner build-side fix is to inject + // `-lc++_shared` into the Rust cdylib link args via + // cargo:rustc-link-lib in a build.rs, producing a DT_NEEDED + // entry that makes this manual ordering unnecessary. + System.loadLibrary("c++_shared") + System.loadLibrary("chanora_bridge") + } catch (t: UnsatisfiedLinkError) { + // SDD-105: panic-safe FFI boundary — log loudly, then rethrow so + // the process fails fast rather than silently running without the + // Rust core. A swallowed link error would manifest much later as + // a confusing UnsatisfiedLinkError on the first FRB call. + Log.e(TAG, "Failed to load native library", t) + throw t + } + } + + companion object { + private const val TAG = "ChanoraApp" + } +} diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt index 826b23c..abd9b14 100644 --- a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt +++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt @@ -1,33 +1,228 @@ package app.chanora.chanora_flutter +import android.content.Context import android.os.Bundle +import app.chanora.chanora_flutter.AndroidPermissionRequester +import app.chanora.chanora_flutter.BackIntentBridge +import app.chanora.chanora_flutter.MethodChannels import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +/** + * Host activity for the Chanora Flutter shell. + * + * Trace: SDD-105 (AndroidJniBootstrap), SDD-028 (Android lifecycle wiring), + * SDD-106 (AndroidPermissionRequester), DEC-004 (single JNI bootstrap path). + * + * Note (SDD-105): + * The `System.loadLibrary("chanora_bridge")` call previously lived in this + * activity's companion-object initialiser. It has been moved to + * [ChanoraApplication.onCreate] so the native library — and therefore + * `JNI_OnLoad`'s `JavaVM*` capture — is available before any plugin or + * background isolate touches the bridge. See ChanoraApplication.kt and + * the AndroidManifest declaration owned by + * Wave 2B-1. + */ class MainActivity : FlutterActivity() { companion object { - init { - // Force-load the chanora_bridge cdylib at activity-class-init - // time so JNI_OnLoad captures the JavaVM* before Flutter / FRB - // tries to call any Rust function. - System.loadLibrary("chanora_bridge") - } - /** * JNI entry point implemented in `chanora_bridge::android_init`. * Initialises `ndk_context` with our Activity so cpal-on-Oboe can * find Android audio services when `chanora_audio` starts the * capture / playback streams. + * + * Trace: SDD-105 (AndroidJniBootstrap). Signature must remain stable; + * the Rust side declares the matching `extern "system"` symbol. */ @JvmStatic - external fun initChanoraContext(context: android.content.Context) + external fun initChanoraContext(context: Context) + + /** + * JNI entry point implemented in `chanora_bridge::permission_jni`. + * Forwards a resolved Android runtime-permission state into the + * Rust bridge, which (a) clamps the audio engine's transmit + * selector when `permission == "android.permission.RECORD_AUDIO"` + * and the state is anything other than `Granted`, and (b) + * broadcasts a `BridgeEvent::PermissionState` so the Dart UI + * observes the authoritative state alongside the existing + * MethodChannel. + * + * The Rust side wraps the body in `catch_unwind` so a panic in + * the bridge never unwinds into the JVM. + * + * Trace: SDD-106 §5, §6; SRS-209. Signature must remain stable; + * the Rust side declares the matching `extern "system"` symbol + * `Java_app_chanora_chanora_1flutter_MainActivity_publishPermissionState`. + */ + @JvmStatic + external fun publishPermissionState(permission: String, state: String) } + // SDD-106: Activity-bound permission requester. Nullable because it is + // only constructed once the FlutterEngine is configured; lifecycle + // callbacks (onResume / onRequestPermissionsResult) must null-guard. + private var permissionRequester: AndroidPermissionRequester? = null + + // SDD-106: Retained so onResume / onDestroy can forward state changes + // to Dart on MethodChannels.ANDROID_PERMISSIONS. + private var permissionsChannel: MethodChannel? = null + + // SDD-028 (M-3 strict-review fix): the back-intent bridge is owned + // by this Activity instance, not a process-wide `object`. Constructed + // in configureFlutterEngine and cleared in onDestroy after detach(). + private var backIntentBridge: BackIntentBridge? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Pass the Application context to the Rust side so the audio - // engine can open device handles. Must run before any - // `chanora_audio` call from Dart. + // SDD-105: pass the Application context to the Rust side so the + // audio engine can open device handles. Must run on the main thread + // before any `chanora_audio` call from Dart. The native library is + // already loaded by ChanoraApplication.onCreate at this point. initChanoraContext(applicationContext) } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + // SDD-028 / DEC-004: attach the Android -> Dart back-intent bridge to + // the FlutterEngine's binary messenger as soon as the engine is + // available. The bridge forwards hardware-back and intent-back events + // into Dart's navigation stack. + // + // SDD-028 (M-3 strict-review fix): BackIntentBridge is now a class + // instance owned by this Activity rather than a process-wide `object`, + // eliminating the singleton-Activity-leak footgun. + val bridge = BackIntentBridge() + bridge.attach(this, flutterEngine.dartExecutor.binaryMessenger) + backIntentBridge = bridge + + // SDD-106: Wired in fast-builder follow-up; closes Wave 2B-4 coordination gap. + // Construct the permissions MethodChannel and the Activity-bound + // requester, then wire stateChangeListener to forward resolved + // PermissionState transitions to Dart. + val channel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + MethodChannels.ANDROID_PERMISSIONS, + ) + permissionsChannel = channel + val requester = AndroidPermissionRequester() + requester.stateChangeListener = { permission, state -> + val stateName = state.toString() + channel.invokeMethod( + MethodChannels.METHOD_PERMISSION_STATE_CHANGED, + mapOf( + "permission" to permission, + "state" to stateName, + ), + ) + // SDD-106 §5/§6: also forward the resolved state into the + // Rust bridge so `TransmitModeSelector` clamps the + // transmit gate authoritatively (independent of whether + // the Dart UI has re-rendered yet). The Rust side is + // panic-safe via `catch_unwind`; we still guard with + // try/catch here so a `UnsatisfiedLinkError` (e.g. an + // unexpected ABI mismatch) cannot crash MainActivity. + // Trace: SDD-106 §5, §6; SRS-209. + try { + publishPermissionState(permission, stateName) + } catch (t: Throwable) { + android.util.Log.w( + "Chanora", + "publishPermissionState JNI hook failed: ${t.message}", + t, + ) + } + } + permissionRequester = requester + + // SDD-106 §1, §3 (Dart-side integration follow-up): handle + // outbound Dart -> Kotlin calls so the Flutter UI can drive the + // runtime permission request and the settings deep-link. The + // resolved state is still delivered asynchronously via + // stateChangeListener -> METHOD_PERMISSION_STATE_CHANGED. + // + // Trace: SDD-106, SRS-209. + channel.setMethodCallHandler { call, result -> + when (call.method) { + "requestRecordAudio" -> { + val r = permissionRequester + if (r != null) { + r.ensureRecordAudioPermission(this) { _ -> } + result.success(null) + } else { + result.error( + "no_requester", + "AndroidPermissionRequester not bound", + null, + ) + } + } + "openAppSettings" -> { + val r = permissionRequester + if (r != null) { + r.openAppSettings(this) + result.success(null) + } else { + result.error( + "no_requester", + "AndroidPermissionRequester not bound", + null, + ) + } + } + else -> result.notImplemented() + } + } + } + + override fun onResume() { + super.onResume() + // SDD-106: re-evaluate Android runtime permission state on resume and + // forward the result to Dart via MethodChannel + // "app.chanora/android_permissions". Must be null-safe: if the + // requester is not yet wired we no-op rather than crashing. + // + // SDD-106: Wired in fast-builder follow-up; closes Wave 2B-4 coordination gap. + val requester = permissionRequester + if (requester != null) { + // SDD-106: state already emitted by AndroidPermissionRequester via stateChangeListener; do NOT double-emit (M-4 fix) + requester.onResume(this) { _ -> } + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + // SDD-106: Wired in fast-builder follow-up; closes Wave 2B-4 coordination gap. + // Forward the system result into the Activity-bound requester so its + // pending callback resolves and stateChangeListener fires. + permissionRequester?.handleRequestPermissionsResult( + this, + requestCode, + permissions, + grantResults, + ) + } + + override fun onDestroy() { + // SDD-028: detach the back-intent bridge before the activity is torn + // down so the FlutterEngine's binary messenger isn't retained. + // + // SDD-028 (M-3 strict-review fix): drop the owning reference so the + // bridge instance — and the Activity it retains — become eligible + // for collection immediately after onDestroy. + backIntentBridge?.detach() + backIntentBridge = null + // SDD-106: drop the Dart->Kotlin handler before nilling the + // channel so a late invokeMethod from Dart cannot land on a + // dangling requester reference. + permissionsChannel?.setMethodCallHandler(null) + permissionRequester = null + permissionsChannel = null + super.onDestroy() + } } diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MethodChannels.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MethodChannels.kt new file mode 100644 index 0000000..0c91561 --- /dev/null +++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MethodChannels.kt @@ -0,0 +1,67 @@ +package app.chanora.chanora_flutter + +/** + * Central registry of Flutter MethodChannel names used by the Android + * platform code. + * + * Trace: + * - SDD-106 (`AndroidPermissionRequester` — bridge event surface) + * - SRS-209 (Android runtime permission UX) + * + * The channels declared here are the Kotlin-side contract only. The + * Dart-side handlers that subscribe / dispatch on these channels are + * deliberately out of scope for the Wave 2B-2 implementation slice and + * are handed off to a follow-up task. + */ +internal object MethodChannels { + /** + * Channel for Android runtime-permission state events emitted by + * [AndroidPermissionRequester]. The Kotlin side invokes + * [METHOD_PERMISSION_STATE_CHANGED] whenever the resolved permission + * state transitions. + * + * Trace: SDD-106 §5 (Bridge event surface), SRS-209. + */ + const val ANDROID_PERMISSIONS: String = "app.chanora/android_permissions" + + /** + * Method name invoked on [ANDROID_PERMISSIONS] when the resolved + * permission state for a tracked Android runtime permission + * changes. Arguments are a `Map` with keys: + * - "permission": String (Android permission constant, e.g. + * "android.permission.RECORD_AUDIO") + * - "state": String (one of "Granted", "Denied", + * "PermanentlyDenied") + * + * Trace: SDD-106 §5. + */ + const val METHOD_PERMISSION_STATE_CHANGED: String = "permissionStateChanged" + + /** + * Channel name for the Android back-intent bridge (Kotlin <-> Dart). + * + * The Dart side uses the matching constant `backIntentChannelName` in + * `lib/services/back_intent_service.dart`; both must remain in sync. + * + * Trace: SDD-028 (BackIntentService), SAD-018. X-2 strict-review fix: + * consolidated from BackIntentBridge.kt's previous private literal. + */ + const val BACK_INTENT: String = "app.chanora/back_intent" + + /** + * Method invoked on [BACK_INTENT] from Kotlin -> Dart when a system + * back event fires. Payload: `{"kind": "system_back"}`. + * + * Trace: SDD-028 §1. X-2 strict-review fix. + */ + const val METHOD_BACK_INTENT: String = "backIntent" + + /** + * Method invoked on [BACK_INTENT] from Dart -> Kotlin when the Dart + * side concludes the back event is unhandled at the root route and + * the activity should finish. + * + * Trace: SDD-028 §1. X-2 strict-review fix. + */ + const val METHOD_POP_TO_SYSTEM: String = "popToSystem" +}