mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
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:
+36
-16
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user