fix(android): release communication route after dictation (#4069)

Stopping capture abandoned audio focus but left Android's communication mode and selected device owned by Paseo, which could keep Bluetooth audio on its narrow-band call route without an active microphone. Release and reacquire the complete route with the audio session, and prevent idle device callbacks from restoring it.
This commit is contained in:
Mohamed Boudra
2026-08-30 11:42:29 +02:00
committed by GitHub
parent ce335de1cd
commit 5e561f8a23
2 changed files with 43 additions and 17 deletions
+6 -4
View File
@@ -259,7 +259,9 @@ background music stays paused. On iOS this is not just a "while recording" probl
`.playAndRecord`/`.voiceChat` category is non-mixing and survives backgrounding, and iOS re-asserts
it every time the app returns to the foreground — so one dictation turn kills music for the life of
the process, on every open, until the app is force-quit. Android holds
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect.
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect. It must also reset
`MODE_IN_COMMUNICATION` and clear the selected communication device; abandoning focus alone can
leave Bluetooth earbuds on their narrow-band call route without an active microphone indicator.
`createAudioEngine` (`packages/app/src/voice/audio-engine.native.ts`) calls
`releaseAudioSession()` whenever capture stops and the playback queue drains, and the iOS module
@@ -267,9 +269,9 @@ also releases on `OnAppEntersBackground`. The native side re-guards on `isRecord
`speechPlayer.isPlaying`, because the native engine is a singleton shared by multiple JS engine
wrappers (voice provider + dictation) and only it knows the true state.
This cannot be validated by a JS test — verify on a device: play music, open Paseo, use dictation
once, stop, and confirm the music resumes; then background/foreground the app and confirm it keeps
playing.
This cannot be validated by a JS test — verify on a device: play music through Bluetooth earbuds,
open Paseo, use dictation once, stop, and confirm the music resumes at full quality; then
background/foreground the app and confirm it keeps playing at full quality.
## Unistyles + Reanimated
@@ -35,6 +35,7 @@ class AudioEngine (context: Context) {
private val executorServiceMicrophone = Executors.newFixedThreadPool(1)
private val executorServicePlayback = Executors.newFixedThreadPool(1)
private var speakerDevice: AudioDeviceInfo? = null
private var communicationRouteActive = false
private var bridgeWindowStartedAtMs = System.currentTimeMillis()
private var micEvents = 0
private var micBytes = 0L
@@ -82,25 +83,26 @@ class AudioEngine (context: Context) {
@SuppressLint("NewApi")
private fun initializeAudio(context:Context) {
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
activateCommunicationRoute()
if (!requestAudioFocus()) {
handleAudioFocusBlocked()
}
// Route audio to external device if connected, otherwise route to speaker
updateAudioRouting()
// Listen for changes in audio routing
audioManager.registerAudioDeviceCallback(object:android.media.AudioDeviceCallback(){
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
Log.d("AudioEngine", "onAudioDevicesAdded")
super.onAudioDevicesAdded(addedDevices)
updateAudioRouting()
if (communicationRouteActive) {
updateAudioRouting()
}
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
Log.d("AudioEngine", "onAudioDevicesRemoved")
super.onAudioDevicesRemoved(removedDevices)
updateAudioRouting()
if (communicationRouteActive) {
updateAudioRouting()
}
}
}, null)
@@ -169,10 +171,29 @@ class AudioEngine (context: Context) {
}
}
@SuppressLint("NewApi")
private fun activateCommunicationRoute() {
communicationRouteActive = true
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
updateAudioRouting()
}
@SuppressLint("NewApi")
private fun releaseCommunicationRoute() {
communicationRouteActive = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.clearCommunicationDevice()
} else {
@Suppress("DEPRECATION")
audioManager.isSpeakerphoneOn = false
}
audioManager.mode = AudioManager.MODE_NORMAL
}
/**
* Give audio focus back once we are neither recording nor playing. The focus request is
* AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, so holding it after a dictation turn leaves the
* user's music paused indefinitely.
* Give the complete communication session back once we are neither recording nor playing.
* Focus pauses the user's music, while MODE_IN_COMMUNICATION and the selected communication
* device can leave Bluetooth earbuds on their narrow-band call route after capture stops.
*/
@SuppressLint("NewApi")
fun releaseAudioSession() {
@@ -186,6 +207,7 @@ class AudioEngine (context: Context) {
if (::audioTrack.isInitialized) {
audioTrack.pause()
}
releaseCommunicationRoute()
}
/**
@@ -209,6 +231,7 @@ class AudioEngine (context: Context) {
if (audioFocusRequest != null) {
return
}
activateCommunicationRoute()
requestAudioFocus()
if (::audioTrack.isInitialized) {
audioTrack.play()
@@ -273,6 +296,7 @@ class AudioEngine (context: Context) {
if (::audioTrack.isInitialized) {
audioTrack.pause()
}
releaseCommunicationRoute()
audioSampleQueue.clear()
isPlaying = false
isRecordingBeforePause = false
@@ -283,6 +307,7 @@ class AudioEngine (context: Context) {
@RequiresApi(Build.VERSION_CODES.Q)
@SuppressLint("MissingPermission")
private fun startRecording(): Boolean {
activateCommunicationRoute()
if (!requestAudioFocus()) {
handleAudioFocusBlocked()
return false
@@ -453,6 +478,7 @@ class AudioEngine (context: Context) {
if (!wasRecordingBeforePause && !isPlaying) {
return
}
activateCommunicationRoute()
if (!requestAudioFocus()) {
handleAudioFocusBlocked()
return
@@ -489,13 +515,11 @@ class AudioEngine (context: Context) {
fun tearDown() {
stopRecording()
audioTrack.stop()
audioManager.mode = AudioManager.MODE_NORMAL
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.clearCommunicationDevice()
}
releaseCommunicationRoute()
audioFocusRequest?.let { request ->
audioManager.abandonAudioFocusRequest(request)
}
audioFocusRequest = null
executorServiceMicrophone.shutdownNow()
}