Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 64 additions & 44 deletions app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand All @@ -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");

Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading