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