From 2a60dbee009379809d4a596d1661f5c88a3ddcc1 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/2] 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 a27a42c0b77..e2099dc1110 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -919,15 +919,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) { @@ -1142,7 +1144,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 a0fd21325084a4af90c75c6202f0c59d432fc370 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/2] 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();