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/3] 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/3] 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/3] 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)) + } +}