feat: stabilize voice activity and audio routing
This commit is contained in:
+25
-4
@@ -50,7 +50,7 @@ internal class AndroidAudioLifecycleController(
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var callbackRegistered = false
|
||||
|
||||
private var currentRouteType: String? = null
|
||||
private var currentRouteFingerprint: String? = null
|
||||
|
||||
private val audioDeviceCallback = object : AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||
@@ -121,17 +121,38 @@ internal class AndroidAudioLifecycleController(
|
||||
device: AudioDeviceInfo?,
|
||||
) {
|
||||
val routeType = classifyCurrentRoute(device)
|
||||
if (routeType == currentRouteType) {
|
||||
val routeFingerprint = buildRouteFingerprint(routeType)
|
||||
if (routeFingerprint == currentRouteFingerprint) {
|
||||
return
|
||||
}
|
||||
currentRouteType = routeType
|
||||
Log.i(TAG, "route changed to: $routeType")
|
||||
currentRouteFingerprint = routeFingerprint
|
||||
Log.i(TAG, "route changed to: $routeType fingerprint=$routeFingerprint")
|
||||
channel?.invokeMethod(
|
||||
"handleRouteChange",
|
||||
mapOf("routeType" to routeType),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildRouteFingerprint(routeType: String): String {
|
||||
val selectedCommunicationId =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
audioManager.communicationDevice?.id?.toString().orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val inputs = audioManager
|
||||
.getDevices(AudioManager.GET_DEVICES_INPUTS)
|
||||
.map { "${it.id}:${it.type}:${it.productName?.toString()?.trim().orEmpty()}" }
|
||||
.sorted()
|
||||
.joinToString("|")
|
||||
val outputs = audioManager
|
||||
.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||
.map { "${it.id}:${it.type}:${it.productName?.toString()?.trim().orEmpty()}" }
|
||||
.sorted()
|
||||
.joinToString("|")
|
||||
return "$routeType#$selectedCommunicationId#$inputs#$outputs"
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the current audio output route into a stable string
|
||||
* matching the iOS route-classification schema so the Dart-side
|
||||
|
||||
+20
@@ -1,6 +1,8 @@
|
||||
package app.chanora.chanora_flutter
|
||||
|
||||
import android.content.Context
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
@@ -8,6 +10,7 @@ import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
@@ -121,6 +124,18 @@ internal class AndroidAudioOutputController(context: Context) :
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (target.requiresBluetoothConnectPermission() &&
|
||||
ContextCompat.checkSelfPermission(
|
||||
appContext,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"setCommunicationDevice blocked for bluetooth target=${target.logLabel()}: BLUETOOTH_CONNECT not granted",
|
||||
)
|
||||
return false
|
||||
}
|
||||
val changed = audioManager.setCommunicationDevice(target)
|
||||
Log.i(TAG, "setCommunicationDevice device=${target.logLabel()} changed=$changed selected=${audioManager.communicationDevice?.logLabel()}")
|
||||
return changed
|
||||
@@ -230,6 +245,11 @@ internal class AndroidAudioOutputController(context: Context) :
|
||||
type == AudioDeviceInfo.TYPE_BLE_BROADCAST)
|
||||
}
|
||||
|
||||
private fun AudioDeviceInfo.requiresBluetoothConnectPermission(): Boolean =
|
||||
normalizedType() == "bluetoothA2dp" ||
|
||||
normalizedType() == "bluetoothSco" ||
|
||||
normalizedType() == "bluetoothLe"
|
||||
|
||||
private fun AudioDeviceInfo.logLabel(): String =
|
||||
"id=$id type=${normalizedType()} product=${productName?.toString()?.trim().orEmpty()}"
|
||||
|
||||
|
||||
+127
-15
@@ -2,44 +2,50 @@ package app.chanora.chanora_flutter
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.Manifest
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Manages Android Bluetooth SCO (Synchronous Connection-Oriented) audio
|
||||
* for the Chanora voice session.
|
||||
* Manages Android Bluetooth voice routing 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.
|
||||
* On Android 13 / API 33 and newer, VoIP apps are expected to select a
|
||||
* Bluetooth communication route with `AudioManager.setCommunicationDevice()`
|
||||
* so BLE audio headsets are supported. On older Android releases we fall
|
||||
* back to legacy SCO start / stop management.
|
||||
*
|
||||
* ## Lifecycle
|
||||
*
|
||||
* 1. [start] — called by the Rust audio engine (via JNI) after the Oboe
|
||||
* voice streams are opened. Calls `AudioManager.startBluetoothSco()`
|
||||
* voice streams are opened. On API 33+ this prefers
|
||||
* `AudioManager.setCommunicationDevice()` for Bluetooth communication
|
||||
* devices. On older releases it 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
|
||||
* 2. Legacy SCO state changes, or synthetic connected/disconnected state
|
||||
* changes for the API 33+ path, 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.
|
||||
* 3. [stop] — called by the Rust engine on voice stop. Clears the selected
|
||||
* communication device on API 33+ when it is one we selected, otherwise
|
||||
* falls back to `AudioManager.stopBluetoothSco()` and receiver teardown.
|
||||
*
|
||||
* ## 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`.
|
||||
* `AudioManager.startBluetoothSco` is asynchronous on legacy devices —
|
||||
* the platform responds with `ACTION_SCO_AUDIO_STATE_UPDATED` which arrives
|
||||
* on the main thread via the `BroadcastReceiver`.
|
||||
*/
|
||||
internal class AndroidBluetoothScoController {
|
||||
|
||||
@@ -72,6 +78,16 @@ internal class AndroidBluetoothScoController {
|
||||
return
|
||||
}
|
||||
scoStarted = true
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (trySelectBluetoothCommunicationDevice(appContext)) {
|
||||
return
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"No selectable Bluetooth communication device on API 33+; legacy SCO fallback is skipped",
|
||||
)
|
||||
return
|
||||
}
|
||||
registerScoReceiver(appContext)
|
||||
tryStartSco(appContext)
|
||||
}
|
||||
@@ -86,12 +102,17 @@ internal class AndroidBluetoothScoController {
|
||||
fun stop(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
scoStarted = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
clearSelectedBluetoothCommunicationDevice(appContext)
|
||||
return
|
||||
}
|
||||
unregisterScoReceiver(appContext)
|
||||
tryStopSco(appContext)
|
||||
}
|
||||
|
||||
private var scoStarted: Boolean = false
|
||||
private var receiverRegistered: Boolean = false
|
||||
private var selectedCommunicationDeviceId: Int? = null
|
||||
|
||||
private val scoReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
@@ -173,6 +194,15 @@ internal class AndroidBluetoothScoController {
|
||||
Log.e(TAG, "AudioManager unavailable; cannot start SCO")
|
||||
return
|
||||
}
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S &&
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Log.i(TAG, "BLUETOOTH_CONNECT not granted; skipping SCO start")
|
||||
return
|
||||
}
|
||||
|
||||
if (isBluetoothScoOn(am)) {
|
||||
Log.i(TAG, "SCO already on; no-op")
|
||||
@@ -212,6 +242,69 @@ internal class AndroidBluetoothScoController {
|
||||
}
|
||||
}
|
||||
|
||||
private fun trySelectBluetoothCommunicationDevice(context: Context): Boolean {
|
||||
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
if (am == null) {
|
||||
Log.e(TAG, "AudioManager unavailable; cannot select communication device")
|
||||
return false
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
return false
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Log.i(TAG, "BLUETOOTH_CONNECT not granted; skipping bluetooth communication device selection")
|
||||
return false
|
||||
}
|
||||
val target = am.availableCommunicationDevices.firstOrNull { it.isBluetoothCommunicationDevice() }
|
||||
if (target == null) {
|
||||
Log.i(TAG, "No Bluetooth communication device available on API 33+")
|
||||
return false
|
||||
}
|
||||
val current = am.communicationDevice
|
||||
if (current?.id == target.id) {
|
||||
selectedCommunicationDeviceId = target.id
|
||||
publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_CONNECTED)
|
||||
Log.i(TAG, "Bluetooth communication device already selected: ${target.logLabel()}")
|
||||
return true
|
||||
}
|
||||
val changed = am.setCommunicationDevice(target)
|
||||
Log.i(
|
||||
TAG,
|
||||
"setCommunicationDevice bluetooth target=${target.logLabel()} changed=$changed selected=${am.communicationDevice?.logLabel()}",
|
||||
)
|
||||
if (changed) {
|
||||
selectedCommunicationDeviceId = target.id
|
||||
publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_CONNECTED)
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun clearSelectedBluetoothCommunicationDevice(context: Context) {
|
||||
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
if (am == null) {
|
||||
Log.e(TAG, "AudioManager unavailable; cannot clear communication device")
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
return
|
||||
}
|
||||
val selectedId = selectedCommunicationDeviceId
|
||||
val current = am.communicationDevice
|
||||
if (selectedId == null || current?.id != selectedId) {
|
||||
selectedCommunicationDeviceId = null
|
||||
Log.d(TAG, "No app-selected Bluetooth communication device to clear")
|
||||
return
|
||||
}
|
||||
am.clearCommunicationDevice()
|
||||
selectedCommunicationDeviceId = null
|
||||
publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_DISCONNECTED)
|
||||
Log.i(TAG, "clearCommunicationDevice() dispatched for bluetooth route")
|
||||
}
|
||||
|
||||
private fun tryStopSco(context: Context) {
|
||||
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
if (am == null) {
|
||||
@@ -230,6 +323,25 @@ internal class AndroidBluetoothScoController {
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishSyntheticScoState(state: Int) {
|
||||
try {
|
||||
publishScoStateChange(state)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "publishScoStateChange JNI failed: ${t.message}", t)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioDeviceInfo.isBluetoothCommunicationDevice(): Boolean =
|
||||
type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
|
||||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
(type == AudioDeviceInfo.TYPE_BLE_HEADSET ||
|
||||
type == AudioDeviceInfo.TYPE_BLE_SPEAKER ||
|
||||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
type == AudioDeviceInfo.TYPE_BLE_BROADCAST)))
|
||||
|
||||
private fun AudioDeviceInfo.logLabel(): String =
|
||||
"id=$id type=$type product=${productName?.toString()?.trim().orEmpty()}"
|
||||
|
||||
private fun scoStateName(state: Int): String = when (state) {
|
||||
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> "DISCONNECTED"
|
||||
AudioManager.SCO_AUDIO_STATE_CONNECTED -> "CONNECTED"
|
||||
|
||||
Reference in New Issue
Block a user