diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index 69062edf59..bd4bf1475b 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -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 diff --git a/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt b/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt index 9906e5a1d1..a6fe25303e 100644 --- a/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt +++ b/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt @@ -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?) { Log.d("AudioEngine", "onAudioDevicesAdded") super.onAudioDevicesAdded(addedDevices) - updateAudioRouting() + if (communicationRouteActive) { + updateAudioRouting() + } } override fun onAudioDevicesRemoved(removedDevices: Array?) { 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() }