/* * Copyright (C) 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.settings.connecteddevice.audiosharing.audiostreams; import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.BROADCAST_ID; import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.BROADCAST_TITLE; import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.DEVICES; import static com.android.settingslib.accessibility.AccessibilityUtils.setAccessibilityServiceState; import static com.android.settingslib.bluetooth.BluetoothUtils.isAudioSharingHysteresisModeFixAvailable; import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant.LocalBluetoothLeBroadcastSourceState; import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant.LocalBluetoothLeBroadcastSourceState.DECRYPTION_FAILED; import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant.LocalBluetoothLeBroadcastSourceState.PAUSED; import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant.LocalBluetoothLeBroadcastSourceState.STREAMING; import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant.getLocalSourceState; import static java.util.Collections.emptyList; import static java.util.Collections.emptyMap; import static java.util.stream.Collectors.toMap; import android.accessibilityservice.AccessibilityServiceInfo; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothLeAudioContentMetadata; import android.bluetooth.BluetoothLeBroadcastMetadata; import android.bluetooth.BluetoothLeBroadcastReceiveState; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.res.Configuration; import android.util.Log; import android.util.Pair; import android.view.accessibility.AccessibilityManager; import androidx.annotation.VisibleForTesting; import androidx.fragment.app.FragmentActivity; import com.android.settings.R; import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils; import com.android.settingslib.bluetooth.BluetoothUtils; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant; import com.android.settingslib.bluetooth.LocalBluetoothManager; import com.android.settingslib.bluetooth.LocalBluetoothProfileManager; import com.android.settingslib.utils.ThreadUtils; import com.google.android.material.appbar.AppBarLayout; import com.google.common.base.Strings; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Function; import java.util.stream.Stream; import javax.annotation.Nullable; /** * A helper class that adds, removes and retrieves LE broadcast sources for all active sink devices. */ public class AudioStreamsHelper { private static final String TAG = "AudioStreamsHelper"; private static final boolean DEBUG = BluetoothUtils.D; private final @Nullable LocalBluetoothManager mBluetoothManager; private final @Nullable LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant; AudioStreamsHelper(@Nullable LocalBluetoothManager bluetoothManager) { mBluetoothManager = bluetoothManager; mLeBroadcastAssistant = getLeBroadcastAssistant(mBluetoothManager); } /** * Adds the specified LE broadcast source to all active sinks. * * @param source The LE broadcast metadata representing the audio source. */ @VisibleForTesting public void addSource(BluetoothLeBroadcastMetadata source) { if (mLeBroadcastAssistant == null) { Log.w(TAG, "addSource(): LeBroadcastAssistant is null!"); return; } var unused = ThreadUtils.postOnBackgroundThread( () -> { for (var sink : getConnectedBluetoothDevices( mBluetoothManager, /* inSharingOnly= */ false)) { if (DEBUG) { Log.d( TAG, "addSource(): join broadcast broadcastId" + " : " + source.getBroadcastId() + " sink : " + sink.getAddress()); } mLeBroadcastAssistant.addSource(sink, source, false); } }); } /** Removes sources from LE broadcasts associated for all active sinks based on broadcast Id. */ @VisibleForTesting public void removeSource(int broadcastId) { if (mLeBroadcastAssistant == null) { Log.w(TAG, "removeSource(): LeBroadcastAssistant is null!"); return; } var unused = ThreadUtils.postOnBackgroundThread( () -> { for (var sink : getConnectedBluetoothDevices( mBluetoothManager, /* inSharingOnly= */ true)) { if (DEBUG) { Log.d( TAG, "removeSource(): remove all sources with broadcast id :" + broadcastId + " from sink : " + sink.getAddress()); } mLeBroadcastAssistant.getAllSources(sink).stream() .filter(state -> state.getBroadcastId() == broadcastId) .forEach( state -> mLeBroadcastAssistant.removeSource( sink, state.getSourceId())); } }); } /** * Gets a map of connected broadcast IDs to their corresponding local broadcast source states. * *

If multiple sources have the same broadcast ID, the state of the source that is * {@code STREAMING} is preferred. */ public Map getConnectedBroadcastIdAndState( boolean hysteresisModeFixAvailable) { if (mBluetoothManager == null || mLeBroadcastAssistant == null) { Log.w(TAG, "getConnectedBroadcastIdAndState(): BluetoothManager or LeBroadcastAssistant " + "is null!"); return emptyMap(); } return getConnectedBluetoothDevices(mBluetoothManager, /* inSharingOnly= */ true).stream() .flatMap(sink -> mLeBroadcastAssistant.getAllSources(sink).stream()) .map(state -> new Pair<>(state.getBroadcastId(), getLocalSourceState(state))) .filter(pair -> pair.second == STREAMING || (hysteresisModeFixAvailable && pair.second == PAUSED)) .collect(toMap( p -> p.first, p -> p.second, (existingState, newState) -> existingState == STREAMING ? existingState : newState )); } /** Retrieves a list of all LE broadcast receive states keyed by each active device. */ public Map> getAllSourcesByDevice() { if (mLeBroadcastAssistant == null) { Log.w(TAG, "getAllSourcesByDevice(): LeBroadcastAssistant is null!"); return emptyMap(); } return getConnectedBluetoothDevices(mBluetoothManager, /* inSharingOnly= */ true).stream() .collect(toMap(Function.identity(), mLeBroadcastAssistant::getAllSources)); } /** Retrieves LocalBluetoothLeBroadcastAssistant. */ @Nullable public LocalBluetoothLeBroadcastAssistant getLeBroadcastAssistant() { return mLeBroadcastAssistant; } /** * Returns a {@code CachedBluetoothDevice} that is either connected to a broadcast source or is * a connected LE device. */ public static Optional getCachedBluetoothDeviceInSharingOrLeConnected( @androidx.annotation.Nullable LocalBluetoothManager manager) { if (manager == null) { Log.w( TAG, "getCachedBluetoothDeviceInSharingOrLeConnected(): LocalBluetoothManager is" + " null!"); return Optional.empty(); } var groupedDevices = AudioSharingUtils.fetchConnectedDevicesByGroupId(manager); var leadDevices = AudioSharingUtils.buildOrderedConnectedLeadDevices(manager, groupedDevices, false); if (leadDevices.isEmpty()) { Log.w(TAG, "getCachedBluetoothDeviceInSharingOrLeConnected(): No lead device!"); return Optional.empty(); } var deviceHasSource = leadDevices.stream() .filter(device -> hasBroadcastSource(device, manager)) .findFirst(); if (deviceHasSource.isPresent()) { Log.d( TAG, "getCachedBluetoothDeviceInSharingOrLeConnected(): Device has connected source" + " found: " + deviceHasSource.get().getAddress()); return deviceHasSource; } Log.d( TAG, "getCachedBluetoothDeviceInSharingOrLeConnected(): Device connected found: " + leadDevices.get(0).getAddress()); return Optional.of(leadDevices.get(0)); } /** Returns a {@code CachedBluetoothDevice} that has a connected broadcast source. */ static Optional getCachedBluetoothDeviceInSharing( @androidx.annotation.Nullable LocalBluetoothManager manager) { if (manager == null) { Log.w(TAG, "getCachedBluetoothDeviceInSharing(): LocalBluetoothManager is null!"); return Optional.empty(); } var groupedDevices = AudioSharingUtils.fetchConnectedDevicesByGroupId(manager); var leadDevices = AudioSharingUtils.buildOrderedConnectedLeadDevices(manager, groupedDevices, false); if (leadDevices.isEmpty()) { Log.w(TAG, "getCachedBluetoothDeviceInSharing(): No lead device!"); return Optional.empty(); } return leadDevices.stream() .filter(device -> hasBroadcastSource(device, manager)) .findFirst(); } /** * Check if {@link CachedBluetoothDevice} has a broadcast source that is in STREAMING, PAUSED * or DECRYPTION_FAILED state. * * @param cachedDevice The cached bluetooth device to check. * @param localBtManager The BT manager to provide BT functions. * @return Whether the device has a broadcast source. */ public static boolean hasBroadcastSource( CachedBluetoothDevice cachedDevice, LocalBluetoothManager localBtManager) { if (localBtManager == null) { Log.d(TAG, "Skip check hasBroadcastSource due to bt manager is null"); return false; } LocalBluetoothLeBroadcastAssistant assistant = localBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile(); if (assistant == null) { Log.d(TAG, "Skip check hasBroadcastSource due to assistant profile is null"); return false; } List sourceList = assistant.getAllSources(cachedDevice.getDevice()); boolean hysteresisModeFixAvailable = isAudioSharingHysteresisModeFixAvailable( localBtManager.getContext()); if (hasReceiveState(sourceList, hysteresisModeFixAvailable)) { Log.d( TAG, "Lead device has broadcast source, device = " + cachedDevice.getDevice().getAnonymizedAddress()); return true; } // Return true if member device is in broadcast. for (CachedBluetoothDevice device : cachedDevice.getMemberDevice()) { List list = assistant.getAllSources(device.getDevice()); if (hasReceiveState(list, hysteresisModeFixAvailable)) { Log.d( TAG, "Member device has broadcast source, device = " + device.getDevice().getAnonymizedAddress()); return true; } } return false; } private static boolean hasReceiveState(List states, boolean hysteresisModeFixAvailable) { return states.stream().anyMatch(state -> { var localSourceState = getLocalSourceState(state); if (hysteresisModeFixAvailable) { return localSourceState == STREAMING || localSourceState == DECRYPTION_FAILED || localSourceState == PAUSED; } return localSourceState == STREAMING || localSourceState == DECRYPTION_FAILED; }); } /** * Retrieves a list of connected Bluetooth devices that belongs to one {@link * CachedBluetoothDevice} that's either connected to a broadcast source or is a connected LE * audio device. */ static List getConnectedBluetoothDevices( @Nullable LocalBluetoothManager manager, boolean inSharingOnly) { if (manager == null) { Log.w(TAG, "getConnectedBluetoothDevices(): LocalBluetoothManager is null!"); return emptyList(); } var leBroadcastAssistant = getLeBroadcastAssistant(manager); if (leBroadcastAssistant == null) { Log.w(TAG, "getConnectedBluetoothDevices(): LeBroadcastAssistant is null!"); return emptyList(); } List connectedDevices = leBroadcastAssistant.getAllConnectedDevices(); Optional cachedBluetoothDevice = inSharingOnly ? getCachedBluetoothDeviceInSharing(manager) : getCachedBluetoothDeviceInSharingOrLeConnected(manager); List bluetoothDevices = cachedBluetoothDevice .map( c -> Stream.concat( Stream.of(c.getDevice()), c.getMemberDevice().stream() .map( CachedBluetoothDevice ::getDevice)) .filter(connectedDevices::contains) .toList()) .orElse(emptyList()); Log.d(TAG, "getConnectedBluetoothDevices() devices: " + bluetoothDevices); return bluetoothDevices; } private static @Nullable LocalBluetoothLeBroadcastAssistant getLeBroadcastAssistant( @Nullable LocalBluetoothManager manager) { if (manager == null) { Log.w(TAG, "getLeBroadcastAssistant(): LocalBluetoothManager is null!"); return null; } LocalBluetoothProfileManager profileManager = manager.getProfileManager(); if (profileManager == null) { Log.w(TAG, "getLeBroadcastAssistant(): LocalBluetoothProfileManager is null!"); return null; } return profileManager.getLeAudioBroadcastAssistantProfile(); } static String getBroadcastName(BluetoothLeBroadcastMetadata source) { String broadcastName = source.getBroadcastName(); if (broadcastName != null && !broadcastName.isEmpty()) { return broadcastName; } return source.getSubgroups().stream() .map(subgroup -> subgroup.getContentMetadata().getProgramInfo()) .filter(programInfo -> !Strings.isNullOrEmpty(programInfo)) .findFirst() .orElse("Broadcast Id: " + source.getBroadcastId()); } static String getBroadcastName(BluetoothLeBroadcastReceiveState state) { return state.getSubgroupMetadata().stream() .map(BluetoothLeAudioContentMetadata::getProgramInfo) .filter(i -> !Strings.isNullOrEmpty(i)) .findFirst() .orElse("Broadcast Id: " + state.getBroadcastId()); } void startMediaService(Context context, int audioStreamBroadcastId, String title) { List devices = getConnectedBluetoothDevices(mBluetoothManager, /* inSharingOnly= */ true); if (devices.isEmpty()) { return; } var intent = new Intent(context, AudioStreamMediaService.class); intent.putExtra(BROADCAST_ID, audioStreamBroadcastId); intent.putExtra(BROADCAST_TITLE, title); intent.putParcelableArrayListExtra(DEVICES, new ArrayList<>(devices)); context.startService(intent); } static void configureAppBarByOrientation(@Nullable FragmentActivity activity) { if (activity != null) { AppBarLayout appBarLayout = activity.findViewById(R.id.app_bar); if (appBarLayout != null) { boolean canAppBarExpand = activity.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT; appBarLayout.setExpanded(canAppBarExpand); } } } /** * Retrieves a set of enabled screen reader services that are pre-installed. * *

This method checks the accessibility manager for enabled accessibility services * and filters them based on a list of pre-installed screen reader service component names * defined in the {@code config_preinstalled_screen_reader_services} resource array.

* * @param context The context. * @return A set of {@link ComponentName} objects representing the enabled pre-installed * screen reader services, or an empty set if no services are found, or if an error occurs. */ public static Set getEnabledScreenReaderServices(Context context) { AccessibilityManager manager = context.getSystemService(AccessibilityManager.class); if (manager == null) { return Collections.emptySet(); } Set screenReaderServices = new HashSet<>(); Collections.addAll(screenReaderServices, context.getResources() .getStringArray(R.array.config_preinstalled_screen_reader_services)); if (screenReaderServices.isEmpty()) { return Collections.emptySet(); } Set enabledScreenReaderServices = new HashSet<>(); List enabledServices = manager.getEnabledAccessibilityServiceList( AccessibilityServiceInfo.FEEDBACK_ALL_MASK); for (AccessibilityServiceInfo service : enabledServices) { ComponentName componentName = service.getComponentName(); if (screenReaderServices.contains(componentName.flattenToString())) { enabledScreenReaderServices.add(componentName); } } Log.d(TAG, "getEnabledScreenReaderServices(): " + enabledScreenReaderServices); return enabledScreenReaderServices; } /** * Turns off the specified accessibility services. * * This method iterates through a set of ComponentName objects, each representing an * accessibility service, and disables them. * * @param context The application context. * @param services A set of ComponentName objects representing the services to disable. */ public static void setAccessibilityServiceOff(Context context, Set services) { for (ComponentName service : services) { Log.d(TAG, "setScreenReaderOff(): " + service); setAccessibilityServiceState(context, service, false); } } }