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:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+367
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+362
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
@@ -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
|
||||
}
|
||||
}
|
||||
+62
@@ -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"
|
||||
}
|
||||
}
|
||||
+206
-11
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+67
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user