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