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.audiostreams; 18 19 import static java.util.Collections.emptyList; 20 21 import android.app.AlertDialog; 22 import android.app.settings.SettingsEnums; 23 import android.bluetooth.BluetoothLeBroadcastMetadata; 24 import android.bluetooth.BluetoothLeBroadcastReceiveState; 25 import android.bluetooth.BluetoothProfile; 26 import android.content.Context; 27 import android.util.Log; 28 29 import androidx.annotation.NonNull; 30 import androidx.lifecycle.DefaultLifecycleObserver; 31 import androidx.lifecycle.LifecycleOwner; 32 import androidx.preference.PreferenceScreen; 33 34 import com.android.settings.R; 35 import com.android.settings.bluetooth.Utils; 36 import com.android.settings.connecteddevice.ConnectedDeviceDashboardFragment; 37 import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils; 38 import com.android.settings.core.BasePreferenceController; 39 import com.android.settings.core.SubSettingLauncher; 40 import com.android.settingslib.bluetooth.BluetoothCallback; 41 import com.android.settingslib.bluetooth.BluetoothUtils; 42 import com.android.settingslib.bluetooth.CachedBluetoothDevice; 43 import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant; 44 import com.android.settingslib.bluetooth.LocalBluetoothManager; 45 import com.android.settingslib.utils.ThreadUtils; 46 47 import java.util.Comparator; 48 import java.util.concurrent.ConcurrentHashMap; 49 import java.util.concurrent.Executor; 50 import java.util.concurrent.Executors; 51 52 import javax.annotation.Nullable; 53 54 public class AudioStreamsProgressCategoryController extends BasePreferenceController 55 implements DefaultLifecycleObserver { 56 private static final String TAG = "AudioStreamsProgressCategoryController"; 57 private static final boolean DEBUG = BluetoothUtils.D; 58 private static final int UNSET_BROADCAST_ID = -1; 59 private final BluetoothCallback mBluetoothCallback = 60 new BluetoothCallback() { 61 @Override 62 public void onActiveDeviceChanged( 63 @Nullable CachedBluetoothDevice activeDevice, int bluetoothProfile) { 64 if (bluetoothProfile == BluetoothProfile.LE_AUDIO) { 65 mExecutor.execute(() -> init()); 66 } 67 } 68 }; 69 70 private final Comparator<AudioStreamPreference> mComparator = 71 Comparator.<AudioStreamPreference, Boolean>comparing( 72 p -> 73 p.getAudioStreamState() 74 == AudioStreamsProgressCategoryController 75 .AudioStreamState.SOURCE_ADDED) 76 .thenComparingInt(AudioStreamPreference::getAudioStreamRssi) 77 .reversed(); 78 79 public enum AudioStreamState { 80 UNKNOWN, 81 // When mSourceFromQrCode is present and this source has not been synced. 82 WAIT_FOR_SYNC, 83 // When source has been synced but not added to any sink. 84 SYNCED, 85 // When addSource is called for this source and waiting for response. 86 ADD_SOURCE_WAIT_FOR_RESPONSE, 87 // When addSource result in a bad code response. 88 ADD_SOURCE_BAD_CODE, 89 // When addSource result in other bad state. 90 ADD_SOURCE_FAILED, 91 // Source is added to active sink. 92 SOURCE_ADDED, 93 } 94 95 private final Executor mExecutor; 96 private final AudioStreamsProgressCategoryCallback mBroadcastAssistantCallback; 97 private final AudioStreamsHelper mAudioStreamsHelper; 98 private final MediaControlHelper mMediaControlHelper; 99 private final @Nullable LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant; 100 private final @Nullable LocalBluetoothManager mBluetoothManager; 101 private final ConcurrentHashMap<Integer, AudioStreamPreference> mBroadcastIdToPreferenceMap = 102 new ConcurrentHashMap<>(); 103 private @Nullable BluetoothLeBroadcastMetadata mSourceFromQrCode; 104 private SourceOriginForLogging mSourceFromQrCodeOriginForLogging; 105 @Nullable private AudioStreamsProgressCategoryPreference mCategoryPreference; 106 @Nullable private AudioStreamsDashboardFragment mFragment; 107 AudioStreamsProgressCategoryController(Context context, String preferenceKey)108 public AudioStreamsProgressCategoryController(Context context, String preferenceKey) { 109 super(context, preferenceKey); 110 mExecutor = Executors.newSingleThreadExecutor(); 111 mBluetoothManager = Utils.getLocalBtManager(mContext); 112 mAudioStreamsHelper = new AudioStreamsHelper(mBluetoothManager); 113 mMediaControlHelper = new MediaControlHelper(mContext, mBluetoothManager); 114 mLeBroadcastAssistant = mAudioStreamsHelper.getLeBroadcastAssistant(); 115 mBroadcastAssistantCallback = new AudioStreamsProgressCategoryCallback(this); 116 } 117 118 @Override getAvailabilityStatus()119 public int getAvailabilityStatus() { 120 return AVAILABLE; 121 } 122 123 @Override displayPreference(PreferenceScreen screen)124 public void displayPreference(PreferenceScreen screen) { 125 super.displayPreference(screen); 126 mCategoryPreference = screen.findPreference(getPreferenceKey()); 127 } 128 129 @Override onStart(@onNull LifecycleOwner owner)130 public void onStart(@NonNull LifecycleOwner owner) { 131 if (mBluetoothManager != null) { 132 mBluetoothManager.getEventManager().registerCallback(mBluetoothCallback); 133 } 134 mExecutor.execute(this::init); 135 } 136 137 @Override onStop(@onNull LifecycleOwner owner)138 public void onStop(@NonNull LifecycleOwner owner) { 139 if (mBluetoothManager != null) { 140 mBluetoothManager.getEventManager().unregisterCallback(mBluetoothCallback); 141 } 142 mExecutor.execute(this::stopScanning); 143 } 144 setFragment(AudioStreamsDashboardFragment fragment)145 void setFragment(AudioStreamsDashboardFragment fragment) { 146 mFragment = fragment; 147 } 148 149 @Nullable getFragment()150 AudioStreamsDashboardFragment getFragment() { 151 return mFragment; 152 } 153 setSourceFromQrCode( BluetoothLeBroadcastMetadata source, SourceOriginForLogging sourceOriginForLogging)154 void setSourceFromQrCode( 155 BluetoothLeBroadcastMetadata source, SourceOriginForLogging sourceOriginForLogging) { 156 if (DEBUG) { 157 Log.d(TAG, "setSourceFromQrCode(): broadcastId " + source.getBroadcastId()); 158 } 159 mSourceFromQrCode = source; 160 mSourceFromQrCodeOriginForLogging = sourceOriginForLogging; 161 } 162 setScanning(boolean isScanning)163 void setScanning(boolean isScanning) { 164 ThreadUtils.postOnMainThread( 165 () -> { 166 if (mCategoryPreference != null) mCategoryPreference.setProgress(isScanning); 167 }); 168 } 169 170 // Find preference by scanned source and decide next state. 171 // Expect one of the following: 172 // 1) No preference existed, create new preference with state SYNCED 173 // 2) WAIT_FOR_SYNC, move to ADD_SOURCE_WAIT_FOR_RESPONSE 174 // 3) SOURCE_ADDED, leave as-is handleSourceFound(BluetoothLeBroadcastMetadata source)175 void handleSourceFound(BluetoothLeBroadcastMetadata source) { 176 if (DEBUG) { 177 Log.d(TAG, "handleSourceFound()"); 178 } 179 var broadcastIdFound = source.getBroadcastId(); 180 181 if (mSourceFromQrCode != null && mSourceFromQrCode.getBroadcastId() == UNSET_BROADCAST_ID) { 182 // mSourceFromQrCode could have no broadcast Id, we fill in the broadcast Id from the 183 // scanned metadata. 184 if (DEBUG) { 185 Log.d( 186 TAG, 187 "handleSourceFound() : processing mSourceFromQrCode with broadcastId" 188 + " unset"); 189 } 190 boolean updated = 191 maybeUpdateId( 192 AudioStreamsHelper.getBroadcastName(source), source.getBroadcastId()); 193 if (updated && mBroadcastIdToPreferenceMap.containsKey(UNSET_BROADCAST_ID)) { 194 var preference = mBroadcastIdToPreferenceMap.remove(UNSET_BROADCAST_ID); 195 mBroadcastIdToPreferenceMap.put(source.getBroadcastId(), preference); 196 } 197 } 198 199 mBroadcastIdToPreferenceMap.compute( 200 broadcastIdFound, 201 (k, existingPreference) -> { 202 if (existingPreference == null) { 203 return addNewPreference( 204 source, 205 AudioStreamState.SYNCED, 206 SourceOriginForLogging.BROADCAST_SEARCH); 207 } 208 var fromState = existingPreference.getAudioStreamState(); 209 if (fromState == AudioStreamState.WAIT_FOR_SYNC && mSourceFromQrCode != null) { 210 // A preference with source founded is existed from a QR code scan. As the 211 // source is now synced, we update the preference with source from scanning 212 // as it includes complete broadcast info. 213 existingPreference.setAudioStreamMetadata( 214 new BluetoothLeBroadcastMetadata.Builder(source) 215 .setBroadcastCode(mSourceFromQrCode.getBroadcastCode()) 216 .build()); 217 moveToState( 218 existingPreference, AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE); 219 } else { 220 // A preference with source founded existed either because it's already 221 // connected (SOURCE_ADDED). Any other reason is unexpected. We update the 222 // preference with this source and won't change it's state. 223 existingPreference.setAudioStreamMetadata(source); 224 if (fromState != AudioStreamState.SOURCE_ADDED) { 225 Log.w( 226 TAG, 227 "handleSourceFound(): unexpected state : " 228 + fromState 229 + " for broadcastId : " 230 + broadcastIdFound); 231 } 232 } 233 return existingPreference; 234 }); 235 } 236 maybeUpdateId(String targetBroadcastName, int broadcastIdToSet)237 private boolean maybeUpdateId(String targetBroadcastName, int broadcastIdToSet) { 238 if (mSourceFromQrCode == null) { 239 return false; 240 } 241 if (targetBroadcastName.equals(AudioStreamsHelper.getBroadcastName(mSourceFromQrCode))) { 242 if (DEBUG) { 243 Log.d( 244 TAG, 245 "maybeUpdateId() : updating unset broadcastId for metadataFromQrCode with" 246 + " broadcastName: " 247 + AudioStreamsHelper.getBroadcastName(mSourceFromQrCode) 248 + " to broadcast Id: " 249 + broadcastIdToSet); 250 } 251 mSourceFromQrCode = 252 new BluetoothLeBroadcastMetadata.Builder(mSourceFromQrCode) 253 .setBroadcastId(broadcastIdToSet) 254 .build(); 255 return true; 256 } 257 return false; 258 } 259 260 // Find preference by mSourceFromQrCode and decide next state. 261 // Expect no preference existed, create new preference with state WAIT_FOR_SYNC handleSourceFromQrCodeIfExists()262 private void handleSourceFromQrCodeIfExists() { 263 if (DEBUG) { 264 Log.d(TAG, "handleSourceFromQrCodeIfExists()"); 265 } 266 if (mSourceFromQrCode == null) { 267 return; 268 } 269 mBroadcastIdToPreferenceMap.compute( 270 mSourceFromQrCode.getBroadcastId(), 271 (k, existingPreference) -> { 272 if (existingPreference == null) { 273 // No existing preference for this source from the QR code scan, add one and 274 // set initial state to WAIT_FOR_SYNC. 275 // Check nullability to bypass NullAway check. 276 if (mSourceFromQrCode != null) { 277 return addNewPreference( 278 mSourceFromQrCode, 279 AudioStreamState.WAIT_FOR_SYNC, 280 mSourceFromQrCodeOriginForLogging); 281 } 282 } 283 Log.w( 284 TAG, 285 "handleSourceFromQrCodeIfExists(): unexpected state : " 286 + existingPreference.getAudioStreamState() 287 + " for broadcastId : " 288 + (mSourceFromQrCode == null 289 ? "null" 290 : mSourceFromQrCode.getBroadcastId())); 291 return existingPreference; 292 }); 293 } 294 handleSourceLost(int broadcastId)295 void handleSourceLost(int broadcastId) { 296 if (DEBUG) { 297 Log.d(TAG, "handleSourceLost()"); 298 } 299 if (mAudioStreamsHelper.getAllConnectedSources().stream() 300 .anyMatch(connected -> connected.getBroadcastId() == broadcastId)) { 301 Log.d( 302 TAG, 303 "handleSourceLost() : keep this preference as the source is still connected."); 304 return; 305 } 306 var toRemove = mBroadcastIdToPreferenceMap.remove(broadcastId); 307 if (toRemove != null) { 308 ThreadUtils.postOnMainThread( 309 () -> { 310 if (mCategoryPreference != null) { 311 mCategoryPreference.removePreference(toRemove); 312 } 313 }); 314 } 315 } 316 handleSourceRemoved()317 void handleSourceRemoved() { 318 if (DEBUG) { 319 Log.d(TAG, "handleSourceRemoved()"); 320 } 321 for (var entry : mBroadcastIdToPreferenceMap.entrySet()) { 322 var preference = entry.getValue(); 323 324 // Look for preference has SOURCE_ADDED state, re-check if they are still connected. If 325 // not, means the source is removed from the sink, we move back the preference to SYNCED 326 // state. 327 if (preference.getAudioStreamState() == AudioStreamState.SOURCE_ADDED 328 && mAudioStreamsHelper.getAllConnectedSources().stream() 329 .noneMatch( 330 connected -> 331 connected.getBroadcastId() 332 == preference.getAudioStreamBroadcastId())) { 333 334 ThreadUtils.postOnMainThread( 335 () -> { 336 var metadata = preference.getAudioStreamMetadata(); 337 338 if (metadata != null) { 339 moveToState(preference, AudioStreamState.SYNCED); 340 } else { 341 handleSourceLost(preference.getAudioStreamBroadcastId()); 342 } 343 }); 344 345 return; 346 } 347 } 348 } 349 350 // Find preference by receiveState and decide next state. 351 // Expect one of the following: 352 // 1) No preference existed, create new preference with state SOURCE_ADDED 353 // 2) Any other state, move to SOURCE_ADDED handleSourceConnected(BluetoothLeBroadcastReceiveState receiveState)354 void handleSourceConnected(BluetoothLeBroadcastReceiveState receiveState) { 355 if (DEBUG) { 356 Log.d(TAG, "handleSourceConnected()"); 357 } 358 if (!AudioStreamsHelper.isConnected(receiveState)) { 359 return; 360 } 361 var broadcastIdConnected = receiveState.getBroadcastId(); 362 if (mSourceFromQrCode != null && mSourceFromQrCode.getBroadcastId() == UNSET_BROADCAST_ID) { 363 // mSourceFromQrCode could have no broadcast Id, we fill in the broadcast Id from the 364 // connected source receiveState. 365 if (DEBUG) { 366 Log.d( 367 TAG, 368 "handleSourceConnected() : processing mSourceFromQrCode with broadcastId" 369 + " unset"); 370 } 371 boolean updated = 372 maybeUpdateId( 373 AudioStreamsHelper.getBroadcastName(receiveState), 374 receiveState.getBroadcastId()); 375 if (updated && mBroadcastIdToPreferenceMap.containsKey(UNSET_BROADCAST_ID)) { 376 var preference = mBroadcastIdToPreferenceMap.remove(UNSET_BROADCAST_ID); 377 mBroadcastIdToPreferenceMap.put(receiveState.getBroadcastId(), preference); 378 } 379 } 380 381 mBroadcastIdToPreferenceMap.compute( 382 broadcastIdConnected, 383 (k, existingPreference) -> { 384 if (existingPreference == null) { 385 // No existing preference for this source even if it's already connected, 386 // add one and set initial state to SOURCE_ADDED. This could happen because 387 // we retrieves the connected source during onStart() from 388 // AudioStreamsHelper#getAllConnectedSources() even before the source is 389 // founded by scanning. 390 return addNewPreference(receiveState, AudioStreamState.SOURCE_ADDED); 391 } 392 if (existingPreference.getAudioStreamState() == AudioStreamState.WAIT_FOR_SYNC 393 && existingPreference.getAudioStreamBroadcastId() == UNSET_BROADCAST_ID 394 && mSourceFromQrCode != null) { 395 existingPreference.setAudioStreamMetadata(mSourceFromQrCode); 396 } 397 moveToState(existingPreference, AudioStreamState.SOURCE_ADDED); 398 return existingPreference; 399 }); 400 } 401 402 // Find preference by receiveState and decide next state. 403 // Expect one preference existed, move to ADD_SOURCE_BAD_CODE handleSourceConnectBadCode(BluetoothLeBroadcastReceiveState receiveState)404 void handleSourceConnectBadCode(BluetoothLeBroadcastReceiveState receiveState) { 405 if (DEBUG) { 406 Log.d(TAG, "handleSourceConnectBadCode()"); 407 } 408 if (!AudioStreamsHelper.isBadCode(receiveState)) { 409 return; 410 } 411 mBroadcastIdToPreferenceMap.computeIfPresent( 412 receiveState.getBroadcastId(), 413 (k, existingPreference) -> { 414 moveToState(existingPreference, AudioStreamState.ADD_SOURCE_BAD_CODE); 415 return existingPreference; 416 }); 417 } 418 419 // Find preference by broadcastId and decide next state. 420 // Expect one preference existed, move to ADD_SOURCE_FAILED handleSourceFailedToConnect(int broadcastId)421 void handleSourceFailedToConnect(int broadcastId) { 422 if (DEBUG) { 423 Log.d(TAG, "handleSourceFailedToConnect()"); 424 } 425 mBroadcastIdToPreferenceMap.computeIfPresent( 426 broadcastId, 427 (k, existingPreference) -> { 428 moveToState(existingPreference, AudioStreamState.ADD_SOURCE_FAILED); 429 return existingPreference; 430 }); 431 } 432 433 // Find preference by metadata and decide next state. 434 // Expect one preference existed, move to ADD_SOURCE_WAIT_FOR_RESPONSE handleSourceAddRequest( AudioStreamPreference preference, BluetoothLeBroadcastMetadata metadata)435 void handleSourceAddRequest( 436 AudioStreamPreference preference, BluetoothLeBroadcastMetadata metadata) { 437 if (DEBUG) { 438 Log.d(TAG, "handleSourceAddRequest()"); 439 } 440 mBroadcastIdToPreferenceMap.computeIfPresent( 441 metadata.getBroadcastId(), 442 (k, existingPreference) -> { 443 if (!existingPreference.equals(preference)) { 444 Log.w(TAG, "handleSourceAddedRequest(): existing preference not match"); 445 } 446 existingPreference.setAudioStreamMetadata(metadata); 447 moveToState(existingPreference, AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE); 448 return existingPreference; 449 }); 450 } 451 showToast(String msg)452 void showToast(String msg) { 453 AudioSharingUtils.toastMessage(mContext, msg); 454 } 455 init()456 private void init() { 457 mBroadcastIdToPreferenceMap.clear(); 458 boolean hasConnected = 459 AudioStreamsHelper.getCachedBluetoothDeviceInSharingOrLeConnected(mBluetoothManager) 460 .isPresent(); 461 AudioSharingUtils.postOnMainThread( 462 mContext, 463 () -> { 464 if (mCategoryPreference != null) { 465 mCategoryPreference.removeAudioStreamPreferences(); 466 mCategoryPreference.setVisible(hasConnected); 467 } 468 }); 469 if (hasConnected) { 470 startScanning(); 471 AudioSharingUtils.postOnMainThread( 472 mContext, 473 () -> { 474 if (mFragment != null) { 475 AudioStreamsDialogFragment.dismissAll(mFragment); 476 } 477 }); 478 } else { 479 stopScanning(); 480 AudioSharingUtils.postOnMainThread( 481 mContext, 482 () -> { 483 if (mFragment != null) { 484 AudioStreamsDialogFragment.show( 485 mFragment, 486 getNoLeDeviceDialog(), 487 SettingsEnums.DIALOG_AUDIO_STREAM_MAIN_NO_LE_DEVICE); 488 } 489 }); 490 } 491 } 492 startScanning()493 private void startScanning() { 494 if (mLeBroadcastAssistant == null) { 495 Log.w(TAG, "startScanning(): LeBroadcastAssistant is null!"); 496 return; 497 } 498 if (mLeBroadcastAssistant.isSearchInProgress()) { 499 Log.w(TAG, "startScanning(): scanning still in progress, stop scanning first."); 500 stopScanning(); 501 } 502 mLeBroadcastAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback); 503 mExecutor.execute( 504 () -> { 505 // Handle QR code scan, display currently connected streams then start scanning 506 // sequentially 507 handleSourceFromQrCodeIfExists(); 508 mAudioStreamsHelper 509 .getAllConnectedSources() 510 .forEach(this::handleSourceConnected); 511 mLeBroadcastAssistant.startSearchingForSources(emptyList()); 512 mMediaControlHelper.start(); 513 }); 514 } 515 stopScanning()516 private void stopScanning() { 517 if (mLeBroadcastAssistant == null) { 518 Log.w(TAG, "stopScanning(): LeBroadcastAssistant is null!"); 519 return; 520 } 521 if (mLeBroadcastAssistant.isSearchInProgress()) { 522 if (DEBUG) { 523 Log.d(TAG, "stopScanning()"); 524 } 525 mLeBroadcastAssistant.stopSearchingForSources(); 526 mLeBroadcastAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback); 527 } 528 mMediaControlHelper.stop(); 529 mSourceFromQrCode = null; 530 } 531 addNewPreference( BluetoothLeBroadcastReceiveState receiveState, AudioStreamState state)532 private AudioStreamPreference addNewPreference( 533 BluetoothLeBroadcastReceiveState receiveState, AudioStreamState state) { 534 var preference = AudioStreamPreference.fromReceiveState(mContext, receiveState); 535 moveToState(preference, state); 536 return preference; 537 } 538 addNewPreference( BluetoothLeBroadcastMetadata metadata, AudioStreamState state, SourceOriginForLogging sourceOriginForLogging)539 private AudioStreamPreference addNewPreference( 540 BluetoothLeBroadcastMetadata metadata, 541 AudioStreamState state, 542 SourceOriginForLogging sourceOriginForLogging) { 543 var preference = 544 AudioStreamPreference.fromMetadata(mContext, metadata, sourceOriginForLogging); 545 moveToState(preference, state); 546 return preference; 547 } 548 moveToState(AudioStreamPreference preference, AudioStreamState state)549 private void moveToState(AudioStreamPreference preference, AudioStreamState state) { 550 AudioStreamStateHandler stateHandler = 551 switch (state) { 552 case SYNCED -> SyncedState.getInstance(); 553 case WAIT_FOR_SYNC -> WaitForSyncState.getInstance(); 554 case ADD_SOURCE_WAIT_FOR_RESPONSE -> 555 AddSourceWaitForResponseState.getInstance(); 556 case ADD_SOURCE_BAD_CODE -> AddSourceBadCodeState.getInstance(); 557 case ADD_SOURCE_FAILED -> AddSourceFailedState.getInstance(); 558 case SOURCE_ADDED -> SourceAddedState.getInstance(); 559 default -> throw new IllegalArgumentException("Unsupported state: " + state); 560 }; 561 562 stateHandler.handleStateChange(preference, this, mAudioStreamsHelper); 563 564 // Update UI with the updated preference 565 AudioSharingUtils.postOnMainThread( 566 mContext, 567 () -> { 568 if (mCategoryPreference != null) { 569 mCategoryPreference.addAudioStreamPreference(preference, mComparator); 570 } 571 }); 572 } 573 getNoLeDeviceDialog()574 private AudioStreamsDialogFragment.DialogBuilder getNoLeDeviceDialog() { 575 return new AudioStreamsDialogFragment.DialogBuilder(mContext) 576 .setTitle(mContext.getString(R.string.audio_streams_dialog_no_le_device_title)) 577 .setSubTitle2( 578 mContext.getString(R.string.audio_streams_dialog_no_le_device_subtitle)) 579 .setLeftButtonText(mContext.getString(R.string.audio_streams_dialog_close)) 580 .setLeftButtonOnClickListener(AlertDialog::dismiss) 581 .setRightButtonText( 582 mContext.getString(R.string.audio_streams_dialog_no_le_device_button)) 583 .setRightButtonOnClickListener( 584 dialog -> { 585 new SubSettingLauncher(mContext) 586 .setDestination( 587 ConnectedDeviceDashboardFragment.class.getName()) 588 .setSourceMetricsCategory( 589 SettingsEnums.DIALOG_AUDIO_STREAM_MAIN_NO_LE_DEVICE) 590 .launch(); 591 dialog.dismiss(); 592 }); 593 } 594 } 595