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
This commit is contained in:
Jinwoo Hong
2026-09-21 23:59:04 -04:00
committed by GitHub
parent 9243073b73
commit b9643365ba
@@ -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()
}