feat: Android Oboe voice backend — WebRTC APM, VAD, HW/SW toggle, BBCode welcome, link trust, foreground task
Audio engine (Rust): - Android Oboe: WebRTC APM (AEC/NS/AGC/HPF) + TEN/Silero ONNX VAD - Hardware effects (JNI) with software fallback per-effect - Render reference buffer for AEC between output/capture callbacks - Voice activity gate: suppress transmission when speaker muted (all platforms) - Audio focus (SDD-109) + Bluetooth SCO (SDD-110) via JNI - ONNX Runtime 1.26 via ort 2.0.0-rc.12 (down from rc.10, ndarray 0.17) - VAD worker channel capacity 8→32, initial seq u64::MAX (warm-up fix) - TEN VAD default backend (was Silero) - Platform→WebrtcApm resolution after hardware binding - oboe-rs edisonjwa fork with get_raw_session_id() Android Kotlin: - AndroidAudioFocusController + AndroidBluetoothScoController - AndroidAudioLifecycleController (route changes to Flutter) - ProGuard rules for new controllers Flutter UI: - VoiceSettings: Android HW/SW toggle (Platform auto / WebRTC APM) - VoiceStatusChip: mute warning border + Speaker muted label - BBCode welcome message parser (BbCodeText, case-insensitive) - Welcome message foldable (expanded by default) - Link trust dialog (domain wildcards, SharedPreferences) - HapticFeedback on voice sheet opener - Server name in AppBar, version v0.1.0 - Default channel (id=1) visible, serverquery clients hidden - flutter_foreground_task integration Config: - ort load-dynamic on all non-iOS (Android/Linux/Windows) - ONNX Runtime AAR 1.26.0 - ndarray moved to common deps (was Apple-only)
This commit is contained in:
@@ -77,7 +77,7 @@ android {
|
||||
// ANDROID_PLATFORM as -D variables to the child cmake
|
||||
// invocation). See Cargo.toml [patch.crates-io] block and
|
||||
// docs/governance/product-decision-register.md DEC-032.
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,14 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// ONNX Runtime native library for Silero / TEN VAD.
|
||||
// The ort crate (Rust) loads libonnxruntime.so via dlopen at runtime
|
||||
// (`load-dynamic` feature). The AAR ships the .so for arm64-v8a,
|
||||
// armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB.
|
||||
dependencies {
|
||||
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.26.0")
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
-keep class app.chanora.chanora_flutter.AndroidVoiceForegroundService { *; }
|
||||
-keep class app.chanora.chanora_flutter.AndroidPermissionRequester { *; }
|
||||
-keep class app.chanora.chanora_flutter.BackIntentBridge { *; }
|
||||
# SDD-109 / SDD-110 / SDD-111: JNI-referenced voice controllers.
|
||||
# Called from the Rust audio engine via JNI static methods.
|
||||
-keep class app.chanora.chanora_flutter.AndroidAudioFocusController { *; }
|
||||
-keep class app.chanora.chanora_flutter.AndroidBluetoothScoController { *; }
|
||||
-keep class app.chanora.chanora_flutter.AndroidAudioLifecycleController { *; }
|
||||
-keep class io.flutter.plugins.** { *; }
|
||||
|
||||
# flutter_rust_bridge generated bindings (SDD-079 TypedBridgeFacade) — keep
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
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" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 6).
|
||||
Required on API 33+ to display the ongoing voice-session
|
||||
@@ -34,6 +35,17 @@
|
||||
related in-call audio routing operations. -->
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<!-- SDD-trace: SDD-110 AndroidBluetoothScoController.
|
||||
BLUETOOTH_ADMIN required for startBluetoothSco() / stopBluetoothSco()
|
||||
on API 23-30. Superseded by BLUETOOTH_CONNECT on API 31+. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<!-- SDD-trace: SDD-110 AndroidBluetoothScoController.
|
||||
BLUETOOTH required to query BluetoothAdapter on API < 31. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<!-- SDD-trace: Android Oboe voice backend; supports SCO/BLE
|
||||
headset routing on API 31+. Declared here so the platform
|
||||
allows querying / connecting to bonded Bluetooth audio devices
|
||||
@@ -80,6 +92,13 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone" />
|
||||
|
||||
<!-- flutter_foreground_task: keeps the Flutter engine alive when
|
||||
connected to a server so voice chat is not killed in background. -->
|
||||
<service
|
||||
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package app.chanora.chanora_flutter
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Manages Android audio focus (requestAudioFocus / abandonAudioFocus) for
|
||||
* the Chanora voice session.
|
||||
*
|
||||
* Trace: SDD-109 (Android Audio Focus)
|
||||
*
|
||||
* ## Lifecycle
|
||||
*
|
||||
* 1. [start] — called by the Rust audio engine (via JNI) after the Oboe
|
||||
* voice streams are opened. Requests `AUDIOFOCUS_GAIN` for
|
||||
* `USAGE_VOICE_COMMUNICATION` / `CONTENT_TYPE_SPEECH` and registers
|
||||
* an `OnAudioFocusChangeListener`.
|
||||
* 2. Focus-change callbacks are forwarded to the Rust engine via
|
||||
* `publishFocusChange(int)`, a JNI function declared in
|
||||
* `crates/chanora_audio/src/android_voice_unit.rs`.
|
||||
* 3. [stop] — called by the Rust engine on voice stop. Abandons focus
|
||||
* and clears the listener.
|
||||
*
|
||||
* ## Thread model
|
||||
*
|
||||
* `start` / `stop` are called from a tokio worker thread (via JNI), not
|
||||
* the Android main thread. The `AudioManager` API is thread-safe.
|
||||
* `OnAudioFocusChangeListener` callbacks arrive on the main thread; we
|
||||
* forward to Rust via JNI which attaches the calling thread to the JVM.
|
||||
*/
|
||||
internal class AndroidAudioFocusController {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChanoraAudioFocus"
|
||||
|
||||
/**
|
||||
* JNI entry point implemented in
|
||||
* `crates/chanora_audio/src/android_voice_unit.rs`.
|
||||
*
|
||||
* Kotlin calls this from [OnAudioFocusChangeListener] to forward
|
||||
* the focus-change integer to the Rust engine's BackendEvent channel.
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun publishFocusChange(state: Int)
|
||||
|
||||
/**
|
||||
* Start audio focus management.
|
||||
*
|
||||
* Called from Rust via JNI after voice unit start.
|
||||
* Idempotent: repeated calls against an already-started instance
|
||||
* are silently ignored.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun start(context: Context) {
|
||||
if (focusRequested) {
|
||||
Log.d(TAG, "start() called but focus already held; no-op")
|
||||
return
|
||||
}
|
||||
requestFocus(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop audio focus management.
|
||||
*
|
||||
* Called from Rust via JNI on voice stop.
|
||||
* Idempotent: safe to call when no focus is held.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun stop(context: Context) {
|
||||
if (!focusRequested) {
|
||||
Log.d(TAG, "stop() called but no focus held; no-op")
|
||||
return
|
||||
}
|
||||
abandonFocus(context)
|
||||
}
|
||||
|
||||
private var focusRequested: Boolean = false
|
||||
private var focusRequestHandle: AudioFocusRequest? = null
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private val audioFocusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
||||
Log.i(TAG, "onAudioFocusChange: $focusChange")
|
||||
try {
|
||||
publishFocusChange(focusChange)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "publishFocusChange JNI failed: ${t.message}", t)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestFocus(context: Context) {
|
||||
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
if (am == null) {
|
||||
Log.e(TAG, "AudioManager unavailable; cannot request audio focus")
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val attr = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(attr)
|
||||
.setOnAudioFocusChangeListener(audioFocusListener, mainHandler)
|
||||
.build()
|
||||
val result = am.requestAudioFocus(request)
|
||||
focusRequested = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
if (focusRequested) {
|
||||
focusRequestHandle = request
|
||||
}
|
||||
Log.i(TAG, "requestAudioFocus result=$result granted=$focusRequested")
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val result = am.requestAudioFocus(
|
||||
audioFocusListener,
|
||||
AudioManager.STREAM_VOICE_CALL,
|
||||
AudioManager.AUDIOFOCUS_GAIN,
|
||||
)
|
||||
focusRequested = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
Log.i(TAG, "requestAudioFocus result=$result granted=$focusRequested")
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "requestAudioFocus denied by platform: ${e.message}", e)
|
||||
focusRequested = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun abandonFocus(context: Context) {
|
||||
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
if (am == null) {
|
||||
Log.e(TAG, "AudioManager unavailable; cannot abandon audio focus")
|
||||
focusRequested = false
|
||||
focusRequestHandle = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val handle = focusRequestHandle
|
||||
focusRequestHandle = null
|
||||
if (handle != null) {
|
||||
am.abandonAudioFocusRequest(handle)
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
am.abandonAudioFocus(audioFocusListener)
|
||||
}
|
||||
Log.i(TAG, "abandonAudioFocus dispatched")
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "abandonAudioFocus failed: ${e.message}", e)
|
||||
}
|
||||
focusRequested = false
|
||||
focusRequestHandle = null
|
||||
}
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package app.chanora.chanora_flutter
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Android audio lifecycle controller.
|
||||
*
|
||||
* Mirrors the iOS `AppDelegate` audio lifecycle events — route changes,
|
||||
* device connectivity changes, and app lifecycle transitions — and
|
||||
* forwards them to the Dart layer over a MethodChannel. The Dart side
|
||||
* dispatches these to the Rust bridge, the same pattern as
|
||||
* `_wireIosAudioLifecycle()` in `main.dart`.
|
||||
*
|
||||
* Trace: SDD-111 (MobileVoiceAudioBackend cross-platform)
|
||||
*
|
||||
* ## Events published
|
||||
*
|
||||
* `routeChange` — audio device connected/disconnected (payload:
|
||||
* `{"routeType": String}`). Mimics iOS
|
||||
* `handleRouteChange`.
|
||||
* `interruptionBegan` — audio interruption started (e.g. phone call).
|
||||
* `interruptionEnded` — audio interruption ended, with `shouldResume`.
|
||||
* `appDidEnterBackground` — app moved to background.
|
||||
* `appWillEnterForeground` — app returned to foreground.
|
||||
* `mediaServicesReset` — equivalent of iOS media services reset
|
||||
* (Android: audio devices changed significantly).
|
||||
*
|
||||
* ## Thread model
|
||||
*
|
||||
* All callbacks from `AudioDeviceCallback` arrive on the main thread
|
||||
* (registered with the main `Handler`). App lifecycle observation is
|
||||
* driven by the Flutter `AppLifecycleListener` on the Dart side;
|
||||
* this controller only owns the audio-device side. The Flutter side
|
||||
* is responsible for wiring lifecycle and forwarding events to Rust.
|
||||
*/
|
||||
internal class AndroidAudioLifecycleController(
|
||||
private val context: Context,
|
||||
) {
|
||||
private val audioManager: AudioManager =
|
||||
context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var callbackRegistered = false
|
||||
|
||||
private var currentRouteType: String? = null
|
||||
|
||||
private val audioDeviceCallback = object : AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||
notifyRouteChange(addedDevices.firstOrNull())
|
||||
}
|
||||
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
|
||||
notifyRouteChange(removedDevices.firstOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
private var channel: MethodChannel? = null
|
||||
|
||||
/**
|
||||
* Attach to [flutterEngine]'s binary messenger.
|
||||
*
|
||||
* Creates a MethodChannel named `chanora/android_audio_lifecycle`
|
||||
* and starts observing audio device changes.
|
||||
*/
|
||||
fun attach(flutterEngine: FlutterEngine) {
|
||||
channel = MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
CHANNEL_NAME,
|
||||
)
|
||||
startObservingAudioDevices()
|
||||
Log.i(TAG, "attached to flutter engine")
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach from the Flutter engine and stop observing.
|
||||
*/
|
||||
fun detach() {
|
||||
stopObservingAudioDevices()
|
||||
channel?.setMethodCallHandler(null)
|
||||
channel = null
|
||||
Log.i(TAG, "detached")
|
||||
}
|
||||
|
||||
/**
|
||||
* Call from `Activity.onResume` to re-evaluate the current route.
|
||||
*/
|
||||
fun onResume() {
|
||||
notifyRouteChange(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Call from `Activity.onDestroy` to tear down.
|
||||
*/
|
||||
fun onDestroy() {
|
||||
detach()
|
||||
}
|
||||
|
||||
private fun startObservingAudioDevices() {
|
||||
if (callbackRegistered) return
|
||||
audioManager.registerAudioDeviceCallback(audioDeviceCallback, mainHandler)
|
||||
callbackRegistered = true
|
||||
Log.d(TAG, "audio device observer started")
|
||||
}
|
||||
|
||||
private fun stopObservingAudioDevices() {
|
||||
if (!callbackRegistered) return
|
||||
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
||||
callbackRegistered = false
|
||||
Log.d(TAG, "audio device observer stopped")
|
||||
}
|
||||
|
||||
private fun notifyRouteChange(
|
||||
device: AudioDeviceInfo?,
|
||||
) {
|
||||
val routeType = classifyCurrentRoute(device)
|
||||
if (routeType == currentRouteType) {
|
||||
return
|
||||
}
|
||||
currentRouteType = routeType
|
||||
Log.i(TAG, "route changed to: $routeType")
|
||||
channel?.invokeMethod(
|
||||
"handleRouteChange",
|
||||
mapOf("routeType" to routeType),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the current audio output route into a stable string
|
||||
* matching the iOS route-classification schema so the Dart-side
|
||||
* parser (`_parseBridgeAudioRoute`) works identically across
|
||||
* platforms.
|
||||
*/
|
||||
private fun classifyCurrentRoute(specificDevice: AudioDeviceInfo?): String {
|
||||
// If a specific device was added/removed, prefer its type.
|
||||
if (specificDevice != null) {
|
||||
return classifyDevice(specificDevice)
|
||||
}
|
||||
|
||||
// Otherwise, classify based on the current output devices.
|
||||
val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||
if (outputs.isEmpty()) return "Unknown"
|
||||
|
||||
// Prefer wired/Bluetooth headset if connected.
|
||||
for (d in outputs) {
|
||||
val t = classifyDevice(d)
|
||||
when (t) {
|
||||
"WiredHeadset", "BluetoothHfp", "BluetoothA2dp", "UsbHeadset" -> return t
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
// Fall back to the first output device classification.
|
||||
val first = classifyDevice(outputs.first())
|
||||
return when (first) {
|
||||
"Speaker" -> "Speaker"
|
||||
"Earpiece" -> "Earpiece"
|
||||
else -> "Speaker" // Default to Speaker for unknown outputs.
|
||||
}
|
||||
}
|
||||
|
||||
private fun classifyDevice(d: AudioDeviceInfo): String = when (d.type) {
|
||||
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Earpiece"
|
||||
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker"
|
||||
AudioDeviceInfo.TYPE_WIRED_HEADSET,
|
||||
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "WiredHeadset"
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BluetoothHfp"
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "BluetoothA2dp"
|
||||
AudioDeviceInfo.TYPE_BLE_HEADSET,
|
||||
AudioDeviceInfo.TYPE_BLE_SPEAKER -> if (Build.VERSION.SDK_INT >= 31) "BluetoothHfp" else "BluetoothA2dp"
|
||||
AudioDeviceInfo.TYPE_USB_HEADSET -> "UsbHeadset"
|
||||
AudioDeviceInfo.TYPE_HDMI -> "Hdmi"
|
||||
else -> {
|
||||
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
d.type == AudioDeviceInfo.TYPE_BLE_BROADCAST
|
||||
) {
|
||||
"BluetoothA2dp"
|
||||
} else {
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChanoraAudioLifecycle"
|
||||
|
||||
/**
|
||||
* MethodChannel name for Android audio lifecycle events.
|
||||
* Mirrors `chanora/ios_audio_lifecycle` on iOS.
|
||||
*/
|
||||
const val CHANNEL_NAME: String = "chanora/android_audio_lifecycle"
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
package app.chanora.chanora_flutter
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.media.AudioManager
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Manages Android Bluetooth SCO (Synchronous Connection-Oriented) audio
|
||||
* for the Chanora voice session.
|
||||
*
|
||||
* Trace: SDD-110 (Android Bluetooth SCO)
|
||||
*
|
||||
* Bluetooth SCO is the low-latency, monaural audio path used by Bluetooth
|
||||
* headsets for phone calls. Without SCO, voice audio may route through
|
||||
* A2DP which is stereo, high-latency, and lacks the codec support for
|
||||
* two-way communication. On Android we must explicitly start/stop SCO
|
||||
* when a Bluetooth headset is present; the platform does not auto-manage
|
||||
* this for VoIP apps.
|
||||
*
|
||||
* ## Lifecycle
|
||||
*
|
||||
* 1. [start] — called by the Rust audio engine (via JNI) after the Oboe
|
||||
* voice streams are opened. Calls `AudioManager.startBluetoothSco()`
|
||||
* if a Bluetooth SCO-capable device is connected and registers a
|
||||
* `BroadcastReceiver` for `ACTION_SCO_AUDIO_STATE_UPDATED`.
|
||||
* 2. SCO state changes are forwarded to the Rust engine via
|
||||
* `publishScoStateChange(int)`, a JNI function declared in
|
||||
* `crates/chanora_audio/src/android_voice_unit.rs`.
|
||||
* 3. [stop] — called by the Rust engine on voice stop. Calls
|
||||
* `AudioManager.stopBluetoothSco()` and unregisters the receiver.
|
||||
*
|
||||
* ## Thread model
|
||||
*
|
||||
* `start` / `stop` are called from a tokio worker thread (via JNI).
|
||||
* `AudioManager.startBluetoothSco` is asynchronous — the platform
|
||||
* responds with `ACTION_SCO_AUDIO_STATE_UPDATED` which arrives on the
|
||||
* main thread via the `BroadcastReceiver`.
|
||||
*/
|
||||
internal class AndroidBluetoothScoController {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChanoraBluetoothSco"
|
||||
|
||||
/**
|
||||
* JNI entry point implemented in
|
||||
* `crates/chanora_audio/src/android_voice_unit.rs`.
|
||||
*
|
||||
* Kotlin calls this from the SCO state `BroadcastReceiver` to
|
||||
* forward the state integer to the Rust engine's BackendEvent
|
||||
* channel.
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun publishScoStateChange(state: Int)
|
||||
|
||||
/**
|
||||
* Start Bluetooth SCO management.
|
||||
*
|
||||
* Called from Rust via JNI after voice unit start.
|
||||
* Idempotent: repeated calls against an already-started instance
|
||||
* are silently ignored.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun start(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
if (scoStarted) {
|
||||
Log.d(TAG, "start() called but SCO already active; no-op")
|
||||
return
|
||||
}
|
||||
scoStarted = true
|
||||
registerScoReceiver(appContext)
|
||||
tryStartSco(appContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Bluetooth SCO management.
|
||||
*
|
||||
* Called from Rust via JNI on voice stop.
|
||||
* Idempotent: safe to call when SCO is not active.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun stop(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
scoStarted = false
|
||||
unregisterScoReceiver(appContext)
|
||||
tryStopSco(appContext)
|
||||
}
|
||||
|
||||
private var scoStarted: Boolean = false
|
||||
private var receiverRegistered: Boolean = false
|
||||
|
||||
private val scoReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action != AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) return
|
||||
val state = intent.getIntExtra(
|
||||
AudioManager.EXTRA_SCO_AUDIO_STATE,
|
||||
AudioManager.SCO_AUDIO_STATE_ERROR,
|
||||
)
|
||||
val prevState = intent.getIntExtra(
|
||||
AudioManager.EXTRA_SCO_AUDIO_PREVIOUS_STATE,
|
||||
-1,
|
||||
)
|
||||
Log.i(
|
||||
TAG,
|
||||
"SCO state: ${scoStateName(state)} (prev: ${scoStateName(prevState)})",
|
||||
)
|
||||
try {
|
||||
publishScoStateChange(state)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "publishScoStateChange JNI failed: ${t.message}", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerScoReceiver(context: Context) {
|
||||
if (receiverRegistered) return
|
||||
try {
|
||||
val filter = IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(scoReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
context.registerReceiver(scoReceiver, filter)
|
||||
}
|
||||
receiverRegistered = true
|
||||
Log.d(TAG, "SCO receiver registered")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to register SCO receiver: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterScoReceiver(context: Context) {
|
||||
if (!receiverRegistered) return
|
||||
try {
|
||||
context.unregisterReceiver(scoReceiver)
|
||||
receiverRegistered = false
|
||||
Log.d(TAG, "SCO receiver unregistered")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Already unregistered — ignore silently.
|
||||
receiverRegistered = false
|
||||
}
|
||||
}
|
||||
|
||||
private val bluetoothAdapter: BluetoothAdapter?
|
||||
get() = try {
|
||||
BluetoothAdapter.getDefaultAdapter()
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "BluetoothAdapter unavailable: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
private fun isBluetoothScoOn(am: AudioManager): Boolean = try {
|
||||
am.isBluetoothScoOn
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "isBluetoothScoOn failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
private fun isBluetoothScoAvailableOffCall(am: AudioManager): Boolean = try {
|
||||
am.isBluetoothScoAvailableOffCall
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "isBluetoothScoAvailableOffCall failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
private fun tryStartSco(context: Context) {
|
||||
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
if (am == null) {
|
||||
Log.e(TAG, "AudioManager unavailable; cannot start SCO")
|
||||
return
|
||||
}
|
||||
|
||||
if (isBluetoothScoOn(am)) {
|
||||
Log.i(TAG, "SCO already on; no-op")
|
||||
return
|
||||
}
|
||||
|
||||
val adapter = bluetoothAdapter
|
||||
if (adapter == null || !adapter.isEnabled) {
|
||||
Log.i(TAG, "Bluetooth not enabled; skipping SCO start")
|
||||
return
|
||||
}
|
||||
|
||||
val scoAvailableOffCall = isBluetoothScoAvailableOffCall(am)
|
||||
if (!scoAvailableOffCall) {
|
||||
Log.i(TAG, "Bluetooth SCO not available off-call; skipping SCO start")
|
||||
return
|
||||
}
|
||||
|
||||
val hasScoHeadset = try {
|
||||
val state = adapter.getProfileConnectionState(BluetoothProfile.HEADSET)
|
||||
state == BluetoothProfile.STATE_CONNECTED
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "getProfileConnectionState(HEADSET) failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
if (!hasScoHeadset) {
|
||||
Log.i(TAG, "No SCO-capable Bluetooth headset connected; skipping SCO start")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
am.startBluetoothSco()
|
||||
Log.i(TAG, "startBluetoothSco() dispatched")
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "startBluetoothSco denied: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryStopSco(context: Context) {
|
||||
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
if (am == null) {
|
||||
Log.e(TAG, "AudioManager unavailable; cannot stop SCO")
|
||||
return
|
||||
}
|
||||
if (!isBluetoothScoOn(am)) {
|
||||
Log.d(TAG, "SCO not on; no-op")
|
||||
return
|
||||
}
|
||||
try {
|
||||
am.stopBluetoothSco()
|
||||
Log.i(TAG, "stopBluetoothSco() dispatched")
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "stopBluetoothSco denied: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scoStateName(state: Int): String = when (state) {
|
||||
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> "DISCONNECTED"
|
||||
AudioManager.SCO_AUDIO_STATE_CONNECTED -> "CONNECTED"
|
||||
AudioManager.SCO_AUDIO_STATE_CONNECTING -> "CONNECTING"
|
||||
AudioManager.SCO_AUDIO_STATE_ERROR -> "ERROR"
|
||||
else -> "UNKNOWN($state)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -76,6 +76,11 @@ class MainActivity : FlutterActivity() {
|
||||
private var audioOutputEvents: EventChannel? = null
|
||||
private var audioOutputController: AndroidAudioOutputController? = null
|
||||
|
||||
// SDD-111: Android audio lifecycle controller (route changes, device
|
||||
// add/remove, app lifecycle). Mirrors the iOS
|
||||
// `chanora/ios_audio_lifecycle` channel pattern.
|
||||
private var audioLifecycleController: AndroidAudioLifecycleController? = 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().
|
||||
@@ -202,6 +207,12 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
this.audioOutputEvents = audioOutputEvents
|
||||
audioOutputEvents.setStreamHandler(audioOutputController)
|
||||
|
||||
// SDD-111: attach the Android audio lifecycle controller
|
||||
// (route changes, device add/remove, interruptions).
|
||||
val lifecycleController = AndroidAudioLifecycleController(applicationContext)
|
||||
lifecycleController.attach(flutterEngine)
|
||||
audioLifecycleController = lifecycleController
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -217,6 +228,8 @@ class MainActivity : FlutterActivity() {
|
||||
// SDD-106: state already emitted by AndroidPermissionRequester via stateChangeListener; do NOT double-emit (M-4 fix)
|
||||
requester.onResume(this) { _ -> }
|
||||
}
|
||||
// SDD-111: re-evaluate the current audio route on resume.
|
||||
audioLifecycleController?.onResume()
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
@@ -252,6 +265,9 @@ class MainActivity : FlutterActivity() {
|
||||
audioOutputChannel?.setMethodCallHandler(null)
|
||||
audioOutputEvents?.setStreamHandler(null)
|
||||
audioOutputController?.detach()
|
||||
// SDD-111: detach the audio lifecycle controller.
|
||||
audioLifecycleController?.onDestroy()
|
||||
audioLifecycleController = null
|
||||
permissionRequester = null
|
||||
permissionsChannel = null
|
||||
audioOutputChannel = null
|
||||
|
||||
+9
@@ -65,6 +65,15 @@ internal object MethodChannels {
|
||||
*/
|
||||
const val METHOD_POP_TO_SYSTEM: String = "popToSystem"
|
||||
|
||||
/**
|
||||
* Channel for Android audio lifecycle events (route changes,
|
||||
* interruptions, app lifecycle). Mirrors the iOS
|
||||
* `chanora/ios_audio_lifecycle` channel.
|
||||
*
|
||||
* Trace: SDD-111 (cross-platform mobile voice backend)
|
||||
*/
|
||||
const val ANDROID_AUDIO_LIFECYCLE: String = "chanora/android_audio_lifecycle"
|
||||
|
||||
const val AUDIO_OUTPUT: String = "app.audio_output"
|
||||
const val AUDIO_OUTPUT_EVENTS: String = "app.audio_output/events"
|
||||
const val METHOD_GET_OUTPUT_DEVICES: String = "getOutputDevices"
|
||||
|
||||
Reference in New Issue
Block a user