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