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:
Edison Jwa
2026-05-22 09:29:57 +09:00
parent 6af4ecab0f
commit bf284018e6
37 changed files with 3676 additions and 703 deletions
@@ -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 = "../.."
}
+5
View File
@@ -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
@@ -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
}
}
}
@@ -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"
}
}
@@ -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)"
}
}
}
@@ -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
@@ -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"