diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java index 4ea8f3b91f..aec2561da7 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java @@ -24,7 +24,9 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.pm.PackageManager; +import android.media.AudioAttributes; import android.media.AudioDeviceInfo; +import android.media.AudioFocusRequest; import android.media.AudioManager; import android.util.Log; @@ -65,6 +67,8 @@ public class WebRtcAudioManager { private final BroadcastReceiver wiredHeadsetReceiver; private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener; + private AudioFocusRequest audioFocusRequest; + private final AudioFocusState audioFocusState = new AudioFocusState(); private final PowerManagerUtils powerManagerUtils; @@ -157,50 +161,11 @@ public void start(AudioManagerListener audioManagerListener) { savedIsMicrophoneMute = audioManager.isMicrophoneMute(); hasWiredHeadset = hasWiredHeadset(); - // Create an AudioManager.OnAudioFocusChangeListener instance. - audioFocusChangeListener = new AudioManager.OnAudioFocusChangeListener() { - // Called on the listener to notify if the audio focus for this listener has been changed. - // The |focusChange| value indicates whether the focus was gained, whether the focus was lost, - // and whether that loss is transient, or whether the new focus holder will hold it for an - // unknown amount of time. - // TODO(henrika): possibly extend support of handling audio-focus changes. Only contains - // logging for now. - @Override - public void onAudioFocusChange(int focusChange) { - String typeOfChange = "AUDIOFOCUS_NOT_DEFINED"; - switch (focusChange) { - case AudioManager.AUDIOFOCUS_GAIN: - typeOfChange = "AUDIOFOCUS_GAIN"; - break; - case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT: - typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT"; - break; - case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE: - typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE"; - break; - case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK: - typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK"; - break; - case AudioManager.AUDIOFOCUS_LOSS: - typeOfChange = "AUDIOFOCUS_LOSS"; - break; - case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: - typeOfChange = "AUDIOFOCUS_LOSS_TRANSIENT"; - break; - case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: - typeOfChange = "AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK"; - break; - default: - typeOfChange = "AUDIOFOCUS_INVALID"; - break; - } - Log.d(TAG, "onAudioFocusChange: " + typeOfChange); - } - }; + audioFocusChangeListener = this::onAudioFocusChange; - // Request audio playout focus (without ducking) and install listener for changes in focus. - int result = audioManager.requestAudioFocus(audioFocusChangeListener, - AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT); + // Request audio focus for a long-running call (delivered on the main thread). + audioFocusRequest = buildCallAudioFocusRequest(audioFocusChangeListener); + int result = audioManager.requestAudioFocus(audioFocusRequest); if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { Log.d(TAG, "Audio focus request granted for VOICE_CALL streams"); } else { @@ -236,6 +201,58 @@ public void onAudioFocusChange(int focusChange) { Log.d(TAG, "AudioManager started"); } + /** + * Handles audio focus changes (called on the main thread). Re-asserts the communication mode and audio route + * when focus returns after a transient loss, see {@link AudioFocusState}. + */ + void onAudioFocusChange(int focusChange) { + if (audioFocusState.handle(focusChange) && amState == AudioManagerState.RUNNING) { + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + updateAudioDeviceState(); + } + Log.d(TAG, "onAudioFocusChange: " + focusChange); + } + + static AudioFocusRequest buildCallAudioFocusRequest(AudioManager.OnAudioFocusChangeListener listener) { + return new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build()) + .setAcceptsDelayedFocusGain(true) + .setWillPauseWhenDucked(false) + .setOnAudioFocusChangeListener(listener) + .build(); + } + + /** + * Tracks audio focus losses during a call. + * + * A transient focus holder such as the telephony stack also switches the global audio mode and restores its own + * saved mode on release, clobbering MODE_IN_COMMUNICATION. "handle" reports whether the communication mode must + * be re-asserted for a focus change, so the call does not continue without hardware echo cancellation and proper + * VoIP routing after an interruption. + */ + static class AudioFocusState { + private boolean transientLoss = false; + + boolean handle(int focusChange) { + switch (focusChange) { + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + transientLoss = true; + return false; + case AudioManager.AUDIOFOCUS_GAIN: + boolean restore = transientLoss; + transientLoss = false; + return restore; + default: + transientLoss = false; + return false; + } + } + } + @SuppressLint("WrongConstant") public void stop() { Log.d(TAG, "stop"); @@ -258,7 +275,10 @@ public void stop() { audioManager.setMode(savedAudioMode); // Abandon audio focus. Gives the previous focus owner, if any, focus. - audioManager.abandonAudioFocus(audioFocusChangeListener); + if (audioFocusRequest != null) { + audioManager.abandonAudioFocusRequest(audioFocusRequest); + audioFocusRequest = null; + } audioFocusChangeListener = null; Log.d(TAG, "Abandoned audio focus for VOICE_CALL streams"); diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/WebRtcAudioManagerFocusTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/WebRtcAudioManagerFocusTest.kt new file mode 100644 index 0000000000..6c5af15c54 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/webrtc/WebRtcAudioManagerFocusTest.kt @@ -0,0 +1,81 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.webrtc + +import android.media.AudioAttributes +import android.media.AudioManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Audio focus handling of [WebRtcAudioManager] (issue #6541). + * + * A transient focus holder (e.g. telephony during a GSM call) switches the global audio mode and restores its own + * saved mode on release, clobbering MODE_IN_COMMUNICATION. [WebRtcAudioManager.AudioFocusState] decides when the + * communication mode must be re-asserted, and [WebRtcAudioManager.buildCallAudioFocusRequest] pins the focus + * request configuration for a long-running call. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class WebRtcAudioManagerFocusTest { + + @Test + fun `focus request asks for long-term voice communication gain`() { + val request = WebRtcAudioManager.buildCallAudioFocusRequest { } + + assertEquals(AudioManager.AUDIOFOCUS_GAIN, request.focusGain) + assertEquals(AudioAttributes.USAGE_VOICE_COMMUNICATION, request.audioAttributes.usage) + assertTrue(request.acceptsDelayedFocusGain()) + assertFalse(request.willPauseWhenDucked()) + } + + @Test + fun `restore is reported when focus returns after transient loss`() { + val state = WebRtcAudioManager.AudioFocusState() + + assertFalse(state.handle(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)) + assertTrue(state.handle(AudioManager.AUDIOFOCUS_GAIN)) + } + + @Test + fun `restore is reported only once per transient loss`() { + val state = WebRtcAudioManager.AudioFocusState() + + state.handle(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) + assertTrue(state.handle(AudioManager.AUDIOFOCUS_GAIN)) + assertFalse(state.handle(AudioManager.AUDIOFOCUS_GAIN)) + } + + @Test + fun `duckable transient loss also requires restore`() { + val state = WebRtcAudioManager.AudioFocusState() + + assertFalse(state.handle(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK)) + assertTrue(state.handle(AudioManager.AUDIOFOCUS_GAIN)) + } + + @Test + fun `focus gain without preceding loss does not report restore`() { + val state = WebRtcAudioManager.AudioFocusState() + + assertFalse(state.handle(AudioManager.AUDIOFOCUS_GAIN)) + } + + @Test + fun `permanent loss clears a pending transient loss`() { + val state = WebRtcAudioManager.AudioFocusState() + + state.handle(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) + state.handle(AudioManager.AUDIOFOCUS_LOSS) + assertFalse(state.handle(AudioManager.AUDIOFOCUS_GAIN)) + } +}