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 25 import android.bluetooth.BluetoothAdapter; 26 import android.bluetooth.BluetoothCsipSetCoordinator; 27 import android.bluetooth.BluetoothDevice; 28 import android.bluetooth.BluetoothLeBroadcastMetadata; 29 import android.bluetooth.BluetoothProfile; 30 import android.bluetooth.BluetoothStatusCodes; 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.LocalBluetoothProfile; 48 import com.android.settingslib.bluetooth.LocalBluetoothProfileManager; 49 import com.android.settingslib.bluetooth.VolumeControlProfile; 50 import com.android.settingslib.flags.Flags; 51 52 import java.util.ArrayList; 53 import java.util.HashMap; 54 import java.util.List; 55 import java.util.Map; 56 import java.util.stream.Collectors; 57 58 public class AudioSharingUtils { 59 public static final String SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID = 60 "bluetooth_le_broadcast_fallback_active_group_id"; 61 private static final String TAG = "AudioSharingUtils"; 62 private static final boolean DEBUG = BluetoothUtils.D; 63 64 public enum MetricKey { 65 METRIC_KEY_SOURCE_PAGE_ID, 66 METRIC_KEY_PAGE_ID, 67 METRIC_KEY_USER_TRIGGERED, 68 METRIC_KEY_DEVICE_COUNT_IN_SHARING, 69 METRIC_KEY_CANDIDATE_DEVICE_COUNT 70 } 71 72 /** 73 * Fetch {@link CachedBluetoothDevice}s connected to the broadcast assistant. The devices are 74 * grouped by CSIP group id. 75 * 76 * @param localBtManager The BT manager to provide BT functions. 77 * @return A map of connected devices grouped by CSIP group id. 78 */ fetchConnectedDevicesByGroupId( @ullable LocalBluetoothManager localBtManager)79 public static Map<Integer, List<CachedBluetoothDevice>> fetchConnectedDevicesByGroupId( 80 @Nullable LocalBluetoothManager localBtManager) { 81 Map<Integer, List<CachedBluetoothDevice>> groupedDevices = new HashMap<>(); 82 if (localBtManager == null) { 83 Log.d(TAG, "Skip fetchConnectedDevicesByGroupId due to bt manager is null"); 84 return groupedDevices; 85 } 86 LocalBluetoothLeBroadcastAssistant assistant = 87 localBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile(); 88 if (assistant == null) { 89 Log.d(TAG, "Skip fetchConnectedDevicesByGroupId due to assistant profile is null"); 90 return groupedDevices; 91 } 92 List<BluetoothDevice> connectedDevices = 93 assistant.getDevicesMatchingConnectionStates( 94 new int[] {BluetoothProfile.STATE_CONNECTED}); 95 CachedBluetoothDeviceManager cacheManager = localBtManager.getCachedDeviceManager(); 96 for (BluetoothDevice device : connectedDevices) { 97 CachedBluetoothDevice cachedDevice = cacheManager.findDevice(device); 98 if (cachedDevice == null) { 99 Log.d(TAG, "Skip device due to not being cached: " + device.getAnonymizedAddress()); 100 continue; 101 } 102 int groupId = getGroupId(cachedDevice); 103 if (groupId == BluetoothCsipSetCoordinator.GROUP_ID_INVALID) { 104 Log.d( 105 TAG, 106 "Skip device due to no valid group id: " + device.getAnonymizedAddress()); 107 continue; 108 } 109 if (!groupedDevices.containsKey(groupId)) { 110 groupedDevices.put(groupId, new ArrayList<>()); 111 } 112 groupedDevices.get(groupId).add(cachedDevice); 113 } 114 if (DEBUG) { 115 Log.d(TAG, "fetchConnectedDevicesByGroupId: " + groupedDevices); 116 } 117 return groupedDevices; 118 } 119 120 /** 121 * Fetch a list of ordered connected lead {@link CachedBluetoothDevice}s eligible for audio 122 * sharing. The active device is placed in the first place if it exists. The devices can be 123 * filtered by whether it is already in the audio sharing session. 124 * 125 * @param localBtManager The BT manager to provide BT functions. * 126 * @param groupedConnectedDevices devices connected to broadcast assistant grouped by CSIP group 127 * id. 128 * @param filterByInSharing Whether to filter the device by if is already in the sharing 129 * session. 130 * @return A list of ordered connected devices eligible for the audio sharing. The active device 131 * is placed in the first place if it exists. 132 */ buildOrderedConnectedLeadDevices( @ullable LocalBluetoothManager localBtManager, Map<Integer, List<CachedBluetoothDevice>> groupedConnectedDevices, boolean filterByInSharing)133 public static List<CachedBluetoothDevice> buildOrderedConnectedLeadDevices( 134 @Nullable LocalBluetoothManager localBtManager, 135 Map<Integer, List<CachedBluetoothDevice>> groupedConnectedDevices, 136 boolean filterByInSharing) { 137 List<CachedBluetoothDevice> orderedDevices = new ArrayList<>(); 138 for (List<CachedBluetoothDevice> devices : groupedConnectedDevices.values()) { 139 CachedBluetoothDevice leadDevice = getLeadDevice(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( 155 (CachedBluetoothDevice d1, CachedBluetoothDevice d2) -> { 156 // Active above not inactive 157 int comparison = 158 (isActiveLeAudioDevice(d2) ? 1 : 0) 159 - (isActiveLeAudioDevice(d1) ? 1 : 0); 160 if (comparison != 0) return comparison; 161 // Bonded above not bonded 162 comparison = 163 (d2.getBondState() == BluetoothDevice.BOND_BONDED ? 1 : 0) 164 - (d1.getBondState() == BluetoothDevice.BOND_BONDED ? 1 : 0); 165 if (comparison != 0) return comparison; 166 // Bond timestamp available above unavailable 167 comparison = 168 (d2.getBondTimestamp() != null ? 1 : 0) 169 - (d1.getBondTimestamp() != null ? 1 : 0); 170 if (comparison != 0) return comparison; 171 // Order by bond timestamp if it is available 172 // Otherwise order by device name 173 return d1.getBondTimestamp() != null 174 ? d1.getBondTimestamp().compareTo(d2.getBondTimestamp()) 175 : d1.getName().compareTo(d2.getName()); 176 }); 177 return orderedDevices; 178 } 179 180 /** 181 * Get the lead device from a list of devices with same group id. 182 * 183 * @param devices A list of devices with same group id. 184 * @return The lead device 185 */ 186 @Nullable getLeadDevice( @onNull List<CachedBluetoothDevice> devices)187 public static CachedBluetoothDevice getLeadDevice( 188 @NonNull List<CachedBluetoothDevice> devices) { 189 if (devices.isEmpty()) return null; 190 for (CachedBluetoothDevice device : devices) { 191 if (!device.getMemberDevice().isEmpty()) { 192 return device; 193 } 194 } 195 CachedBluetoothDevice leadDevice = devices.get(0); 196 Log.d( 197 TAG, 198 "No lead device in the group, pick arbitrary device as the lead: " 199 + leadDevice.getDevice().getAnonymizedAddress()); 200 return leadDevice; 201 } 202 203 /** 204 * Fetch a list of ordered connected lead {@link AudioSharingDeviceItem}s eligible for audio 205 * sharing. The active device is placed in the first place if it exists. The devices can be 206 * filtered by whether it is already in the audio sharing session. 207 * 208 * @param localBtManager The BT manager to provide BT functions. * 209 * @param groupedConnectedDevices devices connected to broadcast assistant grouped by CSIP group 210 * id. 211 * @param filterByInSharing Whether to filter the device by if is already in the sharing 212 * session. 213 * @return A list of ordered connected devices eligible for the audio sharing. The active device 214 * is placed in the first place if it exists. 215 */ 216 @NonNull buildOrderedConnectedLeadAudioSharingDeviceItem( @ullable LocalBluetoothManager localBtManager, Map<Integer, List<CachedBluetoothDevice>> groupedConnectedDevices, boolean filterByInSharing)217 public static List<AudioSharingDeviceItem> buildOrderedConnectedLeadAudioSharingDeviceItem( 218 @Nullable LocalBluetoothManager localBtManager, 219 Map<Integer, List<CachedBluetoothDevice>> groupedConnectedDevices, 220 boolean filterByInSharing) { 221 return buildOrderedConnectedLeadDevices( 222 localBtManager, groupedConnectedDevices, filterByInSharing) 223 .stream() 224 .map(AudioSharingUtils::buildAudioSharingDeviceItem) 225 .collect(Collectors.toList()); 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 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(CachedBluetoothDevice cachedDevice)243 public static boolean isActiveLeAudioDevice(CachedBluetoothDevice cachedDevice) { 244 return 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 /** Returns if the le audio sharing is enabled. */ isFeatureEnabled()254 public static boolean isFeatureEnabled() { 255 BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); 256 return Flags.enableLeAudioSharing() 257 && adapter.isLeAudioBroadcastSourceSupported() 258 == BluetoothStatusCodes.FEATURE_SUPPORTED 259 && adapter.isLeAudioBroadcastAssistantSupported() 260 == BluetoothStatusCodes.FEATURE_SUPPORTED; 261 } 262 263 /** Add source to target sinks. */ addSourceToTargetSinks( List<BluetoothDevice> sinks, @Nullable LocalBluetoothManager localBtManager)264 public static void addSourceToTargetSinks( 265 List<BluetoothDevice> sinks, @Nullable LocalBluetoothManager localBtManager) { 266 if (localBtManager == null) { 267 Log.d(TAG, "skip addSourceToTargetDevices: LocalBluetoothManager is null!"); 268 return; 269 } 270 if (sinks.isEmpty()) { 271 Log.d(TAG, "Skip addSourceToTargetDevices. No sinks."); 272 return; 273 } 274 LocalBluetoothLeBroadcast broadcast = 275 localBtManager.getProfileManager().getLeAudioBroadcastProfile(); 276 if (broadcast == null) { 277 Log.d(TAG, "skip addSourceToTargetDevices. Broadcast profile is null."); 278 return; 279 } 280 LocalBluetoothLeBroadcastAssistant assistant = 281 localBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile(); 282 if (assistant == null) { 283 Log.d(TAG, "skip addSourceToTargetDevices. Assistant profile is null."); 284 return; 285 } 286 BluetoothLeBroadcastMetadata broadcastMetadata = 287 broadcast.getLatestBluetoothLeBroadcastMetadata(); 288 if (broadcastMetadata == null) { 289 Log.d(TAG, "skip addSourceToTargetDevices: There is no broadcastMetadata."); 290 return; 291 } 292 List<BluetoothDevice> connectedDevices = 293 assistant.getDevicesMatchingConnectionStates( 294 new int[] {BluetoothProfile.STATE_CONNECTED}); 295 for (BluetoothDevice sink : sinks) { 296 if (connectedDevices.contains(sink)) { 297 Log.d( 298 TAG, 299 "Add broadcast with broadcastId: " 300 + broadcastMetadata.getBroadcastId() 301 + " to the device: " 302 + sink.getAnonymizedAddress()); 303 assistant.addSource(sink, broadcastMetadata, /* isGroupOp= */ false); 304 } else { 305 Log.d( 306 TAG, 307 "Skip add broadcast with broadcastId: " 308 + broadcastMetadata.getBroadcastId() 309 + " to the not connected device: " 310 + sink.getAnonymizedAddress()); 311 } 312 } 313 } 314 315 /** Returns if the broadcast is on-going. */ isBroadcasting(@ullable LocalBluetoothManager manager)316 public static boolean isBroadcasting(@Nullable LocalBluetoothManager manager) { 317 if (manager == null) return false; 318 LocalBluetoothLeBroadcast broadcast = 319 manager.getProfileManager().getLeAudioBroadcastProfile(); 320 return broadcast != null && broadcast.isEnabled(null); 321 } 322 323 /** Stops the latest broadcast. */ stopBroadcasting(@ullable LocalBluetoothManager manager)324 public static void stopBroadcasting(@Nullable LocalBluetoothManager manager) { 325 if (manager == null) { 326 Log.d(TAG, "Skip stop broadcasting due to bt manager is null"); 327 return; 328 } 329 LocalBluetoothLeBroadcast broadcast = 330 manager.getProfileManager().getLeAudioBroadcastProfile(); 331 if (broadcast == null) { 332 Log.d(TAG, "Skip stop broadcasting due to broadcast profile is null"); 333 } else { 334 broadcast.stopBroadcast(broadcast.getLatestBroadcastId()); 335 } 336 } 337 338 /** 339 * Get CSIP group id for {@link CachedBluetoothDevice}. 340 * 341 * <p>If CachedBluetoothDevice#getGroupId is invalid, fetch group id from 342 * LeAudioProfile#getGroupId. 343 */ getGroupId(CachedBluetoothDevice cachedDevice)344 public static int getGroupId(CachedBluetoothDevice cachedDevice) { 345 int groupId = cachedDevice.getGroupId(); 346 String anonymizedAddress = cachedDevice.getDevice().getAnonymizedAddress(); 347 if (groupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID) { 348 Log.d(TAG, "getGroupId by CSIP profile for device: " + anonymizedAddress); 349 return groupId; 350 } 351 for (LocalBluetoothProfile profile : cachedDevice.getProfiles()) { 352 if (profile instanceof LeAudioProfile) { 353 Log.d(TAG, "getGroupId by LEA profile for device: " + anonymizedAddress); 354 return ((LeAudioProfile) profile).getGroupId(cachedDevice.getDevice()); 355 } 356 } 357 Log.d(TAG, "getGroupId return invalid id for device: " + anonymizedAddress); 358 return BluetoothCsipSetCoordinator.GROUP_ID_INVALID; 359 } 360 361 /** Get the fallback active group id from SettingsProvider. */ getFallbackActiveGroupId(@onNull Context context)362 public static int getFallbackActiveGroupId(@NonNull Context context) { 363 return Settings.Secure.getInt( 364 context.getContentResolver(), 365 SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID, 366 BluetoothCsipSetCoordinator.GROUP_ID_INVALID); 367 } 368 369 /** Post the runnable to main thread. */ postOnMainThread(@onNull Context context, @NonNull Runnable runnable)370 public static void postOnMainThread(@NonNull Context context, @NonNull Runnable runnable) { 371 context.getMainExecutor().execute(runnable); 372 } 373 374 /** Check if the {@link CachedBluetoothDevice} supports LE Audio profile */ isLeAudioSupported(CachedBluetoothDevice cachedDevice)375 public static boolean isLeAudioSupported(CachedBluetoothDevice cachedDevice) { 376 return cachedDevice.getProfiles().stream() 377 .anyMatch( 378 profile -> 379 profile instanceof LeAudioProfile 380 && profile.isEnabled(cachedDevice.getDevice())); 381 } 382 383 /** Check if the LE Audio related profiles ready */ isAudioSharingProfileReady( @ullable LocalBluetoothProfileManager profileManager)384 public static boolean isAudioSharingProfileReady( 385 @Nullable LocalBluetoothProfileManager profileManager) { 386 if (profileManager == null) return false; 387 LocalBluetoothLeBroadcast broadcast = profileManager.getLeAudioBroadcastProfile(); 388 if (broadcast == null || !broadcast.isProfileReady()) { 389 return false; 390 } 391 LocalBluetoothLeBroadcastAssistant assistant = 392 profileManager.getLeAudioBroadcastAssistantProfile(); 393 if (assistant == null || !assistant.isProfileReady()) { 394 return false; 395 } 396 VolumeControlProfile vc = profileManager.getVolumeControlProfile(); 397 return vc != null && vc.isProfileReady(); 398 } 399 400 /** 401 * Build audio sharing dialog log event data 402 * 403 * @param sourcePageId The source page id on which the dialog is shown. * 404 * @param pageId The page id of the dialog. 405 * @param userTriggered Indicates whether the dialog is triggered by user click. 406 * @param deviceCountInSharing The count of the devices joining the audio sharing. 407 * @param candidateDeviceCount The count of the eligible devices to join the audio sharing. 408 * @return The event data to be attached to the audio sharing action logs. 409 */ 410 @NonNull buildAudioSharingDialogEventData( int sourcePageId, int pageId, boolean userTriggered, int deviceCountInSharing, int candidateDeviceCount)411 public static Pair<Integer, Object>[] buildAudioSharingDialogEventData( 412 int sourcePageId, 413 int pageId, 414 boolean userTriggered, 415 int deviceCountInSharing, 416 int candidateDeviceCount) { 417 return new Pair[] { 418 Pair.create(METRIC_KEY_SOURCE_PAGE_ID.ordinal(), sourcePageId), 419 Pair.create(METRIC_KEY_PAGE_ID.ordinal(), pageId), 420 Pair.create(METRIC_KEY_USER_TRIGGERED.ordinal(), userTriggered ? 1 : 0), 421 Pair.create(METRIC_KEY_DEVICE_COUNT_IN_SHARING.ordinal(), deviceCountInSharing), 422 Pair.create(METRIC_KEY_CANDIDATE_DEVICE_COUNT.ordinal(), candidateDeviceCount) 423 }; 424 } 425 } 426