1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.settings.connecteddevice.audiosharing; 18 19 import static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.MetricKey.METRIC_KEY_CANDIDATE_DEVICE_COUNT; 20 import static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.MetricKey.METRIC_KEY_DEVICE_COUNT_IN_SHARING; 21 import static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.MetricKey.METRIC_KEY_PAGE_ID; 22 import static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.MetricKey.METRIC_KEY_SOURCE_PAGE_ID; 23 import static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.MetricKey.METRIC_KEY_USER_TRIGGERED; 24 import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast.BLUETOOTH_LE_BROADCAST_PRIMARY_DEVICE_GROUP_ID; 25 26 import static java.util.stream.Collectors.toList; 27 28 import android.bluetooth.BluetoothCsipSetCoordinator; 29 import android.bluetooth.BluetoothDevice; 30 import android.bluetooth.BluetoothLeBroadcastMetadata; 31 import android.content.Context; 32 import android.provider.Settings; 33 import android.util.Log; 34 import android.util.Pair; 35 import android.widget.Toast; 36 37 import androidx.annotation.NonNull; 38 import androidx.annotation.Nullable; 39 40 import com.android.settingslib.bluetooth.BluetoothUtils; 41 import com.android.settingslib.bluetooth.CachedBluetoothDevice; 42 import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager; 43 import com.android.settingslib.bluetooth.LeAudioProfile; 44 import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast; 45 import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant; 46 import com.android.settingslib.bluetooth.LocalBluetoothManager; 47 import com.android.settingslib.bluetooth.LocalBluetoothProfileManager; 48 import com.android.settingslib.bluetooth.VolumeControlProfile; 49 50 import java.util.ArrayList; 51 import java.util.Comparator; 52 import java.util.HashMap; 53 import java.util.List; 54 import java.util.Map; 55 import java.util.Objects; 56 57 public class AudioSharingUtils { 58 private static final String TAG = "AudioSharingUtils"; 59 private static final boolean DEBUG = BluetoothUtils.D; 60 61 public enum MetricKey { 62 METRIC_KEY_SOURCE_PAGE_ID, 63 METRIC_KEY_PAGE_ID, 64 METRIC_KEY_USER_TRIGGERED, 65 METRIC_KEY_DEVICE_COUNT_IN_SHARING, 66 METRIC_KEY_CANDIDATE_DEVICE_COUNT 67 } 68 69 /** 70 * Fetch {@link BluetoothDevice}s connected to the broadcast assistant. The devices are grouped 71 * by CSIP group id. 72 * 73 * @param localBtManager The BT manager to provide BT functions. 74 * @return A map of connected devices grouped by CSIP group id. 75 */ fetchConnectedDevicesByGroupId( @ullable LocalBluetoothManager localBtManager)76 public static Map<Integer, List<BluetoothDevice>> fetchConnectedDevicesByGroupId( 77 @Nullable LocalBluetoothManager localBtManager) { 78 Map<Integer, List<BluetoothDevice>> groupedDevices = new HashMap<>(); 79 if (localBtManager == null) { 80 Log.d(TAG, "Skip fetchConnectedDevicesByGroupId due to bt manager is null"); 81 return groupedDevices; 82 } 83 LocalBluetoothLeBroadcastAssistant assistant = 84 localBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile(); 85 if (assistant == null) { 86 Log.d(TAG, "Skip fetchConnectedDevicesByGroupId due to assistant profile is null"); 87 return groupedDevices; 88 } 89 List<BluetoothDevice> connectedDevices = assistant.getAllConnectedDevices(); 90 CachedBluetoothDeviceManager cacheManager = localBtManager.getCachedDeviceManager(); 91 for (BluetoothDevice device : connectedDevices) { 92 CachedBluetoothDevice cachedDevice = cacheManager.findDevice(device); 93 if (cachedDevice == null) { 94 Log.d(TAG, "Skip device due to not being cached: " + device.getAnonymizedAddress()); 95 continue; 96 } 97 int groupId = BluetoothUtils.getGroupId(cachedDevice); 98 if (groupId == BluetoothCsipSetCoordinator.GROUP_ID_INVALID) { 99 Log.d( 100 TAG, 101 "Skip device due to no valid group id: " + device.getAnonymizedAddress()); 102 continue; 103 } 104 if (!groupedDevices.containsKey(groupId)) { 105 groupedDevices.put(groupId, new ArrayList<>()); 106 } 107 groupedDevices.get(groupId).add(device); 108 } 109 if (DEBUG) { 110 Log.d(TAG, "fetchConnectedDevicesByGroupId: " + groupedDevices); 111 } 112 return groupedDevices; 113 } 114 115 /** 116 * Fetch a list of ordered connected lead {@link CachedBluetoothDevice}s eligible for audio 117 * sharing. The active device is placed in the first place if it exists. The devices can be 118 * filtered by whether it is already in the audio sharing session. 119 * 120 * @param localBtManager The BT manager to provide BT functions. 121 * @param groupedConnectedDevices devices connected to broadcast assistant grouped by CSIP group 122 * id. 123 * @param filterByInSharing Whether to filter the device by if is already in the sharing 124 * session. 125 * @return A list of ordered connected devices eligible for the audio sharing. The active device 126 * is placed in the first place if it exists. 127 */ buildOrderedConnectedLeadDevices( @ullable LocalBluetoothManager localBtManager, Map<Integer, List<BluetoothDevice>> groupedConnectedDevices, boolean filterByInSharing)128 public static List<CachedBluetoothDevice> buildOrderedConnectedLeadDevices( 129 @Nullable LocalBluetoothManager localBtManager, 130 Map<Integer, List<BluetoothDevice>> groupedConnectedDevices, 131 boolean filterByInSharing) { 132 List<CachedBluetoothDevice> orderedDevices = new ArrayList<>(); 133 if (localBtManager == null) { 134 Log.d(TAG, "Skip buildOrderedConnectedLeadDevices due to bt manager is null"); 135 return orderedDevices; 136 } 137 CachedBluetoothDeviceManager deviceManager = localBtManager.getCachedDeviceManager(); 138 for (List<BluetoothDevice> devices : groupedConnectedDevices.values()) { 139 CachedBluetoothDevice leadDevice = getLeadDevice(deviceManager, devices); 140 if (leadDevice == null) { 141 Log.d(TAG, "Skip due to no lead device"); 142 continue; 143 } 144 if (filterByInSharing 145 && !BluetoothUtils.hasConnectedBroadcastSource(leadDevice, localBtManager)) { 146 Log.d( 147 TAG, 148 "Filtered the device due to not in sharing session: " 149 + leadDevice.getDevice().getAnonymizedAddress()); 150 continue; 151 } 152 orderedDevices.add(leadDevice); 153 } 154 orderedDevices.sort(sCachedDeviceComparator); 155 return orderedDevices; 156 } 157 158 /** 159 * Get the lead device from a list of devices with same group id. 160 * 161 * @param deviceManager CachedBluetoothDeviceManager 162 * @param devices A list of devices with same group id. 163 * @return The lead device 164 */ 165 @Nullable getLeadDevice( @ullable CachedBluetoothDeviceManager deviceManager, @NonNull List<BluetoothDevice> devices)166 public static CachedBluetoothDevice getLeadDevice( 167 @Nullable CachedBluetoothDeviceManager deviceManager, 168 @NonNull List<BluetoothDevice> devices) { 169 if (deviceManager == null || devices.isEmpty()) return null; 170 List<CachedBluetoothDevice> cachedDevices = 171 devices.stream() 172 .map(device -> deviceManager.findDevice(device)) 173 .filter(Objects::nonNull) 174 .collect(toList()); 175 for (CachedBluetoothDevice cachedDevice : cachedDevices) { 176 if (!cachedDevice.getMemberDevice().isEmpty()) { 177 return cachedDevice; 178 } 179 } 180 CachedBluetoothDevice leadDevice = cachedDevices.isEmpty() ? null : cachedDevices.get(0); 181 Log.d( 182 TAG, 183 "No lead device in the group, pick arbitrary device as the lead: " 184 + (leadDevice == null 185 ? "null" 186 : leadDevice.getDevice().getAnonymizedAddress())); 187 return leadDevice; 188 } 189 190 /** 191 * Fetch a list of ordered connected lead {@link AudioSharingDeviceItem}s eligible for audio 192 * sharing. The active device is placed in the first place if it exists. The devices can be 193 * filtered by whether it is already in the audio sharing session. 194 * 195 * @param localBtManager The BT manager to provide BT functions. 196 * @param groupedConnectedDevices devices connected to broadcast assistant grouped by CSIP group 197 * id. 198 * @param filterByInSharing Whether to filter the device by if is already in the sharing 199 * session. 200 * @return A list of ordered connected devices eligible for the audio sharing. The active device 201 * is placed in the first place if it exists. 202 */ 203 @NonNull buildOrderedConnectedLeadAudioSharingDeviceItem( @ullable LocalBluetoothManager localBtManager, Map<Integer, List<BluetoothDevice>> groupedConnectedDevices, boolean filterByInSharing)204 public static List<AudioSharingDeviceItem> buildOrderedConnectedLeadAudioSharingDeviceItem( 205 @Nullable LocalBluetoothManager localBtManager, 206 Map<Integer, List<BluetoothDevice>> groupedConnectedDevices, 207 boolean filterByInSharing) { 208 return buildOrderedConnectedLeadDevices( 209 localBtManager, groupedConnectedDevices, filterByInSharing) 210 .stream() 211 .map(AudioSharingUtils::buildAudioSharingDeviceItem) 212 .collect(toList()); 213 } 214 215 /** Return if there exists active connected lead device. */ hasActiveConnectedLeadDevice( @ullable LocalBluetoothManager localBtManager)216 public static boolean hasActiveConnectedLeadDevice( 217 @Nullable LocalBluetoothManager localBtManager) { 218 CachedBluetoothDeviceManager deviceManager = 219 localBtManager == null ? null : localBtManager.getCachedDeviceManager(); 220 if (deviceManager == null) { 221 Log.d(TAG, "hasActiveConnectedLeadDevice return false due to null device manager."); 222 return false; 223 } 224 return deviceManager.getCachedDevicesCopy().stream() 225 .anyMatch(BluetoothUtils::isActiveMediaDevice); 226 } 227 228 /** Build {@link AudioSharingDeviceItem} from {@link CachedBluetoothDevice}. */ buildAudioSharingDeviceItem( CachedBluetoothDevice cachedDevice)229 public static AudioSharingDeviceItem buildAudioSharingDeviceItem( 230 CachedBluetoothDevice cachedDevice) { 231 return new AudioSharingDeviceItem( 232 cachedDevice.getName(), 233 BluetoothUtils.getGroupId(cachedDevice), 234 isActiveLeAudioDevice(cachedDevice)); 235 } 236 237 /** 238 * Check if {@link CachedBluetoothDevice} is an active le audio device. 239 * 240 * @param cachedDevice The cached bluetooth device to check. 241 * @return Whether the device is an active le audio device. 242 */ isActiveLeAudioDevice(@ullable CachedBluetoothDevice cachedDevice)243 public static boolean isActiveLeAudioDevice(@Nullable CachedBluetoothDevice cachedDevice) { 244 return cachedDevice != null && BluetoothUtils.isActiveLeAudioDevice(cachedDevice); 245 } 246 247 /** Toast message on main thread. */ toastMessage(Context context, String message)248 public static void toastMessage(Context context, String message) { 249 context.getMainExecutor() 250 .execute(() -> Toast.makeText(context, message, Toast.LENGTH_LONG).show()); 251 } 252 253 /** Add source to target sinks. */ addSourceToTargetSinks( List<BluetoothDevice> sinks, @Nullable LocalBluetoothManager localBtManager)254 public static void addSourceToTargetSinks( 255 List<BluetoothDevice> sinks, @Nullable LocalBluetoothManager localBtManager) { 256 if (localBtManager == null) { 257 Log.d(TAG, "skip addSourceToTargetDevices: LocalBluetoothManager is null!"); 258 return; 259 } 260 if (sinks.isEmpty()) { 261 Log.d(TAG, "Skip addSourceToTargetDevices. No sinks."); 262 return; 263 } 264 LocalBluetoothLeBroadcast broadcast = 265 localBtManager.getProfileManager().getLeAudioBroadcastProfile(); 266 if (broadcast == null) { 267 Log.d(TAG, "skip addSourceToTargetDevices. Broadcast profile is null."); 268 return; 269 } 270 LocalBluetoothLeBroadcastAssistant assistant = 271 localBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile(); 272 if (assistant == null) { 273 Log.d(TAG, "skip addSourceToTargetDevices. Assistant profile is null."); 274 return; 275 } 276 BluetoothLeBroadcastMetadata broadcastMetadata = 277 broadcast.getLatestBluetoothLeBroadcastMetadata(); 278 if (broadcastMetadata == null) { 279 Log.d(TAG, "skip addSourceToTargetDevices: There is no broadcastMetadata."); 280 return; 281 } 282 List<BluetoothDevice> connectedDevices = assistant.getAllConnectedDevices(); 283 for (BluetoothDevice sink : sinks) { 284 if (connectedDevices.contains(sink)) { 285 Log.d( 286 TAG, 287 "Add broadcast with broadcastId: " 288 + broadcastMetadata.getBroadcastId() 289 + " to the device: " 290 + sink.getAnonymizedAddress()); 291 assistant.addSource(sink, broadcastMetadata, /* isGroupOp= */ false); 292 } else { 293 Log.d( 294 TAG, 295 "Skip add broadcast with broadcastId: " 296 + broadcastMetadata.getBroadcastId() 297 + " to the not connected device: " 298 + sink.getAnonymizedAddress()); 299 } 300 } 301 } 302 303 /** Stops the latest broadcast. */ stopBroadcasting(@ullable LocalBluetoothManager manager)304 public static void stopBroadcasting(@Nullable LocalBluetoothManager manager) { 305 if (manager == null) { 306 Log.d(TAG, "Skip stop broadcasting due to bt manager is null"); 307 return; 308 } 309 LocalBluetoothLeBroadcast broadcast = 310 manager.getProfileManager().getLeAudioBroadcastProfile(); 311 if (broadcast == null) { 312 Log.d(TAG, "Skip stop broadcasting due to broadcast profile is null"); 313 } else { 314 broadcast.stopBroadcast(broadcast.getLatestBroadcastId()); 315 } 316 } 317 318 /** Post the runnable to main thread. */ postOnMainThread(@onNull Context context, @NonNull Runnable runnable)319 public static void postOnMainThread(@NonNull Context context, @NonNull Runnable runnable) { 320 context.getMainExecutor().execute(runnable); 321 } 322 323 /** Check if the {@link CachedBluetoothDevice} supports LE Audio profile */ isLeAudioSupported(CachedBluetoothDevice cachedDevice)324 public static boolean isLeAudioSupported(CachedBluetoothDevice cachedDevice) { 325 return cachedDevice.getProfiles().stream() 326 .anyMatch( 327 profile -> 328 profile instanceof LeAudioProfile 329 && profile.isEnabled(cachedDevice.getDevice())); 330 } 331 332 /** Check if the LE Audio related profiles ready */ isAudioSharingProfileReady( @ullable LocalBluetoothProfileManager profileManager)333 public static boolean isAudioSharingProfileReady( 334 @Nullable LocalBluetoothProfileManager profileManager) { 335 if (profileManager == null) return false; 336 LocalBluetoothLeBroadcast broadcast = profileManager.getLeAudioBroadcastProfile(); 337 if (broadcast == null || !broadcast.isProfileReady()) { 338 return false; 339 } 340 LocalBluetoothLeBroadcastAssistant assistant = 341 profileManager.getLeAudioBroadcastAssistantProfile(); 342 if (assistant == null || !assistant.isProfileReady()) { 343 return false; 344 } 345 VolumeControlProfile vc = profileManager.getVolumeControlProfile(); 346 return vc != null && vc.isProfileReady(); 347 } 348 349 /** Set {@link CachedBluetoothDevice} as user preferred primary device for call audio */ setUserPreferredPrimary( @onNull Context context, @Nullable CachedBluetoothDevice cachedDevice)350 public static void setUserPreferredPrimary( 351 @NonNull Context context, @Nullable CachedBluetoothDevice cachedDevice) { 352 if (cachedDevice == null) return; 353 if (BluetoothUtils.isAudioSharingHysteresisModeFixAvailable(context)) { 354 int groupId = BluetoothUtils.getGroupId(cachedDevice); 355 // TODO: use real key name in SettingsProvider 356 int userPreferredId = 357 Settings.Secure.getInt( 358 context.getContentResolver(), 359 BLUETOOTH_LE_BROADCAST_PRIMARY_DEVICE_GROUP_ID, 360 BluetoothCsipSetCoordinator.GROUP_ID_INVALID); 361 if (groupId != userPreferredId) { 362 Settings.Secure.putInt( 363 context.getContentResolver(), 364 BLUETOOTH_LE_BROADCAST_PRIMARY_DEVICE_GROUP_ID, 365 groupId); 366 } 367 } 368 } 369 370 /** 371 * Build audio sharing dialog log event data 372 * 373 * @param sourcePageId The source page id on which the dialog is shown. * 374 * @param pageId The page id of the dialog. 375 * @param userTriggered Indicates whether the dialog is triggered by user click. 376 * @param deviceCountInSharing The count of the devices joining the audio sharing. 377 * @param candidateDeviceCount The count of the eligible devices to join the audio sharing. 378 * @return The event data to be attached to the audio sharing action logs. 379 */ 380 @NonNull buildAudioSharingDialogEventData( int sourcePageId, int pageId, boolean userTriggered, int deviceCountInSharing, int candidateDeviceCount)381 public static Pair<Integer, Object>[] buildAudioSharingDialogEventData( 382 int sourcePageId, 383 int pageId, 384 boolean userTriggered, 385 int deviceCountInSharing, 386 int candidateDeviceCount) { 387 return new Pair[] { 388 Pair.create(METRIC_KEY_SOURCE_PAGE_ID.ordinal(), sourcePageId), 389 Pair.create(METRIC_KEY_PAGE_ID.ordinal(), pageId), 390 Pair.create(METRIC_KEY_USER_TRIGGERED.ordinal(), userTriggered ? 1 : 0), 391 Pair.create(METRIC_KEY_DEVICE_COUNT_IN_SHARING.ordinal(), deviceCountInSharing), 392 Pair.create(METRIC_KEY_CANDIDATE_DEVICE_COUNT.ordinal(), candidateDeviceCount) 393 }; 394 } 395 396 private static final Comparator<CachedBluetoothDevice> sCachedDeviceComparator = 397 (CachedBluetoothDevice d1, CachedBluetoothDevice d2) -> { 398 // Active above not inactive 399 int comparison = 400 (isActiveLeAudioDevice(d2) ? 1 : 0) - (isActiveLeAudioDevice(d1) ? 1 : 0); 401 if (comparison != 0) return comparison; 402 // Bonded above not bonded 403 comparison = 404 (d2.getBondState() == BluetoothDevice.BOND_BONDED ? 1 : 0) 405 - (d1.getBondState() == BluetoothDevice.BOND_BONDED ? 1 : 0); 406 if (comparison != 0) return comparison; 407 // Bond timestamp available above unavailable 408 comparison = 409 (d2.getBondTimestamp() != null ? 1 : 0) 410 - (d1.getBondTimestamp() != null ? 1 : 0); 411 if (comparison != 0) return comparison; 412 // Order by bond timestamp if it is available 413 // Otherwise order by device name 414 return d1.getBondTimestamp() != null 415 ? d1.getBondTimestamp().compareTo(d2.getBondTimestamp()) 416 : d1.getName().compareTo(d2.getName()); 417 }; 418 } 419