feat(android,p0): foreground service, permission requester, application class, build automation

Android P0 platform shell:

- ChanoraApplication: early System.loadLibrary("c++_shared") +
  System.loadLibrary("chanora_bridge") so JNI is hot before
  MainActivity.onCreate.
- MainActivity: configureFlutterEngine + onResume/onDestroy wiring
  for BackIntentBridge and AndroidPermissionRequester; publishes
  permission-state changes through both the MethodChannel (Dart UI)
  and the JNI hook (Rust audio engine).
- AndroidVoiceForegroundService: microphone-type foreground service
  with notification channel chanora.voice.session per SDD-107.
- AndroidPermissionRequester: RECORD_AUDIO state machine with
  persisted "has-ever-requested" flag so PermanentlyDenied is
  correctly distinguished from never-asked across cold launches.
- BackIntentBridge: API 33+ OnBackInvokedCallback + pre-33
  OnBackPressedDispatcher with deterministic Dart-side policy.
- MethodChannels: centralized constants for app.chanora/*.
- build.gradle.kts: SDD-118 Gradle automation that auto-builds the
  Rust cdylib via cargo-ndk with per-ABI Exec tasks, minimal-env
  isolation, CMAKE_TOOLCHAIN_FILE pinning, libc++_shared.so staging,
  release-inspection assertion. abiFilters temporarily reduced to
  arm64-v8a only per DEC-032 (multi-ABI restoration pending).
- AndroidManifest.xml: INTERNET, RECORD_AUDIO, FOREGROUND_SERVICE,
  FOREGROUND_SERVICE_MICROPHONE, POST_NOTIFICATIONS,
  MODIFY_AUDIO_SETTINGS, BLUETOOTH_CONNECT permissions; service
  declaration with foregroundServiceType=microphone.
- proguard-rules.pro: keep rules for JNI native methods + Flutter
  plugin entry points + FRB bindings.

Trace: SDD-073, SDD-105, SDD-106, SDD-107, SDD-108, SDD-110, SDD-118,
SRS-111, SRS-119, SRS-163, SRS-187, SRS-209, SRS-215.
This commit is contained in:
EdisonJwa
2026-05-18 12:35:19 +08:00
parent 7966a7c8c6
commit 0dc8297568
9 changed files with 1865 additions and 26 deletions
+516 -11
View File
@@ -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=<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/<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",
"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<String> = 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 <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 { 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 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",
"armeabi-v7a" to "arm-linux-androideabi",
"x86_64" to "x86_64-linux-android",
)
fun registerJniLibsCopyTask(profile: String): TaskProvider<Copy> {
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<Copy>("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/<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"
)
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<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
)
}
+61
View File
@@ -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 <methods>;
}
# --- 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.
@@ -1,16 +1,49 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required for the protocol layer to dial a TeamSpeak-compatible
server over UDP. -->
<!-- SDD-trace: SRS-045 (protocol adapter dials TeamSpeak-compatible
server) / SAD-032 (protocol-adapter isolation). Also supports
SRS-130 network-failure error reporting. Required for the
protocol layer to dial a TeamSpeak-compatible server over UDP. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required for the audio engine (chanora_audio) to open the
<!-- SDD-trace: SDD-106 AndroidPermissionRequester.
Required for the audio engine (chanora_audio) to open the
capture stream for voice transmission. The runtime grant
must still be requested; this declaration only allows the
app to ask. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 3).
Required on API 28+ to start a foreground service that keeps
the voice session alive while the UI is backgrounded. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 3).
Required on API 34+ when the foreground service declares
foregroundServiceType="microphone". Declared here for the
entire API ladder; the platform ignores it on older releases. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 6).
Required on API 33+ to display the ongoing voice-session
notification. Runtime-requested via AndroidPermissionRequester
(SDD-106); denial does not block the service. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- SDD-trace: SDD-108 AndroidAudioModeController.
Required for AudioManager.setMode(MODE_IN_COMMUNICATION) and
related in-call audio routing operations. -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- SDD-trace: cpal-on-Android research report; supports SCO/BLE
headset routing on API 31+. Declared here so the platform
allows querying / connecting to bonded Bluetooth audio devices
for the voice session. -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- SDD-105: Application class loads chanora_bridge native library before MainActivity onCreate -->
<application
android:label="Chanora"
android:name="${applicationName}"
android:name="app.chanora.chanora_flutter.ChanoraApplication"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
@@ -34,6 +67,19 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 2).
Manifest declaration for the foreground service that owns
the Android voice-session lifecycle. The Kotlin class is
owned by Wave 2B-2 (apps/chanora_flutter/android/app/src/main/kotlin/).
android:foregroundServiceType="microphone" is required by
API 30+ to gate background microphone access; ignored on
API 28-29 where capture is permitted without it. -->
<service
android:name="app.chanora.chanora_flutter.AndroidVoiceForegroundService"
android:exported="false"
android:foregroundServiceType="microphone" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -0,0 +1,367 @@
package app.chanora.chanora_flutter
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
/**
* Android runtime-permission requester for the audio subsystem.
*
* Trace:
* - SDD-106 `AndroidPermissionRequester` (RECORD_AUDIO runtime flow with
* listen-only fallback, settings deep-link for permanent denial,
* revocation handling, bridge event surface).
* - SRS-209 (Android runtime permission UX).
* - SAD-085 (source SAD for this SDD unit).
*
* ## Why Activity-bound, not a global singleton
*
* Android runtime permission requests are inherently tied to a
* concrete [Activity] (the system dialog is hosted by the Activity and
* the result is delivered through `onRequestPermissionsResult`). A
* process-wide singleton would have to track which Activity is
* currently in the foreground and would race with configuration
* changes. Binding the requester to the host Activity ([MainActivity],
* owned by Wave 2B-4) keeps the lifecycle simple and audit-clear: one
* requester per Activity instance, no static state to leak across
* Activity recreation.
*
* ## Contract with the host Activity
*
* The host Activity MUST:
* 1. Construct one [AndroidPermissionRequester] in `onCreate`.
* 2. Forward `onRequestPermissionsResult` to
* [handleRequestPermissionsResult].
* 3. Call [onResume] from its `Activity.onResume` so mid-session
* revocation (per SDD-106 §4) is observed.
*
* The Dart-side wiring (publishing `BridgeEvent::PermissionState` over
* the bridge event stream, per SDD-106 §5) is delivered through the
* [MethodChannels.ANDROID_PERMISSIONS] channel. The actual MethodChannel
* handler is wired by [MainActivity] in a follow-up task; for now this
* class exposes [stateChangeListener] and the channel name constant.
*
* ## Thread model
*
* All public methods are expected to be called from the Android main
* thread (Activity lifecycle thread). The Rust audio engine queries
* cached state via a separate JNI helper (SDD-106 §7, out of scope for
* this Kotlin class) — this class does not directly expose state to
* other threads.
*/
class AndroidPermissionRequester {
/**
* Discrete permission-state values surfaced to the host Activity
* and onward to the Rust bridge.
*
* Trace: SDD-106 §5 (state machine), SRS-209.
*
* Note: SDD-106 §5 also lists `Undetermined` as a fourth state.
* That state is internal to the Rust-side cache (initial value
* before the first query); the Kotlin requester never emits it
* because every emission corresponds to a resolved
* `checkSelfPermission` result.
*/
sealed class PermissionState {
/** Permission granted by the user. */
object Granted : PermissionState()
/** Permission denied, but the user may still be re-prompted. */
object Denied : PermissionState()
/**
* Permission denied with "do not ask again" — Android will no
* longer show the system dialog. The UI must deep-link to app
* settings via [openAppSettings].
*
* Trace: SDD-106 §3.
*/
object PermanentlyDenied : PermissionState()
}
private companion object {
private const val TAG = "ChanoraPerm"
/**
* Stable request code for `RECORD_AUDIO`. Must remain stable
* across releases so that result dispatch in
* [handleRequestPermissionsResult] matches the request.
*/
private const val REQ_RECORD_AUDIO = 0x52454341 // "RECA"
/**
* SharedPreferences file backing the cross-process / cross-launch
* "has the user ever been asked for this permission?" flag.
*
* Trace: SDD-106 §3, §4 (M-1 strict-review fix).
*/
private const val PREFS_FILE = "chanora_permissions"
/**
* Boolean key set to `true` the first time we invoke
* [ActivityCompat.requestPermissions] for `RECORD_AUDIO`.
*
* Contract (M-1 strict-review fix):
* - `false` (default) — the user has never been prompted in
* any prior process for `RECORD_AUDIO`. In this state,
* `shouldShowRequestPermissionRationale == false` means
* "fresh / never asked", NOT "permanently denied".
* - `true` — the user has been prompted at least once in
* some prior (or current) process. In this state,
* `shouldShowRequestPermissionRationale == false` after a
* non-granted result indicates "Do not ask again" /
* permanent denial.
*
* Persisting this across process death is what lets
* [onResume] and [handleRequestPermissionsResult] faithfully
* observe revocation per SDD-106 §4 even when Android killed
* and restarted the process during a settings round-trip.
*/
private const val KEY_RECORD_AUDIO_HAS_REQUESTED = "record_audio_has_requested"
}
/**
* In-flight callback for the active permission request, if any.
* Cleared in [handleRequestPermissionsResult]. Single-flight is
* enforced by overwriting (the most recent caller wins); concurrent
* `voice_join` coalescing (SDD-106 §8) is owned by the Rust side.
*/
private var pendingCallback: ((PermissionState) -> 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<out String>,
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()
}
}
}
@@ -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)
}
}
}
@@ -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
}
}
@@ -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 <application> 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/<abi>/. 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"
}
}
@@ -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 <application android:name> 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<out String>,
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()
}
}
@@ -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<String, Any>` 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"
}