From b9643365ba8ba9d01f5a6e16f1aa28e855da3952 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:59:04 -0400 Subject: [PATCH] fix(mobile): the Android audio engine forgets a stop issued while paused (#22132) * fix(mobile): the Android audio engine forgets a stop issued while paused A JS toggleRecording(false) arriving while the activity was paused hit the value == isRecording early return, never reached stopRecording(), and left isRecordingBeforePause armed, so resumeRecordingAndPlayer() reopened the microphone with no JS owner (54 minutes on a Galaxy S24, OTA 0.0.51). Every stop now clears the resume flag and only the pause itself keeps it, via stopRecording(clearPauseResume), mirroring iOS's stopRecordingAndPlayer(clearInterruptionResume:); resume consumes the flag before acting on it. requestAudioFocus() also overwrote audioFocusRequest without abandoning the previous one, so every resume left a stale listener on the focus stack and tearDown() could only abandon the newest. Both paths now go through one abandonAudioFocus() owner. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the pre-Q permanent focus loss gives the focus back too (round 1) CodeRabbit on #22132: on API 21-28 the AUDIOFOCUS_LOSS branch stops recording and playback for good but kept the focus request, so an idle engine could hold focus after the other app released it. Q+ pauses and keeps focus to resume. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a resume never stops a recording it did not pause (round 2) pullfrog on #22132: with the equality guard gone, resume's toggleRecording(false) on a cleared flag stopped a recording JS started while the activity was paused, which a start straddling the permission activity does. Resume now only reopens. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../expo/modules/twowayaudio/AudioEngine.kt | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/mobile/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt b/mobile/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt index b2d3a1ebc72..5e174251bec 100644 --- a/mobile/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt +++ b/mobile/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt @@ -192,6 +192,9 @@ class AudioEngine (context: Context) { @SuppressLint("NewApi") private fun requestAudioFocus() { + // Every resume re-requests; without abandoning first the previous listener stays on the focus stack. + abandonAudioFocus() + val listener = AudioManager.OnAudioFocusChangeListener { focusChange -> when (focusChange) { AudioManager.AUDIOFOCUS_LOSS -> { @@ -201,6 +204,8 @@ class AudioEngine (context: Context) { } else { stopRecording() stopPlayback() + // Pre-Q stops for good rather than pausing, so the focus goes back too. + abandonAudioFocus() } onAudioInterruptionCallback?.let { it("blocked") } } @@ -237,6 +242,18 @@ class AudioEngine (context: Context) { } } + @SuppressLint("NewApi") + private fun abandonAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } + audioFocusRequest = null + } else { + @Suppress("DEPRECATION") + audioFocusChangeListener?.let { audioManager.abandonAudioFocus(it) } + } + audioFocusChangeListener = null + } + @RequiresApi(Build.VERSION_CODES.Q) @SuppressLint("MissingPermission") private fun startRecording(){ @@ -300,7 +317,11 @@ class AudioEngine (context: Context) { } } - private fun stopRecording() { + private fun stopRecording(clearPauseResume: Boolean = true) { + // Only the activity-pause stop keeps the resume flag; every other stop ends the recording for good. + if (clearPauseResume) { + isRecordingBeforePause = false + } if (!isRecording) return isRecording = false if (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) { @@ -311,13 +332,12 @@ class AudioEngine (context: Context) { } @RequiresApi(Build.VERSION_CODES.Q) - fun toggleRecording(value: Boolean): Boolean { - if (value == isRecording) return isRecording - + fun toggleRecording(value: Boolean, clearPauseResume: Boolean = true): Boolean { if (value) { - startRecording() + if (!isRecording) startRecording() } else { - stopRecording() + // Runs even when already stopped, so a stop issued while paused still clears the resume flag. + stopRecording(clearPauseResume) } isRecording = value @@ -378,14 +398,21 @@ class AudioEngine (context: Context) { @RequiresApi(Build.VERSION_CODES.Q) fun pauseRecordingAndPlayer() { isRecordingBeforePause = isRecording - isRecording = toggleRecording(false) + isRecording = toggleRecording(false, clearPauseResume = false) audioTrack.pause() } @RequiresApi(Build.VERSION_CODES.Q) fun resumeRecordingAndPlayer() { requestAudioFocus() - isRecording = toggleRecording(isRecordingBeforePause) + // Consume the flag: a second resume without an intervening pause must not reopen the microphone. + val shouldResumeRecording = isRecordingBeforePause + isRecordingBeforePause = false + // Only ever reopens: a false flag means the pause closed the mic, so no stop is owed, and a + // recording JS started while paused (a start straddling the permission activity) stays live. + if (shouldResumeRecording) { + isRecording = toggleRecording(true) + } audioTrack.play() } @@ -430,14 +457,7 @@ class AudioEngine (context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { audioManager.clearCommunicationDevice() } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - audioFocusRequest?.let { request -> - audioManager.abandonAudioFocusRequest(request) - } - } else { - @Suppress("DEPRECATION") - audioManager.abandonAudioFocus(audioFocusChangeListener) - } + abandonAudioFocus() executorServiceMicrophone.shutdownNow() executorServicePlayback.shutdownNow() }