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