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 {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,6 +7,33 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
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 {
|
android {
|
||||||
namespace = "app.chanora.chanora_flutter"
|
namespace = "app.chanora.chanora_flutter"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -21,25 +50,105 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "app.chanora.chanora_flutter"
|
applicationId = "app.chanora.chanora_flutter"
|
||||||
// DEC-004 (register v0.9.5): Android minimum API 28 (Android 9.0).
|
// DEC-004 (register v0.9.5) / SDD-073 item 1: Android minimum API
|
||||||
// Raised from the original recommendation of API 24 by owner ruling
|
// 28 (Android 9.0). Raised from the original recommendation of
|
||||||
// on 2026-05-14 for a simpler audio path (AAudio stable from API 28)
|
// API 24 by owner ruling on 2026-05-14 for a simpler audio path
|
||||||
// and a narrower compatibility / scoped-storage surface. Do NOT
|
// (AAudio stable from API 28) and a narrower compatibility /
|
||||||
// lower without re-opening DEC-004.
|
// scoped-storage surface. Do NOT lower without re-opening DEC-004.
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
// DEC-005: target the Google Play-required API level on upload date.
|
// DEC-005 / SDD-073 item 2 / SRS-188: target the Google
|
||||||
// Flutter's default is kept; release-time CI must verify this still
|
// Play-required API level on upload date. Flutter's default is
|
||||||
// satisfies the current Play policy.
|
// kept; release-time CI must verify this still satisfies the
|
||||||
|
// current Play policy (SDD-109 release-inspection hook).
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// SDD-073 item 5: release builds MUST use a non-debug
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// signing config. When the four CHANORA_RELEASE_* inputs
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
// 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 {
|
flutter {
|
||||||
source = "../.."
|
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">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<!-- Required for the protocol layer to dial a TeamSpeak-compatible
|
<!-- SDD-trace: SRS-045 (protocol adapter dials TeamSpeak-compatible
|
||||||
server over UDP. -->
|
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" />
|
<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
|
capture stream for voice transmission. The runtime grant
|
||||||
must still be requested; this declaration only allows the
|
must still be requested; this declaration only allows the
|
||||||
app to ask. -->
|
app to ask. -->
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<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
|
<application
|
||||||
android:label="Chanora"
|
android:label="Chanora"
|
||||||
android:name="${applicationName}"
|
android:name="app.chanora.chanora_flutter.ChanoraApplication"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -34,6 +67,19 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<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
|
package app.chanora.chanora_flutter
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Bundle
|
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.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() {
|
class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
companion object {
|
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`.
|
* JNI entry point implemented in `chanora_bridge::android_init`.
|
||||||
* Initialises `ndk_context` with our Activity so cpal-on-Oboe can
|
* Initialises `ndk_context` with our Activity so cpal-on-Oboe can
|
||||||
* find Android audio services when `chanora_audio` starts the
|
* find Android audio services when `chanora_audio` starts the
|
||||||
* capture / playback streams.
|
* capture / playback streams.
|
||||||
|
*
|
||||||
|
* Trace: SDD-105 (AndroidJniBootstrap). Signature must remain stable;
|
||||||
|
* the Rust side declares the matching `extern "system"` symbol.
|
||||||
*/
|
*/
|
||||||
@JvmStatic
|
@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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// Pass the Application context to the Rust side so the audio
|
// SDD-105: pass the Application context to the Rust side so the
|
||||||
// engine can open device handles. Must run before any
|
// audio engine can open device handles. Must run on the main thread
|
||||||
// `chanora_audio` call from Dart.
|
// before any `chanora_audio` call from Dart. The native library is
|
||||||
|
// already loaded by ChanoraApplication.onCreate at this point.
|
||||||
initChanoraContext(applicationContext)
|
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