From bdd471955e3556bc5b642f64516a08f7afc93530 Mon Sep 17 00:00:00 2001 From: Oleg Cherry <80347136+flake92@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:41:26 +0300 Subject: [PATCH 1/6] fix(call): restore Bluetooth audio routing Prefer call-capable Bluetooth communication devices and preserve selection across reconnects. Assisted-by: Codex:gpt-5 Signed-off-by: Oleg Cherry <80347136+flake92@users.noreply.github.com> --- .../nextcloud/talk/activities/CallActivity.kt | 14 +- .../talk/ui/dialog/AudioOutputDialog.kt | 27 +- .../talk/webrtc/AudioRoutePolicy.java | 64 ++ .../talk/webrtc/WebRtcAudioManager.java | 344 ++++++-- .../talk/webrtc/WebRtcBluetoothManager.java | 789 +++++++++++++++++- .../talk/webrtc/AudioRoutePolicyTest.java | 83 ++ ...luetoothCommunicationDevicePolicyTest.java | 64 ++ .../webrtc/BluetoothRouteStatePolicyTest.java | 223 +++++ 8 files changed, 1505 insertions(+), 103 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.java create mode 100644 app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.java create mode 100644 app/src/test/java/com/nextcloud/talk/webrtc/BluetoothCommunicationDevicePolicyTest.java create mode 100644 app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.java diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 61cdca604e4..46b0e5dab3e 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -921,15 +921,17 @@ class CallActivity : CallBaseActivity() { fun setDefaultAudioOutputChannel(selectedAudioDevice: AudioDevice?) { if (audioManager != null) { audioManager!!.setDefaultAudioDevice(selectedAudioDevice) - updateAudioOutputButton(audioManager!!.currentAudioDevice) + updateAudioOutputButton(audioManager!!.audioDeviceForUi) } } - fun setAudioOutputChannel(selectedAudioDevice: AudioDevice?) { - if (audioManager != null) { - audioManager!!.selectAudioDevice(selectedAudioDevice) - updateAudioOutputButton(audioManager!!.currentAudioDevice) + fun setAudioOutputChannel(selectedAudioDevice: AudioDevice?): Boolean { + val activeAudioManager = audioManager ?: return false + val accepted = activeAudioManager.selectAudioDevice(selectedAudioDevice) + if (accepted) { + updateAudioOutputButton(activeAudioManager.audioDeviceForUi) } + return accepted } private fun updateAudioOutputButton(activeAudioDevice: AudioDevice) { @@ -1144,7 +1146,7 @@ class CallActivity : CallBaseActivity() { if (audioOutputDialog != null) { audioOutputDialog!!.updateOutputDeviceList() } - updateAudioOutputButton(currentDevice) + updateAudioOutputButton(audioManager?.audioDeviceForUi ?: currentDevice) } private fun cameraInitialization() { diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/AudioOutputDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/AudioOutputDialog.kt index 9cdeff7dbb1..659599fc286 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/AudioOutputDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/AudioOutputDialog.kt @@ -44,25 +44,26 @@ class AudioOutputDialog(val callActivity: CallActivity) : BottomSheetDialog(call } fun updateOutputDeviceList() { - if (callActivity.audioManager?.audioDevices?.contains(WebRtcAudioManager.AudioDevice.BLUETOOTH) == false) { + val activeAudioManager = callActivity.audioManager + if (activeAudioManager?.audioDevices?.contains(WebRtcAudioManager.AudioDevice.BLUETOOTH) != true) { dialogAudioOutputBinding.audioOutputBluetooth.visibility = View.GONE } else { dialogAudioOutputBinding.audioOutputBluetooth.visibility = View.VISIBLE } - if (callActivity.audioManager?.audioDevices?.contains(WebRtcAudioManager.AudioDevice.EARPIECE) == false) { + if (activeAudioManager?.audioDevices?.contains(WebRtcAudioManager.AudioDevice.EARPIECE) != true) { dialogAudioOutputBinding.audioOutputEarspeaker.visibility = View.GONE } else { dialogAudioOutputBinding.audioOutputEarspeaker.visibility = View.VISIBLE } - if (callActivity.audioManager?.audioDevices?.contains(WebRtcAudioManager.AudioDevice.SPEAKER_PHONE) == false) { + if (activeAudioManager?.audioDevices?.contains(WebRtcAudioManager.AudioDevice.SPEAKER_PHONE) != true) { dialogAudioOutputBinding.audioOutputSpeaker.visibility = View.GONE } else { dialogAudioOutputBinding.audioOutputSpeaker.visibility = View.VISIBLE } - if (callActivity.audioManager?.currentAudioDevice?.equals( + if (activeAudioManager?.currentAudioDevice?.equals( WebRtcAudioManager.AudioDevice.WIRED_HEADSET ) == true ) { @@ -77,7 +78,8 @@ class AudioOutputDialog(val callActivity: CallActivity) : BottomSheetDialog(call } private fun highlightActiveOutputChannel() { - when (callActivity.audioManager?.currentAudioDevice) { + viewThemeUtils.platform.themeDialogDark(dialogAudioOutputBinding.root) + when (callActivity.audioManager?.audioDeviceForUi) { WebRtcAudioManager.AudioDevice.BLUETOOTH -> { viewThemeUtils.platform.colorImageView( dialogAudioOutputBinding.audioOutputBluetoothIcon, @@ -118,18 +120,21 @@ class AudioOutputDialog(val callActivity: CallActivity) : BottomSheetDialog(call private fun initClickListeners() { dialogAudioOutputBinding.audioOutputBluetooth.setOnClickListener { - callActivity.setAudioOutputChannel(WebRtcAudioManager.AudioDevice.BLUETOOTH) - dismiss() + if (callActivity.setAudioOutputChannel(WebRtcAudioManager.AudioDevice.BLUETOOTH)) { + dismiss() + } } dialogAudioOutputBinding.audioOutputSpeaker.setOnClickListener { - callActivity.setAudioOutputChannel(WebRtcAudioManager.AudioDevice.SPEAKER_PHONE) - dismiss() + if (callActivity.setAudioOutputChannel(WebRtcAudioManager.AudioDevice.SPEAKER_PHONE)) { + dismiss() + } } dialogAudioOutputBinding.audioOutputEarspeaker.setOnClickListener { - callActivity.setAudioOutputChannel(WebRtcAudioManager.AudioDevice.EARPIECE) - dismiss() + if (callActivity.setAudioOutputChannel(WebRtcAudioManager.AudioDevice.EARPIECE)) { + dismiss() + } } } diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.java b/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.java new file mode 100644 index 00000000000..ddacb2b8568 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.java @@ -0,0 +1,64 @@ +/* + * 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 java.util.Set; + +final class AudioRoutePolicy { + private AudioRoutePolicy() { + } + + static WebRtcAudioManager.AudioDevice selectAudioDevice( + Set availableDevices, + WebRtcAudioManager.AudioDevice userSelectedDevice, + WebRtcAudioManager.AudioDevice defaultDevice, + boolean hasWiredHeadset, + boolean bluetoothConnected) { + if (bluetoothConnected) { + return WebRtcAudioManager.AudioDevice.BLUETOOTH; + } + + if (hasWiredHeadset) { + return WebRtcAudioManager.AudioDevice.WIRED_HEADSET; + } + + if (userSelectedDevice != WebRtcAudioManager.AudioDevice.NONE + && userSelectedDevice != WebRtcAudioManager.AudioDevice.BLUETOOTH + && availableDevices.contains(userSelectedDevice)) { + return userSelectedDevice; + } + + if (defaultDevice != WebRtcAudioManager.AudioDevice.NONE && availableDevices.contains(defaultDevice)) { + return defaultDevice; + } + + if (availableDevices.contains(WebRtcAudioManager.AudioDevice.EARPIECE)) { + return WebRtcAudioManager.AudioDevice.EARPIECE; + } + if (availableDevices.contains(WebRtcAudioManager.AudioDevice.SPEAKER_PHONE)) { + return WebRtcAudioManager.AudioDevice.SPEAKER_PHONE; + } + return WebRtcAudioManager.AudioDevice.NONE; + } + + static boolean shouldPreferBluetooth( + WebRtcAudioManager.AudioDevice userSelectedDevice, + boolean bluetoothCurrentlyPreferred, + boolean bluetoothExpected, + boolean bluetoothUnavailable) { + if (userSelectedDevice == WebRtcAudioManager.AudioDevice.BLUETOOTH) { + return true; + } + if (userSelectedDevice != WebRtcAudioManager.AudioDevice.NONE) { + return false; + } + if (bluetoothExpected) { + return true; + } + return bluetoothCurrentlyPreferred && !bluetoothUnavailable; + } +} 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 aec2561da77..8027aabb4b6 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java @@ -28,6 +28,7 @@ import android.media.AudioDeviceInfo; import android.media.AudioFocusRequest; import android.media.AudioManager; +import android.os.Build; import android.util.Log; import com.nextcloud.talk.events.ProximitySensorEvent; @@ -42,6 +43,9 @@ import java.util.HashSet; import java.util.Set; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + public class WebRtcAudioManager { private static final String TAG = WebRtcAudioManager.class.getSimpleName(); private final Context context; @@ -54,10 +58,12 @@ public class WebRtcAudioManager { private boolean savedIsSpeakerPhoneOn = false; private boolean savedIsMicrophoneMute = false; private boolean hasWiredHeadset = false; + private boolean bluetoothPreferredForCall = false; - private AudioDevice userSelectedAudioDevice; - private AudioDevice currentAudioDevice; - private AudioDevice defaultAudioDevice; + private AudioDevice userSelectedAudioDevice = AudioDevice.NONE; + private AudioDevice currentAudioDevice = AudioDevice.NONE; + private AudioDevice defaultAudioDevice = AudioDevice.NONE; + private AudioDevice lastReportedAudioDeviceForUi = AudioDevice.NONE; private ProximitySensor proximitySensor = null; @@ -85,7 +91,6 @@ private WebRtcAudioManager(Context context, boolean useProximitySensor) { powerManagerUtils.updatePhoneState(PowerManagerUtils.PhoneState.WITH_PROXIMITY_SENSOR_LOCK); this.useProximitySensor = useProximitySensor; - updateAudioDeviceState(); // Create and initialize the proximity sensor. // Tablet devices (e.g. Nexus 7) does not support proximity sensors. @@ -184,6 +189,8 @@ public void start(AudioManagerListener audioManagerListener) { userSelectedAudioDevice = AudioDevice.NONE; currentAudioDevice = AudioDevice.NONE; defaultAudioDevice = AudioDevice.NONE; + bluetoothPreferredForCall = false; + lastReportedAudioDeviceForUi = AudioDevice.NONE; audioDevices.clear(); internalAudioDevices.clear(); @@ -208,6 +215,7 @@ public void start(AudioManagerListener audioManagerListener) { void onAudioFocusChange(int focusChange) { if (audioFocusState.handle(focusChange) && amState == AudioManagerState.RUNNING) { audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + bluetoothManager.reassertBluetoothAudioAfterFocusGain(bluetoothPreferredForCall, hasWiredHeadset); updateAudioDeviceState(); } Log.d(TAG, "onAudioFocusChange: " + focusChange); @@ -270,8 +278,13 @@ public void stop() { } // Restore previously stored audio states. - setSpeakerphoneOn(savedIsSpeakerPhoneOn); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + setSpeakerphoneOn(savedIsSpeakerPhoneOn); + } setMicrophoneMute(savedIsMicrophoneMute); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + clearCommunicationDevice(); + } audioManager.setMode(savedAudioMode); // Abandon audio focus. Gives the previous focus owner, if any, focus. @@ -301,19 +314,32 @@ public void stop() { private void setAudioDeviceInternal(AudioDevice audioDevice) { Log.d(TAG, "setAudioDeviceInternal(device=" + audioDevice + ")"); + if (audioDevice == AudioDevice.NONE) { + currentAudioDevice = AudioDevice.NONE; + return; + } + if (audioDevices.contains(audioDevice)) { - switch (audioDevice) { - case SPEAKER_PHONE: - setSpeakerphoneOn(true); - break; - case EARPIECE: - case WIRED_HEADSET: - case BLUETOOTH: - setSpeakerphoneOn(false); - break; - default: - Log.e(TAG, "Invalid audio device selection"); - break; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (!setCommunicationDevice(audioDevice)) { + Log.e(TAG, "Unable to select communication device " + audioDevice); + currentAudioDevice = AudioDevice.NONE; + return; + } + } else { + switch (audioDevice) { + case SPEAKER_PHONE: + setSpeakerphoneOn(true); + break; + case EARPIECE: + case WIRED_HEADSET: + case BLUETOOTH: + setSpeakerphoneOn(false); + break; + default: + Log.e(TAG, "Invalid audio device selection"); + break; + } } currentAudioDevice = audioDevice; } @@ -333,14 +359,47 @@ public void setDefaultAudioDevice(AudioDevice device) { /** * Changes selection of the currently active audio device. + * + * @return {@code true} when the route is active or Android accepted/queued the request; {@code false} when no + * selection state was retained */ - public void selectAudioDevice(AudioDevice device) { + public boolean selectAudioDevice(AudioDevice device) { ThreadUtils.checkIsOnMainThread(); + if (device == AudioDevice.BLUETOOTH) { + AudioDevice previousUserSelectedAudioDevice = userSelectedAudioDevice; + boolean wasBluetoothPreferredForCall = bluetoothPreferredForCall; + if (!bluetoothManager.requestBluetoothAudioSelection()) { + Log.e(TAG, "Bluetooth is not available for communication audio"); + updateAudioDeviceState(); + return false; + } + userSelectedAudioDevice = AudioDevice.BLUETOOTH; + bluetoothPreferredForCall = true; + updateAudioDeviceState(); + if (bluetoothManager.isBluetoothSelectionActive()) { + return true; + } + userSelectedAudioDevice = previousUserSelectedAudioDevice; + bluetoothPreferredForCall = wasBluetoothPreferredForCall; + updateAudioDeviceState(); + return false; + } if (!audioDevices.contains(device)) { Log.e(TAG, "Can not select " + device + " from available " + audioDevices); + return false; } + AudioDevice previousUserSelectedAudioDevice = userSelectedAudioDevice; + boolean wasBluetoothPreferredForCall = bluetoothPreferredForCall; userSelectedAudioDevice = device; + bluetoothPreferredForCall = false; + updateAudioDeviceState(); + if (currentAudioDevice == device) { + return true; + } + userSelectedAudioDevice = previousUserSelectedAudioDevice; + bluetoothPreferredForCall = wasBluetoothPreferredForCall; updateAudioDeviceState(); + return false; } /** @@ -359,6 +418,20 @@ public AudioDevice getCurrentAudioDevice() { return currentAudioDevice; } + /** + * Returns the active route, or Bluetooth while Android is processing an accepted Bluetooth request. + */ + public AudioDevice getAudioDeviceForUi() { + ThreadUtils.checkIsOnMainThread(); + if (bluetoothPreferredForCall + && !hasWiredHeadset + && audioDevices.contains(AudioDevice.BLUETOOTH) + && bluetoothManager.isBluetoothSelectionActive()) { + return AudioDevice.BLUETOOTH; + } + return currentAudioDevice; + } + /** * Helper method for receiver registration. */ @@ -384,6 +457,95 @@ private void setSpeakerphoneOn(boolean on) { audioManager.setSpeakerphoneOn(on); } + @RequiresApi(Build.VERSION_CODES.S) + private boolean setCommunicationDevice(AudioDevice audioDevice) { + if (audioDevice == AudioDevice.BLUETOOTH + && bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_CONNECTED) { + return true; + } + try { + AudioDeviceInfo currentDevice = getCommunicationDevice(); + if (currentDevice != null && matchesAudioDevice(currentDevice, audioDevice)) { + return true; + } + + AudioDeviceInfo selectedDevice = null; + int selectedPriority = -1; + for (AudioDeviceInfo device : audioManager.getAvailableCommunicationDevices()) { + if (matchesAudioDevice(device, audioDevice)) { + int priority = audioDevice == AudioDevice.BLUETOOTH + ? WebRtcBluetoothManager.bluetoothCommunicationDevicePriority( + device.getType(), + Build.VERSION.SDK_INT + ) + : 0; + if (priority > selectedPriority) { + selectedDevice = device; + selectedPriority = priority; + } + } + } + if (selectedDevice != null) { + boolean selected = audioManager.setCommunicationDevice(selectedDevice); + if (!selected) { + Log.w(TAG, "Failed to select communication device " + selectedDevice.getType()); + } + return selected; + } + } catch (SecurityException | IllegalArgumentException exception) { + Log.e(TAG, "Communication device disappeared while it was being selected", exception); + } + return false; + } + + @RequiresApi(Build.VERSION_CODES.S) + private boolean isCommunicationDeviceSelected(AudioDevice audioDevice) { + AudioDeviceInfo communicationDevice = getCommunicationDevice(); + return communicationDevice != null && matchesAudioDevice(communicationDevice, audioDevice); + } + + @RequiresApi(Build.VERSION_CODES.S) + private boolean matchesAudioDevice(AudioDeviceInfo device, AudioDevice audioDevice) { + int type = device.getType(); + switch (audioDevice) { + case BLUETOOTH: + return WebRtcBluetoothManager.isBluetoothCommunicationDeviceType(type); + case WIRED_HEADSET: + return type == AudioDeviceInfo.TYPE_WIRED_HEADSET + || type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES + || type == AudioDeviceInfo.TYPE_USB_HEADSET + || type == AudioDeviceInfo.TYPE_USB_DEVICE + || type == AudioDeviceInfo.TYPE_USB_ACCESSORY; + case EARPIECE: + return type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE; + case SPEAKER_PHONE: + return type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + || type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE; + default: + return false; + } + } + + @RequiresApi(Build.VERSION_CODES.S) + @Nullable + private AudioDeviceInfo getCommunicationDevice() { + try { + return audioManager.getCommunicationDevice(); + } catch (SecurityException exception) { + Log.e(TAG, "Permission was revoked while reading the communication device", exception); + return null; + } + } + + @RequiresApi(Build.VERSION_CODES.S) + private void clearCommunicationDevice() { + try { + audioManager.clearCommunicationDevice(); + } catch (SecurityException exception) { + Log.e(TAG, "Permission was revoked while clearing the communication device", exception); + } + } + /** * Sets the microphone mute state. */ @@ -409,7 +571,8 @@ private boolean hasEarpiece() { */ @Deprecated private boolean hasWiredHeadset() { - @SuppressLint("WrongConstant") final AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL); + @SuppressLint("WrongConstant") final AudioDeviceInfo[] devices = + audioManager.getDevices(AudioManager.GET_DEVICES_ALL); for (AudioDeviceInfo device : devices) { final int type = device.getType(); if (type == AudioDeviceInfo.TYPE_WIRED_HEADSET) { @@ -423,6 +586,37 @@ private boolean hasWiredHeadset() { return false; } + private boolean hasBluetoothCommunicationOutput() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + for (AudioDeviceInfo device : audioManager.getAvailableCommunicationDevices()) { + if (WebRtcBluetoothManager.isBluetoothCommunicationDeviceType(device.getType())) { + return true; + } + } + } catch (SecurityException exception) { + Log.e(TAG, "Bluetooth permission was revoked while enumerating communication devices", exception); + } + return false; + } + + for (AudioDeviceInfo device : audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { + if (device.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) { + return true; + } + } + return false; + } + + private boolean isBluetoothSelectionPending() { + WebRtcBluetoothManager.State state = bluetoothManager.getState(); + return bluetoothPreferredForCall + && !hasWiredHeadset + && (state == WebRtcBluetoothManager.State.SCO_CONNECTING + || state == WebRtcBluetoothManager.State.SCO_DISCONNECTING + || bluetoothManager.isBluetoothRouteRetryScheduled()); + } + public final void updateAudioDeviceState() { ThreadUtils.checkIsOnMainThread(); Log.d(TAG, "--- updateAudioDeviceState: " @@ -436,16 +630,26 @@ public final void updateAudioDeviceState() { + "user selected=" + userSelectedAudioDevice); if (bluetoothManager.getState() == WebRtcBluetoothManager.State.HEADSET_AVAILABLE - || bluetoothManager.getState() == WebRtcBluetoothManager.State.HEADSET_UNAVAILABLE - || bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_DISCONNECTING) { + || bluetoothManager.getState() == WebRtcBluetoothManager.State.HEADSET_UNAVAILABLE) { bluetoothManager.updateDevice(); } + boolean bluetoothCommunicationOutputAvailable = hasBluetoothCommunicationOutput(); + boolean bluetoothExpected = bluetoothManager.started() + && (bluetoothCommunicationOutputAvailable || bluetoothManager.isHeadsetProfileExpected()); + bluetoothPreferredForCall = AudioRoutePolicy.shouldPreferBluetooth( + userSelectedAudioDevice, + bluetoothPreferredForCall, + bluetoothExpected, + bluetoothManager.getState() == WebRtcBluetoothManager.State.HEADSET_UNAVAILABLE + ); + Set newInternalAudioDevices = new HashSet<>(); if (bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_CONNECTED || bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_CONNECTING - || bluetoothManager.getState() == WebRtcBluetoothManager.State.HEADSET_AVAILABLE) { + || bluetoothManager.getState() == WebRtcBluetoothManager.State.HEADSET_AVAILABLE + || bluetoothExpected) { newInternalAudioDevices.add(AudioDevice.BLUETOOTH); } @@ -463,11 +667,8 @@ public final void updateAudioDeviceState() { } } - // Correct user selected audio devices if needed. - if (userSelectedAudioDevice == AudioDevice.BLUETOOTH - && bluetoothManager.getState() == WebRtcBluetoothManager.State.HEADSET_UNAVAILABLE) { - userSelectedAudioDevice = AudioDevice.SPEAKER_PHONE; - } + // Correct user selected wired audio devices if needed. An explicit Bluetooth selection remains sticky so it + // can resume after the endpoint reconnects. if (userSelectedAudioDevice == AudioDevice.SPEAKER_PHONE && hasWiredHeadset) { userSelectedAudioDevice = AudioDevice.WIRED_HEADSET; } @@ -478,18 +679,19 @@ public final void updateAudioDeviceState() { // Need to start Bluetooth if it is available and user either selected it explicitly or // user did not select any output device. - boolean needBluetoothScoStart = - bluetoothManager.getState() == WebRtcBluetoothManager.State.HEADSET_AVAILABLE - && (userSelectedAudioDevice == AudioDevice.NONE - || userSelectedAudioDevice == AudioDevice.BLUETOOTH); + boolean needBluetoothScoStart = WebRtcBluetoothManager.shouldStartBluetoothRoute( + bluetoothManager.getState(), + bluetoothPreferredForCall, + hasWiredHeadset, + bluetoothManager.isBluetoothRouteRetryScheduled() + ); // Need to stop Bluetooth audio if user selected different device and // Bluetooth SCO connection is established or in the process. boolean needBluetoothScoStop = (bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_CONNECTED || bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_CONNECTING) - && (userSelectedAudioDevice != AudioDevice.NONE - && userSelectedAudioDevice != AudioDevice.BLUETOOTH); + && (!bluetoothPreferredForCall || hasWiredHeadset); if (bluetoothManager.getState() == WebRtcBluetoothManager.State.HEADSET_AVAILABLE || bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_CONNECTING @@ -502,11 +704,8 @@ public final void updateAudioDeviceState() { // Start or stop Bluetooth SCO connection given states set earlier. if (needBluetoothScoStop) { bluetoothManager.stopScoAudio(); - bluetoothManager.updateDevice(); } else if (needBluetoothScoStart && !bluetoothManager.startScoAudio()) { - // Remove BLUETOOTH and BLUETOOTH_SCO from list of available devices since SCO start has - // reported no longer available or too many failed attempts. - newInternalAudioDevices.remove(AudioDevice.BLUETOOTH); + // Keep Bluetooth visible so an explicit user selection can reset the bounded retry counter. newInternalAudioDevices.remove(AudioDevice.BLUETOOTH_SCO); } @@ -517,46 +716,53 @@ public final void updateAudioDeviceState() { audioDevices.remove(AudioDevice.BLUETOOTH_SCO); - // Update selected audio device. - AudioDevice newCurrentAudioDevice; - - if ((bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_CONNECTED) - && newInternalAudioDevices.contains(AudioDevice.BLUETOOTH_SCO)) - { - // If Bluetooth SCO is connected and available to use, then it has been selected by user or - // auto-selected and it should be used as output audio device. - newCurrentAudioDevice = AudioDevice.BLUETOOTH; - } else if (hasWiredHeadset) { - // If a wired headset is connected, but Bluetooth SCO is not, then wired headset is used as - // audio device. - newCurrentAudioDevice = AudioDevice.WIRED_HEADSET; - } else { - // No wired headset and no Bluetooth SCO, hence the audio-device list can contain speaker - // phone (on a tablet), or speaker phone and earpiece (on mobile phone). - // |userSelectedAudioDevice| may contain either AudioDevice.SPEAKER_PHONE or AudioDevice.EARPIECE - // depending on the user's selection. |defaultAudioDevice|, which is set in code depending on - // call is audio only or video, to be used if user hasn't made an explicit selection - if ((userSelectedAudioDevice == AudioDevice.NONE) && (defaultAudioDevice != AudioDevice.NONE)) - newCurrentAudioDevice = defaultAudioDevice; - else - newCurrentAudioDevice = userSelectedAudioDevice; - } + boolean bluetoothConnected = bluetoothPreferredForCall + && !hasWiredHeadset + && bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_CONNECTED + && newInternalAudioDevices.contains(AudioDevice.BLUETOOTH_SCO); + AudioDevice newCurrentAudioDevice = AudioRoutePolicy.selectAudioDevice( + audioDevices, + userSelectedAudioDevice, + defaultAudioDevice, + hasWiredHeadset, + bluetoothConnected + ); + boolean communicationRouteNeedsSelection = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + && newCurrentAudioDevice != AudioDevice.NONE + && !(newCurrentAudioDevice == AudioDevice.BLUETOOTH + && bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_CONNECTED) + && !isCommunicationDeviceSelected(newCurrentAudioDevice); + boolean audioDeviceUpdateNeeded = newCurrentAudioDevice != currentAudioDevice + || audioDeviceSetUpdated + || communicationRouteNeedsSelection; + AudioDevice previousCurrentAudioDevice = currentAudioDevice; // Switch to new device but only if there has been any changes. - if (newCurrentAudioDevice != currentAudioDevice || audioDeviceSetUpdated) { - // Do the required device switch. - setAudioDeviceInternal(newCurrentAudioDevice); + if (audioDeviceUpdateNeeded) { + boolean bluetoothSelectionPending = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + && isBluetoothSelectionPending(); + if (!bluetoothSelectionPending) { + setAudioDeviceInternal(newCurrentAudioDevice); + } Log.d(TAG, "New device status: " + "internally available=" + internalAudioDevices + ", " + "externally available=" + audioDevices + ", " - + "current(new)=" + newCurrentAudioDevice); - if (audioManagerListener != null) { - // Notify a listening client that audio device has been changed. - audioManagerListener.onAudioDeviceChanged(currentAudioDevice, audioDevices); - } + + "current(new)=" + currentAudioDevice); } + + boolean audioDeviceChanged = previousCurrentAudioDevice != currentAudioDevice || audioDeviceSetUpdated; + notifyAudioRouteStateIfChanged(audioDeviceChanged); Log.d(TAG, "--- updateAudioDeviceState done"); } + private void notifyAudioRouteStateIfChanged(boolean audioDeviceChanged) { + AudioDevice audioDeviceForUi = getAudioDeviceForUi(); + boolean audioDeviceForUiChanged = audioDeviceForUi != lastReportedAudioDeviceForUi; + lastReportedAudioDeviceForUi = audioDeviceForUi; + if ((audioDeviceChanged || audioDeviceForUiChanged) && audioManagerListener != null) { + audioManagerListener.onAudioDeviceChanged(currentAudioDevice, audioDevices); + } + } + /** * AudioDevice is the names of possible audio devices that we currently support. */ diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java index 7835e6f2fa2..28ece5c23d1 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java @@ -29,6 +29,8 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.pm.PackageManager; +import android.media.AudioDeviceCallback; +import android.media.AudioDeviceInfo; import android.media.AudioManager; import android.os.Build; import android.os.Handler; @@ -41,9 +43,11 @@ import org.webrtc.ThreadUtils; +import java.util.HashSet; import java.util.List; import java.util.Set; +import androidx.annotation.RequiresApi; import androidx.core.app.ActivityCompat; public class WebRtcBluetoothManager { @@ -51,6 +55,7 @@ public class WebRtcBluetoothManager { // Timeout interval for starting or stopping audio to a Bluetooth SCO device. private static final int BLUETOOTH_SCO_TIMEOUT_MS = 4000; + private static final int BLUETOOTH_ROUTE_RETRY_DELAY_MS = 500; // Maximum number of SCO connection attempts. private static final int MAX_SCO_CONNECTION_ATTEMPTS = 2; private final Context apprtcContext; @@ -64,10 +69,14 @@ public class WebRtcBluetoothManager { private BluetoothAdapter bluetoothAdapter; private BluetoothHeadset bluetoothHeadset; private BluetoothDevice bluetoothDevice; + private ModernBluetoothRoute modernBluetoothRoute; + private boolean headsetProfileExpected; // Runs when the Bluetooth timeout expires. We use that timeout after calling // startScoAudio() or stopScoAudio() because we're not guaranteed to get a // callback after those calls. private final Runnable bluetoothTimeoutRunnable = this::bluetoothTimeout; + private final Runnable bluetoothRouteRetryRunnable = this::retryBluetoothRoute; + private boolean bluetoothRouteRetryScheduled; private boolean started = false; protected WebRtcBluetoothManager(Context context, WebRtcAudioManager audioManager) { @@ -89,6 +98,121 @@ static WebRtcBluetoothManager create(Context context, WebRtcAudioManager audioMa return new WebRtcBluetoothManager(context, audioManager); } + static boolean isBluetoothCommunicationDeviceType(int type) { + return bluetoothCommunicationDevicePriority(type, Build.VERSION.SDK_INT) >= 0; + } + + @SuppressLint("InlinedApi") + static int bluetoothCommunicationDevicePriority(int type, int sdkInt) { + if (sdkInt >= Build.VERSION_CODES.S && type == AudioDeviceInfo.TYPE_BLE_HEADSET) { + return 4; + } + if (type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) { + return 3; + } + if (sdkInt >= Build.VERSION_CODES.S && type == AudioDeviceInfo.TYPE_HEARING_AID) { + return 2; + } + if (sdkInt >= Build.VERSION_CODES.S && type == AudioDeviceInfo.TYPE_BLE_SPEAKER) { + return 1; + } + // A2DP is a media-only output and cannot be used as a two-way call route. + return -1; + } + + static boolean isBluetoothTransitionInProgress(State state) { + return state == State.SCO_CONNECTING || state == State.SCO_DISCONNECTING; + } + + static boolean isBluetoothSelectionActive( + State state, + boolean retryScheduled, + boolean legacyHeadsetProfileExpected) { + return state == State.SCO_CONNECTING + || state == State.SCO_CONNECTED + || state == State.SCO_DISCONNECTING + || retryScheduled + || (state == State.HEADSET_UNAVAILABLE && legacyHeadsetProfileExpected); + } + + static boolean shouldKeepModernBluetoothState( + State state, + boolean requestedDeviceAvailable, + boolean confirmedDeviceAvailable, + boolean anyBluetoothDeviceAvailable) { + if (state == State.SCO_CONNECTING) { + return requestedDeviceAvailable; + } + if (state == State.SCO_CONNECTED) { + return confirmedDeviceAvailable; + } + return state == State.SCO_DISCONNECTING && anyBluetoothDeviceAvailable; + } + + static boolean shouldResetModernBluetoothAttempts( + State state, + boolean requestedDeviceAvailable, + boolean anyBluetoothDeviceAvailable) { + return state == State.SCO_CONNECTING + && !requestedDeviceAvailable + && anyBluetoothDeviceAvailable; + } + + static boolean shouldStartBluetoothRoute( + State state, + boolean bluetoothPreferred, + boolean hasWiredHeadset, + boolean retryScheduled) { + return state == State.HEADSET_AVAILABLE + && bluetoothPreferred + && !hasWiredHeadset + && !retryScheduled; + } + + static boolean shouldAcceptModernBluetoothCallback( + State state, + boolean routeSelectionControlled, + boolean routeClearPending, + boolean pendingRequestMatches, + boolean callbackMatchesCurrentRoute) { + if (state == State.SCO_DISCONNECTING || routeClearPending) { + return false; + } + if (state == State.SCO_CONNECTING) { + return pendingRequestMatches; + } + if (state == State.SCO_CONNECTED) { + return true; + } + return !routeSelectionControlled || callbackMatchesCurrentRoute; + } + + static boolean shouldAcceptLegacyScoConnected(State state) { + return state == State.SCO_CONNECTING || state == State.SCO_CONNECTED; + } + + static State stateAfterModernRouteClear(boolean bluetoothAvailable) { + return bluetoothAvailable ? State.HEADSET_AVAILABLE : State.HEADSET_UNAVAILABLE; + } + + static boolean shouldKeepModernRouteClearPending( + boolean routeClearPending, + boolean currentRouteKnown, + boolean bluetoothSelected) { + return routeClearPending && (!currentRouteKnown || bluetoothSelected); + } + + static boolean shouldReassertModernBluetoothAfterFocusGain( + State state, + boolean bluetoothPreferred, + boolean hasWiredHeadset, + int sdkInt) { + return sdkInt >= Build.VERSION_CODES.S + && state == State.SCO_CONNECTED + && bluetoothPreferred + && !hasWiredHeadset; + } + /** * Returns the internal state. */ @@ -97,6 +221,74 @@ public State getState() { return bluetoothState; } + public boolean isHeadsetProfileExpected() { + ThreadUtils.checkIsOnMainThread(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && modernBluetoothRoute != null) { + return modernBluetoothRoute.hasBluetoothDevice(); + } + return headsetProfileExpected; + } + + public boolean isBluetoothSelectionActive() { + ThreadUtils.checkIsOnMainThread(); + boolean legacyHeadsetProfilePending = Build.VERSION.SDK_INT < Build.VERSION_CODES.S + && started + && headsetProfileExpected; + return isBluetoothSelectionActive( + bluetoothState, + bluetoothRouteRetryScheduled, + legacyHeadsetProfilePending + ); + } + + boolean isBluetoothRouteRetryScheduled() { + ThreadUtils.checkIsOnMainThread(); + return bluetoothRouteRetryScheduled; + } + + public void resetScoConnectionAttempts() { + ThreadUtils.checkIsOnMainThread(); + scoConnectionAttempts = 0; + cancelBluetoothRouteRetry(); + } + + public void reassertBluetoothAudioAfterFocusGain(boolean bluetoothPreferred, boolean hasWiredHeadset) { + ThreadUtils.checkIsOnMainThread(); + if (!started + || modernBluetoothRoute == null + || !shouldReassertModernBluetoothAfterFocusGain( + bluetoothState, + bluetoothPreferred, + hasWiredHeadset, + Build.VERSION.SDK_INT + )) { + return; + } + + cancelTimer(); + resetScoConnectionAttempts(); + if (!modernBluetoothRoute.hasConfirmedBluetoothDevice()) { + modernBluetoothRoute.clearConfirmedBluetoothDevice(); + bluetoothState = stateAfterModernRouteClear(modernBluetoothRoute.hasBluetoothDevice()); + return; + } + bluetoothState = State.SCO_CONNECTING; + scoConnectionAttempts++; + boolean requestAccepted = modernBluetoothRoute.reselectConfirmedBluetoothDevice(); + if (bluetoothState == State.SCO_CONNECTED) { + return; + } + if (!requestAccepted) { + bluetoothState = stateAfterModernRouteClear(modernBluetoothRoute.hasBluetoothDevice()); + if (bluetoothState == State.HEADSET_AVAILABLE) { + scheduleBluetoothRouteRetry(); + } + return; + } + startTimer(); + Log.d(TAG, "Reasserting the confirmed Bluetooth route after audio focus returned"); + } + /** * Activates components required to detect Bluetooth devices and to enable * BT SCO (audio is routed via BT SCO) for the headset profile. The end @@ -114,9 +306,6 @@ public State getState() { public void start() { ThreadUtils.checkIsOnMainThread(); Log.d(TAG, "start"); - if(hasNoBluetoothPermission()){ - return; - } if (bluetoothState != State.UNINITIALIZED) { Log.w(TAG, "Invalid BT state"); return; @@ -124,12 +313,32 @@ public void start() { bluetoothHeadset = null; bluetoothDevice = null; scoConnectionAttempts = 0; + bluetoothRouteRetryScheduled = false; + headsetProfileExpected = false; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + bluetoothState = State.HEADSET_UNAVAILABLE; + modernBluetoothRoute = new ModernBluetoothRoute(); + started = true; + modernBluetoothRoute.start(); + updateDevice(); + Log.d(TAG, "Modern Bluetooth communication route started: " + bluetoothState); + return; + } + // BluetoothHeadset requires the runtime Bluetooth permission. The Android 12+ + // communication-device API above only requires MODIFY_AUDIO_SETTINGS. + if (hasNoBluetoothPermission()) { + return; + } // Get a handle to the default local Bluetooth adapter. bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); if (bluetoothAdapter == null) { Log.w(TAG, "Device does not support Bluetooth"); return; } + int headsetProfileState = bluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEADSET); + headsetProfileExpected = headsetProfileState == BluetoothProfile.STATE_CONNECTED + || headsetProfileState == BluetoothProfile.STATE_CONNECTING; + Log.d(TAG, "HEADSET profile state: " + stateToString(headsetProfileState)); // Ensure that the device supports use of BT SCO audio for off call use cases. if (!audioManager.isBluetoothScoAvailableOffCall()) { Log.e(TAG, "Bluetooth SCO audio is not available off call"); @@ -150,8 +359,6 @@ public void start() { // Register receiver for change in audio connection state of the Headset profile. bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED); registerReceiver(bluetoothHeadsetReceiver, bluetoothHeadsetFilter); - Log.d(TAG, "HEADSET profile state: " - + stateToString(bluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEADSET))); Log.d(TAG, "Bluetooth proxy for headset profile has started"); bluetoothState = State.HEADSET_UNAVAILABLE; started = true; @@ -164,6 +371,17 @@ public void start() { public void stop() { ThreadUtils.checkIsOnMainThread(); Log.d(TAG, "stop: BT state=" + bluetoothState); + cancelBluetoothRouteRetry(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && modernBluetoothRoute != null) { + cancelTimer(); + modernBluetoothRoute.stop(); + modernBluetoothRoute = null; + bluetoothState = State.UNINITIALIZED; + headsetProfileExpected = false; + started = false; + Log.d(TAG, "Modern Bluetooth communication route stopped"); + return; + } if (bluetoothAdapter == null) { return; } @@ -182,6 +400,8 @@ public void stop() { bluetoothAdapter = null; bluetoothDevice = null; bluetoothState = State.UNINITIALIZED; + headsetProfileExpected = false; + started = false; Log.d(TAG, "stop done: BT state=" + bluetoothState); } @@ -211,6 +431,24 @@ public boolean startScoAudio() { Log.e(TAG, "BT SCO connection fails - no headset available"); return false; } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && modernBluetoothRoute != null) { + cancelBluetoothRouteRetry(); + bluetoothState = State.SCO_CONNECTING; + scoConnectionAttempts++; + boolean requestAccepted = modernBluetoothRoute.selectBluetoothDevice(); + if (bluetoothState == State.SCO_CONNECTED) { + return true; + } + if (!requestAccepted) { + Log.w(TAG, "Android rejected the Bluetooth communication-device request"); + bluetoothState = State.HEADSET_AVAILABLE; + scheduleBluetoothRouteRetry(); + return false; + } + startTimer(); + Log.d(TAG, "Waiting for Android to select the Bluetooth communication device"); + return true; + } // Start BT SCO channel and wait for ACTION_AUDIO_STATE_CHANGED. Log.d(TAG, "Starting Bluetooth SCO and waits for ACTION_AUDIO_STATE_CHANGED..."); // The SCO connection establishment can take several seconds, hence we cannot rely on the @@ -237,9 +475,18 @@ public void stopScoAudio() { return; } cancelTimer(); + cancelBluetoothRouteRetry(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && modernBluetoothRoute != null) { + bluetoothState = State.SCO_DISCONNECTING; + startTimer(); + modernBluetoothRoute.clearCommunicationDeviceRequest(); + Log.d(TAG, "Bluetooth communication-device request cleared"); + return; + } + bluetoothState = State.SCO_DISCONNECTING; + startTimer(); audioManager.stopBluetoothSco(); audioManager.setBluetoothScoOn(false); - bluetoothState = State.SCO_DISCONNECTING; Log.d(TAG, "stopScoAudio done: BT state=" + bluetoothState + ", " + "SCO is on: " + isScoOn()); } @@ -253,10 +500,51 @@ public void stopScoAudio() { */ @SuppressLint("MissingPermission") public void updateDevice() { - boolean hasNoBluetoothPermissions = hasNoBluetoothPermission(); - if (hasNoBluetoothPermissions || - bluetoothState == State.UNINITIALIZED || - bluetoothHeadset == null) { + if (bluetoothState == State.UNINITIALIZED) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && modernBluetoothRoute != null) { + boolean bluetoothAvailable = modernBluetoothRoute.hasBluetoothDevice(); + if (bluetoothState == State.SCO_CONNECTING) { + boolean requestedBluetoothDeviceAvailable = modernBluetoothRoute.hasRequestedBluetoothDevice(); + if (!requestedBluetoothDeviceAvailable) { + cancelTimer(); + modernBluetoothRoute.discardPendingBluetoothRequest(); + if (shouldResetModernBluetoothAttempts( + bluetoothState, + requestedBluetoothDeviceAvailable, + bluetoothAvailable)) { + scoConnectionAttempts = 0; + } + bluetoothState = stateAfterModernRouteClear(bluetoothAvailable); + } + } else if (bluetoothState == State.SCO_DISCONNECTING) { + if (!bluetoothAvailable) { + cancelTimer(); + modernBluetoothRoute.clearConfirmedBluetoothDevice(); + bluetoothState = State.HEADSET_UNAVAILABLE; + } + } else if (bluetoothState == State.SCO_CONNECTED) { + if (!modernBluetoothRoute.hasConfirmedBluetoothDevice()) { + modernBluetoothRoute.clearConfirmedBluetoothDevice(); + bluetoothState = stateAfterModernRouteClear(bluetoothAvailable); + } + } else if (modernBluetoothRoute.confirmInitialBluetoothRoute()) { + // Bluetooth may already be the active system route when the call starts. + bluetoothState = State.SCO_CONNECTED; + scoConnectionAttempts = 0; + } else if (bluetoothAvailable) { + bluetoothState = State.HEADSET_AVAILABLE; + } else { + bluetoothState = State.HEADSET_UNAVAILABLE; + } + Log.d(TAG, "Modern Bluetooth route state=" + bluetoothState); + return; + } + if (hasNoBluetoothPermission()) { + return; + } + if (bluetoothHeadset == null) { return; } Log.d(TAG, "updateDevice"); @@ -267,11 +555,13 @@ public void updateDevice() { if (devices.isEmpty()) { bluetoothDevice = null; bluetoothState = State.HEADSET_UNAVAILABLE; + headsetProfileExpected = false; Log.d(TAG, "No connected bluetooth headset"); } else { // Always use first device in list. Android only supports one device. bluetoothDevice = devices.get(0); bluetoothState = State.HEADSET_AVAILABLE; + headsetProfileExpected = true; Log.d(TAG, "Connected bluetooth headset: " + "name=" + bluetoothDevice.getName() + ", " + "state=" + stateToString(bluetoothHeadset.getConnectionState(bluetoothDevice)) @@ -280,6 +570,54 @@ public void updateDevice() { Log.d(TAG, "updateDevice done: BT state=" + bluetoothState); } + /** + * Re-arms Bluetooth after an explicit user selection. An already accepted request is kept; + * clearing it on a second tap can race with an already queued framework callback. + */ + public boolean requestBluetoothAudioSelection() { + ThreadUtils.checkIsOnMainThread(); + resetScoConnectionAttempts(); + if (!started) { + start(); + } + if (!started) { + return false; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && modernBluetoothRoute != null) { + if (!modernBluetoothRoute.hasBluetoothDevice()) { + cancelTimer(); + bluetoothState = State.HEADSET_UNAVAILABLE; + return false; + } + // Do not cancel an accepted request on a manual tap. Some firmware has already queued + // its success callback; clearing here creates a stale callback that can falsely report + // Bluetooth as active after Android has moved back to the earpiece. + if (isBluetoothTransitionInProgress(bluetoothState)) { + return true; + } + if (bluetoothState == State.SCO_CONNECTED) { + return true; + } + bluetoothState = State.HEADSET_AVAILABLE; + return true; + } + + if (bluetoothState == State.SCO_CONNECTED) { + return true; + } + if (bluetoothState == State.SCO_CONNECTING) { + // Keep the accepted SCO attempt. A late CONNECTED broadcast from an attempt which was + // stopped here could otherwise be mistaken for the new manual request. + return true; + } + if (bluetoothState == State.SCO_DISCONNECTING) { + return true; + } + updateDevice(); + return bluetoothState == State.HEADSET_AVAILABLE || headsetProfileExpected; + } + /** * Stubs for test mocks. */ @@ -355,6 +693,7 @@ private void updateAudioDeviceState() { private void startTimer() { ThreadUtils.checkIsOnMainThread(); Log.d(TAG, "startTimer"); + handler.removeCallbacks(bluetoothTimeoutRunnable); handler.postDelayed(bluetoothTimeoutRunnable, BLUETOOTH_SCO_TIMEOUT_MS); } @@ -367,6 +706,31 @@ private void cancelTimer() { handler.removeCallbacks(bluetoothTimeoutRunnable); } + private void scheduleBluetoothRouteRetry() { + ThreadUtils.checkIsOnMainThread(); + cancelBluetoothRouteRetry(); + if (started && scoConnectionAttempts < MAX_SCO_CONNECTION_ATTEMPTS) { + bluetoothRouteRetryScheduled = true; + handler.postDelayed(bluetoothRouteRetryRunnable, BLUETOOTH_ROUTE_RETRY_DELAY_MS); + } + } + + private void cancelBluetoothRouteRetry() { + ThreadUtils.checkIsOnMainThread(); + handler.removeCallbacks(bluetoothRouteRetryRunnable); + bluetoothRouteRetryScheduled = false; + } + + private void retryBluetoothRoute() { + ThreadUtils.checkIsOnMainThread(); + bluetoothRouteRetryScheduled = false; + if (!started || bluetoothState != State.HEADSET_AVAILABLE) { + return; + } + Log.d(TAG, "Retrying Bluetooth communication-device selection"); + updateAudioDeviceState(); + } + /** * Called when start of the BT SCO channel takes too long time. Usually * happens when the BT device has been turned on during an ongoing call. @@ -374,18 +738,48 @@ private void cancelTimer() { @SuppressLint("MissingPermission") private void bluetoothTimeout() { ThreadUtils.checkIsOnMainThread(); - boolean hasNoBluetoothPermissions = hasNoBluetoothPermission(); - if (hasNoBluetoothPermissions || - bluetoothState == State.UNINITIALIZED || - bluetoothHeadset == null) { + if (bluetoothState == State.UNINITIALIZED || + (modernBluetoothRoute == null && bluetoothHeadset == null)) { return; } Log.d(TAG, "bluetoothTimeout: BT state=" + bluetoothState + ", " + "attempts: " + scoConnectionAttempts + ", " + "SCO is on: " + isScoOn()); + if (bluetoothState == State.SCO_DISCONNECTING) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && modernBluetoothRoute != null) { + // Never resurrect a request which was explicitly cleared. The getter can still + // expose the old Bluetooth route while clearCommunicationDevice() is settling. + modernBluetoothRoute.reconcileRouteClearFromGetter(); + bluetoothState = stateAfterModernRouteClear(modernBluetoothRoute.hasBluetoothDevice()); + } else { + if (hasNoBluetoothPermission()) { + return; + } + updateDevice(); + } + updateAudioDeviceState(); + return; + } if (bluetoothState != State.SCO_CONNECTING) { return; } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && modernBluetoothRoute != null) { + if (modernBluetoothRoute.isRequestedBluetoothSelected()) { + modernBluetoothRoute.confirmRequestedBluetoothRoute(); + bluetoothState = State.SCO_CONNECTED; + scoConnectionAttempts = 0; + } else { + Log.w(TAG, "Bluetooth communication-device selection timed out"); + bluetoothState = State.SCO_DISCONNECTING; + startTimer(); + modernBluetoothRoute.clearCommunicationDeviceRequest(); + } + updateAudioDeviceState(); + return; + } + if (hasNoBluetoothPermission()) { + return; + } // Bluetooth SCO should be connecting; check the latest result. boolean scoConnected = false; List devices = bluetoothHeadset.getConnectedDevices(); @@ -415,6 +809,9 @@ private void bluetoothTimeout() { * Checks whether audio uses Bluetooth SCO. */ private boolean isScoOn() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && modernBluetoothRoute != null) { + return modernBluetoothRoute.isBluetoothSelected(); + } return audioManager.isBluetoothScoOn(); } @@ -448,6 +845,352 @@ private String stateToString(int state) { } } + @RequiresApi(Build.VERSION_CODES.S) + private class ModernBluetoothRoute { + private final Set knownBluetoothDeviceIds = new HashSet<>(); + private static final int NO_DEVICE_ID = -1; + private boolean routeSelectionControlled; + private boolean routeClearPending; + private boolean routeRequestPending; + private int requestedBluetoothDeviceId = NO_DEVICE_ID; + private int confirmedBluetoothDeviceId = NO_DEVICE_ID; + private final AudioManager.OnCommunicationDeviceChangedListener communicationDeviceChangedListener = + this::onCommunicationDeviceChanged; + private final AudioDeviceCallback audioDeviceCallback = new AudioDeviceCallback() { + @Override + public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) { + for (AudioDeviceInfo device : addedDevices) { + if (isBluetoothCommunicationDeviceType(device.getType()) + && knownBluetoothDeviceIds.add(device.getId())) { + scoConnectionAttempts = 0; + } + } + onDeviceStateChanged(); + } + + @Override + public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) { + for (AudioDeviceInfo device : removedDevices) { + knownBluetoothDeviceIds.remove(device.getId()); + } + onDeviceStateChanged(); + } + }; + + void start() { + rememberCurrentBluetoothDevices(); + audioManager.addOnCommunicationDeviceChangedListener( + apprtcContext.getMainExecutor(), + communicationDeviceChangedListener + ); + audioManager.registerAudioDeviceCallback(audioDeviceCallback, handler); + } + + void stop() { + audioManager.removeOnCommunicationDeviceChangedListener(communicationDeviceChangedListener); + audioManager.unregisterAudioDeviceCallback(audioDeviceCallback); + clearCommunicationDeviceRequest(); + } + + boolean selectBluetoothDevice() { + return selectBluetoothDevice(findBluetoothDevice()); + } + + boolean reselectConfirmedBluetoothDevice() { + AudioDeviceInfo bluetoothDeviceInfo = confirmedBluetoothDeviceId == NO_DEVICE_ID + ? null + : findBluetoothDevice(confirmedBluetoothDeviceId); + confirmedBluetoothDeviceId = NO_DEVICE_ID; + return selectBluetoothDevice(bluetoothDeviceInfo); + } + + private boolean selectBluetoothDevice(AudioDeviceInfo bluetoothDeviceInfo) { + if (bluetoothDeviceInfo == null) { + return false; + } + routeSelectionControlled = true; + routeClearPending = false; + routeRequestPending = true; + requestedBluetoothDeviceId = bluetoothDeviceInfo.getId(); + try { + boolean accepted = audioManager.setCommunicationDevice(bluetoothDeviceInfo); + if (!accepted) { + routeRequestPending = false; + requestedBluetoothDeviceId = NO_DEVICE_ID; + } + return accepted; + } catch (SecurityException | IllegalArgumentException exception) { + routeRequestPending = false; + requestedBluetoothDeviceId = NO_DEVICE_ID; + Log.e(TAG, "Bluetooth device disappeared while it was being selected", exception); + return false; + } + } + + void clearCommunicationDeviceRequest() { + routeSelectionControlled = true; + routeClearPending = true; + routeRequestPending = false; + requestedBluetoothDeviceId = NO_DEVICE_ID; + confirmedBluetoothDeviceId = NO_DEVICE_ID; + try { + audioManager.clearCommunicationDevice(); + } catch (SecurityException exception) { + Log.e(TAG, "Bluetooth permission was revoked while clearing the communication device", exception); + } + } + + boolean hasBluetoothDevice() { + return findBluetoothDevice() != null; + } + + boolean isBluetoothSelected() { + try { + AudioDeviceInfo device = audioManager.getCommunicationDevice(); + return device != null && isBluetoothCommunicationDeviceType(device.getType()); + } catch (SecurityException exception) { + Log.e(TAG, "Bluetooth permission was revoked while reading the communication device", exception); + return false; + } + } + + boolean canTrustInitialRouteSnapshot() { + return !routeSelectionControlled; + } + + boolean confirmInitialBluetoothRoute() { + if (!canTrustInitialRouteSnapshot()) { + return false; + } + try { + AudioDeviceInfo device = audioManager.getCommunicationDevice(); + if (device != null + && isBluetoothCommunicationDeviceType(device.getType()) + && isBluetoothDeviceAvailable(device.getId())) { + confirmBluetoothRoute(device); + return true; + } + } catch (SecurityException exception) { + Log.e(TAG, "Unable to confirm the initial Bluetooth communication device", exception); + } + return false; + } + + boolean isRequestedBluetoothSelected() { + if (!routeRequestPending) { + return false; + } + try { + AudioDeviceInfo device = audioManager.getCommunicationDevice(); + return device != null + && device.getId() == requestedBluetoothDeviceId + && isBluetoothCommunicationDeviceType(device.getType()) + && isBluetoothDeviceAvailable(device.getId()); + } catch (SecurityException exception) { + Log.e(TAG, "Unable to read the requested Bluetooth communication device", exception); + return false; + } + } + + boolean matchesPendingBluetoothRequest(AudioDeviceInfo device) { + return routeRequestPending + && device != null + && device.getId() == requestedBluetoothDeviceId + && isBluetoothCommunicationDeviceType(device.getType()) + && isBluetoothDeviceAvailable(device.getId()); + } + + boolean matchesCurrentCommunicationDevice(AudioDeviceInfo expectedDevice) { + try { + AudioDeviceInfo currentDevice = audioManager.getCommunicationDevice(); + return currentDevice != null && currentDevice.getId() == expectedDevice.getId(); + } catch (SecurityException exception) { + Log.e(TAG, "Unable to verify the current Bluetooth communication device", exception); + return false; + } + } + + void confirmRequestedBluetoothRoute() { + confirmedBluetoothDeviceId = requestedBluetoothDeviceId; + routeRequestPending = false; + requestedBluetoothDeviceId = NO_DEVICE_ID; + } + + void confirmBluetoothRoute(AudioDeviceInfo device) { + confirmedBluetoothDeviceId = device.getId(); + routeRequestPending = false; + requestedBluetoothDeviceId = NO_DEVICE_ID; + } + + boolean hasRequestedBluetoothDevice() { + return routeRequestPending && isBluetoothDeviceAvailable(requestedBluetoothDeviceId); + } + + boolean hasConfirmedBluetoothDevice() { + return confirmedBluetoothDeviceId != NO_DEVICE_ID + && isBluetoothDeviceAvailable(confirmedBluetoothDeviceId); + } + + void discardPendingBluetoothRequest() { + routeRequestPending = false; + requestedBluetoothDeviceId = NO_DEVICE_ID; + } + + void clearConfirmedBluetoothDevice() { + confirmedBluetoothDeviceId = NO_DEVICE_ID; + } + + void reconcileRouteClearFromGetter() { + if (!routeClearPending) { + return; + } + boolean currentRouteKnown = false; + boolean bluetoothSelected = false; + try { + AudioDeviceInfo device = audioManager.getCommunicationDevice(); + currentRouteKnown = true; + bluetoothSelected = device != null && isBluetoothCommunicationDeviceType(device.getType()); + } catch (SecurityException exception) { + Log.e(TAG, "Unable to reconcile the cleared Bluetooth communication device", exception); + } + routeClearPending = shouldKeepModernRouteClearPending( + routeClearPending, + currentRouteKnown, + bluetoothSelected + ); + } + + private AudioDeviceInfo findBluetoothDevice() { + return findBluetoothDevice(NO_DEVICE_ID); + } + + private AudioDeviceInfo findBluetoothDevice(int exactDeviceId) { + try { + AudioDeviceInfo selectedDevice = null; + int selectedPriority = -1; + for (AudioDeviceInfo device : audioManager.getAvailableCommunicationDevices()) { + int priority = bluetoothCommunicationDevicePriority(device.getType(), Build.VERSION.SDK_INT); + if (priority < 0) { + continue; + } + if (exactDeviceId != NO_DEVICE_ID) { + if (device.getId() == exactDeviceId) { + return device; + } + } else if (priority > selectedPriority) { + selectedDevice = device; + selectedPriority = priority; + } + } + return selectedDevice; + } catch (SecurityException exception) { + Log.e(TAG, "Unable to enumerate Bluetooth communication devices", exception); + } + return null; + } + + private boolean isBluetoothDeviceAvailable(int deviceId) { + try { + for (AudioDeviceInfo device : audioManager.getAvailableCommunicationDevices()) { + if (device.getId() == deviceId + && isBluetoothCommunicationDeviceType(device.getType())) { + return true; + } + } + } catch (SecurityException exception) { + Log.e(TAG, "Unable to verify the Bluetooth communication device", exception); + } + return false; + } + + private void rememberCurrentBluetoothDevices() { + try { + for (AudioDeviceInfo device : audioManager.getAvailableCommunicationDevices()) { + if (isBluetoothCommunicationDeviceType(device.getType())) { + knownBluetoothDeviceIds.add(device.getId()); + } + } + } catch (SecurityException exception) { + Log.e(TAG, "Bluetooth permission was revoked while remembering communication devices", exception); + } + } + + private void onDeviceStateChanged() { + if (bluetoothState == State.UNINITIALIZED) { + return; + } + State previousState = bluetoothState; + boolean available = hasBluetoothDevice(); + if (shouldKeepModernBluetoothState( + previousState, + hasRequestedBluetoothDevice(), + hasConfirmedBluetoothDevice(), + available)) { + return; + } + updateDevice(); + if (previousState == State.SCO_DISCONNECTING) { + reconcileRouteClearFromGetter(); + } + if (bluetoothState == State.SCO_CONNECTED || previousState == State.SCO_DISCONNECTING) { + cancelTimer(); + } + if (bluetoothState == State.SCO_CONNECTED) { + scoConnectionAttempts = 0; + cancelBluetoothRouteRetry(); + } + updateAudioDeviceState(); + } + + private void onCommunicationDeviceChanged(AudioDeviceInfo device) { + if (bluetoothState == State.UNINITIALIZED) { + return; + } + boolean bluetoothSelected = device != null && isBluetoothCommunicationDeviceType(device.getType()); + if (bluetoothSelected) { + boolean pendingRequestMatches = matchesPendingBluetoothRequest(device); + boolean callbackMatchesCurrentRoute = matchesCurrentCommunicationDevice(device); + if (!shouldAcceptModernBluetoothCallback( + bluetoothState, + routeSelectionControlled, + routeClearPending, + pendingRequestMatches, + callbackMatchesCurrentRoute)) { + Log.d(TAG, "Ignoring a Bluetooth callback for an inactive or cleared route request"); + return; + } + if (!isBluetoothDeviceAvailable(device.getId())) { + Log.w(TAG, "Ignoring a Bluetooth callback for an endpoint which is no longer available"); + return; + } + // The callback argument is authoritative. Re-reading getCommunicationDevice() + // here returns the old earpiece for a short interval on some Samsung devices. + cancelTimer(); + cancelBluetoothRouteRetry(); + confirmBluetoothRoute(device); + bluetoothState = State.SCO_CONNECTED; + scoConnectionAttempts = 0; + updateAudioDeviceState(); + return; + } + + routeClearPending = false; + + if (bluetoothState == State.SCO_CONNECTED || bluetoothState == State.SCO_DISCONNECTING) { + cancelTimer(); + clearConfirmedBluetoothDevice(); + bluetoothState = stateAfterModernRouteClear(hasBluetoothDevice()); + updateAudioDeviceState(); + return; + } + + // A queued callback for the previous earpiece route can arrive after Android accepted + // a Bluetooth request. Keep CONNECTING until Bluetooth is confirmed or the request + // times out; still let the audio manager report its unchanged route state. + updateAudioDeviceState(); + } + } + public boolean started() { return started; } @@ -505,6 +1248,7 @@ public void onServiceDisconnected(int profile) { bluetoothHeadset = null; bluetoothDevice = null; bluetoothState = State.HEADSET_UNAVAILABLE; + headsetProfileExpected = false; updateAudioDeviceState(); Log.d(TAG, "onServiceDisconnected done: BT state=" + bluetoothState); } @@ -532,9 +1276,11 @@ public void onReceive(Context context, Intent intent) { + "sb=" + isInitialStickyBroadcast() + ", " + "BT state: " + bluetoothState); if (state == BluetoothHeadset.STATE_CONNECTED) { + headsetProfileExpected = true; scoConnectionAttempts = 0; updateAudioDeviceState(); } else if (state == BluetoothHeadset.STATE_CONNECTING) { + headsetProfileExpected = true; Log.d(TAG, "+++ Bluetooth is connecting..."); // No action needed. } else if (state == BluetoothHeadset.STATE_DISCONNECTING) { @@ -542,6 +1288,7 @@ public void onReceive(Context context, Intent intent) { // No action needed. } else if (state == BluetoothHeadset.STATE_DISCONNECTED) { // Bluetooth is probably powered off during the call. + headsetProfileExpected = false; stopScoAudio(); updateAudioDeviceState(); } @@ -556,14 +1303,15 @@ public void onReceive(Context context, Intent intent) { + "sb=" + isInitialStickyBroadcast() + ", " + "BT state: " + bluetoothState); if (state == BluetoothHeadset.STATE_AUDIO_CONNECTED) { - cancelTimer(); - if (bluetoothState == State.SCO_CONNECTING) { + if (shouldAcceptLegacyScoConnected(bluetoothState)) { + cancelTimer(); Log.d(TAG, "+++ Bluetooth audio SCO is now connected"); bluetoothState = State.SCO_CONNECTED; scoConnectionAttempts = 0; + cancelBluetoothRouteRetry(); updateAudioDeviceState(); } else { - Log.w(TAG, "Unexpected state BluetoothHeadset.STATE_AUDIO_CONNECTED"); + Log.d(TAG, "Ignoring SCO connected callback in state " + bluetoothState); } } else if (state == BluetoothHeadset.STATE_AUDIO_CONNECTING) { Log.d(TAG, "+++ Bluetooth audio SCO is now connecting..."); @@ -573,6 +1321,13 @@ public void onReceive(Context context, Intent intent) { Log.d(TAG, "Ignore STATE_AUDIO_DISCONNECTED initial sticky broadcast."); return; } + cancelTimer(); + if (bluetoothState == State.SCO_CONNECTED + || bluetoothState == State.SCO_CONNECTING + || bluetoothState == State.SCO_DISCONNECTING) { + bluetoothState = State.HEADSET_AVAILABLE; + updateDevice(); + } updateAudioDeviceState(); } } diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.java b/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.java new file mode 100644 index 00000000000..6eb1d6cc51e --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.java @@ -0,0 +1,83 @@ +/* + * 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 org.junit.Test; + +import java.util.EnumSet; +import java.util.Set; + +import static com.nextcloud.talk.webrtc.WebRtcAudioManager.AudioDevice.BLUETOOTH; +import static com.nextcloud.talk.webrtc.WebRtcAudioManager.AudioDevice.EARPIECE; +import static com.nextcloud.talk.webrtc.WebRtcAudioManager.AudioDevice.NONE; +import static com.nextcloud.talk.webrtc.WebRtcAudioManager.AudioDevice.SPEAKER_PHONE; +import static com.nextcloud.talk.webrtc.WebRtcAudioManager.AudioDevice.WIRED_HEADSET; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class AudioRoutePolicyTest { + + @Test + public void bluetoothConnectionHasPriority() { + assertSelected(BLUETOOTH, devices(BLUETOOTH, EARPIECE, SPEAKER_PHONE), SPEAKER_PHONE, + SPEAKER_PHONE, false, true); + } + + @Test + public void wiredHeadsetHasPriorityOverBluetoothPreference() { + assertSelected(WIRED_HEADSET, devices(BLUETOOTH, WIRED_HEADSET), BLUETOOTH, + SPEAKER_PHONE, true, false); + } + + @Test + public void explicitSpeakerSelectionIsHonoredWhenBluetoothIsNotPreferred() { + assertSelected(SPEAKER_PHONE, devices(EARPIECE, SPEAKER_PHONE), SPEAKER_PHONE, + EARPIECE, false, false); + } + + @Test + public void configuredDefaultIsUsedWithoutAnExplicitSelection() { + assertSelected(SPEAKER_PHONE, devices(EARPIECE, SPEAKER_PHONE), NONE, + SPEAKER_PHONE, false, false); + } + + @Test + public void autoBluetoothPreferenceIsReleasedAfterEndpointDisappears() { + assertFalse(AudioRoutePolicy.shouldPreferBluetooth(NONE, true, false, true)); + } + + @Test + public void explicitBluetoothPreferenceSurvivesEndpointDisappearance() { + assertTrue(AudioRoutePolicy.shouldPreferBluetooth(BLUETOOTH, true, false, true)); + } + + @Test + public void automaticBluetoothPreferenceSurvivesAnUnconfirmedTransition() { + assertTrue(AudioRoutePolicy.shouldPreferBluetooth(NONE, true, false, false)); + } + + private static Set devices(WebRtcAudioManager.AudioDevice... devices) { + return EnumSet.of(devices[0], devices); + } + + private static void assertSelected( + WebRtcAudioManager.AudioDevice expected, + Set availableDevices, + WebRtcAudioManager.AudioDevice userSelectedDevice, + WebRtcAudioManager.AudioDevice defaultDevice, + boolean hasWiredHeadset, + boolean bluetoothConnected) { + assertEquals(expected, AudioRoutePolicy.selectAudioDevice( + availableDevices, + userSelectedDevice, + defaultDevice, + hasWiredHeadset, + bluetoothConnected + )); + } +} diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothCommunicationDevicePolicyTest.java b/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothCommunicationDevicePolicyTest.java new file mode 100644 index 00000000000..a48eea34284 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothCommunicationDevicePolicyTest.java @@ -0,0 +1,64 @@ +/* + * 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.AudioDeviceInfo; +import android.os.Build; + +import org.junit.Test; + +import static org.junit.Assert.assertTrue; + +public class BluetoothCommunicationDevicePolicyTest { + + @Test + public void a2dpIsNeverUsedForTwoWayCallAudio() { + assertUnsupported(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, Build.VERSION_CODES.BAKLAVA); + } + + @Test + public void classicScoIsSupportedOnOldAndCurrentAndroid() { + assertSupported(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, Build.VERSION_CODES.O); + assertSupported(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, Build.VERSION_CODES.BAKLAVA); + } + + @Test + public void hearingAidRequiresModernCommunicationDeviceApi() { + assertUnsupported(AudioDeviceInfo.TYPE_HEARING_AID, Build.VERSION_CODES.R); + assertSupported(AudioDeviceInfo.TYPE_HEARING_AID, Build.VERSION_CODES.S); + } + + @Test + public void bleCommunicationDevicesRequireAndroidTwelve() { + assertUnsupported(AudioDeviceInfo.TYPE_BLE_HEADSET, Build.VERSION_CODES.R); + assertUnsupported(AudioDeviceInfo.TYPE_BLE_SPEAKER, Build.VERSION_CODES.R); + assertSupported(AudioDeviceInfo.TYPE_BLE_HEADSET, Build.VERSION_CODES.S); + assertSupported(AudioDeviceInfo.TYPE_BLE_SPEAKER, Build.VERSION_CODES.S); + } + + @Test + public void headsetEndpointsArePreferredOverOutputOnlyBleSpeaker() { + int headsetPriority = WebRtcBluetoothManager.bluetoothCommunicationDevicePriority( + AudioDeviceInfo.TYPE_BLE_HEADSET, + Build.VERSION_CODES.BAKLAVA + ); + int speakerPriority = WebRtcBluetoothManager.bluetoothCommunicationDevicePriority( + AudioDeviceInfo.TYPE_BLE_SPEAKER, + Build.VERSION_CODES.BAKLAVA + ); + + assertTrue(headsetPriority > speakerPriority); + } + + private static void assertSupported(int deviceType, int sdkInt) { + assertTrue(WebRtcBluetoothManager.bluetoothCommunicationDevicePriority(deviceType, sdkInt) >= 0); + } + + private static void assertUnsupported(int deviceType, int sdkInt) { + assertTrue(WebRtcBluetoothManager.bluetoothCommunicationDevicePriority(deviceType, sdkInt) < 0); + } +} diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.java b/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.java new file mode 100644 index 00000000000..f4debe3773d --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.java @@ -0,0 +1,223 @@ +/* + * 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.os.Build; + +import org.junit.Test; + +import static com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.HEADSET_AVAILABLE; +import static com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.HEADSET_UNAVAILABLE; +import static com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.SCO_CONNECTED; +import static com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.SCO_CONNECTING; +import static com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.SCO_DISCONNECTING; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class BluetoothRouteStatePolicyTest { + + @Test + public void manualTapKeepsAnAcceptedConnectingRequest() { + assertTrue(WebRtcBluetoothManager.isBluetoothTransitionInProgress(SCO_CONNECTING)); + } + + @Test + public void manualTapWaitsForAnInProgressDisconnectBeforeRetrying() { + assertTrue(WebRtcBluetoothManager.isBluetoothTransitionInProgress(SCO_DISCONNECTING)); + } + + @Test + public void acceptedAndQueuedSelectionsRemainVisibleToTheUi() { + assertTrue(WebRtcBluetoothManager.isBluetoothSelectionActive(SCO_CONNECTING, false, false)); + assertTrue(WebRtcBluetoothManager.isBluetoothSelectionActive(HEADSET_AVAILABLE, true, false)); + assertFalse(WebRtcBluetoothManager.isBluetoothSelectionActive(HEADSET_AVAILABLE, false, false)); + } + + @Test + public void aScheduledRetryPreventsAnImmediateSecondBluetoothAttempt() { + assertFalse(WebRtcBluetoothManager.shouldStartBluetoothRoute( + HEADSET_AVAILABLE, + true, + false, + true + )); + assertTrue(WebRtcBluetoothManager.shouldStartBluetoothRoute( + HEADSET_AVAILABLE, + true, + false, + false + )); + } + + @Test + public void legacyProfileConnectionRemainsAnAcceptedPendingSelection() { + assertTrue(WebRtcBluetoothManager.isBluetoothSelectionActive(HEADSET_UNAVAILABLE, false, true)); + } + + @Test + public void removingTheRequestedEndpointDoesNotKeepConnectingToAnotherEndpoint() { + assertFalse(WebRtcBluetoothManager.shouldKeepModernBluetoothState( + SCO_CONNECTING, + false, + false, + true + )); + assertTrue(WebRtcBluetoothManager.shouldResetModernBluetoothAttempts( + SCO_CONNECTING, + false, + true + )); + } + + @Test + public void removingTheConfirmedEndpointDoesNotTreatAnotherEndpointAsConnected() { + assertFalse(WebRtcBluetoothManager.shouldKeepModernBluetoothState( + SCO_CONNECTED, + false, + false, + true + )); + } + + @Test + public void removingAnUnrelatedEndpointKeepsTheConfirmedRoute() { + assertTrue(WebRtcBluetoothManager.shouldKeepModernBluetoothState( + SCO_CONNECTED, + false, + true, + true + )); + } + + @Test + public void queuedBluetoothCallbackAfterClearIsRejected() { + assertFalse(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback( + HEADSET_AVAILABLE, + true, + true, + false, + true + )); + } + + @Test + public void lateBluetoothCallbackAfterRejectedRequestIsRejected() { + assertFalse(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback( + HEADSET_AVAILABLE, + true, + false, + false, + false + )); + } + + @Test + public void onlyTheMatchingPendingRequestCanConfirmModernBluetooth() { + assertFalse(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback( + SCO_CONNECTING, + true, + false, + false, + false + )); + assertTrue(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback( + SCO_CONNECTING, + true, + false, + true, + true + )); + } + + @Test + public void initialSystemBluetoothRouteAndConnectedDuplicateAreAccepted() { + assertTrue(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback( + HEADSET_AVAILABLE, + false, + false, + false, + false + )); + assertTrue(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback( + SCO_CONNECTED, + true, + false, + false, + false + )); + } + + @Test + public void authoritativeSystemPickerCallbackIsAcceptedAfterAnAppControlledRoute() { + assertTrue(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback( + HEADSET_AVAILABLE, + true, + false, + false, + true + )); + } + + @Test + public void disconnectTimeoutNeverUsesAStaleGetterToResurrectConnectedState() { + assertEquals(HEADSET_AVAILABLE, WebRtcBluetoothManager.stateAfterModernRouteClear(true)); + assertEquals(HEADSET_UNAVAILABLE, WebRtcBluetoothManager.stateAfterModernRouteClear(false)); + } + + @Test + public void routeClearFinishesAfterAConfirmedNonBluetoothRoute() { + assertFalse(WebRtcBluetoothManager.shouldKeepModernRouteClearPending(true, true, false)); + assertFalse(WebRtcBluetoothManager.shouldKeepModernRouteClearPending(false, true, true)); + } + + @Test + public void routeClearRemainsPendingForBluetoothOrAnUnknownGetterResult() { + assertTrue(WebRtcBluetoothManager.shouldKeepModernRouteClearPending(true, true, true)); + assertTrue(WebRtcBluetoothManager.shouldKeepModernRouteClearPending(true, false, false)); + } + + @Test + public void focusGainReassertsOnlyThePreferredModernBluetoothRoute() { + assertTrue(WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + SCO_CONNECTED, + true, + false, + Build.VERSION_CODES.S + )); + assertFalse(WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + SCO_CONNECTED, + true, + false, + Build.VERSION_CODES.R + )); + assertFalse(WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + SCO_CONNECTED, + false, + false, + Build.VERSION_CODES.S + )); + assertFalse(WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + SCO_CONNECTED, + true, + true, + Build.VERSION_CODES.S + )); + assertFalse(WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + HEADSET_AVAILABLE, + true, + false, + Build.VERSION_CODES.S + )); + } + + @Test + public void legacyConnectedCallbackIsRejectedDuringDisconnect() { + assertTrue(WebRtcBluetoothManager.shouldAcceptLegacyScoConnected(SCO_CONNECTING)); + assertFalse(WebRtcBluetoothManager.shouldAcceptLegacyScoConnected(SCO_DISCONNECTING)); + } +} From a95662629273e4cea1dbcfd83b6a9507a17952c4 Mon Sep 17 00:00:00 2001 From: Oleg Cherry <80347136+flake92@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:09:56 +0300 Subject: [PATCH 2/6] refactor(call): reduce Bluetooth selection complexity Assisted-by: Codex:gpt-5 Signed-off-by: Oleg Cherry <80347136+flake92@users.noreply.github.com> --- .../nextcloud/talk/webrtc/WebRtcBluetoothManager.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java index 28ece5c23d1..d39ce0920bb 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java @@ -606,12 +606,9 @@ public boolean requestBluetoothAudioSelection() { if (bluetoothState == State.SCO_CONNECTED) { return true; } - if (bluetoothState == State.SCO_CONNECTING) { - // Keep the accepted SCO attempt. A late CONNECTED broadcast from an attempt which was - // stopped here could otherwise be mistaken for the new manual request. - return true; - } - if (bluetoothState == State.SCO_DISCONNECTING) { + if (isBluetoothTransitionInProgress(bluetoothState)) { + // Do not restart SCO while its state is settling. A late CONNECTED broadcast could + // otherwise be mistaken for the new manual request. return true; } updateDevice(); From ef84967d9b85eb6448c6e783025ac7c82f196c62 Mon Sep 17 00:00:00 2001 From: flake92 Date: Wed, 26 Aug 2026 23:14:26 +0300 Subject: [PATCH 3/6] fix(call): harden Bluetooth route transitions Keep the working route when a device switch is rejected, preserve bounded retry state across focus changes, reject stale callbacks, and share wired-device detection. Convert the new policy and tests to Kotlin. Assisted-by: Codex:gpt-5 Signed-off-by: flake92 --- .../talk/webrtc/AudioRoutePolicy.java | 64 ----- .../nextcloud/talk/webrtc/AudioRoutePolicy.kt | 58 +++++ .../talk/webrtc/WebRtcAudioManager.java | 86 ++++--- .../talk/webrtc/WebRtcBluetoothManager.java | 61 +++-- .../talk/webrtc/AudioRoutePolicyTest.java | 83 ------- .../talk/webrtc/AudioRoutePolicyTest.kt | 142 +++++++++++ ...BluetoothCommunicationDevicePolicyTest.kt} | 61 +++-- .../webrtc/BluetoothRouteStatePolicyTest.java | 223 ------------------ .../webrtc/BluetoothRouteStatePolicyTest.kt | 179 ++++++++++++++ 9 files changed, 496 insertions(+), 461 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.java create mode 100644 app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.kt delete mode 100644 app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.java create mode 100644 app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.kt rename app/src/test/java/com/nextcloud/talk/webrtc/{BluetoothCommunicationDevicePolicyTest.java => BluetoothCommunicationDevicePolicyTest.kt} (50%) delete mode 100644 app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.java create mode 100644 app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.java b/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.java deleted file mode 100644 index ddacb2b8568..00000000000 --- a/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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 java.util.Set; - -final class AudioRoutePolicy { - private AudioRoutePolicy() { - } - - static WebRtcAudioManager.AudioDevice selectAudioDevice( - Set availableDevices, - WebRtcAudioManager.AudioDevice userSelectedDevice, - WebRtcAudioManager.AudioDevice defaultDevice, - boolean hasWiredHeadset, - boolean bluetoothConnected) { - if (bluetoothConnected) { - return WebRtcAudioManager.AudioDevice.BLUETOOTH; - } - - if (hasWiredHeadset) { - return WebRtcAudioManager.AudioDevice.WIRED_HEADSET; - } - - if (userSelectedDevice != WebRtcAudioManager.AudioDevice.NONE - && userSelectedDevice != WebRtcAudioManager.AudioDevice.BLUETOOTH - && availableDevices.contains(userSelectedDevice)) { - return userSelectedDevice; - } - - if (defaultDevice != WebRtcAudioManager.AudioDevice.NONE && availableDevices.contains(defaultDevice)) { - return defaultDevice; - } - - if (availableDevices.contains(WebRtcAudioManager.AudioDevice.EARPIECE)) { - return WebRtcAudioManager.AudioDevice.EARPIECE; - } - if (availableDevices.contains(WebRtcAudioManager.AudioDevice.SPEAKER_PHONE)) { - return WebRtcAudioManager.AudioDevice.SPEAKER_PHONE; - } - return WebRtcAudioManager.AudioDevice.NONE; - } - - static boolean shouldPreferBluetooth( - WebRtcAudioManager.AudioDevice userSelectedDevice, - boolean bluetoothCurrentlyPreferred, - boolean bluetoothExpected, - boolean bluetoothUnavailable) { - if (userSelectedDevice == WebRtcAudioManager.AudioDevice.BLUETOOTH) { - return true; - } - if (userSelectedDevice != WebRtcAudioManager.AudioDevice.NONE) { - return false; - } - if (bluetoothExpected) { - return true; - } - return bluetoothCurrentlyPreferred && !bluetoothUnavailable; - } -} diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.kt b/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.kt new file mode 100644 index 00000000000..615a56c56b5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.kt @@ -0,0 +1,58 @@ +/* + * 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.AudioDeviceInfo +import com.nextcloud.talk.webrtc.WebRtcAudioManager.AudioDevice + +internal object AudioRoutePolicy { + @JvmStatic + fun selectAudioDevice( + availableDevices: Set, + userSelectedDevice: AudioDevice, + defaultDevice: AudioDevice, + hasWiredHeadset: Boolean, + bluetoothConnected: Boolean + ): AudioDevice = + when { + bluetoothConnected -> AudioDevice.BLUETOOTH + hasWiredHeadset -> AudioDevice.WIRED_HEADSET + userSelectedDevice != AudioDevice.NONE && + userSelectedDevice != AudioDevice.BLUETOOTH && + availableDevices.contains(userSelectedDevice) -> userSelectedDevice + defaultDevice != AudioDevice.NONE && availableDevices.contains(defaultDevice) -> defaultDevice + availableDevices.contains(AudioDevice.EARPIECE) -> AudioDevice.EARPIECE + availableDevices.contains(AudioDevice.SPEAKER_PHONE) -> AudioDevice.SPEAKER_PHONE + else -> AudioDevice.NONE + } + + @JvmStatic + fun shouldPreferBluetooth( + userSelectedDevice: AudioDevice, + bluetoothCurrentlyPreferred: Boolean, + bluetoothExpected: Boolean, + bluetoothUnavailable: Boolean + ): Boolean = + when { + userSelectedDevice == AudioDevice.BLUETOOTH -> true + userSelectedDevice != AudioDevice.NONE -> false + bluetoothExpected -> true + else -> bluetoothCurrentlyPreferred && !bluetoothUnavailable + } + + @JvmStatic + fun shouldSetCommunicationDevice(currentRouteMatches: Boolean, bluetoothSelectionActive: Boolean): Boolean = + !currentRouteMatches || bluetoothSelectionActive + + @JvmStatic + fun isWiredCommunicationDeviceType(type: Int): Boolean = + type == AudioDeviceInfo.TYPE_WIRED_HEADSET || + type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || + type == AudioDeviceInfo.TYPE_USB_HEADSET || + type == AudioDeviceInfo.TYPE_USB_DEVICE || + type == AudioDeviceInfo.TYPE_USB_ACCESSORY +} 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 8027aabb4b6..958bf96f4a3 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java @@ -311,38 +311,40 @@ public void stop() { /** * Changes selection of the currently active audio device. */ - private void setAudioDeviceInternal(AudioDevice audioDevice) { + private boolean setAudioDeviceInternal(AudioDevice audioDevice) { Log.d(TAG, "setAudioDeviceInternal(device=" + audioDevice + ")"); if (audioDevice == AudioDevice.NONE) { currentAudioDevice = AudioDevice.NONE; - return; + return true; } - if (audioDevices.contains(audioDevice)) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (!setCommunicationDevice(audioDevice)) { - Log.e(TAG, "Unable to select communication device " + audioDevice); - currentAudioDevice = AudioDevice.NONE; - return; - } - } else { - switch (audioDevice) { - case SPEAKER_PHONE: - setSpeakerphoneOn(true); - break; - case EARPIECE: - case WIRED_HEADSET: - case BLUETOOTH: - setSpeakerphoneOn(false); - break; - default: - Log.e(TAG, "Invalid audio device selection"); - break; - } + if (!audioDevices.contains(audioDevice)) { + return false; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (!setCommunicationDevice(audioDevice)) { + Log.e(TAG, "Unable to select communication device " + audioDevice); + return false; + } + } else { + switch (audioDevice) { + case SPEAKER_PHONE: + setSpeakerphoneOn(true); + break; + case EARPIECE: + case WIRED_HEADSET: + case BLUETOOTH: + setSpeakerphoneOn(false); + break; + default: + Log.e(TAG, "Invalid audio device selection"); + return false; } - currentAudioDevice = audioDevice; } + currentAudioDevice = audioDevice; + return true; } /** @@ -392,6 +394,20 @@ public boolean selectAudioDevice(AudioDevice device) { boolean wasBluetoothPreferredForCall = bluetoothPreferredForCall; userSelectedAudioDevice = device; bluetoothPreferredForCall = false; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Ask Android to switch first. If the request is rejected, Bluetooth remains the active route and the + // previous preference can be restored without waiting for the asynchronous Bluetooth teardown timeout. + if (!setAudioDeviceInternal(device)) { + userSelectedAudioDevice = previousUserSelectedAudioDevice; + bluetoothPreferredForCall = wasBluetoothPreferredForCall; + return false; + } + bluetoothManager.onNonBluetoothCommunicationDeviceSelected(); + updateAudioDeviceState(); + return currentAudioDevice == device; + } + updateAudioDeviceState(); if (currentAudioDevice == device) { return true; @@ -465,7 +481,10 @@ private boolean setCommunicationDevice(AudioDevice audioDevice) { } try { AudioDeviceInfo currentDevice = getCommunicationDevice(); - if (currentDevice != null && matchesAudioDevice(currentDevice, audioDevice)) { + boolean currentRouteMatches = currentDevice != null && matchesAudioDevice(currentDevice, audioDevice); + if (!AudioRoutePolicy.shouldSetCommunicationDevice( + currentRouteMatches, + bluetoothManager.isBluetoothSelectionActive())) { return true; } @@ -511,11 +530,7 @@ private boolean matchesAudioDevice(AudioDeviceInfo device, AudioDevice audioDevi case BLUETOOTH: return WebRtcBluetoothManager.isBluetoothCommunicationDeviceType(type); case WIRED_HEADSET: - return type == AudioDeviceInfo.TYPE_WIRED_HEADSET - || type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES - || type == AudioDeviceInfo.TYPE_USB_HEADSET - || type == AudioDeviceInfo.TYPE_USB_DEVICE - || type == AudioDeviceInfo.TYPE_USB_ACCESSORY; + return AudioRoutePolicy.isWiredCommunicationDeviceType(type); case EARPIECE: return type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE; case SPEAKER_PHONE: @@ -574,12 +589,8 @@ private boolean hasWiredHeadset() { @SuppressLint("WrongConstant") final AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL); for (AudioDeviceInfo device : devices) { - final int type = device.getType(); - if (type == AudioDeviceInfo.TYPE_WIRED_HEADSET) { - Log.d(TAG, "hasWiredHeadset: found wired headset"); - return true; - } else if (type == AudioDeviceInfo.TYPE_USB_DEVICE) { - Log.d(TAG, "hasWiredHeadset: found USB audio device"); + if (AudioRoutePolicy.isWiredCommunicationDeviceType(device.getType())) { + Log.d(TAG, "hasWiredHeadset: found wired or USB audio device"); return true; } } @@ -683,7 +694,8 @@ public final void updateAudioDeviceState() { bluetoothManager.getState(), bluetoothPreferredForCall, hasWiredHeadset, - bluetoothManager.isBluetoothRouteRetryScheduled() + bluetoothManager.isBluetoothRouteRetryScheduled(), + bluetoothManager.hasRemainingScoConnectionAttempts() ); // Need to stop Bluetooth audio if user selected different device and diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java index d39ce0920bb..20bd741f2f0 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java @@ -162,19 +162,20 @@ static boolean shouldStartBluetoothRoute( State state, boolean bluetoothPreferred, boolean hasWiredHeadset, - boolean retryScheduled) { + boolean retryScheduled, + boolean attemptsAvailable) { return state == State.HEADSET_AVAILABLE && bluetoothPreferred && !hasWiredHeadset - && !retryScheduled; + && !retryScheduled + && attemptsAvailable; } static boolean shouldAcceptModernBluetoothCallback( State state, boolean routeSelectionControlled, boolean routeClearPending, - boolean pendingRequestMatches, - boolean callbackMatchesCurrentRoute) { + boolean pendingRequestMatches) { if (state == State.SCO_DISCONNECTING || routeClearPending) { return false; } @@ -184,7 +185,7 @@ static boolean shouldAcceptModernBluetoothCallback( if (state == State.SCO_CONNECTED) { return true; } - return !routeSelectionControlled || callbackMatchesCurrentRoute; + return !routeSelectionControlled; } static boolean shouldAcceptLegacyScoConnected(State state) { @@ -206,11 +207,13 @@ static boolean shouldReassertModernBluetoothAfterFocusGain( State state, boolean bluetoothPreferred, boolean hasWiredHeadset, - int sdkInt) { + int sdkInt, + boolean attemptsAvailable) { return sdkInt >= Build.VERSION_CODES.S && state == State.SCO_CONNECTED && bluetoothPreferred - && !hasWiredHeadset; + && !hasWiredHeadset + && attemptsAvailable; } /** @@ -246,6 +249,11 @@ boolean isBluetoothRouteRetryScheduled() { return bluetoothRouteRetryScheduled; } + boolean hasRemainingScoConnectionAttempts() { + ThreadUtils.checkIsOnMainThread(); + return scoConnectionAttempts < MAX_SCO_CONNECTION_ATTEMPTS; + } + public void resetScoConnectionAttempts() { ThreadUtils.checkIsOnMainThread(); scoConnectionAttempts = 0; @@ -260,13 +268,14 @@ public void reassertBluetoothAudioAfterFocusGain(boolean bluetoothPreferred, boo bluetoothState, bluetoothPreferred, hasWiredHeadset, - Build.VERSION.SDK_INT + Build.VERSION.SDK_INT, + hasRemainingScoConnectionAttempts() )) { return; } cancelTimer(); - resetScoConnectionAttempts(); + cancelBluetoothRouteRetry(); if (!modernBluetoothRoute.hasConfirmedBluetoothDevice()) { modernBluetoothRoute.clearConfirmedBluetoothDevice(); bluetoothState = stateAfterModernRouteClear(modernBluetoothRoute.hasBluetoothDevice()); @@ -289,6 +298,18 @@ public void reassertBluetoothAudioAfterFocusGain(boolean bluetoothPreferred, boo Log.d(TAG, "Reasserting the confirmed Bluetooth route after audio focus returned"); } + void onNonBluetoothCommunicationDeviceSelected() { + ThreadUtils.checkIsOnMainThread(); + if (!started || modernBluetoothRoute == null) { + return; + } + cancelTimer(); + cancelBluetoothRouteRetry(); + modernBluetoothRoute.confirmNonBluetoothRouteSelection(); + bluetoothState = stateAfterModernRouteClear(modernBluetoothRoute.hasBluetoothDevice()); + Log.d(TAG, "A non-Bluetooth communication device was selected without clearing the accepted route"); + } + /** * Activates components required to detect Bluetooth devices and to enable * BT SCO (audio is routed via BT SCO) for the headset profile. The end @@ -937,6 +958,14 @@ void clearCommunicationDeviceRequest() { } } + void confirmNonBluetoothRouteSelection() { + routeSelectionControlled = true; + routeClearPending = false; + routeRequestPending = false; + requestedBluetoothDeviceId = NO_DEVICE_ID; + confirmedBluetoothDeviceId = NO_DEVICE_ID; + } + boolean hasBluetoothDevice() { return findBluetoothDevice() != null; } @@ -997,16 +1026,6 @@ && isBluetoothCommunicationDeviceType(device.getType()) && isBluetoothDeviceAvailable(device.getId()); } - boolean matchesCurrentCommunicationDevice(AudioDeviceInfo expectedDevice) { - try { - AudioDeviceInfo currentDevice = audioManager.getCommunicationDevice(); - return currentDevice != null && currentDevice.getId() == expectedDevice.getId(); - } catch (SecurityException exception) { - Log.e(TAG, "Unable to verify the current Bluetooth communication device", exception); - return false; - } - } - void confirmRequestedBluetoothRoute() { confirmedBluetoothDeviceId = requestedBluetoothDeviceId; routeRequestPending = false; @@ -1146,13 +1165,11 @@ private void onCommunicationDeviceChanged(AudioDeviceInfo device) { boolean bluetoothSelected = device != null && isBluetoothCommunicationDeviceType(device.getType()); if (bluetoothSelected) { boolean pendingRequestMatches = matchesPendingBluetoothRequest(device); - boolean callbackMatchesCurrentRoute = matchesCurrentCommunicationDevice(device); if (!shouldAcceptModernBluetoothCallback( bluetoothState, routeSelectionControlled, routeClearPending, - pendingRequestMatches, - callbackMatchesCurrentRoute)) { + pendingRequestMatches)) { Log.d(TAG, "Ignoring a Bluetooth callback for an inactive or cleared route request"); return; } diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.java b/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.java deleted file mode 100644 index 6eb1d6cc51e..00000000000 --- a/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * 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 org.junit.Test; - -import java.util.EnumSet; -import java.util.Set; - -import static com.nextcloud.talk.webrtc.WebRtcAudioManager.AudioDevice.BLUETOOTH; -import static com.nextcloud.talk.webrtc.WebRtcAudioManager.AudioDevice.EARPIECE; -import static com.nextcloud.talk.webrtc.WebRtcAudioManager.AudioDevice.NONE; -import static com.nextcloud.talk.webrtc.WebRtcAudioManager.AudioDevice.SPEAKER_PHONE; -import static com.nextcloud.talk.webrtc.WebRtcAudioManager.AudioDevice.WIRED_HEADSET; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class AudioRoutePolicyTest { - - @Test - public void bluetoothConnectionHasPriority() { - assertSelected(BLUETOOTH, devices(BLUETOOTH, EARPIECE, SPEAKER_PHONE), SPEAKER_PHONE, - SPEAKER_PHONE, false, true); - } - - @Test - public void wiredHeadsetHasPriorityOverBluetoothPreference() { - assertSelected(WIRED_HEADSET, devices(BLUETOOTH, WIRED_HEADSET), BLUETOOTH, - SPEAKER_PHONE, true, false); - } - - @Test - public void explicitSpeakerSelectionIsHonoredWhenBluetoothIsNotPreferred() { - assertSelected(SPEAKER_PHONE, devices(EARPIECE, SPEAKER_PHONE), SPEAKER_PHONE, - EARPIECE, false, false); - } - - @Test - public void configuredDefaultIsUsedWithoutAnExplicitSelection() { - assertSelected(SPEAKER_PHONE, devices(EARPIECE, SPEAKER_PHONE), NONE, - SPEAKER_PHONE, false, false); - } - - @Test - public void autoBluetoothPreferenceIsReleasedAfterEndpointDisappears() { - assertFalse(AudioRoutePolicy.shouldPreferBluetooth(NONE, true, false, true)); - } - - @Test - public void explicitBluetoothPreferenceSurvivesEndpointDisappearance() { - assertTrue(AudioRoutePolicy.shouldPreferBluetooth(BLUETOOTH, true, false, true)); - } - - @Test - public void automaticBluetoothPreferenceSurvivesAnUnconfirmedTransition() { - assertTrue(AudioRoutePolicy.shouldPreferBluetooth(NONE, true, false, false)); - } - - private static Set devices(WebRtcAudioManager.AudioDevice... devices) { - return EnumSet.of(devices[0], devices); - } - - private static void assertSelected( - WebRtcAudioManager.AudioDevice expected, - Set availableDevices, - WebRtcAudioManager.AudioDevice userSelectedDevice, - WebRtcAudioManager.AudioDevice defaultDevice, - boolean hasWiredHeadset, - boolean bluetoothConnected) { - assertEquals(expected, AudioRoutePolicy.selectAudioDevice( - availableDevices, - userSelectedDevice, - defaultDevice, - hasWiredHeadset, - bluetoothConnected - )); - } -} diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.kt new file mode 100644 index 00000000000..830294c4b17 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.kt @@ -0,0 +1,142 @@ +/* + * 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.AudioDeviceInfo +import com.nextcloud.talk.webrtc.WebRtcAudioManager.AudioDevice +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AudioRoutePolicyTest { + @Test + fun `Bluetooth connection has priority`() { + assertSelected( + AudioDevice.BLUETOOTH, + devices(AudioDevice.BLUETOOTH, AudioDevice.EARPIECE, AudioDevice.SPEAKER_PHONE), + AudioDevice.SPEAKER_PHONE, + AudioDevice.SPEAKER_PHONE, + hasWiredHeadset = false, + bluetoothConnected = true + ) + } + + @Test + fun `wired headset has priority over Bluetooth preference`() { + assertSelected( + AudioDevice.WIRED_HEADSET, + devices(AudioDevice.BLUETOOTH, AudioDevice.WIRED_HEADSET), + AudioDevice.BLUETOOTH, + AudioDevice.SPEAKER_PHONE, + hasWiredHeadset = true, + bluetoothConnected = false + ) + } + + @Test + fun `explicit speaker selection is honored when Bluetooth is not preferred`() { + assertSelected( + AudioDevice.SPEAKER_PHONE, + devices(AudioDevice.EARPIECE, AudioDevice.SPEAKER_PHONE), + AudioDevice.SPEAKER_PHONE, + AudioDevice.EARPIECE, + hasWiredHeadset = false, + bluetoothConnected = false + ) + } + + @Test + fun `configured default is used without an explicit selection`() { + assertSelected( + AudioDevice.SPEAKER_PHONE, + devices(AudioDevice.EARPIECE, AudioDevice.SPEAKER_PHONE), + AudioDevice.NONE, + AudioDevice.SPEAKER_PHONE, + hasWiredHeadset = false, + bluetoothConnected = false + ) + } + + @Test + fun `automatic Bluetooth preference is released after endpoint disappears`() { + assertFalse(AudioRoutePolicy.shouldPreferBluetooth(AudioDevice.NONE, true, false, true)) + } + + @Test + fun `explicit Bluetooth preference survives endpoint disappearance`() { + assertTrue(AudioRoutePolicy.shouldPreferBluetooth(AudioDevice.BLUETOOTH, true, false, true)) + } + + @Test + fun `automatic Bluetooth preference survives an unconfirmed transition`() { + assertTrue(AudioRoutePolicy.shouldPreferBluetooth(AudioDevice.NONE, true, false, false)) + } + + @Test + fun `active Bluetooth selection forces a superseding request despite a stale matching getter`() { + assertTrue( + AudioRoutePolicy.shouldSetCommunicationDevice( + currentRouteMatches = true, + bluetoothSelectionActive = true + ) + ) + } + + @Test + fun `matching current route is reused without an active Bluetooth selection`() { + assertFalse( + AudioRoutePolicy.shouldSetCommunicationDevice( + currentRouteMatches = true, + bluetoothSelectionActive = false + ) + ) + } + + @Test + fun `different current route always requires a communication-device request`() { + assertTrue( + AudioRoutePolicy.shouldSetCommunicationDevice( + currentRouteMatches = false, + bluetoothSelectionActive = false + ) + ) + } + + @Test + fun `wired detection covers every selectable wired communication device`() { + assertTrue(AudioRoutePolicy.isWiredCommunicationDeviceType(AudioDeviceInfo.TYPE_WIRED_HEADSET)) + assertTrue(AudioRoutePolicy.isWiredCommunicationDeviceType(AudioDeviceInfo.TYPE_WIRED_HEADPHONES)) + assertTrue(AudioRoutePolicy.isWiredCommunicationDeviceType(AudioDeviceInfo.TYPE_USB_HEADSET)) + assertTrue(AudioRoutePolicy.isWiredCommunicationDeviceType(AudioDeviceInfo.TYPE_USB_DEVICE)) + assertTrue(AudioRoutePolicy.isWiredCommunicationDeviceType(AudioDeviceInfo.TYPE_USB_ACCESSORY)) + assertFalse(AudioRoutePolicy.isWiredCommunicationDeviceType(AudioDeviceInfo.TYPE_BLUETOOTH_SCO)) + } + + private fun devices(vararg devices: AudioDevice): Set = setOf(*devices) + + @Suppress("LongParameterList") + private fun assertSelected( + expected: AudioDevice, + availableDevices: Set, + userSelectedDevice: AudioDevice, + defaultDevice: AudioDevice, + hasWiredHeadset: Boolean, + bluetoothConnected: Boolean + ) { + assertEquals( + expected, + AudioRoutePolicy.selectAudioDevice( + availableDevices, + userSelectedDevice, + defaultDevice, + hasWiredHeadset, + bluetoothConnected + ) + ) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothCommunicationDevicePolicyTest.java b/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothCommunicationDevicePolicyTest.kt similarity index 50% rename from app/src/test/java/com/nextcloud/talk/webrtc/BluetoothCommunicationDevicePolicyTest.java rename to app/src/test/java/com/nextcloud/talk/webrtc/BluetoothCommunicationDevicePolicyTest.kt index a48eea34284..a48316cecca 100644 --- a/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothCommunicationDevicePolicyTest.java +++ b/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothCommunicationDevicePolicyTest.kt @@ -4,61 +4,58 @@ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ -package com.nextcloud.talk.webrtc; +package com.nextcloud.talk.webrtc -import android.media.AudioDeviceInfo; -import android.os.Build; - -import org.junit.Test; - -import static org.junit.Assert.assertTrue; - -public class BluetoothCommunicationDevicePolicyTest { +import android.media.AudioDeviceInfo +import android.os.Build +import org.junit.Assert.assertTrue +import org.junit.Test +class BluetoothCommunicationDevicePolicyTest { @Test - public void a2dpIsNeverUsedForTwoWayCallAudio() { - assertUnsupported(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, Build.VERSION_CODES.BAKLAVA); + fun `A2DP is never used for two-way call audio`() { + assertUnsupported(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, Build.VERSION_CODES.BAKLAVA) } @Test - public void classicScoIsSupportedOnOldAndCurrentAndroid() { - assertSupported(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, Build.VERSION_CODES.O); - assertSupported(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, Build.VERSION_CODES.BAKLAVA); + fun `classic SCO is supported on old and current Android`() { + assertSupported(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, Build.VERSION_CODES.O) + assertSupported(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, Build.VERSION_CODES.BAKLAVA) } @Test - public void hearingAidRequiresModernCommunicationDeviceApi() { - assertUnsupported(AudioDeviceInfo.TYPE_HEARING_AID, Build.VERSION_CODES.R); - assertSupported(AudioDeviceInfo.TYPE_HEARING_AID, Build.VERSION_CODES.S); + fun `hearing aid requires modern communication-device API`() { + assertUnsupported(AudioDeviceInfo.TYPE_HEARING_AID, Build.VERSION_CODES.R) + assertSupported(AudioDeviceInfo.TYPE_HEARING_AID, Build.VERSION_CODES.S) } @Test - public void bleCommunicationDevicesRequireAndroidTwelve() { - assertUnsupported(AudioDeviceInfo.TYPE_BLE_HEADSET, Build.VERSION_CODES.R); - assertUnsupported(AudioDeviceInfo.TYPE_BLE_SPEAKER, Build.VERSION_CODES.R); - assertSupported(AudioDeviceInfo.TYPE_BLE_HEADSET, Build.VERSION_CODES.S); - assertSupported(AudioDeviceInfo.TYPE_BLE_SPEAKER, Build.VERSION_CODES.S); + fun `BLE communication devices require Android twelve`() { + assertUnsupported(AudioDeviceInfo.TYPE_BLE_HEADSET, Build.VERSION_CODES.R) + assertUnsupported(AudioDeviceInfo.TYPE_BLE_SPEAKER, Build.VERSION_CODES.R) + assertSupported(AudioDeviceInfo.TYPE_BLE_HEADSET, Build.VERSION_CODES.S) + assertSupported(AudioDeviceInfo.TYPE_BLE_SPEAKER, Build.VERSION_CODES.S) } @Test - public void headsetEndpointsArePreferredOverOutputOnlyBleSpeaker() { - int headsetPriority = WebRtcBluetoothManager.bluetoothCommunicationDevicePriority( + fun `headset endpoints are preferred over output-only BLE speaker`() { + val headsetPriority = WebRtcBluetoothManager.bluetoothCommunicationDevicePriority( AudioDeviceInfo.TYPE_BLE_HEADSET, Build.VERSION_CODES.BAKLAVA - ); - int speakerPriority = WebRtcBluetoothManager.bluetoothCommunicationDevicePriority( + ) + val speakerPriority = WebRtcBluetoothManager.bluetoothCommunicationDevicePriority( AudioDeviceInfo.TYPE_BLE_SPEAKER, Build.VERSION_CODES.BAKLAVA - ); + ) - assertTrue(headsetPriority > speakerPriority); + assertTrue(headsetPriority > speakerPriority) } - private static void assertSupported(int deviceType, int sdkInt) { - assertTrue(WebRtcBluetoothManager.bluetoothCommunicationDevicePriority(deviceType, sdkInt) >= 0); + private fun assertSupported(deviceType: Int, sdkInt: Int) { + assertTrue(WebRtcBluetoothManager.bluetoothCommunicationDevicePriority(deviceType, sdkInt) >= 0) } - private static void assertUnsupported(int deviceType, int sdkInt) { - assertTrue(WebRtcBluetoothManager.bluetoothCommunicationDevicePriority(deviceType, sdkInt) < 0); + private fun assertUnsupported(deviceType: Int, sdkInt: Int) { + assertTrue(WebRtcBluetoothManager.bluetoothCommunicationDevicePriority(deviceType, sdkInt) < 0) } } diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.java b/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.java deleted file mode 100644 index f4debe3773d..00000000000 --- a/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.java +++ /dev/null @@ -1,223 +0,0 @@ -/* - * 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.os.Build; - -import org.junit.Test; - -import static com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.HEADSET_AVAILABLE; -import static com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.HEADSET_UNAVAILABLE; -import static com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.SCO_CONNECTED; -import static com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.SCO_CONNECTING; -import static com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.SCO_DISCONNECTING; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class BluetoothRouteStatePolicyTest { - - @Test - public void manualTapKeepsAnAcceptedConnectingRequest() { - assertTrue(WebRtcBluetoothManager.isBluetoothTransitionInProgress(SCO_CONNECTING)); - } - - @Test - public void manualTapWaitsForAnInProgressDisconnectBeforeRetrying() { - assertTrue(WebRtcBluetoothManager.isBluetoothTransitionInProgress(SCO_DISCONNECTING)); - } - - @Test - public void acceptedAndQueuedSelectionsRemainVisibleToTheUi() { - assertTrue(WebRtcBluetoothManager.isBluetoothSelectionActive(SCO_CONNECTING, false, false)); - assertTrue(WebRtcBluetoothManager.isBluetoothSelectionActive(HEADSET_AVAILABLE, true, false)); - assertFalse(WebRtcBluetoothManager.isBluetoothSelectionActive(HEADSET_AVAILABLE, false, false)); - } - - @Test - public void aScheduledRetryPreventsAnImmediateSecondBluetoothAttempt() { - assertFalse(WebRtcBluetoothManager.shouldStartBluetoothRoute( - HEADSET_AVAILABLE, - true, - false, - true - )); - assertTrue(WebRtcBluetoothManager.shouldStartBluetoothRoute( - HEADSET_AVAILABLE, - true, - false, - false - )); - } - - @Test - public void legacyProfileConnectionRemainsAnAcceptedPendingSelection() { - assertTrue(WebRtcBluetoothManager.isBluetoothSelectionActive(HEADSET_UNAVAILABLE, false, true)); - } - - @Test - public void removingTheRequestedEndpointDoesNotKeepConnectingToAnotherEndpoint() { - assertFalse(WebRtcBluetoothManager.shouldKeepModernBluetoothState( - SCO_CONNECTING, - false, - false, - true - )); - assertTrue(WebRtcBluetoothManager.shouldResetModernBluetoothAttempts( - SCO_CONNECTING, - false, - true - )); - } - - @Test - public void removingTheConfirmedEndpointDoesNotTreatAnotherEndpointAsConnected() { - assertFalse(WebRtcBluetoothManager.shouldKeepModernBluetoothState( - SCO_CONNECTED, - false, - false, - true - )); - } - - @Test - public void removingAnUnrelatedEndpointKeepsTheConfirmedRoute() { - assertTrue(WebRtcBluetoothManager.shouldKeepModernBluetoothState( - SCO_CONNECTED, - false, - true, - true - )); - } - - @Test - public void queuedBluetoothCallbackAfterClearIsRejected() { - assertFalse(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback( - HEADSET_AVAILABLE, - true, - true, - false, - true - )); - } - - @Test - public void lateBluetoothCallbackAfterRejectedRequestIsRejected() { - assertFalse(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback( - HEADSET_AVAILABLE, - true, - false, - false, - false - )); - } - - @Test - public void onlyTheMatchingPendingRequestCanConfirmModernBluetooth() { - assertFalse(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback( - SCO_CONNECTING, - true, - false, - false, - false - )); - assertTrue(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback( - SCO_CONNECTING, - true, - false, - true, - true - )); - } - - @Test - public void initialSystemBluetoothRouteAndConnectedDuplicateAreAccepted() { - assertTrue(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback( - HEADSET_AVAILABLE, - false, - false, - false, - false - )); - assertTrue(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback( - SCO_CONNECTED, - true, - false, - false, - false - )); - } - - @Test - public void authoritativeSystemPickerCallbackIsAcceptedAfterAnAppControlledRoute() { - assertTrue(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback( - HEADSET_AVAILABLE, - true, - false, - false, - true - )); - } - - @Test - public void disconnectTimeoutNeverUsesAStaleGetterToResurrectConnectedState() { - assertEquals(HEADSET_AVAILABLE, WebRtcBluetoothManager.stateAfterModernRouteClear(true)); - assertEquals(HEADSET_UNAVAILABLE, WebRtcBluetoothManager.stateAfterModernRouteClear(false)); - } - - @Test - public void routeClearFinishesAfterAConfirmedNonBluetoothRoute() { - assertFalse(WebRtcBluetoothManager.shouldKeepModernRouteClearPending(true, true, false)); - assertFalse(WebRtcBluetoothManager.shouldKeepModernRouteClearPending(false, true, true)); - } - - @Test - public void routeClearRemainsPendingForBluetoothOrAnUnknownGetterResult() { - assertTrue(WebRtcBluetoothManager.shouldKeepModernRouteClearPending(true, true, true)); - assertTrue(WebRtcBluetoothManager.shouldKeepModernRouteClearPending(true, false, false)); - } - - @Test - public void focusGainReassertsOnlyThePreferredModernBluetoothRoute() { - assertTrue(WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( - SCO_CONNECTED, - true, - false, - Build.VERSION_CODES.S - )); - assertFalse(WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( - SCO_CONNECTED, - true, - false, - Build.VERSION_CODES.R - )); - assertFalse(WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( - SCO_CONNECTED, - false, - false, - Build.VERSION_CODES.S - )); - assertFalse(WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( - SCO_CONNECTED, - true, - true, - Build.VERSION_CODES.S - )); - assertFalse(WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( - HEADSET_AVAILABLE, - true, - false, - Build.VERSION_CODES.S - )); - } - - @Test - public void legacyConnectedCallbackIsRejectedDuringDisconnect() { - assertTrue(WebRtcBluetoothManager.shouldAcceptLegacyScoConnected(SCO_CONNECTING)); - assertFalse(WebRtcBluetoothManager.shouldAcceptLegacyScoConnected(SCO_DISCONNECTING)); - } -} diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.kt new file mode 100644 index 00000000000..84905234058 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.kt @@ -0,0 +1,179 @@ +/* + * 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.os.Build +import com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.HEADSET_AVAILABLE +import com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.HEADSET_UNAVAILABLE +import com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.SCO_CONNECTED +import com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.SCO_CONNECTING +import com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.SCO_DISCONNECTING +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@Suppress("TooManyFunctions") +class BluetoothRouteStatePolicyTest { + @Test + fun `manual tap keeps an accepted connecting request`() { + assertTrue(WebRtcBluetoothManager.isBluetoothTransitionInProgress(SCO_CONNECTING)) + } + + @Test + fun `manual tap waits for an in-progress disconnect before retrying`() { + assertTrue(WebRtcBluetoothManager.isBluetoothTransitionInProgress(SCO_DISCONNECTING)) + } + + @Test + fun `accepted and queued selections remain visible to the UI`() { + assertTrue(WebRtcBluetoothManager.isBluetoothSelectionActive(SCO_CONNECTING, false, false)) + assertTrue(WebRtcBluetoothManager.isBluetoothSelectionActive(HEADSET_AVAILABLE, true, false)) + assertFalse(WebRtcBluetoothManager.isBluetoothSelectionActive(HEADSET_AVAILABLE, false, false)) + } + + @Test + fun `scheduled retry prevents an immediate second Bluetooth attempt`() { + assertFalse(WebRtcBluetoothManager.shouldStartBluetoothRoute(HEADSET_AVAILABLE, true, false, true, true)) + assertTrue(WebRtcBluetoothManager.shouldStartBluetoothRoute(HEADSET_AVAILABLE, true, false, false, true)) + } + + @Test + fun `exhausted attempts prevent automatic Bluetooth restarts`() { + assertFalse(WebRtcBluetoothManager.shouldStartBluetoothRoute(HEADSET_AVAILABLE, true, false, false, false)) + } + + @Test + fun `legacy profile connection remains an accepted pending selection`() { + assertTrue(WebRtcBluetoothManager.isBluetoothSelectionActive(HEADSET_UNAVAILABLE, false, true)) + } + + @Test + fun `removing requested endpoint does not keep connecting to another endpoint`() { + assertFalse(WebRtcBluetoothManager.shouldKeepModernBluetoothState(SCO_CONNECTING, false, false, true)) + assertTrue(WebRtcBluetoothManager.shouldResetModernBluetoothAttempts(SCO_CONNECTING, false, true)) + } + + @Test + fun `removing confirmed endpoint does not treat another endpoint as connected`() { + assertFalse(WebRtcBluetoothManager.shouldKeepModernBluetoothState(SCO_CONNECTED, false, false, true)) + } + + @Test + fun `removing unrelated endpoint keeps the confirmed route`() { + assertTrue(WebRtcBluetoothManager.shouldKeepModernBluetoothState(SCO_CONNECTED, false, true, true)) + } + + @Test + fun `queued Bluetooth callback after clear is rejected`() { + assertFalse(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback(HEADSET_AVAILABLE, true, true, false)) + } + + @Test + fun `late Bluetooth callback after rejected request is rejected`() { + assertFalse(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback(HEADSET_AVAILABLE, true, false, false)) + } + + @Test + fun `only matching pending request can confirm modern Bluetooth`() { + assertFalse(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback(SCO_CONNECTING, true, false, false)) + assertTrue(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback(SCO_CONNECTING, true, false, true)) + } + + @Test + fun `initial system Bluetooth route and connected duplicate are accepted`() { + assertTrue(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback(HEADSET_AVAILABLE, false, false, false)) + assertTrue(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback(SCO_CONNECTED, true, false, false)) + } + + @Test + fun `queued Bluetooth callback cannot restore app-controlled non-Bluetooth route`() { + assertFalse(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback(HEADSET_AVAILABLE, true, false, true)) + } + + @Test + fun `disconnect timeout never uses stale getter to resurrect connected state`() { + assertEquals(HEADSET_AVAILABLE, WebRtcBluetoothManager.stateAfterModernRouteClear(true)) + assertEquals(HEADSET_UNAVAILABLE, WebRtcBluetoothManager.stateAfterModernRouteClear(false)) + } + + @Test + fun `route clear finishes after a confirmed non-Bluetooth route`() { + assertFalse(WebRtcBluetoothManager.shouldKeepModernRouteClearPending(true, true, false)) + assertFalse(WebRtcBluetoothManager.shouldKeepModernRouteClearPending(false, true, true)) + } + + @Test + fun `route clear remains pending for Bluetooth or unknown getter result`() { + assertTrue(WebRtcBluetoothManager.shouldKeepModernRouteClearPending(true, true, true)) + assertTrue(WebRtcBluetoothManager.shouldKeepModernRouteClearPending(true, false, false)) + } + + @Test + fun `focus gain reasserts only preferred modern Bluetooth route with retries available`() { + assertTrue( + WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + SCO_CONNECTED, + true, + false, + Build.VERSION_CODES.S, + true + ) + ) + assertFalse( + WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + SCO_CONNECTED, + true, + false, + Build.VERSION_CODES.R, + true + ) + ) + assertFalse( + WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + SCO_CONNECTED, + false, + false, + Build.VERSION_CODES.S, + true + ) + ) + assertFalse( + WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + SCO_CONNECTED, + true, + true, + Build.VERSION_CODES.S, + true + ) + ) + assertFalse( + WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + HEADSET_AVAILABLE, + true, + false, + Build.VERSION_CODES.S, + true + ) + ) + assertFalse( + WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + SCO_CONNECTED, + true, + false, + Build.VERSION_CODES.S, + false + ) + ) + } + + @Test + fun `legacy connected callback is rejected during disconnect`() { + assertTrue(WebRtcBluetoothManager.shouldAcceptLegacyScoConnected(SCO_CONNECTING)) + assertFalse(WebRtcBluetoothManager.shouldAcceptLegacyScoConnected(SCO_DISCONNECTING)) + } +} From 79a6337ebfa3394bdefbdbcac6c9155a0b573268 Mon Sep 17 00:00:00 2001 From: flake92 Date: Thu, 27 Aug 2026 12:14:13 +0300 Subject: [PATCH 4/6] fix(call): address Bluetooth routing review feedback Preserve the active route until a non-Bluetooth replacement is confirmed. Keep automatic retry limits across focus recovery, reject stale callbacks, and reconcile wired and USB devices consistently. Assisted-by: OpenAI Codex:gpt-5.6-sol Signed-off-by: flake92 --- .../nextcloud/talk/webrtc/AudioRoutePolicy.kt | 30 +++- .../talk/webrtc/WebRtcAudioManager.java | 139 ++++++++++++++---- .../talk/webrtc/WebRtcBluetoothManager.java | 107 +++++++++++--- .../talk/webrtc/AudioRoutePolicyTest.kt | 83 +++++++++-- .../webrtc/BluetoothRouteStatePolicyTest.kt | 124 ++++++++++------ .../webrtc/WebRtcAudioManagerFocusTest.kt | 15 ++ 6 files changed, 392 insertions(+), 106 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.kt b/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.kt index 615a56c56b5..da1e9c7afc3 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.kt @@ -45,14 +45,28 @@ internal object AudioRoutePolicy { } @JvmStatic - fun shouldSetCommunicationDevice(currentRouteMatches: Boolean, bluetoothSelectionActive: Boolean): Boolean = - !currentRouteMatches || bluetoothSelectionActive + fun shouldSetCommunicationDevice(currentRouteMatches: Boolean, routeSelectionMustBeReasserted: Boolean): Boolean = + !currentRouteMatches || routeSelectionMustBeReasserted @JvmStatic - fun isWiredCommunicationDeviceType(type: Int): Boolean = - type == AudioDeviceInfo.TYPE_WIRED_HEADSET || - type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || - type == AudioDeviceInfo.TYPE_USB_HEADSET || - type == AudioDeviceInfo.TYPE_USB_DEVICE || - type == AudioDeviceInfo.TYPE_USB_ACCESSORY + fun shouldSelectBeforeBluetoothTeardown(bluetoothStopNeeded: Boolean, targetDevice: AudioDevice): Boolean = + bluetoothStopNeeded && targetDevice != AudioDevice.NONE && targetDevice != AudioDevice.BLUETOOTH + + @JvmStatic + fun canFinishBluetoothTeardown( + bluetoothStopNeeded: Boolean, + targetMustBeSelectedFirst: Boolean, + targetSelectionSucceeded: Boolean + ): Boolean = bluetoothStopNeeded && (!targetMustBeSelectedFirst || targetSelectionSucceeded) + + @JvmStatic + fun isWiredCommunicationOutput(type: Int, isSink: Boolean): Boolean = + when (type) { + AudioDeviceInfo.TYPE_WIRED_HEADSET, + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + AudioDeviceInfo.TYPE_USB_HEADSET, + AudioDeviceInfo.TYPE_USB_DEVICE, + AudioDeviceInfo.TYPE_USB_ACCESSORY -> isSink + else -> false + } } 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 958bf96f4a3..33b71caa0a3 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java @@ -25,6 +25,7 @@ import android.content.IntentFilter; import android.content.pm.PackageManager; import android.media.AudioAttributes; +import android.media.AudioDeviceCallback; import android.media.AudioDeviceInfo; import android.media.AudioFocusRequest; import android.media.AudioManager; @@ -39,6 +40,7 @@ import org.greenrobot.eventbus.EventBus; import org.webrtc.ThreadUtils; +import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.Set; @@ -72,6 +74,8 @@ public class WebRtcAudioManager { private Set internalAudioDevices = new HashSet<>(); private final BroadcastReceiver wiredHeadsetReceiver; + private final AudioDeviceCallback wiredAudioDeviceCallback; + private boolean wiredRouteRefreshPending; private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener; private AudioFocusRequest audioFocusRequest; private final AudioFocusState audioFocusState = new AudioFocusState(); @@ -85,6 +89,17 @@ private WebRtcAudioManager(Context context, boolean useProximitySensor) { audioManager = ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE)); bluetoothManager = WebRtcBluetoothManager.create(context, this); wiredHeadsetReceiver = new WiredHeadsetReceiver(); + wiredAudioDeviceCallback = new AudioDeviceCallback() { + @Override + public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) { + onWiredAudioDevicesChanged(addedDevices); + } + + @Override + public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) { + onWiredAudioDevicesChanged(removedDevices); + } + }; amState = AudioManagerState.UNINITIALIZED; powerManagerUtils = new PowerManagerUtils(); @@ -190,10 +205,14 @@ public void start(AudioManagerListener audioManagerListener) { currentAudioDevice = AudioDevice.NONE; defaultAudioDevice = AudioDevice.NONE; bluetoothPreferredForCall = false; + audioFocusState.reset(); lastReportedAudioDeviceForUi = AudioDevice.NONE; audioDevices.clear(); internalAudioDevices.clear(); + wiredRouteRefreshPending = false; + audioManager.registerAudioDeviceCallback(wiredAudioDeviceCallback, null); + hasWiredHeadset = hasWiredHeadset(); startBluetoothManager(); // Do initial selection of audio device. This setting can later be changed @@ -216,6 +235,11 @@ void onAudioFocusChange(int focusChange) { if (audioFocusState.handle(focusChange) && amState == AudioManagerState.RUNNING) { audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); bluetoothManager.reassertBluetoothAudioAfterFocusGain(bluetoothPreferredForCall, hasWiredHeadset); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S + && currentAudioDevice != AudioDevice.NONE + && currentAudioDevice != AudioDevice.BLUETOOTH) { + setAudioDeviceInternal(currentAudioDevice); + } updateAudioDeviceState(); } Log.d(TAG, "onAudioFocusChange: " + focusChange); @@ -259,6 +283,14 @@ boolean handle(int focusChange) { return false; } } + + boolean hasTransientLoss() { + return transientLoss; + } + + void reset() { + transientLoss = false; + } } @SuppressLint("WrongConstant") @@ -272,6 +304,7 @@ public void stop() { amState = AudioManagerState.UNINITIALIZED; unregisterReceiver(wiredHeadsetReceiver); + audioManager.unregisterAudioDeviceCallback(wiredAudioDeviceCallback); if(bluetoothManager.started()) { bluetoothManager.stop(); @@ -343,6 +376,7 @@ private boolean setAudioDeviceInternal(AudioDevice audioDevice) { return false; } } + currentAudioDevice = audioDevice; return true; } @@ -484,7 +518,7 @@ private boolean setCommunicationDevice(AudioDevice audioDevice) { boolean currentRouteMatches = currentDevice != null && matchesAudioDevice(currentDevice, audioDevice); if (!AudioRoutePolicy.shouldSetCommunicationDevice( currentRouteMatches, - bluetoothManager.isBluetoothSelectionActive())) { + bluetoothManager.isBluetoothSelectionActive() || wiredRouteRefreshPending)) { return true; } @@ -530,7 +564,7 @@ private boolean matchesAudioDevice(AudioDeviceInfo device, AudioDevice audioDevi case BLUETOOTH: return WebRtcBluetoothManager.isBluetoothCommunicationDeviceType(type); case WIRED_HEADSET: - return AudioRoutePolicy.isWiredCommunicationDeviceType(type); + return AudioRoutePolicy.isWiredCommunicationOutput(type, device.isSink()); case EARPIECE: return type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE; case SPEAKER_PHONE: @@ -580,16 +614,14 @@ private boolean hasEarpiece() { } /** - * Checks whether a wired headset is connected or not. This is not a valid indication that audio playback is - * actually over the wired headset as audio routing depends on other conditions. We only use it as an early - * indicator (during initialization) of an attached wired headset. + * Checks whether a wired or USB output sink is currently available. Input-only USB devices are not routes. */ - @Deprecated private boolean hasWiredHeadset() { - @SuppressLint("WrongConstant") final AudioDeviceInfo[] devices = - audioManager.getDevices(AudioManager.GET_DEVICES_ALL); + Iterable devices = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + ? audioManager.getAvailableCommunicationDevices() + : Arrays.asList(audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)); for (AudioDeviceInfo device : devices) { - if (AudioRoutePolicy.isWiredCommunicationDeviceType(device.getType())) { + if (AudioRoutePolicy.isWiredCommunicationOutput(device.getType(), device.isSink())) { Log.d(TAG, "hasWiredHeadset: found wired or USB audio device"); return true; } @@ -597,6 +629,33 @@ private boolean hasWiredHeadset() { return false; } + private void onWiredAudioDevicesChanged(AudioDeviceInfo[] changedDevices) { + ThreadUtils.checkIsOnMainThread(); + for (AudioDeviceInfo device : changedDevices) { + if (AudioRoutePolicy.isWiredCommunicationOutput(device.getType(), device.isSink())) { + wiredRouteRefreshPending = true; + refreshWiredHeadsetState(); + return; + } + } + } + + private void refreshWiredHeadsetState() { + ThreadUtils.checkIsOnMainThread(); + if (amState != AudioManagerState.RUNNING) { + return; + } + boolean wiredHeadsetAvailable = hasWiredHeadset(); + if (!wiredHeadsetAvailable && hasWiredHeadset == wiredHeadsetAvailable) { + wiredRouteRefreshPending = false; + } + if (hasWiredHeadset == wiredHeadsetAvailable && !wiredRouteRefreshPending) { + return; + } + hasWiredHeadset = wiredHeadsetAvailable; + updateAudioDeviceState(); + } + private boolean hasBluetoothCommunicationOutput() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { try { @@ -695,15 +754,17 @@ public final void updateAudioDeviceState() { bluetoothPreferredForCall, hasWiredHeadset, bluetoothManager.isBluetoothRouteRetryScheduled(), - bluetoothManager.hasRemainingScoConnectionAttempts() + bluetoothManager.hasRemainingScoConnectionAttempts(), + audioFocusState.hasTransientLoss() ); + boolean nonBluetoothFallbackPending = bluetoothManager.isNonBluetoothFallbackPending(); // Need to stop Bluetooth audio if user selected different device and // Bluetooth SCO connection is established or in the process. boolean needBluetoothScoStop = (bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_CONNECTED || bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_CONNECTING) - && (!bluetoothPreferredForCall || hasWiredHeadset); + && (!bluetoothPreferredForCall || hasWiredHeadset || nonBluetoothFallbackPending); if (bluetoothManager.getState() == WebRtcBluetoothManager.State.HEADSET_AVAILABLE || bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_CONNECTING @@ -713,10 +774,8 @@ public final void updateAudioDeviceState() { + "BT state=" + bluetoothManager.getState()); } - // Start or stop Bluetooth SCO connection given states set earlier. - if (needBluetoothScoStop) { - bluetoothManager.stopScoAudio(); - } else if (needBluetoothScoStart && !bluetoothManager.startScoAudio()) { + // Start Bluetooth SCO when no transition away from it is required. + if (!needBluetoothScoStop && needBluetoothScoStart && !bluetoothManager.startScoAudio()) { // Keep Bluetooth visible so an explicit user selection can reset the bounded retry counter. newInternalAudioDevices.remove(AudioDevice.BLUETOOTH_SCO); } @@ -730,15 +789,22 @@ public final void updateAudioDeviceState() { boolean bluetoothConnected = bluetoothPreferredForCall && !hasWiredHeadset + && !nonBluetoothFallbackPending && bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_CONNECTED && newInternalAudioDevices.contains(AudioDevice.BLUETOOTH_SCO); + AudioDevice selectableDefaultAudioDevice = nonBluetoothFallbackPending + && defaultAudioDevice == AudioDevice.BLUETOOTH ? AudioDevice.NONE : defaultAudioDevice; AudioDevice newCurrentAudioDevice = AudioRoutePolicy.selectAudioDevice( audioDevices, userSelectedAudioDevice, - defaultAudioDevice, + selectableDefaultAudioDevice, hasWiredHeadset, bluetoothConnected ); + boolean selectBeforeBluetoothTeardown = AudioRoutePolicy.shouldSelectBeforeBluetoothTeardown( + needBluetoothScoStop, + newCurrentAudioDevice + ); boolean communicationRouteNeedsSelection = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && newCurrentAudioDevice != AudioDevice.NONE && !(newCurrentAudioDevice == AudioDevice.BLUETOOTH @@ -746,14 +812,18 @@ public final void updateAudioDeviceState() { && !isCommunicationDeviceSelected(newCurrentAudioDevice); boolean audioDeviceUpdateNeeded = newCurrentAudioDevice != currentAudioDevice || audioDeviceSetUpdated - || communicationRouteNeedsSelection; + || communicationRouteNeedsSelection + || selectBeforeBluetoothTeardown + || wiredRouteRefreshPending; AudioDevice previousCurrentAudioDevice = currentAudioDevice; + boolean routeSelectionSucceeded = false; // Switch to new device but only if there has been any changes. if (audioDeviceUpdateNeeded) { boolean bluetoothSelectionPending = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - && isBluetoothSelectionPending(); + && isBluetoothSelectionPending() + && !selectBeforeBluetoothTeardown; if (!bluetoothSelectionPending) { - setAudioDeviceInternal(newCurrentAudioDevice); + routeSelectionSucceeded = setAudioDeviceInternal(newCurrentAudioDevice); } Log.d(TAG, "New device status: " + "internally available=" + internalAudioDevices + ", " @@ -761,6 +831,27 @@ public final void updateAudioDeviceState() { + "current(new)=" + currentAudioDevice); } + if (wiredRouteRefreshPending + && (routeSelectionSucceeded || newCurrentAudioDevice == AudioDevice.NONE)) { + wiredRouteRefreshPending = false; + } + + if ((selectBeforeBluetoothTeardown || nonBluetoothFallbackPending) + && newCurrentAudioDevice != AudioDevice.BLUETOOTH + && routeSelectionSucceeded) { + newInternalAudioDevices.remove(AudioDevice.BLUETOOTH_SCO); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + bluetoothManager.onNonBluetoothCommunicationDeviceSelected(); + } else if (selectBeforeBluetoothTeardown) { + bluetoothManager.stopScoAudio(); + } + } else if (AudioRoutePolicy.canFinishBluetoothTeardown( + needBluetoothScoStop, + selectBeforeBluetoothTeardown, + routeSelectionSucceeded)) { + bluetoothManager.stopScoAudio(); + } + boolean audioDeviceChanged = previousCurrentAudioDevice != currentAudioDevice || audioDeviceSetUpdated; notifyAudioRouteStateIfChanged(audioDeviceChanged); Log.d(TAG, "--- updateAudioDeviceState done"); @@ -803,17 +894,9 @@ void onAudioDeviceChanged( /* Receiver which handles changes in wired headset availability. */ private class WiredHeadsetReceiver extends BroadcastReceiver { - private static final int STATE_UNPLUGGED = 0; - private static final int STATE_PLUGGED = 1; - private static final int HAS_NO_MIC = 0; - @Override public void onReceive(Context context, Intent intent) { - int state = intent.getIntExtra("state", STATE_UNPLUGGED); - // int microphone = intent.getIntExtra("microphone", HAS_NO_MIC); - // String name = intent.getStringExtra("name"); - hasWiredHeadset = (state == STATE_PLUGGED); - updateAudioDeviceState(); + refreshWiredHeadsetState(); } } } diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java index 20bd741f2f0..255aef754ad 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java @@ -77,6 +77,7 @@ public class WebRtcBluetoothManager { private final Runnable bluetoothTimeoutRunnable = this::bluetoothTimeout; private final Runnable bluetoothRouteRetryRunnable = this::retryBluetoothRoute; private boolean bluetoothRouteRetryScheduled; + private boolean nonBluetoothFallbackPending; private boolean started = false; protected WebRtcBluetoothManager(Context context, WebRtcAudioManager audioManager) { @@ -163,12 +164,14 @@ static boolean shouldStartBluetoothRoute( boolean bluetoothPreferred, boolean hasWiredHeadset, boolean retryScheduled, - boolean attemptsAvailable) { + boolean attemptsAvailable, + boolean transientFocusLoss) { return state == State.HEADSET_AVAILABLE && bluetoothPreferred && !hasWiredHeadset && !retryScheduled - && attemptsAvailable; + && attemptsAvailable + && !transientFocusLoss; } static boolean shouldAcceptModernBluetoothCallback( @@ -203,17 +206,43 @@ static boolean shouldKeepModernRouteClearPending( return routeClearPending && (!currentRouteKnown || bluetoothSelected); } - static boolean shouldReassertModernBluetoothAfterFocusGain( + static boolean shouldReassertBluetoothAfterFocusGain( State state, boolean bluetoothPreferred, - boolean hasWiredHeadset, - int sdkInt, - boolean attemptsAvailable) { - return sdkInt >= Build.VERSION_CODES.S - && state == State.SCO_CONNECTED + boolean hasWiredHeadset) { + return state == State.SCO_CONNECTED && bluetoothPreferred - && !hasWiredHeadset - && attemptsAvailable; + && !hasWiredHeadset; + } + + enum ModernFocusRecoveryAction { + REASSERT, + FALL_BACK + } + + static ModernFocusRecoveryAction modernFocusRecoveryAction(boolean attemptsAvailable) { + return attemptsAvailable ? ModernFocusRecoveryAction.REASSERT : ModernFocusRecoveryAction.FALL_BACK; + } + + enum LegacyFocusRecoveryAction { + RECLAIM_CONNECTED, + RESTART_DISCONNECTED, + FALL_BACK + } + + static LegacyFocusRecoveryAction legacyFocusRecoveryAction( + boolean headsetAvailable, + boolean headsetAudioConnected, + boolean attemptsAvailable) { + if (!headsetAvailable) { + return LegacyFocusRecoveryAction.FALL_BACK; + } + if (headsetAudioConnected) { + return LegacyFocusRecoveryAction.RECLAIM_CONNECTED; + } + return attemptsAvailable + ? LegacyFocusRecoveryAction.RESTART_DISCONNECTED + : LegacyFocusRecoveryAction.FALL_BACK; } /** @@ -254,23 +283,31 @@ boolean hasRemainingScoConnectionAttempts() { return scoConnectionAttempts < MAX_SCO_CONNECTION_ATTEMPTS; } + boolean isNonBluetoothFallbackPending() { + ThreadUtils.checkIsOnMainThread(); + return nonBluetoothFallbackPending; + } + public void resetScoConnectionAttempts() { ThreadUtils.checkIsOnMainThread(); scoConnectionAttempts = 0; + nonBluetoothFallbackPending = false; cancelBluetoothRouteRetry(); } public void reassertBluetoothAudioAfterFocusGain(boolean bluetoothPreferred, boolean hasWiredHeadset) { ThreadUtils.checkIsOnMainThread(); if (!started - || modernBluetoothRoute == null - || !shouldReassertModernBluetoothAfterFocusGain( - bluetoothState, - bluetoothPreferred, - hasWiredHeadset, - Build.VERSION.SDK_INT, - hasRemainingScoConnectionAttempts() - )) { + || !shouldReassertBluetoothAfterFocusGain( + bluetoothState, + bluetoothPreferred, + hasWiredHeadset + )) { + return; + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || modernBluetoothRoute == null) { + reassertLegacyBluetoothAudioAfterFocusGain(); return; } @@ -281,6 +318,11 @@ public void reassertBluetoothAudioAfterFocusGain(boolean bluetoothPreferred, boo bluetoothState = stateAfterModernRouteClear(modernBluetoothRoute.hasBluetoothDevice()); return; } + if (modernFocusRecoveryAction(hasRemainingScoConnectionAttempts()) + == ModernFocusRecoveryAction.FALL_BACK) { + nonBluetoothFallbackPending = true; + return; + } bluetoothState = State.SCO_CONNECTING; scoConnectionAttempts++; boolean requestAccepted = modernBluetoothRoute.reselectConfirmedBluetoothDevice(); @@ -298,6 +340,33 @@ public void reassertBluetoothAudioAfterFocusGain(boolean bluetoothPreferred, boo Log.d(TAG, "Reasserting the confirmed Bluetooth route after audio focus returned"); } + @SuppressLint("MissingPermission") + private void reassertLegacyBluetoothAudioAfterFocusGain() { + boolean headsetAvailable = bluetoothHeadset != null + && bluetoothDevice != null + && bluetoothHeadset.getConnectionState(bluetoothDevice) == BluetoothProfile.STATE_CONNECTED; + boolean headsetAudioConnected = headsetAvailable + && bluetoothHeadset.isAudioConnected(bluetoothDevice); + LegacyFocusRecoveryAction action = legacyFocusRecoveryAction( + headsetAvailable, + headsetAudioConnected, + hasRemainingScoConnectionAttempts() + ); + if (action == LegacyFocusRecoveryAction.RECLAIM_CONNECTED) { + audioManager.setBluetoothScoOn(true); + Log.d(TAG, "Reasserted the connected legacy Bluetooth SCO route after audio focus returned"); + return; + } + + bluetoothState = headsetAvailable ? State.HEADSET_AVAILABLE : State.HEADSET_UNAVAILABLE; + if (action == LegacyFocusRecoveryAction.RESTART_DISCONNECTED) { + Log.w(TAG, "Legacy Bluetooth SCO was lost while audio focus was away; restarting it"); + startScoAudio(); + } else { + Log.w(TAG, "Legacy Bluetooth SCO cannot be recovered after audio focus returned"); + } + } + void onNonBluetoothCommunicationDeviceSelected() { ThreadUtils.checkIsOnMainThread(); if (!started || modernBluetoothRoute == null) { @@ -305,6 +374,7 @@ void onNonBluetoothCommunicationDeviceSelected() { } cancelTimer(); cancelBluetoothRouteRetry(); + nonBluetoothFallbackPending = false; modernBluetoothRoute.confirmNonBluetoothRouteSelection(); bluetoothState = stateAfterModernRouteClear(modernBluetoothRoute.hasBluetoothDevice()); Log.d(TAG, "A non-Bluetooth communication device was selected without clearing the accepted route"); @@ -335,6 +405,7 @@ public void start() { bluetoothDevice = null; scoConnectionAttempts = 0; bluetoothRouteRetryScheduled = false; + nonBluetoothFallbackPending = false; headsetProfileExpected = false; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { bluetoothState = State.HEADSET_UNAVAILABLE; diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.kt index 830294c4b17..cc4200d2970 100644 --- a/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.kt +++ b/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.kt @@ -13,6 +13,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +@Suppress("TooManyFunctions") class AudioRoutePolicyTest { @Test fun `Bluetooth connection has priority`() { @@ -78,11 +79,11 @@ class AudioRoutePolicyTest { } @Test - fun `active Bluetooth selection forces a superseding request despite a stale matching getter`() { + fun `active route reconciliation forces a request despite a stale matching getter`() { assertTrue( AudioRoutePolicy.shouldSetCommunicationDevice( currentRouteMatches = true, - bluetoothSelectionActive = true + routeSelectionMustBeReasserted = true ) ) } @@ -92,7 +93,7 @@ class AudioRoutePolicyTest { assertFalse( AudioRoutePolicy.shouldSetCommunicationDevice( currentRouteMatches = true, - bluetoothSelectionActive = false + routeSelectionMustBeReasserted = false ) ) } @@ -102,23 +103,83 @@ class AudioRoutePolicyTest { assertTrue( AudioRoutePolicy.shouldSetCommunicationDevice( currentRouteMatches = false, - bluetoothSelectionActive = false + routeSelectionMustBeReasserted = false ) ) } @Test - fun `wired detection covers every selectable wired communication device`() { - assertTrue(AudioRoutePolicy.isWiredCommunicationDeviceType(AudioDeviceInfo.TYPE_WIRED_HEADSET)) - assertTrue(AudioRoutePolicy.isWiredCommunicationDeviceType(AudioDeviceInfo.TYPE_WIRED_HEADPHONES)) - assertTrue(AudioRoutePolicy.isWiredCommunicationDeviceType(AudioDeviceInfo.TYPE_USB_HEADSET)) - assertTrue(AudioRoutePolicy.isWiredCommunicationDeviceType(AudioDeviceInfo.TYPE_USB_DEVICE)) - assertTrue(AudioRoutePolicy.isWiredCommunicationDeviceType(AudioDeviceInfo.TYPE_USB_ACCESSORY)) - assertFalse(AudioRoutePolicy.isWiredCommunicationDeviceType(AudioDeviceInfo.TYPE_BLUETOOTH_SCO)) + fun `Bluetooth teardown waits for a required target selection`() { + assertTrue( + AudioRoutePolicy.shouldSelectBeforeBluetoothTeardown( + bluetoothStopNeeded = true, + targetDevice = AudioDevice.WIRED_HEADSET + ) + ) + assertTrue( + AudioRoutePolicy.shouldSelectBeforeBluetoothTeardown( + bluetoothStopNeeded = true, + targetDevice = AudioDevice.SPEAKER_PHONE + ) + ) + assertFalse( + AudioRoutePolicy.shouldSelectBeforeBluetoothTeardown( + bluetoothStopNeeded = false, + targetDevice = AudioDevice.WIRED_HEADSET + ) + ) + assertFalse( + AudioRoutePolicy.shouldSelectBeforeBluetoothTeardown( + bluetoothStopNeeded = true, + targetDevice = AudioDevice.BLUETOOTH + ) + ) + assertFalse( + AudioRoutePolicy.shouldSelectBeforeBluetoothTeardown( + bluetoothStopNeeded = true, + targetDevice = AudioDevice.NONE + ) + ) + assertFalse( + AudioRoutePolicy.canFinishBluetoothTeardown( + bluetoothStopNeeded = true, + targetMustBeSelectedFirst = true, + targetSelectionSucceeded = false + ) + ) + assertTrue( + AudioRoutePolicy.canFinishBluetoothTeardown( + bluetoothStopNeeded = true, + targetMustBeSelectedFirst = true, + targetSelectionSucceeded = true + ) + ) + assertTrue( + AudioRoutePolicy.canFinishBluetoothTeardown( + bluetoothStopNeeded = true, + targetMustBeSelectedFirst = false, + targetSelectionSucceeded = false + ) + ) + } + + @Test + fun `wired communication routes require an output sink`() { + assertWiredOutput(AudioDeviceInfo.TYPE_WIRED_HEADSET) + assertWiredOutput(AudioDeviceInfo.TYPE_WIRED_HEADPHONES) + assertWiredOutput(AudioDeviceInfo.TYPE_USB_HEADSET) + assertWiredOutput(AudioDeviceInfo.TYPE_USB_DEVICE) + assertWiredOutput(AudioDeviceInfo.TYPE_USB_ACCESSORY) + assertFalse(AudioRoutePolicy.isWiredCommunicationOutput(AudioDeviceInfo.TYPE_USB_DEVICE, isSink = false)) + assertFalse(AudioRoutePolicy.isWiredCommunicationOutput(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, isSink = true)) } private fun devices(vararg devices: AudioDevice): Set = setOf(*devices) + private fun assertWiredOutput(type: Int) { + assertTrue(AudioRoutePolicy.isWiredCommunicationOutput(type, isSink = true)) + } + @Suppress("LongParameterList") private fun assertSelected( expected: AudioDevice, diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.kt index 84905234058..7ff4e9c6ac3 100644 --- a/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.kt +++ b/app/src/test/java/com/nextcloud/talk/webrtc/BluetoothRouteStatePolicyTest.kt @@ -6,7 +6,6 @@ */ package com.nextcloud.talk.webrtc -import android.os.Build import com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.HEADSET_AVAILABLE import com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.HEADSET_UNAVAILABLE import com.nextcloud.talk.webrtc.WebRtcBluetoothManager.State.SCO_CONNECTED @@ -38,13 +37,50 @@ class BluetoothRouteStatePolicyTest { @Test fun `scheduled retry prevents an immediate second Bluetooth attempt`() { - assertFalse(WebRtcBluetoothManager.shouldStartBluetoothRoute(HEADSET_AVAILABLE, true, false, true, true)) - assertTrue(WebRtcBluetoothManager.shouldStartBluetoothRoute(HEADSET_AVAILABLE, true, false, false, true)) + assertFalse( + WebRtcBluetoothManager.shouldStartBluetoothRoute( + HEADSET_AVAILABLE, + true, + false, + true, + true, + false + ) + ) + assertTrue( + WebRtcBluetoothManager.shouldStartBluetoothRoute( + HEADSET_AVAILABLE, + true, + false, + false, + true, + false + ) + ) } @Test - fun `exhausted attempts prevent automatic Bluetooth restarts`() { - assertFalse(WebRtcBluetoothManager.shouldStartBluetoothRoute(HEADSET_AVAILABLE, true, false, false, false)) + fun `automatic Bluetooth restarts wait for focus and remaining attempts`() { + assertFalse( + WebRtcBluetoothManager.shouldStartBluetoothRoute( + HEADSET_AVAILABLE, + true, + false, + false, + false, + false + ) + ) + assertFalse( + WebRtcBluetoothManager.shouldStartBluetoothRoute( + HEADSET_AVAILABLE, + true, + false, + false, + true, + true + ) + ) } @Test @@ -74,8 +110,8 @@ class BluetoothRouteStatePolicyTest { } @Test - fun `late Bluetooth callback after rejected request is rejected`() { - assertFalse(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback(HEADSET_AVAILABLE, true, false, false)) + fun `late matching Bluetooth callback after rejected request is rejected`() { + assertFalse(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback(HEADSET_AVAILABLE, true, false, true)) } @Test @@ -92,7 +128,7 @@ class BluetoothRouteStatePolicyTest { @Test fun `queued Bluetooth callback cannot restore app-controlled non-Bluetooth route`() { - assertFalse(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback(HEADSET_AVAILABLE, true, false, true)) + assertFalse(WebRtcBluetoothManager.shouldAcceptModernBluetoothCallback(HEADSET_AVAILABLE, true, false, false)) } @Test @@ -114,63 +150,69 @@ class BluetoothRouteStatePolicyTest { } @Test - fun `focus gain reasserts only preferred modern Bluetooth route with retries available`() { + fun `focus gain recovery requires connected preferred Bluetooth without wired output`() { assertTrue( - WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( - SCO_CONNECTED, - true, - false, - Build.VERSION_CODES.S, - true - ) - ) - assertFalse( - WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + WebRtcBluetoothManager.shouldReassertBluetoothAfterFocusGain( SCO_CONNECTED, true, - false, - Build.VERSION_CODES.R, - true + false ) ) assertFalse( - WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + WebRtcBluetoothManager.shouldReassertBluetoothAfterFocusGain( SCO_CONNECTED, false, - false, - Build.VERSION_CODES.S, - true + false ) ) assertFalse( - WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + WebRtcBluetoothManager.shouldReassertBluetoothAfterFocusGain( SCO_CONNECTED, true, - true, - Build.VERSION_CODES.S, true ) ) assertFalse( - WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( + WebRtcBluetoothManager.shouldReassertBluetoothAfterFocusGain( HEADSET_AVAILABLE, true, - false, - Build.VERSION_CODES.S, - true - ) - ) - assertFalse( - WebRtcBluetoothManager.shouldReassertModernBluetoothAfterFocusGain( - SCO_CONNECTED, - true, - false, - Build.VERSION_CODES.S, false ) ) } + @Test + fun `modern focus recovery preserves the retry limit`() { + assertEquals( + WebRtcBluetoothManager.ModernFocusRecoveryAction.REASSERT, + WebRtcBluetoothManager.modernFocusRecoveryAction(true) + ) + assertEquals( + WebRtcBluetoothManager.ModernFocusRecoveryAction.FALL_BACK, + WebRtcBluetoothManager.modernFocusRecoveryAction(false) + ) + } + + @Test + fun `legacy focus recovery distinguishes reclaim restart and fallback`() { + assertEquals( + WebRtcBluetoothManager.LegacyFocusRecoveryAction.RECLAIM_CONNECTED, + WebRtcBluetoothManager.legacyFocusRecoveryAction(true, true, false) + ) + assertEquals( + WebRtcBluetoothManager.LegacyFocusRecoveryAction.RESTART_DISCONNECTED, + WebRtcBluetoothManager.legacyFocusRecoveryAction(true, false, true) + ) + assertEquals( + WebRtcBluetoothManager.LegacyFocusRecoveryAction.FALL_BACK, + WebRtcBluetoothManager.legacyFocusRecoveryAction(true, false, false) + ) + assertEquals( + WebRtcBluetoothManager.LegacyFocusRecoveryAction.FALL_BACK, + WebRtcBluetoothManager.legacyFocusRecoveryAction(false, true, true) + ) + } + @Test fun `legacy connected callback is rejected during disconnect`() { assertTrue(WebRtcBluetoothManager.shouldAcceptLegacyScoConnected(SCO_CONNECTING)) diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/WebRtcAudioManagerFocusTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/WebRtcAudioManagerFocusTest.kt index 6c5af15c549..1ca1992e176 100644 --- a/app/src/test/java/com/nextcloud/talk/webrtc/WebRtcAudioManagerFocusTest.kt +++ b/app/src/test/java/com/nextcloud/talk/webrtc/WebRtcAudioManagerFocusTest.kt @@ -43,7 +43,9 @@ class WebRtcAudioManagerFocusTest { val state = WebRtcAudioManager.AudioFocusState() assertFalse(state.handle(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)) + assertTrue(state.hasTransientLoss()) assertTrue(state.handle(AudioManager.AUDIOFOCUS_GAIN)) + assertFalse(state.hasTransientLoss()) } @Test @@ -60,7 +62,9 @@ class WebRtcAudioManagerFocusTest { val state = WebRtcAudioManager.AudioFocusState() assertFalse(state.handle(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK)) + assertTrue(state.hasTransientLoss()) assertTrue(state.handle(AudioManager.AUDIOFOCUS_GAIN)) + assertFalse(state.hasTransientLoss()) } @Test @@ -78,4 +82,15 @@ class WebRtcAudioManagerFocusTest { state.handle(AudioManager.AUDIOFOCUS_LOSS) assertFalse(state.handle(AudioManager.AUDIOFOCUS_GAIN)) } + + @Test + fun `new call clears a transient loss left by the previous call`() { + val state = WebRtcAudioManager.AudioFocusState() + + state.handle(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) + state.reset() + + assertFalse(state.hasTransientLoss()) + assertFalse(state.handle(AudioManager.AUDIOFOCUS_GAIN)) + } } From 2f12731090c9eeccf44b65f2c80e38b1b063a126 Mon Sep 17 00:00:00 2001 From: flake92 Date: Thu, 27 Aug 2026 12:48:03 +0300 Subject: [PATCH 5/6] refactor(call): reduce focus recovery complexity Extract modern Bluetooth focus recovery into a dedicated helper to keep the public dispatcher below the Codacy PMD NPath threshold without changing behavior. Assisted-by: OpenAI Codex:gpt-5.6-sol Signed-off-by: flake92 --- .../com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java index 255aef754ad..347bceba81a 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcBluetoothManager.java @@ -311,6 +311,10 @@ public void reassertBluetoothAudioAfterFocusGain(boolean bluetoothPreferred, boo return; } + reassertModernBluetoothAudioAfterFocusGain(); + } + + private void reassertModernBluetoothAudioAfterFocusGain() { cancelTimer(); cancelBluetoothRouteRetry(); if (!modernBluetoothRoute.hasConfirmedBluetoothDevice()) { From 3ef2e46b7ebc5590f2803f7ffed5760acc74af5c Mon Sep 17 00:00:00 2001 From: flake92 <80347136+flake92@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:49:33 +0300 Subject: [PATCH 6/6] fix(call): wait for the confirmed audio route Delay ringback and remote audio playout until Android confirms the selected communication route. Keep reconnects silent and preserve the working privacy build behavior while retaining the hardened Bluetooth transition policy. Assisted-by: OpenAI Codex:gpt-5.6-sol Signed-off-by: flake92 <80347136+flake92@users.noreply.github.com> --- .../nextcloud/talk/activities/CallActivity.kt | 133 ++++++++++++++---- .../nextcloud/talk/webrtc/AudioRoutePolicy.kt | 12 ++ .../talk/webrtc/PeerConnectionWrapper.java | 83 ++++++++++- .../talk/webrtc/WebRtcAudioManager.java | 26 +++- .../talk/webrtc/AudioRoutePolicyTest.kt | 44 ++++++ 5 files changed, 267 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 46b0e5dab3e..1f6a0ee5f30 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -193,6 +193,7 @@ import org.webrtc.VideoSource import org.webrtc.VideoTrack import java.io.IOException import java.util.Objects +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject @@ -248,7 +249,7 @@ class CallActivity : CallBaseActivity() { private var callSession: String? = null private var localStream: MediaStream? = null private var credentials: String? = null - private val peerConnectionWrapperList: MutableList = ArrayList() + private val peerConnectionWrapperList: MutableList = CopyOnWriteArrayList() private var videoOn = false private var microphoneOn = false var isVoiceOnlyCall = false @@ -318,9 +319,23 @@ class CallActivity : CallBaseActivity() { private var conversationPassword: String? = null private var powerManagerUtils: PowerManagerUtils? = null private var handler: Handler? = null + + private val callingTimeoutRunnable = Runnable { setCallState(CallStatus.CALLING_TIMEOUT) } + + @Volatile private var currentCallStatus: CallStatus? = null + private var mediaPlayer: MediaPlayer? = null + @Volatile + private var callingSoundRequested = false + + @Volatile + private var audioRouteReady = false + + @Volatile + private var remoteAudioPlayoutEnabled = false + private var binding: CallActivityBinding? = null private var audioOutputDialog: AudioOutputDialog? = null private var moreCallActionsDialog: MoreCallActionsDialog? = null @@ -1056,7 +1071,7 @@ class CallActivity : CallBaseActivity() { } private fun prepareCall() { - stopCallingSound() + releaseCallingSound() basicInitialization() initViews() // updateSelfVideoViewPosition(true) @@ -1134,6 +1149,13 @@ class CallActivity : CallBaseActivity() { private fun onAudioManagerDevicesChanged(currentDevice: AudioDevice, availableDevices: Set) { Log.d(TAG, "onAudioManagerDevicesChanged: $availableDevices, currentDevice: $currentDevice") + audioRouteReady = audioManager?.isAudioRouteReady == true + updateRemoteAudioPlayout() + if (audioRouteReady) { + maybeStartCallingSound() + } else { + releaseCallingSound() + } val shouldDisableProximityLock = currentDevice == AudioDevice.WIRED_HEADSET || currentDevice == AudioDevice.SPEAKER_PHONE || @@ -2052,6 +2074,9 @@ class CallActivity : CallBaseActivity() { audioSource = null } runOnUiThread { + audioRouteReady = false + remoteAudioPlayoutEnabled = false + peerConnectionWrapperList.forEach { it.setRemoteAudioPlayoutEnabled(false) } if (audioManager != null) { audioManager!!.stop() audioManager = null @@ -2433,6 +2458,7 @@ class CallActivity : CallBaseActivity() { } peerConnectionWrapper = createPeerConnectionWrapperForSessionIdAndType(publisher, sessionId, type) peerConnectionWrapperList.add(peerConnectionWrapper) + peerConnectionWrapper.setRemoteAudioPlayoutEnabled(remoteAudioPlayoutEnabled) if (!publisher) { if (!callViewModel.doesParticipantExist(sessionId)) { addCallParticipant(sessionId) @@ -2676,13 +2702,14 @@ class CallActivity : CallBaseActivity() { } else { handler!!.removeCallbacksAndMessages(null) } + handler!!.post { updateRemoteAudioPlayout() } when (callState) { CallStatus.CONNECTING -> handler!!.post { handleCallStateConnected() } CallStatus.CALLING_TIMEOUT -> handler!!.post { handleCallStateCallingTimeout() } CallStatus.PUBLISHER_FAILED -> handler!!.post { handleCallStatePublisherFailed() } CallStatus.RECONNECTING -> handler!!.post { handleCallStateReconnecting() } CallStatus.JOINED -> { - handler!!.postDelayed({ setCallState(CallStatus.CALLING_TIMEOUT) }, CALLING_TIMEOUT) + handler!!.postDelayed(callingTimeoutRunnable, CALLING_TIMEOUT) handler!!.post { handleCallStateJoined() } } @@ -2759,7 +2786,7 @@ class CallActivity : CallBaseActivity() { } private fun handleCallStateReconnecting() { - playCallingSound() + stopCallingSound() binding!!.callStates.callStateTextView.setText(R.string.nc_call_reconnecting) if (binding!!.callStates.callStateRelativeLayout.visibility != View.VISIBLE) { binding!!.callStates.callStateRelativeLayout.visibility = View.VISIBLE @@ -2777,6 +2804,7 @@ class CallActivity : CallBaseActivity() { private fun handleCallStatePublisherFailed() { // No calling sound when the publisher failed + stopCallingSound() binding!!.callStates.callStateTextView.setText(R.string.nc_call_reconnecting) if (binding!!.callStates.callStateRelativeLayout.visibility != View.VISIBLE) { binding!!.callStates.callStateRelativeLayout.visibility = View.VISIBLE @@ -2811,7 +2839,7 @@ class CallActivity : CallBaseActivity() { } private fun handleCallStateConnected() { - playCallingSound() + requestCallingSound() if (isIncomingCallFromNotification) { binding!!.callStates.callStateTextView.setText(R.string.nc_call_incoming) } else { @@ -2832,46 +2860,95 @@ class CallActivity : CallBaseActivity() { } } - private fun playCallingSound() { - stopCallingSound() + private fun requestCallingSound() { + if (Looper.myLooper() != Looper.getMainLooper()) { + runOnUiThread { requestCallingSound() } + return + } + callingSoundRequested = true + maybeStartCallingSound() + } + + private fun isRemoteAudioPlayoutAllowed(): Boolean = + (currentCallStatus === CallStatus.JOINED || currentCallStatus === CallStatus.IN_CONVERSATION) && + audioRouteReady + + private fun updateRemoteAudioPlayout() { + val enabled = isRemoteAudioPlayoutAllowed() + remoteAudioPlayoutEnabled = enabled + peerConnectionWrapperList.forEach { it.setRemoteAudioPlayoutEnabled(enabled) } + } + + private fun maybeStartCallingSound() { + if (!callingSoundRequested || mediaPlayer != null || audioManager?.isAudioRouteReady != true) { + return + } val ringtoneUri: Uri? = if (isIncomingCallFromNotification) { getCallRingtoneUri(applicationContext, appPreferences) } else { ("android.resource://" + applicationContext.packageName + "/raw/tr110_1_kap8_3_freiton1").toUri() } if (ringtoneUri != null) { - mediaPlayer = MediaPlayer() + val player = MediaPlayer() + mediaPlayer = player try { - mediaPlayer!!.setDataSource(this, ringtoneUri) - mediaPlayer!!.isLooping = true + player.setDataSource(this, ringtoneUri) + player.isLooping = true val audioAttributes = AudioAttributes.Builder().setContentType( AudioAttributes.CONTENT_TYPE_SONIFICATION ) .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) .build() - mediaPlayer!!.setAudioAttributes(audioAttributes) - mediaPlayer!!.setOnPreparedListener { mp: MediaPlayer? -> mediaPlayer!!.start() } - mediaPlayer!!.prepareAsync() + player.setAudioAttributes(audioAttributes) + player.setOnPreparedListener { preparedPlayer -> + if (mediaPlayer === preparedPlayer && + callingSoundRequested && + audioManager?.isAudioRouteReady == true + ) { + preparedPlayer.start() + } else { + if (mediaPlayer === preparedPlayer) { + mediaPlayer = null + } + preparedPlayer.release() + } + } + player.prepareAsync() } catch (e: IOException) { - Log.e(TAG, "Failed to play sound") + Log.e(TAG, "Failed to play sound", e) + if (mediaPlayer === player) { + mediaPlayer = null + } + player.release() } } } private fun stopCallingSound() { - if (mediaPlayer != null) { - try { - if (mediaPlayer!!.isPlaying) { - mediaPlayer!!.stop() - } - } catch (e: IllegalStateException) { - Log.e(TAG, "mediaPlayer was not initialized", e) - } finally { - if (mediaPlayer != null) { - mediaPlayer!!.release() - } - mediaPlayer = null + if (Looper.myLooper() != Looper.getMainLooper()) { + runOnUiThread { stopCallingSound() } + return + } + callingSoundRequested = false + releaseCallingSound() + } + + private fun releaseCallingSound() { + if (Looper.myLooper() != Looper.getMainLooper()) { + runOnUiThread { releaseCallingSound() } + return + } + val player = mediaPlayer ?: return + mediaPlayer = null + player.setOnPreparedListener(null) + try { + if (player.isPlaying) { + player.stop() } + } catch (e: IllegalStateException) { + Log.e(TAG, "mediaPlayer was not initialized", e) + } finally { + player.release() } } @@ -3083,13 +3160,13 @@ class CallActivity : CallBaseActivity() { fun onMessageEvent(networkEvent: NetworkEvent) { if (networkEvent.networkConnectionEvent == NetworkEvent.NetworkConnectionEvent.NETWORK_CONNECTED) { if (handler != null) { - handler!!.removeCallbacksAndMessages(null) + handler!!.removeCallbacks(callingTimeoutRunnable) } } else if (networkEvent.networkConnectionEvent == NetworkEvent.NetworkConnectionEvent.NETWORK_DISCONNECTED ) { if (handler != null) { - handler!!.removeCallbacksAndMessages(null) + handler!!.removeCallbacks(callingTimeoutRunnable) } } } diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.kt b/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.kt index da1e9c7afc3..cc1d89f627e 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/AudioRoutePolicy.kt @@ -59,6 +59,18 @@ internal object AudioRoutePolicy { targetSelectionSucceeded: Boolean ): Boolean = bluetoothStopNeeded && (!targetMustBeSelectedFirst || targetSelectionSucceeded) + @JvmStatic + fun isAudioRouteReady( + currentDevice: AudioDevice, + bluetoothSelectionPending: Boolean, + bluetoothConnected: Boolean, + selectedCommunicationRouteConfirmed: Boolean + ): Boolean = + currentDevice != AudioDevice.NONE && + !bluetoothSelectionPending && + (currentDevice != AudioDevice.BLUETOOTH || bluetoothConnected) && + selectedCommunicationRouteConfirmed + @JvmStatic fun isWiredCommunicationOutput(type: Int, isSink: Boolean): Boolean = when (type) { diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java b/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java index 484ad344607..b5feb91c8d2 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java @@ -35,6 +35,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -67,6 +68,8 @@ public class PeerConnectionWrapper { // It is assumed that there will be at most one remote stream at each time. private MediaStream stream; + private volatile boolean remoteAudioPlayoutEnabled = false; + private final Map remoteAudioTracks = new HashMap<>(); /** * Listener for data channel messages. @@ -255,6 +258,51 @@ public MediaStream getStream() { return stream; } + public synchronized void setRemoteAudioPlayoutEnabled(boolean enabled) { + remoteAudioPlayoutEnabled = enabled; + double volume = enabled ? 1.0 : 0.0; + Iterator> iterator = remoteAudioTracks.entrySet().iterator(); + while (iterator.hasNext()) { + try { + iterator.next().getValue().setVolume(volume); + } catch (IllegalStateException exception) { + iterator.remove(); + Log.w(TAG, "Remote audio track was already disposed", exception); + } + } + } + + private void applyRemoteAudioVolume(@Nullable MediaStream mediaStream) { + if (mediaStream == null) { + return; + } + double volume = remoteAudioPlayoutEnabled ? 1.0 : 0.0; + for (AudioTrack audioTrack : mediaStream.audioTracks) { + applyRemoteAudioVolume(audioTrack, volume); + } + } + + private void applyRemoteAudioVolume(AudioTrack audioTrack, double volume) { + try { + String trackId = audioTrack.id(); + audioTrack.setVolume(volume); + remoteAudioTracks.put(trackId, audioTrack); + } catch (IllegalStateException exception) { + Log.w(TAG, "Remote audio track was already disposed", exception); + } + } + + private void removeRemoteAudioTrack(MediaStreamTrack mediaStreamTrack) { + try { + String trackId = mediaStreamTrack.id(); + if (remoteAudioTracks.get(trackId) == mediaStreamTrack) { + remoteAudioTracks.remove(trackId); + } + } catch (IllegalStateException exception) { + Log.w(TAG, "Remote audio track was already disposed", exception); + } + } + public void removePeerConnection() { signalingMessageReceiver.removeListener(webRtcMessageListener); @@ -276,6 +324,8 @@ public void removePeerConnection() { } synchronized (this) { + stream = null; + remoteAudioTracks.clear(); for (DataChannel dataChannel : dataChannels.values()) { String label; try { @@ -582,14 +632,22 @@ public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) { @Override public void onAddStream(MediaStream mediaStream) { - stream = mediaStream; + synchronized (PeerConnectionWrapper.this) { + stream = mediaStream; + applyRemoteAudioVolume(mediaStream); + } peerConnectionNotifier.notifyStreamAdded(mediaStream); } @Override public void onRemoveStream(MediaStream mediaStream) { - stream = null; + synchronized (PeerConnectionWrapper.this) { + stream = null; + for (AudioTrack audioTrack : mediaStream.audioTracks) { + removeRemoteAudioTrack(audioTrack); + } + } peerConnectionNotifier.notifyStreamRemoved(mediaStream); } @@ -648,6 +706,27 @@ public void onRenegotiationNeeded() { @Override public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) { + MediaStreamTrack track = rtpReceiver.track(); + synchronized (PeerConnectionWrapper.this) { + if (track instanceof AudioTrack) { + AudioTrack audioTrack = (AudioTrack) track; + applyRemoteAudioVolume(audioTrack, remoteAudioPlayoutEnabled ? 1.0 : 0.0); + } + for (MediaStream mediaStream : mediaStreams) { + stream = mediaStream; + applyRemoteAudioVolume(mediaStream); + } + } + } + + @Override + public void onRemoveTrack(RtpReceiver rtpReceiver) { + MediaStreamTrack track = rtpReceiver.track(); + if (track instanceof AudioTrack) { + synchronized (PeerConnectionWrapper.this) { + removeRemoteAudioTrack(track); + } + } } } 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 33b71caa0a3..8fb8836af0a 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebRtcAudioManager.java @@ -66,6 +66,7 @@ public class WebRtcAudioManager { private AudioDevice currentAudioDevice = AudioDevice.NONE; private AudioDevice defaultAudioDevice = AudioDevice.NONE; private AudioDevice lastReportedAudioDeviceForUi = AudioDevice.NONE; + private boolean lastReportedAudioRouteReady = false; private ProximitySensor proximitySensor = null; @@ -207,6 +208,7 @@ public void start(AudioManagerListener audioManagerListener) { bluetoothPreferredForCall = false; audioFocusState.reset(); lastReportedAudioDeviceForUi = AudioDevice.NONE; + lastReportedAudioRouteReady = false; audioDevices.clear(); internalAudioDevices.clear(); wiredRouteRefreshPending = false; @@ -482,6 +484,24 @@ public AudioDevice getAudioDeviceForUi() { return currentAudioDevice; } + /** + * Returns whether call audio can be played without leaking to a temporary route while Android + * is still switching communication devices. + */ + public boolean isAudioRouteReady() { + ThreadUtils.checkIsOnMainThread(); + boolean bluetoothConnected = bluetoothManager.getState() == WebRtcBluetoothManager.State.SCO_CONNECTED; + boolean selectedCommunicationRouteConfirmed = currentAudioDevice == AudioDevice.BLUETOOTH + || Build.VERSION.SDK_INT < Build.VERSION_CODES.S + || isCommunicationDeviceSelected(currentAudioDevice); + return AudioRoutePolicy.isAudioRouteReady( + currentAudioDevice, + isBluetoothSelectionPending(), + bluetoothConnected, + selectedCommunicationRouteConfirmed + ); + } + /** * Helper method for receiver registration. */ @@ -860,8 +880,12 @@ && isBluetoothSelectionPending() private void notifyAudioRouteStateIfChanged(boolean audioDeviceChanged) { AudioDevice audioDeviceForUi = getAudioDeviceForUi(); boolean audioDeviceForUiChanged = audioDeviceForUi != lastReportedAudioDeviceForUi; + boolean audioRouteReady = isAudioRouteReady(); + boolean audioRouteReadinessChanged = audioRouteReady != lastReportedAudioRouteReady; lastReportedAudioDeviceForUi = audioDeviceForUi; - if ((audioDeviceChanged || audioDeviceForUiChanged) && audioManagerListener != null) { + lastReportedAudioRouteReady = audioRouteReady; + if ((audioDeviceChanged || audioDeviceForUiChanged || audioRouteReadinessChanged) + && audioManagerListener != null) { audioManagerListener.onAudioDeviceChanged(currentAudioDevice, audioDevices); } } diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.kt index cc4200d2970..b5a713daf51 100644 --- a/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.kt +++ b/app/src/test/java/com/nextcloud/talk/webrtc/AudioRoutePolicyTest.kt @@ -174,6 +174,50 @@ class AudioRoutePolicyTest { assertFalse(AudioRoutePolicy.isWiredCommunicationOutput(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, isSink = true)) } + @Test + fun `audio playout waits for the selected communication route`() { + assertFalse( + AudioRoutePolicy.isAudioRouteReady( + currentDevice = AudioDevice.NONE, + bluetoothSelectionPending = false, + bluetoothConnected = false, + selectedCommunicationRouteConfirmed = false + ) + ) + assertFalse( + AudioRoutePolicy.isAudioRouteReady( + currentDevice = AudioDevice.EARPIECE, + bluetoothSelectionPending = true, + bluetoothConnected = false, + selectedCommunicationRouteConfirmed = true + ) + ) + assertFalse( + AudioRoutePolicy.isAudioRouteReady( + currentDevice = AudioDevice.BLUETOOTH, + bluetoothSelectionPending = false, + bluetoothConnected = false, + selectedCommunicationRouteConfirmed = true + ) + ) + assertFalse( + AudioRoutePolicy.isAudioRouteReady( + currentDevice = AudioDevice.EARPIECE, + bluetoothSelectionPending = false, + bluetoothConnected = false, + selectedCommunicationRouteConfirmed = false + ) + ) + assertTrue( + AudioRoutePolicy.isAudioRouteReady( + currentDevice = AudioDevice.BLUETOOTH, + bluetoothSelectionPending = false, + bluetoothConnected = true, + selectedCommunicationRouteConfirmed = true + ) + ) + } + private fun devices(vararg devices: AudioDevice): Set = setOf(*devices) private fun assertWiredOutput(type: Int) {