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

Android P0 platform shell:

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

Trace: SDD-073, SDD-105, SDD-106, SDD-107, SDD-108, SDD-110, SDD-118,
SRS-111, SRS-119, SRS-163, SRS-187, SRS-209, SRS-215.
This commit is contained in:
EdisonJwa
2026-05-18 12:35:19 +08:00
parent 7966a7c8c6
commit 0dc8297568
9 changed files with 1865 additions and 26 deletions
@@ -1,16 +1,49 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required for the protocol layer to dial a TeamSpeak-compatible
server over UDP. -->
<!-- SDD-trace: SRS-045 (protocol adapter dials TeamSpeak-compatible
server) / SAD-032 (protocol-adapter isolation). Also supports
SRS-130 network-failure error reporting. Required for the
protocol layer to dial a TeamSpeak-compatible server over UDP. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required for the audio engine (chanora_audio) to open the
<!-- SDD-trace: SDD-106 AndroidPermissionRequester.
Required for the audio engine (chanora_audio) to open the
capture stream for voice transmission. The runtime grant
must still be requested; this declaration only allows the
app to ask. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 3).
Required on API 28+ to start a foreground service that keeps
the voice session alive while the UI is backgrounded. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 3).
Required on API 34+ when the foreground service declares
foregroundServiceType="microphone". Declared here for the
entire API ladder; the platform ignores it on older releases. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 6).
Required on API 33+ to display the ongoing voice-session
notification. Runtime-requested via AndroidPermissionRequester
(SDD-106); denial does not block the service. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- SDD-trace: SDD-108 AndroidAudioModeController.
Required for AudioManager.setMode(MODE_IN_COMMUNICATION) and
related in-call audio routing operations. -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- SDD-trace: cpal-on-Android research report; supports SCO/BLE
headset routing on API 31+. Declared here so the platform
allows querying / connecting to bonded Bluetooth audio devices
for the voice session. -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- SDD-105: Application class loads chanora_bridge native library before MainActivity onCreate -->
<application
android:label="Chanora"
android:name="${applicationName}"
android:name="app.chanora.chanora_flutter.ChanoraApplication"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
@@ -34,6 +67,19 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 2).
Manifest declaration for the foreground service that owns
the Android voice-session lifecycle. The Kotlin class is
owned by Wave 2B-2 (apps/chanora_flutter/android/app/src/main/kotlin/).
android:foregroundServiceType="microphone" is required by
API 30+ to gate background microphone access; ignored on
API 28-29 where capture is permitted without it. -->
<service
android:name="app.chanora.chanora_flutter.AndroidVoiceForegroundService"
android:exported="false"
android:foregroundServiceType="microphone" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -0,0 +1,367 @@
package app.chanora.chanora_flutter
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
/**
* Android runtime-permission requester for the audio subsystem.
*
* Trace:
* - SDD-106 `AndroidPermissionRequester` (RECORD_AUDIO runtime flow with
* listen-only fallback, settings deep-link for permanent denial,
* revocation handling, bridge event surface).
* - SRS-209 (Android runtime permission UX).
* - SAD-085 (source SAD for this SDD unit).
*
* ## Why Activity-bound, not a global singleton
*
* Android runtime permission requests are inherently tied to a
* concrete [Activity] (the system dialog is hosted by the Activity and
* the result is delivered through `onRequestPermissionsResult`). A
* process-wide singleton would have to track which Activity is
* currently in the foreground and would race with configuration
* changes. Binding the requester to the host Activity ([MainActivity],
* owned by Wave 2B-4) keeps the lifecycle simple and audit-clear: one
* requester per Activity instance, no static state to leak across
* Activity recreation.
*
* ## Contract with the host Activity
*
* The host Activity MUST:
* 1. Construct one [AndroidPermissionRequester] in `onCreate`.
* 2. Forward `onRequestPermissionsResult` to
* [handleRequestPermissionsResult].
* 3. Call [onResume] from its `Activity.onResume` so mid-session
* revocation (per SDD-106 §4) is observed.
*
* The Dart-side wiring (publishing `BridgeEvent::PermissionState` over
* the bridge event stream, per SDD-106 §5) is delivered through the
* [MethodChannels.ANDROID_PERMISSIONS] channel. The actual MethodChannel
* handler is wired by [MainActivity] in a follow-up task; for now this
* class exposes [stateChangeListener] and the channel name constant.
*
* ## Thread model
*
* All public methods are expected to be called from the Android main
* thread (Activity lifecycle thread). The Rust audio engine queries
* cached state via a separate JNI helper (SDD-106 §7, out of scope for
* this Kotlin class) — this class does not directly expose state to
* other threads.
*/
class AndroidPermissionRequester {
/**
* Discrete permission-state values surfaced to the host Activity
* and onward to the Rust bridge.
*
* Trace: SDD-106 §5 (state machine), SRS-209.
*
* Note: SDD-106 §5 also lists `Undetermined` as a fourth state.
* That state is internal to the Rust-side cache (initial value
* before the first query); the Kotlin requester never emits it
* because every emission corresponds to a resolved
* `checkSelfPermission` result.
*/
sealed class PermissionState {
/** Permission granted by the user. */
object Granted : PermissionState()
/** Permission denied, but the user may still be re-prompted. */
object Denied : PermissionState()
/**
* Permission denied with "do not ask again" — Android will no
* longer show the system dialog. The UI must deep-link to app
* settings via [openAppSettings].
*
* Trace: SDD-106 §3.
*/
object PermanentlyDenied : PermissionState()
}
private companion object {
private const val TAG = "ChanoraPerm"
/**
* Stable request code for `RECORD_AUDIO`. Must remain stable
* across releases so that result dispatch in
* [handleRequestPermissionsResult] matches the request.
*/
private const val REQ_RECORD_AUDIO = 0x52454341 // "RECA"
/**
* SharedPreferences file backing the cross-process / cross-launch
* "has the user ever been asked for this permission?" flag.
*
* Trace: SDD-106 §3, §4 (M-1 strict-review fix).
*/
private const val PREFS_FILE = "chanora_permissions"
/**
* Boolean key set to `true` the first time we invoke
* [ActivityCompat.requestPermissions] for `RECORD_AUDIO`.
*
* Contract (M-1 strict-review fix):
* - `false` (default) — the user has never been prompted in
* any prior process for `RECORD_AUDIO`. In this state,
* `shouldShowRequestPermissionRationale == false` means
* "fresh / never asked", NOT "permanently denied".
* - `true` — the user has been prompted at least once in
* some prior (or current) process. In this state,
* `shouldShowRequestPermissionRationale == false` after a
* non-granted result indicates "Do not ask again" /
* permanent denial.
*
* Persisting this across process death is what lets
* [onResume] and [handleRequestPermissionsResult] faithfully
* observe revocation per SDD-106 §4 even when Android killed
* and restarted the process during a settings round-trip.
*/
private const val KEY_RECORD_AUDIO_HAS_REQUESTED = "record_audio_has_requested"
}
/**
* In-flight callback for the active permission request, if any.
* Cleared in [handleRequestPermissionsResult]. Single-flight is
* enforced by overwriting (the most recent caller wins); concurrent
* `voice_join` coalescing (SDD-106 §8) is owned by the Rust side.
*/
private var pendingCallback: ((PermissionState) -> Unit)? = null
/**
* Optional listener invoked on every resolved state change. The
* MainActivity wires this to a [io.flutter.plugin.common.MethodChannel]
* on [MethodChannels.ANDROID_PERMISSIONS] in a follow-up task.
*
* Trace: SDD-106 §5.
*/
var stateChangeListener: ((permission: String, state: PermissionState) -> Unit)? = null
/**
* Ensure `RECORD_AUDIO` is granted, prompting the user if not.
*
* Behaviour matrix (SDD-106 §1–§3):
* - Already granted → [callback] invoked synchronously with
* [PermissionState.Granted].
* - Not granted, may prompt → system dialog shown; result
* delivered asynchronously via
* [handleRequestPermissionsResult].
* - Permanently denied → caller is expected to surface
* "Open settings" affordance and call [openAppSettings].
*
* Trace: SDD-106 §1, §2, §3.
*/
fun ensureRecordAudioPermission(
activity: Activity,
callback: (PermissionState) -> Unit,
) {
val permission = Manifest.permission.RECORD_AUDIO
val granted = ContextCompat.checkSelfPermission(activity, permission) ==
PackageManager.PERMISSION_GRANTED
if (granted) {
emit(permission, PermissionState.Granted, callback)
return
}
// Stash the callback; result is dispatched in
// handleRequestPermissionsResult.
pendingCallback = callback
// SDD-106 §3, §4 (M-1 strict-review fix): record that the user
// has now been prompted at least once. This persists across
// process death so subsequent shouldShowRequestPermissionRationale
// == false readings can be classified as permanent denial rather
// than "never asked".
markRecordAudioRequested(activity)
ActivityCompat.requestPermissions(
activity,
arrayOf(permission),
REQ_RECORD_AUDIO,
)
}
/**
* Forwarded from `Activity.onRequestPermissionsResult`. Returns
* `true` if the result was consumed by this requester, `false`
* otherwise (so the caller can chain other requesters).
*
* Trace: SDD-106 §2, §3.
*/
fun handleRequestPermissionsResult(
activity: Activity,
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
): Boolean {
if (requestCode != REQ_RECORD_AUDIO) return false
val cb = pendingCallback
pendingCallback = null
val idx = permissions.indexOf(Manifest.permission.RECORD_AUDIO)
if (idx < 0 || idx >= grantResults.size) {
// Edge case: user dismissed dialog without a result (e.g.
// tap outside on some OEMs). Treat as Denied (re-promptable).
Log.w(TAG, "RECORD_AUDIO result missing from callback; treating as Denied")
emit(Manifest.permission.RECORD_AUDIO, PermissionState.Denied, cb)
return true
}
val state: PermissionState = if (grantResults[idx] == PackageManager.PERMISSION_GRANTED) {
PermissionState.Granted
} else {
// Per SDD-106 §3 (M-1 strict-review fix): distinguish
// "permanently denied" via shouldShowRequestPermissionRationale
// == false AFTER a denial. Because we just returned from a
// system dialog, the persistent has-ever-requested flag is
// guaranteed true at this point; we still consult it for
// symmetry with onResume and to make the contract explicit.
val shouldRationale = ActivityCompat.shouldShowRequestPermissionRationale(
activity,
Manifest.permission.RECORD_AUDIO,
)
val hasEverRequested = hasEverRequestedRecordAudio(activity)
if (shouldRationale) {
PermissionState.Denied
} else if (hasEverRequested) {
PermissionState.PermanentlyDenied
} else {
// Defensive: should be unreachable because we set the
// flag immediately before requestPermissions, but if a
// host bypasses ensureRecordAudioPermission and forwards
// a result, treat the absence of prior ask as Denied
// rather than over-classifying as permanent.
PermissionState.Denied
}
}
emit(Manifest.permission.RECORD_AUDIO, state, cb)
return true
}
/**
* Re-check `RECORD_AUDIO` state on Activity resume.
*
* Contract: [MainActivity] (Wave 2B-4) MUST invoke this from its
* `onResume` so mid-session revocation (SDD-106 §4) — which Android
* may apply by killing/restarting the process — is observed and
* propagated to the Rust audio engine via [stateChangeListener].
*
* The active voice session (per SDD-094) is not torn down here;
* only the cached permission state is refreshed. Clamping
* `capture_active = false` in the listen-only path is the Rust
* audio engine's responsibility (SDD-106 §4, §6).
*
* Trace: SDD-106 §4.
*/
fun onResume(activity: Activity, callback: (PermissionState) -> Unit) {
val permission = Manifest.permission.RECORD_AUDIO
val granted = ContextCompat.checkSelfPermission(activity, permission) ==
PackageManager.PERMISSION_GRANTED
val state: PermissionState = if (granted) {
PermissionState.Granted
} else {
// SDD-106 §3, §4 (M-1 strict-review fix): distinguish three
// cases that all present as "not granted" on cold launch:
//
// (a) Permission was never asked in any prior process —
// persistent flag is false. Surface Denied (re-promptable);
// UI will trigger the system dialog on first use.
// (b) Permission was asked before and is currently denied
// but still re-promptable — shouldShowRequestPermissionRationale
// returns true. Surface Denied.
// (c) Permission was asked before and the user selected
// "Don't ask again" or revoked from Settings — flag is
// true AND shouldShowRequestPermissionRationale is false.
// Surface PermanentlyDenied so the UI can deep-link to
// app settings (SDD-106 §3).
//
// Without the persistent flag, cases (a) and (c) are
// indistinguishable after a process restart, which is what
// the original implementation conservatively folded into
// Denied at the cost of misreporting revocation. The
// SharedPreferences-backed flag closes that gap.
val hasEverRequested = hasEverRequestedRecordAudio(activity)
if (!hasEverRequested) {
PermissionState.Denied
} else {
val shouldRationale = ActivityCompat.shouldShowRequestPermissionRationale(
activity,
Manifest.permission.RECORD_AUDIO,
)
if (shouldRationale) PermissionState.Denied else PermissionState.PermanentlyDenied
}
}
emit(permission, state, callback)
}
/**
* Deep-link to the application's Settings → App info page so the
* user can re-grant a permanently-denied permission.
*
* Trace: SDD-106 §3.
*/
fun openAppSettings(activity: Activity) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", activity.packageName, null)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
activity.startActivity(intent)
} catch (e: android.content.ActivityNotFoundException) {
// Surface for diagnostics; we do not silently swallow.
Log.e(TAG, "Failed to launch app settings: ${e.message}", e)
}
}
/** Emit a resolved state to both the per-request callback and the listener. */
private fun emit(
permission: String,
state: PermissionState,
callback: ((PermissionState) -> Unit)?,
) {
callback?.invoke(state)
stateChangeListener?.invoke(permission, state)
// TODO(Wave 2B follow-up): wire this into a Flutter MethodChannel
// named MethodChannels.ANDROID_PERMISSIONS, invoking method
// MethodChannels.METHOD_PERMISSION_STATE_CHANGED. The Dart
// handler then publishes the corresponding
// BridgeEvent::PermissionState onto the bridge event stream
// (SDD-106 §5).
}
/**
* Read the persistent "has the user ever been asked for RECORD_AUDIO?"
* flag. See [KEY_RECORD_AUDIO_HAS_REQUESTED] for the contract.
*
* Trace: SDD-106 §3, §4 (M-1 strict-review fix).
*/
private fun hasEverRequestedRecordAudio(activity: Activity): Boolean {
val prefs = activity.applicationContext.getSharedPreferences(
PREFS_FILE,
Context.MODE_PRIVATE,
)
return prefs.getBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, false)
}
/**
* Persist that the user has now been prompted for RECORD_AUDIO at
* least once. Idempotent. Uses `apply` (async, lossless across
* process death once committed) since the flag is consulted on
* subsequent launches, not in the same critical section.
*
* Trace: SDD-106 §3, §4 (M-1 strict-review fix).
*/
private fun markRecordAudioRequested(activity: Activity) {
val prefs = activity.applicationContext.getSharedPreferences(
PREFS_FILE,
Context.MODE_PRIVATE,
)
if (!prefs.getBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, false)) {
prefs.edit().putBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, true).apply()
}
}
}
@@ -0,0 +1,362 @@
package app.chanora.chanora_flutter
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/**
* Android foreground service that hosts the lifecycle of an active
* Chanora voice session.
*
* Trace:
* - SDD-107 `AndroidVoiceForegroundService` (foreground service class,
* notification channel id `chanora.voice.session`,
* `foregroundServiceType=microphone` on API 30+, POST_NOTIFICATIONS
* on API 33+, START_NOT_STICKY, ongoing-notification re-post on
* dismissal, lifecycle bound to voice_join / voice_leave /
* shutdown_if_idle).
* - SDD-105 (`AndroidJniBootstrap` — `JavaVM*` capture; the Rust side
* invokes [start] / [stop] via JNI per SDD-107 §10).
* - SDD-106 (`AndroidPermissionRequester` — service may start in
* listen-only mode; type=microphone is still declared so capture can
* resume on grant without a service restart, per SDD-107 §8).
* - SDD-108 (`AndroidAudioModeController` — owns
* `AudioManager.setMode`; this service does NOT touch the audio
* mode, per SDD-107 §8 and SDD-108 §1).
* - SAD-086 (source SAD).
*
* ## Naming
*
* The SDD-107 implementation text references the simple name
* `ChanoraVoiceForegroundService` in a `voice/` subpackage. This file
* uses the FQN `app.chanora.chanora_flutter.AndroidVoiceForegroundService`
* as coordinated for Wave 2B (the AndroidManifest entry declared by
* Wave 2B-1 matches this FQN). The behaviour and lifecycle contract
* are unchanged.
*
* ## Responsibilities
*
* This service is purely the foreground-lifecycle host: it keeps the
* process foregrounded so the Rust audio engine (`crates/chanora_audio`)
* can keep capture / playback streams open while the UI is backgrounded.
*
* It does NOT:
* - call `AudioManager.setMode` (owned by SDD-108 /
* `AndroidAudioModeController`),
* - open or drive audio streams (owned by the Rust audio engine
* via existing JNI on `crates/chanora_audio`),
* - perform any networking.
*/
class AndroidVoiceForegroundService : Service() {
companion object {
private const val TAG = "ChanoraVoiceFGS"
/**
* Notification channel id.
*
* Trace: SDD-107 §4.
*/
private const val CHANNEL_ID = "chanora.voice.session"
/**
* Notification channel user-visible name.
*
* TODO(localization): SDD-107 §4 requires this to be sourced
* from a product string resource. Hard-coded here for the
* Wave 2B implementation slice; localised strings land
* alongside the broader Android string-resource pass.
*/
private const val CHANNEL_NAME = "Voice session"
/**
* Notification channel description (product copy only, no
* server-supplied content per SDD-107 §5 privacy note).
*
* TODO(localization): see [CHANNEL_NAME].
*/
private const val CHANNEL_DESCRIPTION =
"Shown while a Chanora voice session is active."
/**
* Stable notification id ("CHAN") per SDD-107 §5. Must remain
* stable so that re-posts after user dismissal land on the
* same notification slot.
*/
private const val NOTIFICATION_ID = 0x4348414E
/** Intent action: begin / refresh the foreground session. */
const val ACTION_START_VOICE_SESSION: String =
"app.chanora.action.START_VOICE_SESSION"
/** Intent action: terminate the foreground session. */
const val ACTION_STOP_VOICE_SESSION: String =
"app.chanora.action.STOP_VOICE_SESSION"
/**
* Start the service in voice-session mode.
*
* Intended call sites:
* - Kotlin: [MainActivity] or platform glue.
* - Rust: invoked from the `voice_join` bridge handler via
* JNI per SDD-107 §10 (the `JavaVM*` captured by SDD-105
* is used to call this static method).
*
* Trace: SDD-107 §7 (lifecycle — start).
*/
@JvmStatic
fun start(context: Context) {
val intent = Intent(context, AndroidVoiceForegroundService::class.java).apply {
action = ACTION_START_VOICE_SESSION
}
ContextCompat.startForegroundService(context, intent)
}
/**
* Stop the service. Idempotent per SDD-107 §7.
*
* Trace: SDD-107 §7 (lifecycle — stop).
*/
@JvmStatic
fun stop(context: Context) {
val intent = Intent(context, AndroidVoiceForegroundService::class.java).apply {
action = ACTION_STOP_VOICE_SESSION
}
// We deliberately route through startService so the running
// service receives ACTION_STOP_VOICE_SESSION via
// onStartCommand and can perform an orderly stopForeground +
// stopSelf. If the service is already stopped this is a
// no-op aside from a brief onCreate/onDestroy cycle, which
// satisfies the idempotent-stop contract.
try {
context.startService(intent)
} catch (e: IllegalStateException) {
// Background-start restrictions: if the app is in a
// state that disallows starting services (e.g.,
// process being torn down), there is nothing left to
// stop. Log and continue.
Log.w(TAG, "stop() could not deliver intent: ${e.message}")
}
}
}
/**
* Service is start-only — no clients bind. Per SDD-107 §1.
*
* Trace: SDD-107 §1.
*/
override fun onBind(intent: Intent?): IBinder? = null
/**
* Lifecycle entry. Promotes the service to foreground within the
* 5-second platform deadline per SDD-107 §5, dispatches on the
* incoming action, and returns [START_NOT_STICKY] per SDD-107 §7
* (process death must NOT auto-restart; state is rebuilt from the
* audio engine on the next `voice_join`).
*
* Trace: SDD-107 §5, §7.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP_VOICE_SESSION -> {
stopForegroundCompat()
stopSelf()
}
// Treat null action (e.g., service re-creation by the
// system before we return START_NOT_STICKY takes effect)
// and any unknown action the same as START — we must call
// startForeground within 5 seconds of onStartCommand or the
// platform will kill us with a ForegroundServiceDidNotStart
// exception (Android 12+).
ACTION_START_VOICE_SESSION, null -> promoteToForeground()
else -> {
Log.w(TAG, "Unknown action: ${intent.action}; treating as START")
promoteToForeground()
}
}
// SDD-107 §7: do not auto-restart on process death; the next
// voice_join re-starts the service explicitly.
return START_NOT_STICKY
}
/**
* Tear-down. Removes the ongoing notification and clears the
* channel slot per SDD-107 §7 (stop path).
*
* If the user manually dismissed the notification while the
* service was still running (API 34+ allows this per SDD-107 §7),
* the dismissal does NOT stop the session — the audio engine is
* the sole authority for when the service goes away. The service
* re-posts the notification on the next state transition; in
* practice "next state transition" means the next [start] call
* arriving with ACTION_START_VOICE_SESSION, which calls
* [startForeground] again. We do NOT schedule a JobScheduler /
* AlarmManager re-post (per the task scope) — re-posting is
* driven by the Rust audio engine emitting a state tick.
*
* Trace: SDD-107 §7.
*/
override fun onDestroy() {
stopForegroundCompat()
try {
val nm = getSystemService(NOTIFICATION_SERVICE) as? NotificationManager
nm?.cancel(NOTIFICATION_ID)
} catch (e: SecurityException) {
// Unlikely on cancel(), but POST_NOTIFICATIONS-related
// SecurityException surfaces have been reported on some
// OEM builds. Do not crash teardown.
Log.w(TAG, "cancel() raised SecurityException: ${e.message}")
}
super.onDestroy()
}
/**
* Build (or refresh) the channel and call [startForeground].
*
* Trace: SDD-107 §4 (channel), §5 (notification), §6
* (POST_NOTIFICATIONS handling).
*/
private fun promoteToForeground() {
ensureChannel()
val notification = buildNotification()
// POST_NOTIFICATIONS (API 33+): per SDD-107 §6, denial must NOT
// block the service. We still call startForeground; the
// platform will silently suppress the notification if the
// permission is missing. We log a warning so this case is
// observable.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val granted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
if (!granted) {
Log.w(
TAG,
"POST_NOTIFICATIONS not granted; service will run without a " +
"visible notification (SDD-107 §6).",
)
}
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Typed overload required on API 29+ when the manifest
// declares foregroundServiceType=microphone (SDD-107
// §2). On API 34+ this is enforced; declaring it on
// 29+ is forward-safe.
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
)
} else {
// API 28 (minSdk per build.gradle.kts): un-typed
// startForeground is the only available overload.
@Suppress("DEPRECATION")
startForeground(NOTIFICATION_ID, notification)
}
} catch (e: SecurityException) {
// FOREGROUND_SERVICE_MICROPHONE missing on API 34+, or
// RECORD_AUDIO missing while type=microphone is declared.
// Per SDD-107 §6 / §7 we do not crash; the audio engine
// will observe the absence via the BridgeEvent stream and
// clamp to listen-only.
Log.e(
TAG,
"startForeground(type=MICROPHONE) failed: ${e.message}. " +
"Service may not have promoted; SDD-107 §6 listen-only path applies.",
e,
)
}
}
/**
* Create the notification channel on first use. Repeated creates
* are no-ops per Android contract (SDD-107 §4).
*/
private fun ensureChannel() {
// minSdk = 28 (Android 9), so NotificationChannel APIs (O+) are
// unconditionally available.
val nm = getSystemService(NOTIFICATION_SERVICE) as? NotificationManager ?: run {
Log.e(TAG, "NotificationManager unavailable; cannot create channel")
return
}
val existing = nm.getNotificationChannel(CHANNEL_ID)
if (existing != null) return
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW,
).apply {
description = CHANNEL_DESCRIPTION
setShowBadge(false)
}
nm.createNotificationChannel(channel)
}
/**
* Build the ongoing notification.
*
* Privacy: per SDD-107 §5, the notification carries ONLY product
* copy — no server-supplied channel names, user names, or message
* content.
*
* Trace: SDD-107 §5.
*/
private fun buildNotification(): Notification {
val contentIntent: PendingIntent? = packageManager
.getLaunchIntentForPackage(packageName)
?.let { launch ->
PendingIntent.getActivity(
this,
0,
launch,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_speakerphone)
// TODO(SDD-107 §5): replace stat_sys_speakerphone with the
// product icon `ic_chanora_voice` once the drawable lands
// in res/drawable. Using a platform-provided icon as the
// interim placeholder keeps the build green without
// touching res/* (out of this slice's file set).
.setContentTitle("Chanora — Voice session active")
.setContentText("Microphone may be in use.")
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setShowWhen(false)
if (contentIntent != null) {
builder.setContentIntent(contentIntent)
}
return builder.build()
}
/**
* Version-portable `stopForeground` that removes the notification.
*/
private fun stopForegroundCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
}
@@ -0,0 +1,174 @@
package app.chanora.chanora_flutter
import android.os.Build
import android.util.Log
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
/**
* BackIntentBridge — Kotlin half of SDD-028 (`BackIntentService`).
*
* Trace: SDD-028 (Android back-intent registration paths + deterministic
* route-pop ordering) / SAD-018.
*
* Responsibility (Kotlin side):
* * On API 33+ (`Build.VERSION.SDK_INT >= TIRAMISU`) register an
* `OnBackInvokedCallback` against `activity.onBackInvokedDispatcher`
* at `PRIORITY_DEFAULT`.
* * On API < 33, register an `OnBackPressedCallback` (enabled = true)
* against `activity.onBackPressedDispatcher`.
* * Both callbacks consume the system back event (no super / no
* re-dispatch) and forward a single `backIntent` MethodChannel call
* to Dart with payload `{"kind": "system_back"}`. The Dart side
* (`BackIntentService`) owns the deterministic route-pop policy
* (PTT-active → ignore, modal → close, non-root → pop, root →
* `exitCandidate`).
* * When Dart concludes the event is unhandled at the root route, it
* calls back via `popToSystem`, which invokes `activity.finish()`
* exactly once (M-2 strict-review fix: guarded by `isFinishing` /
* `isDestroyed` so a duplicate Dart-side ExitApp decision cannot
* re-enter `finish()`).
*
* Threading: all callbacks are dispatched on the Android main thread,
* matching SDD-028 §4 ("dispatch runs on the platform main thread").
*
* ## Lifecycle / ownership (M-3 strict-review fix)
*
* Previously this type was a Kotlin `object` (process-wide singleton)
* that retained a strong reference to a `FlutterActivity`. Even though
* [detach] cleared the reference, the singleton pattern is an
* Activity-leak footgun: any caller forgetting `detach()` would pin
* the Activity for the lifetime of the process.
*
* The bridge is now a plain `class`. `MainActivity` constructs one
* instance in `configureFlutterEngine`, holds it in a private field,
* and clears the field in `onDestroy` after calling [detach]. The
* Activity is therefore reachable only via the bridge instance, and
* the bridge instance is reachable only via `MainActivity` — when
* `MainActivity` is destroyed, both become eligible for collection.
*/
class BackIntentBridge {
private companion object {
private const val TAG = "BackIntentBridge"
private const val KEY_KIND = "kind"
private const val VALUE_SYSTEM_BACK = "system_back"
}
private var channel: MethodChannel? = null
private var attachedActivity: FlutterActivity? = null
// API 33+ path.
private var onBackInvokedCallback: OnBackInvokedCallback? = null
// Pre-33 path.
private var onBackPressedCallback: OnBackPressedCallback? = null
/**
* Attach the back-intent bridge to [activity] using [messenger] for
* the Dart `MethodChannel`. Idempotent: a second call detaches the
* prior attachment first.
*
* The [activity] reference is retained until [detach] is called.
* The owning `MainActivity` MUST invoke [detach] from its
* `onDestroy` (see class KDoc on lifecycle / ownership).
*/
fun attach(activity: FlutterActivity, messenger: BinaryMessenger) {
// Guard against double-attach (e.g. re-creation under config changes).
detach()
// X-2 strict-review fix: channel + method names sourced from the
// central MethodChannels registry rather than file-local literals.
val ch = MethodChannel(messenger, MethodChannels.BACK_INTENT)
channel = ch
attachedActivity = activity
// SDD-028: Dart -> Kotlin "popToSystem" closes the activity at root.
ch.setMethodCallHandler { call, result ->
when (call.method) {
MethodChannels.METHOD_POP_TO_SYSTEM -> {
// M-2 strict-review fix (SDD-028): guarantee exactly-once
// finish(). If Dart issues two ExitApp decisions in rapid
// succession we must not re-enter Activity teardown.
if (activity.isFinishing || activity.isDestroyed) {
Log.i(
TAG,
"popToSystem received but activity already finishing/destroyed; ignoring duplicate",
)
result.success(null)
return@setMethodCallHandler
}
activity.finish()
result.success(null)
}
else -> result.notImplemented()
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerApi33(activity, ch)
} else {
registerPre33(activity, ch)
}
}
/**
* Detach from the previously-attached activity and tear down all
* registrations. Safe to call repeatedly.
*/
fun detach() {
val activity = attachedActivity
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
onBackInvokedCallback?.let { cb ->
activity.onBackInvokedDispatcher.unregisterOnBackInvokedCallback(cb)
}
}
onBackInvokedCallback = null
onBackPressedCallback?.remove()
onBackPressedCallback = null
channel?.setMethodCallHandler(null)
channel = null
attachedActivity = null
}
private fun registerApi33(activity: FlutterActivity, channel: MethodChannel) {
val cb = OnBackInvokedCallback {
// SDD-028 §1: callback delegates to BackIntentService.dispatch()
// on the Dart side and does NOT call any system fallback.
channel.invokeMethod(
MethodChannels.METHOD_BACK_INTENT,
mapOf(KEY_KIND to VALUE_SYSTEM_BACK),
)
}
activity.onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
cb,
)
onBackInvokedCallback = cb
}
private fun registerPre33(activity: FlutterActivity, channel: MethodChannel) {
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// SDD-028 §1: forward to Dart; do NOT re-invoke the
// system fallback here. If Dart determines the event is
// unhandled (root route), it calls back via
// `popToSystem` which executes `activity.finish()`.
channel.invokeMethod(
MethodChannels.METHOD_BACK_INTENT,
mapOf(KEY_KIND to VALUE_SYSTEM_BACK),
)
}
}
// Cast resolves onBackPressedDispatcher via ComponentActivity (FlutterActivity → FragmentActivity → ComponentActivity).
(activity as ComponentActivity).onBackPressedDispatcher.addCallback(activity, cb)
onBackPressedCallback = cb
}
}
@@ -0,0 +1,62 @@
package app.chanora.chanora_flutter
import android.app.Application
import android.util.Log
/**
* Application subclass that owns the earliest-possible load of the
* `chanora_bridge` native cdylib.
*
* Trace: SDD-105 (AndroidJniBootstrap), DEC-004 (single JavaVM* capture point).
*
* Rationale (SDD-105):
* `JNI_OnLoad` in `crates/chanora_bridge/src/android_init.rs` captures the
* process-wide `JavaVM*` the first time the library is loaded. By performing
* the `System.loadLibrary("chanora_bridge")` call here in `Application.onCreate`
* we guarantee that the VM pointer is available *before* any Flutter plugin,
* background isolate, or FRB-generated stub attempts to call into Rust. This
* also ensures the load happens on the main thread, satisfying the threading
* guarantee documented in SDD-105.
*
* Manifest contract (Wave 2B-1):
* AndroidManifest.xml must reference this class via
* android:name="app.chanora.chanora_flutter.ChanoraApplication"
* in the <application> tag. Wave 2B-1 owns that edit; do not duplicate it
* here.
*/
class ChanoraApplication : Application() {
override fun onCreate() {
super.onCreate()
// SDD-105: load the native bridge as early as possible so JNI_OnLoad
// runs before any FRB call site is reached.
try {
// SDD-105 implementation detail: the Android NDK C++ runtime
// (libc++_shared.so) must be loaded BEFORE chanora_bridge so that
// chanora_bridge's undefined C++ symbols (notably
// __cxa_pure_virtual, __cxa_atexit) resolve via the global
// symbol namespace. libchanora_bridge.so does not carry a
// DT_NEEDED libc++_shared.so entry today (the Rust cdylib build
// does not emit one), so the loader will not auto-pull it just
// because it is co-located in jniLibs/<abi>/. This explicit
// ordered loadLibrary pair is the canonical NDK pattern.
// Follow-up: a cleaner build-side fix is to inject
// `-lc++_shared` into the Rust cdylib link args via
// cargo:rustc-link-lib in a build.rs, producing a DT_NEEDED
// entry that makes this manual ordering unnecessary.
System.loadLibrary("c++_shared")
System.loadLibrary("chanora_bridge")
} catch (t: UnsatisfiedLinkError) {
// SDD-105: panic-safe FFI boundary — log loudly, then rethrow so
// the process fails fast rather than silently running without the
// Rust core. A swallowed link error would manifest much later as
// a confusing UnsatisfiedLinkError on the first FRB call.
Log.e(TAG, "Failed to load native library", t)
throw t
}
}
companion object {
private const val TAG = "ChanoraApp"
}
}
@@ -1,33 +1,228 @@
package app.chanora.chanora_flutter
import android.content.Context
import android.os.Bundle
import app.chanora.chanora_flutter.AndroidPermissionRequester
import app.chanora.chanora_flutter.BackIntentBridge
import app.chanora.chanora_flutter.MethodChannels
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
/**
* Host activity for the Chanora Flutter shell.
*
* Trace: SDD-105 (AndroidJniBootstrap), SDD-028 (Android lifecycle wiring),
* SDD-106 (AndroidPermissionRequester), DEC-004 (single JNI bootstrap path).
*
* Note (SDD-105):
* The `System.loadLibrary("chanora_bridge")` call previously lived in this
* activity's companion-object initialiser. It has been moved to
* [ChanoraApplication.onCreate] so the native library — and therefore
* `JNI_OnLoad`'s `JavaVM*` capture — is available before any plugin or
* background isolate touches the bridge. See ChanoraApplication.kt and
* the AndroidManifest <application android:name> declaration owned by
* Wave 2B-1.
*/
class MainActivity : FlutterActivity() {
companion object {
init {
// Force-load the chanora_bridge cdylib at activity-class-init
// time so JNI_OnLoad captures the JavaVM* before Flutter / FRB
// tries to call any Rust function.
System.loadLibrary("chanora_bridge")
}
/**
* JNI entry point implemented in `chanora_bridge::android_init`.
* Initialises `ndk_context` with our Activity so cpal-on-Oboe can
* find Android audio services when `chanora_audio` starts the
* capture / playback streams.
*
* Trace: SDD-105 (AndroidJniBootstrap). Signature must remain stable;
* the Rust side declares the matching `extern "system"` symbol.
*/
@JvmStatic
external fun initChanoraContext(context: android.content.Context)
external fun initChanoraContext(context: Context)
/**
* JNI entry point implemented in `chanora_bridge::permission_jni`.
* Forwards a resolved Android runtime-permission state into the
* Rust bridge, which (a) clamps the audio engine's transmit
* selector when `permission == "android.permission.RECORD_AUDIO"`
* and the state is anything other than `Granted`, and (b)
* broadcasts a `BridgeEvent::PermissionState` so the Dart UI
* observes the authoritative state alongside the existing
* MethodChannel.
*
* The Rust side wraps the body in `catch_unwind` so a panic in
* the bridge never unwinds into the JVM.
*
* Trace: SDD-106 §5, §6; SRS-209. Signature must remain stable;
* the Rust side declares the matching `extern "system"` symbol
* `Java_app_chanora_chanora_1flutter_MainActivity_publishPermissionState`.
*/
@JvmStatic
external fun publishPermissionState(permission: String, state: String)
}
// SDD-106: Activity-bound permission requester. Nullable because it is
// only constructed once the FlutterEngine is configured; lifecycle
// callbacks (onResume / onRequestPermissionsResult) must null-guard.
private var permissionRequester: AndroidPermissionRequester? = null
// SDD-106: Retained so onResume / onDestroy can forward state changes
// to Dart on MethodChannels.ANDROID_PERMISSIONS.
private var permissionsChannel: MethodChannel? = null
// SDD-028 (M-3 strict-review fix): the back-intent bridge is owned
// by this Activity instance, not a process-wide `object`. Constructed
// in configureFlutterEngine and cleared in onDestroy after detach().
private var backIntentBridge: BackIntentBridge? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Pass the Application context to the Rust side so the audio
// engine can open device handles. Must run before any
// `chanora_audio` call from Dart.
// SDD-105: pass the Application context to the Rust side so the
// audio engine can open device handles. Must run on the main thread
// before any `chanora_audio` call from Dart. The native library is
// already loaded by ChanoraApplication.onCreate at this point.
initChanoraContext(applicationContext)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// SDD-028 / DEC-004: attach the Android -> Dart back-intent bridge to
// the FlutterEngine's binary messenger as soon as the engine is
// available. The bridge forwards hardware-back and intent-back events
// into Dart's navigation stack.
//
// SDD-028 (M-3 strict-review fix): BackIntentBridge is now a class
// instance owned by this Activity rather than a process-wide `object`,
// eliminating the singleton-Activity-leak footgun.
val bridge = BackIntentBridge()
bridge.attach(this, flutterEngine.dartExecutor.binaryMessenger)
backIntentBridge = bridge
// SDD-106: Wired in fast-builder follow-up; closes Wave 2B-4 coordination gap.
// Construct the permissions MethodChannel and the Activity-bound
// requester, then wire stateChangeListener to forward resolved
// PermissionState transitions to Dart.
val channel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
MethodChannels.ANDROID_PERMISSIONS,
)
permissionsChannel = channel
val requester = AndroidPermissionRequester()
requester.stateChangeListener = { permission, state ->
val stateName = state.toString()
channel.invokeMethod(
MethodChannels.METHOD_PERMISSION_STATE_CHANGED,
mapOf(
"permission" to permission,
"state" to stateName,
),
)
// SDD-106 §5/§6: also forward the resolved state into the
// Rust bridge so `TransmitModeSelector` clamps the
// transmit gate authoritatively (independent of whether
// the Dart UI has re-rendered yet). The Rust side is
// panic-safe via `catch_unwind`; we still guard with
// try/catch here so a `UnsatisfiedLinkError` (e.g. an
// unexpected ABI mismatch) cannot crash MainActivity.
// Trace: SDD-106 §5, §6; SRS-209.
try {
publishPermissionState(permission, stateName)
} catch (t: Throwable) {
android.util.Log.w(
"Chanora",
"publishPermissionState JNI hook failed: ${t.message}",
t,
)
}
}
permissionRequester = requester
// SDD-106 §1, §3 (Dart-side integration follow-up): handle
// outbound Dart -> Kotlin calls so the Flutter UI can drive the
// runtime permission request and the settings deep-link. The
// resolved state is still delivered asynchronously via
// stateChangeListener -> METHOD_PERMISSION_STATE_CHANGED.
//
// Trace: SDD-106, SRS-209.
channel.setMethodCallHandler { call, result ->
when (call.method) {
"requestRecordAudio" -> {
val r = permissionRequester
if (r != null) {
r.ensureRecordAudioPermission(this) { _ -> }
result.success(null)
} else {
result.error(
"no_requester",
"AndroidPermissionRequester not bound",
null,
)
}
}
"openAppSettings" -> {
val r = permissionRequester
if (r != null) {
r.openAppSettings(this)
result.success(null)
} else {
result.error(
"no_requester",
"AndroidPermissionRequester not bound",
null,
)
}
}
else -> result.notImplemented()
}
}
}
override fun onResume() {
super.onResume()
// SDD-106: re-evaluate Android runtime permission state on resume and
// forward the result to Dart via MethodChannel
// "app.chanora/android_permissions". Must be null-safe: if the
// requester is not yet wired we no-op rather than crashing.
//
// SDD-106: Wired in fast-builder follow-up; closes Wave 2B-4 coordination gap.
val requester = permissionRequester
if (requester != null) {
// SDD-106: state already emitted by AndroidPermissionRequester via stateChangeListener; do NOT double-emit (M-4 fix)
requester.onResume(this) { _ -> }
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
// SDD-106: Wired in fast-builder follow-up; closes Wave 2B-4 coordination gap.
// Forward the system result into the Activity-bound requester so its
// pending callback resolves and stateChangeListener fires.
permissionRequester?.handleRequestPermissionsResult(
this,
requestCode,
permissions,
grantResults,
)
}
override fun onDestroy() {
// SDD-028: detach the back-intent bridge before the activity is torn
// down so the FlutterEngine's binary messenger isn't retained.
//
// SDD-028 (M-3 strict-review fix): drop the owning reference so the
// bridge instance — and the Activity it retains — become eligible
// for collection immediately after onDestroy.
backIntentBridge?.detach()
backIntentBridge = null
// SDD-106: drop the Dart->Kotlin handler before nilling the
// channel so a late invokeMethod from Dart cannot land on a
// dangling requester reference.
permissionsChannel?.setMethodCallHandler(null)
permissionRequester = null
permissionsChannel = null
super.onDestroy()
}
}
@@ -0,0 +1,67 @@
package app.chanora.chanora_flutter
/**
* Central registry of Flutter MethodChannel names used by the Android
* platform code.
*
* Trace:
* - SDD-106 (`AndroidPermissionRequester` — bridge event surface)
* - SRS-209 (Android runtime permission UX)
*
* The channels declared here are the Kotlin-side contract only. The
* Dart-side handlers that subscribe / dispatch on these channels are
* deliberately out of scope for the Wave 2B-2 implementation slice and
* are handed off to a follow-up task.
*/
internal object MethodChannels {
/**
* Channel for Android runtime-permission state events emitted by
* [AndroidPermissionRequester]. The Kotlin side invokes
* [METHOD_PERMISSION_STATE_CHANGED] whenever the resolved permission
* state transitions.
*
* Trace: SDD-106 §5 (Bridge event surface), SRS-209.
*/
const val ANDROID_PERMISSIONS: String = "app.chanora/android_permissions"
/**
* Method name invoked on [ANDROID_PERMISSIONS] when the resolved
* permission state for a tracked Android runtime permission
* changes. Arguments are a `Map<String, Any>` with keys:
* - "permission": String (Android permission constant, e.g.
* "android.permission.RECORD_AUDIO")
* - "state": String (one of "Granted", "Denied",
* "PermanentlyDenied")
*
* Trace: SDD-106 §5.
*/
const val METHOD_PERMISSION_STATE_CHANGED: String = "permissionStateChanged"
/**
* Channel name for the Android back-intent bridge (Kotlin <-> Dart).
*
* The Dart side uses the matching constant `backIntentChannelName` in
* `lib/services/back_intent_service.dart`; both must remain in sync.
*
* Trace: SDD-028 (BackIntentService), SAD-018. X-2 strict-review fix:
* consolidated from BackIntentBridge.kt's previous private literal.
*/
const val BACK_INTENT: String = "app.chanora/back_intent"
/**
* Method invoked on [BACK_INTENT] from Kotlin -> Dart when a system
* back event fires. Payload: `{"kind": "system_back"}`.
*
* Trace: SDD-028 §1. X-2 strict-review fix.
*/
const val METHOD_BACK_INTENT: String = "backIntent"
/**
* Method invoked on [BACK_INTENT] from Dart -> Kotlin when the Dart
* side concludes the back event is unhandled at the root route and
* the activity should finish.
*
* Trace: SDD-028 §1. X-2 strict-review fix.
*/
const val METHOD_POP_TO_SYSTEM: String = "popToSystem"
}