/*
* 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);
}
}
}