Stop Android audio interruptions crashing voice mode (#1941)
* fix(android): handle denied voice audio focus Android can deny voice-mode audio focus while another system audio owner, such as an incoming call, is active. Treat that as an interruption so resume does not crash the app and JS stops voice mode coherently. * fix(voice): keep interruption state consistent Make native interruption handling payload-aware so iOS resume events do not stop the shared JS runtime, avoid ending voice mode for duck-only Android focus changes, and roll host voice mode back if capture fails after startup enabled it. * fix(voice): avoid duplicate interruption handling Keep exactly one Android audio-focus request active at a time and leave JS capture state untouched for non-terminal interruption events. * fix(android): abandon blocked audio focus requests When Android denies or blocks voice audio focus, abandon the pending focus request so delayed focus gain cannot arrive after voice mode has already stopped. * fix(voice): handle interrupted dictation capture Stop Android resume from replaying after recording restart fails, and propagate native blocked interruptions into dictation so users do not remain in a stale recording state.
This commit is contained in:
+60
-9
@@ -83,7 +83,9 @@ class AudioEngine (context: Context) {
|
||||
private fun initializeAudio(context:Context) {
|
||||
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
requestAudioFocus()
|
||||
if (!requestAudioFocus()) {
|
||||
handleAudioFocusBlocked()
|
||||
}
|
||||
|
||||
// Route audio to external device if connected, otherwise route to speaker
|
||||
updateAudioRouting()
|
||||
@@ -168,7 +170,12 @@ class AudioEngine (context: Context) {
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun requestAudioFocus() {
|
||||
private fun requestAudioFocus(): Boolean {
|
||||
audioFocusRequest?.let { request ->
|
||||
audioManager.abandonAudioFocusRequest(request)
|
||||
audioFocusRequest = null
|
||||
}
|
||||
|
||||
val focusRequest =
|
||||
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
||||
.setAudioAttributes(
|
||||
@@ -180,9 +187,19 @@ class AudioEngine (context: Context) {
|
||||
.setAcceptsDelayedFocusGain(true)
|
||||
.setOnAudioFocusChangeListener { focusChange ->
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
Log.d("AudioEngine", "Audio focus gained")
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
Log.d("AudioEngine", "Audio focus lost")
|
||||
onAudioInterruptionCallback?.let { it("blocked") }
|
||||
handleAudioFocusBlocked()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
Log.d("AudioEngine", "Audio focus lost transiently")
|
||||
handleAudioFocusBlocked()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
Log.d("AudioEngine", "Audio focus duck requested")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,14 +208,40 @@ class AudioEngine (context: Context) {
|
||||
audioFocusRequest = focusRequest
|
||||
val result = audioManager.requestAudioFocus(focusRequest)
|
||||
|
||||
if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
throw RuntimeException("Audio focus request failed")
|
||||
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
return true
|
||||
}
|
||||
|
||||
Log.w("AudioEngine", "Audio focus request was not granted: $result")
|
||||
return false
|
||||
}
|
||||
|
||||
private fun handleAudioFocusBlocked() {
|
||||
if (isRecording) {
|
||||
stopRecording()
|
||||
}
|
||||
audioFocusRequest?.let { request ->
|
||||
audioManager.abandonAudioFocusRequest(request)
|
||||
audioFocusRequest = null
|
||||
}
|
||||
if (::audioTrack.isInitialized) {
|
||||
audioTrack.pause()
|
||||
}
|
||||
audioSampleQueue.clear()
|
||||
isPlaying = false
|
||||
isRecordingBeforePause = false
|
||||
onOutputVolumeCallback?.invoke(0.0F)
|
||||
onAudioInterruptionCallback?.let { it("blocked") }
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startRecording(){
|
||||
private fun startRecording(): Boolean {
|
||||
if (!requestAudioFocus()) {
|
||||
handleAudioFocusBlocked()
|
||||
return false
|
||||
}
|
||||
|
||||
val bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
|
||||
audioRecord = AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||
@@ -231,6 +274,7 @@ class AudioEngine (context: Context) {
|
||||
audioRecord.startRecording()
|
||||
isRecording = true
|
||||
startMicSampleTap()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun startMicSampleTap(){
|
||||
@@ -274,7 +318,7 @@ class AudioEngine (context: Context) {
|
||||
if (value == isRecording) return isRecording
|
||||
|
||||
if (value) {
|
||||
startRecording()
|
||||
return startRecording()
|
||||
} else {
|
||||
stopRecording()
|
||||
}
|
||||
@@ -353,8 +397,15 @@ class AudioEngine (context: Context) {
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun resumeRecordingAndPlayer() {
|
||||
requestAudioFocus()
|
||||
isRecording = toggleRecording(isRecordingBeforePause)
|
||||
if (!requestAudioFocus()) {
|
||||
handleAudioFocusBlocked()
|
||||
return
|
||||
}
|
||||
val wasRecordingBeforePause = isRecordingBeforePause
|
||||
isRecording = toggleRecording(wasRecordingBeforePause)
|
||||
if (wasRecordingBeforePause && !isRecording) {
|
||||
return
|
||||
}
|
||||
audioTrack.play()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user