1 /* 2 * Copyright (C) 2024 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 android.app.settings.SettingsEnums; 20 import android.bluetooth.BluetoothCsipSetCoordinator; 21 import android.bluetooth.BluetoothDevice; 22 import android.bluetooth.BluetoothLeBroadcast; 23 import android.bluetooth.BluetoothLeBroadcastMetadata; 24 import android.bluetooth.BluetoothLeBroadcastReceiveState; 25 import android.content.Context; 26 import android.util.Log; 27 import android.util.Pair; 28 29 import androidx.annotation.NonNull; 30 import androidx.annotation.Nullable; 31 import androidx.annotation.VisibleForTesting; 32 import androidx.fragment.app.DialogFragment; 33 import androidx.fragment.app.Fragment; 34 35 import com.android.settings.bluetooth.Utils; 36 import com.android.settings.core.SubSettingLauncher; 37 import com.android.settings.dashboard.DashboardFragment; 38 import com.android.settings.overlay.FeatureFactory; 39 import com.android.settingslib.bluetooth.BluetoothUtils; 40 import com.android.settingslib.bluetooth.CachedBluetoothDevice; 41 import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast; 42 import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant; 43 import com.android.settingslib.bluetooth.LocalBluetoothManager; 44 import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; 45 import com.android.settingslib.utils.ThreadUtils; 46 47 import com.google.common.collect.ImmutableList; 48 49 import java.util.ArrayList; 50 import java.util.List; 51 import java.util.Map; 52 import java.util.Objects; 53 import java.util.concurrent.Executor; 54 55 public class AudioSharingDialogHandler { 56 private static final String TAG = "AudioSharingDialogHandler"; 57 private final Context mContext; 58 private final Fragment mHostFragment; 59 @Nullable private final LocalBluetoothManager mLocalBtManager; 60 @Nullable private final LocalBluetoothLeBroadcast mBroadcast; 61 @Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant; 62 private final MetricsFeatureProvider mMetricsFeatureProvider; 63 private List<BluetoothDevice> mTargetSinks = new ArrayList<>(); 64 65 @VisibleForTesting 66 final BluetoothLeBroadcast.Callback mBroadcastCallback = 67 new BluetoothLeBroadcast.Callback() { 68 @Override 69 public void onBroadcastStarted(int reason, int broadcastId) { 70 Log.d( 71 TAG, 72 "onBroadcastStarted(), reason = " 73 + reason 74 + ", broadcastId = " 75 + broadcastId); 76 } 77 78 @Override 79 public void onBroadcastStartFailed(int reason) { 80 Log.d(TAG, "onBroadcastStartFailed(), reason = " + reason); 81 AudioSharingUtils.toastMessage( 82 mContext, "Fail to start broadcast, reason " + reason); 83 } 84 85 @Override 86 public void onBroadcastMetadataChanged( 87 int broadcastId, @NonNull BluetoothLeBroadcastMetadata metadata) { 88 Log.d( 89 TAG, 90 "onBroadcastMetadataChanged(), broadcastId = " 91 + broadcastId 92 + ", metadata = " 93 + metadata); 94 } 95 96 @Override 97 public void onBroadcastStopped(int reason, int broadcastId) { 98 Log.d( 99 TAG, 100 "onBroadcastStopped(), reason = " 101 + reason 102 + ", broadcastId = " 103 + broadcastId); 104 } 105 106 @Override 107 public void onBroadcastStopFailed(int reason) { 108 Log.d(TAG, "onBroadcastStopFailed(), reason = " + reason); 109 AudioSharingUtils.toastMessage( 110 mContext, "Fail to stop broadcast, reason " + reason); 111 } 112 113 @Override 114 public void onBroadcastUpdated(int reason, int broadcastId) {} 115 116 @Override 117 public void onBroadcastUpdateFailed(int reason, int broadcastId) {} 118 119 @Override 120 public void onPlaybackStarted(int reason, int broadcastId) { 121 Log.d( 122 TAG, 123 "onPlaybackStarted(), reason = " 124 + reason 125 + ", broadcastId = " 126 + broadcastId); 127 if (!mTargetSinks.isEmpty()) { 128 AudioSharingUtils.addSourceToTargetSinks(mTargetSinks, mLocalBtManager); 129 new SubSettingLauncher(mContext) 130 .setDestination(AudioSharingDashboardFragment.class.getName()) 131 .setSourceMetricsCategory( 132 (mHostFragment instanceof DashboardFragment) 133 ? ((DashboardFragment) mHostFragment) 134 .getMetricsCategory() 135 : SettingsEnums.PAGE_UNKNOWN) 136 .launch(); 137 mTargetSinks = new ArrayList<>(); 138 } 139 } 140 141 @Override 142 public void onPlaybackStopped(int reason, int broadcastId) {} 143 }; 144 AudioSharingDialogHandler(@onNull Context context, @NonNull Fragment fragment)145 public AudioSharingDialogHandler(@NonNull Context context, @NonNull Fragment fragment) { 146 mContext = context; 147 mHostFragment = fragment; 148 mLocalBtManager = Utils.getLocalBluetoothManager(context); 149 mBroadcast = 150 mLocalBtManager != null 151 ? mLocalBtManager.getProfileManager().getLeAudioBroadcastProfile() 152 : null; 153 mAssistant = 154 mLocalBtManager != null 155 ? mLocalBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile() 156 : null; 157 mMetricsFeatureProvider = FeatureFactory.getFeatureFactory().getMetricsFeatureProvider(); 158 } 159 160 /** Register callbacks for dialog handler */ registerCallbacks(Executor executor)161 public void registerCallbacks(Executor executor) { 162 if (mBroadcast != null) { 163 mBroadcast.registerServiceCallBack(executor, mBroadcastCallback); 164 } 165 } 166 167 /** Unregister callbacks for dialog handler */ unregisterCallbacks()168 public void unregisterCallbacks() { 169 if (mBroadcast != null) { 170 mBroadcast.unregisterServiceCallBack(mBroadcastCallback); 171 } 172 } 173 174 /** Handle dialog pop-up logic when device is connected. */ handleDeviceConnected( @onNull CachedBluetoothDevice cachedDevice, boolean userTriggered)175 public void handleDeviceConnected( 176 @NonNull CachedBluetoothDevice cachedDevice, boolean userTriggered) { 177 String anonymizedAddress = cachedDevice.getDevice().getAnonymizedAddress(); 178 boolean isBroadcasting = isBroadcasting(); 179 boolean isLeAudioSupported = AudioSharingUtils.isLeAudioSupported(cachedDevice); 180 if (!isLeAudioSupported) { 181 Log.d(TAG, "Handle non LE audio device connected, device = " + anonymizedAddress); 182 // Handle connected ineligible (non LE audio) remote device 183 handleNonLeAudioDeviceConnected(cachedDevice, isBroadcasting, userTriggered); 184 } else { 185 Log.d(TAG, "Handle LE audio device connected, device = " + anonymizedAddress); 186 // Handle connected eligible (LE audio) remote device 187 handleLeAudioDeviceConnected(cachedDevice, isBroadcasting, userTriggered); 188 } 189 } 190 handleNonLeAudioDeviceConnected( @onNull CachedBluetoothDevice cachedDevice, boolean isBroadcasting, boolean userTriggered)191 private void handleNonLeAudioDeviceConnected( 192 @NonNull CachedBluetoothDevice cachedDevice, 193 boolean isBroadcasting, 194 boolean userTriggered) { 195 if (isBroadcasting) { 196 // Show stop audio sharing dialog when an ineligible (non LE audio) remote device 197 // connected during a sharing session. 198 Map<Integer, List<CachedBluetoothDevice>> groupedDevices = 199 AudioSharingUtils.fetchConnectedDevicesByGroupId(mLocalBtManager); 200 List<AudioSharingDeviceItem> deviceItemsInSharingSession = 201 AudioSharingUtils.buildOrderedConnectedLeadAudioSharingDeviceItem( 202 mLocalBtManager, groupedDevices, /* filterByInSharing= */ true); 203 AudioSharingStopDialogFragment.DialogEventListener listener = 204 () -> { 205 cachedDevice.setActive(); 206 AudioSharingUtils.stopBroadcasting(mLocalBtManager); 207 }; 208 Pair<Integer, Object>[] eventData = 209 AudioSharingUtils.buildAudioSharingDialogEventData( 210 SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY, 211 SettingsEnums.DIALOG_STOP_AUDIO_SHARING, 212 userTriggered, 213 deviceItemsInSharingSession.size(), 214 /* candidateDeviceCount= */ 0); 215 postOnMainThread( 216 () -> { 217 closeOpeningDialogsOtherThan(AudioSharingStopDialogFragment.tag()); 218 AudioSharingStopDialogFragment.show( 219 mHostFragment, 220 deviceItemsInSharingSession, 221 cachedDevice, 222 listener, 223 eventData); 224 }); 225 } else { 226 if (userTriggered) { 227 cachedDevice.setActive(); 228 } 229 // Do nothing for ineligible (non LE audio) remote device when no sharing session. 230 Log.d( 231 TAG, 232 "Ignore onProfileConnectionStateChanged for non LE audio without" 233 + " sharing session"); 234 } 235 } 236 handleLeAudioDeviceConnected( @onNull CachedBluetoothDevice cachedDevice, boolean isBroadcasting, boolean userTriggered)237 private void handleLeAudioDeviceConnected( 238 @NonNull CachedBluetoothDevice cachedDevice, 239 boolean isBroadcasting, 240 boolean userTriggered) { 241 Map<Integer, List<CachedBluetoothDevice>> groupedDevices = 242 AudioSharingUtils.fetchConnectedDevicesByGroupId(mLocalBtManager); 243 if (isBroadcasting) { 244 // If another device within the same is already in the sharing session, add source to 245 // the device automatically. 246 int groupId = AudioSharingUtils.getGroupId(cachedDevice); 247 if (groupedDevices.containsKey(groupId) 248 && groupedDevices.get(groupId).stream() 249 .anyMatch( 250 device -> 251 BluetoothUtils.hasConnectedBroadcastSource( 252 device, mLocalBtManager))) { 253 Log.d( 254 TAG, 255 "Automatically add another device within the same group to the sharing: " 256 + cachedDevice.getDevice().getAnonymizedAddress()); 257 if (mAssistant != null && mBroadcast != null) { 258 mAssistant.addSource( 259 cachedDevice.getDevice(), 260 mBroadcast.getLatestBluetoothLeBroadcastMetadata(), 261 /* isGroupOp= */ false); 262 } 263 return; 264 } 265 266 // Show audio sharing switch or join dialog according to device count in the sharing 267 // session. 268 List<AudioSharingDeviceItem> deviceItemsInSharingSession = 269 AudioSharingUtils.buildOrderedConnectedLeadAudioSharingDeviceItem( 270 mLocalBtManager, groupedDevices, /* filterByInSharing= */ true); 271 // Show audio sharing switch dialog when the third eligible (LE audio) remote device 272 // connected during a sharing session. 273 if (deviceItemsInSharingSession.size() >= 2) { 274 AudioSharingDisconnectDialogFragment.DialogEventListener listener = 275 (AudioSharingDeviceItem item) -> { 276 // Remove all sources from the device user clicked 277 removeSourceForGroup(item.getGroupId(), groupedDevices); 278 // Add current broadcast to the latest connected device 279 addSourceForGroup(groupId, groupedDevices); 280 }; 281 Pair<Integer, Object>[] eventData = 282 AudioSharingUtils.buildAudioSharingDialogEventData( 283 SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY, 284 SettingsEnums.DIALOG_AUDIO_SHARING_SWITCH_DEVICE, 285 userTriggered, 286 deviceItemsInSharingSession.size(), 287 /* candidateDeviceCount= */ 1); 288 postOnMainThread( 289 () -> { 290 closeOpeningDialogsOtherThan( 291 AudioSharingDisconnectDialogFragment.tag()); 292 AudioSharingDisconnectDialogFragment.show( 293 mHostFragment, 294 deviceItemsInSharingSession, 295 cachedDevice, 296 listener, 297 eventData); 298 }); 299 } else { 300 // Show audio sharing join dialog when the first or second eligible (LE audio) 301 // remote device connected during a sharing session. 302 AudioSharingJoinDialogFragment.DialogEventListener listener = 303 new AudioSharingJoinDialogFragment.DialogEventListener() { 304 @Override 305 public void onShareClick() { 306 addSourceForGroup(groupId, groupedDevices); 307 } 308 309 @Override 310 public void onCancelClick() {} 311 }; 312 Pair<Integer, Object>[] eventData = 313 AudioSharingUtils.buildAudioSharingDialogEventData( 314 SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY, 315 SettingsEnums.DIALOG_AUDIO_SHARING_ADD_DEVICE, 316 userTriggered, 317 deviceItemsInSharingSession.size(), 318 /* candidateDeviceCount= */ 1); 319 postOnMainThread( 320 () -> { 321 closeOpeningDialogsOtherThan(AudioSharingJoinDialogFragment.tag()); 322 AudioSharingJoinDialogFragment.show( 323 mHostFragment, 324 deviceItemsInSharingSession, 325 cachedDevice, 326 listener, 327 eventData); 328 }); 329 } 330 } else { 331 List<AudioSharingDeviceItem> deviceItems = new ArrayList<>(); 332 for (List<CachedBluetoothDevice> devices : groupedDevices.values()) { 333 // Use random device in the group within the sharing session to represent the group. 334 CachedBluetoothDevice device = devices.get(0); 335 if (AudioSharingUtils.getGroupId(device) 336 == AudioSharingUtils.getGroupId(cachedDevice)) { 337 continue; 338 } 339 deviceItems.add(AudioSharingUtils.buildAudioSharingDeviceItem(device)); 340 } 341 // Show audio sharing join dialog when the second eligible (LE audio) remote 342 // device connect and no sharing session. 343 if (deviceItems.size() == 1) { 344 AudioSharingJoinDialogFragment.DialogEventListener listener = 345 new AudioSharingJoinDialogFragment.DialogEventListener() { 346 @Override 347 public void onShareClick() { 348 mTargetSinks = new ArrayList<>(); 349 for (List<CachedBluetoothDevice> devices : 350 groupedDevices.values()) { 351 for (CachedBluetoothDevice device : devices) { 352 mTargetSinks.add(device.getDevice()); 353 } 354 } 355 Log.d(TAG, "Start broadcast with sinks = " + mTargetSinks.size()); 356 if (mBroadcast != null) { 357 mBroadcast.startPrivateBroadcast(); 358 } 359 } 360 361 @Override 362 public void onCancelClick() { 363 if (userTriggered) { 364 cachedDevice.setActive(); 365 } 366 } 367 }; 368 369 Pair<Integer, Object>[] eventData = 370 AudioSharingUtils.buildAudioSharingDialogEventData( 371 SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY, 372 SettingsEnums.DIALOG_START_AUDIO_SHARING, 373 userTriggered, 374 /* deviceCountInSharing= */ 0, 375 /* candidateDeviceCount= */ 2); 376 postOnMainThread( 377 () -> { 378 closeOpeningDialogsOtherThan(AudioSharingJoinDialogFragment.tag()); 379 AudioSharingJoinDialogFragment.show( 380 mHostFragment, deviceItems, cachedDevice, listener, eventData); 381 }); 382 } else if (userTriggered) { 383 cachedDevice.setActive(); 384 } 385 } 386 } 387 closeOpeningDialogsOtherThan(String tag)388 private void closeOpeningDialogsOtherThan(String tag) { 389 if (mHostFragment == null) return; 390 List<Fragment> fragments = mHostFragment.getChildFragmentManager().getFragments(); 391 for (Fragment fragment : fragments) { 392 if (fragment instanceof DialogFragment 393 && fragment.getTag() != null 394 && !fragment.getTag().equals(tag)) { 395 Log.d(TAG, "Remove staled opening dialog " + fragment.getTag()); 396 ((DialogFragment) fragment).dismiss(); 397 logDialogDismissEvent(fragment); 398 } 399 } 400 } 401 402 /** Close opening dialogs for le audio device */ closeOpeningDialogsForLeaDevice(@onNull CachedBluetoothDevice cachedDevice)403 public void closeOpeningDialogsForLeaDevice(@NonNull CachedBluetoothDevice cachedDevice) { 404 if (mHostFragment == null) return; 405 int groupId = AudioSharingUtils.getGroupId(cachedDevice); 406 List<Fragment> fragments = mHostFragment.getChildFragmentManager().getFragments(); 407 for (Fragment fragment : fragments) { 408 CachedBluetoothDevice device = getCachedBluetoothDeviceFromDialog(fragment); 409 if (device != null 410 && groupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID 411 && AudioSharingUtils.getGroupId(device) == groupId) { 412 Log.d(TAG, "Remove staled opening dialog for group " + groupId); 413 ((DialogFragment) fragment).dismiss(); 414 logDialogDismissEvent(fragment); 415 } 416 } 417 } 418 419 /** Close opening dialogs for non le audio device */ closeOpeningDialogsForNonLeaDevice(@onNull CachedBluetoothDevice cachedDevice)420 public void closeOpeningDialogsForNonLeaDevice(@NonNull CachedBluetoothDevice cachedDevice) { 421 if (mHostFragment == null) return; 422 String address = cachedDevice.getAddress(); 423 List<Fragment> fragments = mHostFragment.getChildFragmentManager().getFragments(); 424 for (Fragment fragment : fragments) { 425 CachedBluetoothDevice device = getCachedBluetoothDeviceFromDialog(fragment); 426 if (device != null && address != null && address.equals(device.getAddress())) { 427 Log.d( 428 TAG, 429 "Remove staled opening dialog for device " 430 + cachedDevice.getDevice().getAnonymizedAddress()); 431 ((DialogFragment) fragment).dismiss(); 432 logDialogDismissEvent(fragment); 433 } 434 } 435 } 436 437 @Nullable getCachedBluetoothDeviceFromDialog(Fragment fragment)438 private CachedBluetoothDevice getCachedBluetoothDeviceFromDialog(Fragment fragment) { 439 CachedBluetoothDevice device = null; 440 if (fragment instanceof AudioSharingJoinDialogFragment) { 441 device = ((AudioSharingJoinDialogFragment) fragment).getDevice(); 442 } else if (fragment instanceof AudioSharingStopDialogFragment) { 443 device = ((AudioSharingStopDialogFragment) fragment).getDevice(); 444 } else if (fragment instanceof AudioSharingDisconnectDialogFragment) { 445 device = ((AudioSharingDisconnectDialogFragment) fragment).getDevice(); 446 } 447 return device; 448 } 449 removeSourceForGroup( int groupId, Map<Integer, List<CachedBluetoothDevice>> groupedDevices)450 private void removeSourceForGroup( 451 int groupId, Map<Integer, List<CachedBluetoothDevice>> groupedDevices) { 452 if (mAssistant == null) { 453 Log.d(TAG, "Fail to add source due to null profiles, group = " + groupId); 454 return; 455 } 456 if (!groupedDevices.containsKey(groupId)) { 457 Log.d(TAG, "Fail to remove source for group " + groupId); 458 return; 459 } 460 groupedDevices.getOrDefault(groupId, ImmutableList.of()).stream() 461 .map(CachedBluetoothDevice::getDevice) 462 .filter(Objects::nonNull) 463 .forEach( 464 device -> { 465 for (BluetoothLeBroadcastReceiveState source : 466 mAssistant.getAllSources(device)) { 467 mAssistant.removeSource(device, source.getSourceId()); 468 } 469 }); 470 } 471 addSourceForGroup( int groupId, Map<Integer, List<CachedBluetoothDevice>> groupedDevices)472 private void addSourceForGroup( 473 int groupId, Map<Integer, List<CachedBluetoothDevice>> groupedDevices) { 474 if (mBroadcast == null || mAssistant == null) { 475 Log.d(TAG, "Fail to add source due to null profiles, group = " + groupId); 476 return; 477 } 478 if (!groupedDevices.containsKey(groupId)) { 479 Log.d(TAG, "Fail to add source due to invalid group id, group = " + groupId); 480 return; 481 } 482 groupedDevices.getOrDefault(groupId, ImmutableList.of()).stream() 483 .map(CachedBluetoothDevice::getDevice) 484 .filter(Objects::nonNull) 485 .forEach( 486 device -> 487 mAssistant.addSource( 488 device, 489 mBroadcast.getLatestBluetoothLeBroadcastMetadata(), 490 /* isGroupOp= */ false)); 491 } 492 postOnMainThread(@onNull Runnable runnable)493 private void postOnMainThread(@NonNull Runnable runnable) { 494 mContext.getMainExecutor().execute(runnable); 495 } 496 isBroadcasting()497 private boolean isBroadcasting() { 498 return mBroadcast != null && mBroadcast.isEnabled(null); 499 } 500 logDialogDismissEvent(Fragment fragment)501 private void logDialogDismissEvent(Fragment fragment) { 502 var unused = 503 ThreadUtils.postOnBackgroundThread( 504 () -> { 505 int pageId = SettingsEnums.PAGE_UNKNOWN; 506 if (fragment instanceof AudioSharingJoinDialogFragment) { 507 pageId = 508 ((AudioSharingJoinDialogFragment) fragment) 509 .getMetricsCategory(); 510 } else if (fragment instanceof AudioSharingStopDialogFragment) { 511 pageId = 512 ((AudioSharingStopDialogFragment) fragment) 513 .getMetricsCategory(); 514 } else if (fragment instanceof AudioSharingDisconnectDialogFragment) { 515 pageId = 516 ((AudioSharingDisconnectDialogFragment) fragment) 517 .getMetricsCategory(); 518 } 519 mMetricsFeatureProvider.action( 520 mContext, 521 SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_AUTO_DISMISS, 522 pageId); 523 }); 524 } 525 } 526