blob: eb997badca527cb363bb1acf2baaf4ae8430a44c [file] [log] [blame]
/*
* 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.server.media;
import static android.bluetooth.BluetoothAdapter.ACTIVE_DEVICE_AUDIO;
import static android.bluetooth.BluetoothAdapter.STATE_CONNECTED;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.bluetooth.BluetoothA2dp;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothHearingAid;
import android.bluetooth.BluetoothLeAudio;
import android.bluetooth.BluetoothProfile;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.media.AudioManager;
import android.media.AudioSystem;
import android.media.MediaRoute2Info;
import android.os.UserHandle;
import android.text.TextUtils;
import android.util.Slog;
import android.util.SparseBooleanArray;
import android.util.SparseIntArray;
import com.android.internal.R;
import com.android.internal.annotations.VisibleForTesting;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Controls bluetooth routes and provides selected route override.
*
* <p>The controller offers similar functionality to {@link LegacyBluetoothRouteController} but does
* not support routes selection logic. Instead, relies on external clients to make a decision
* about currently selected route.
*
* <p>Selected route override should be used by {@link AudioManager} which is aware of Audio
* Policies.
*/
/* package */ class AudioPoliciesBluetoothRouteController
implements BluetoothRouteController {
private static final String TAG = "APBtRouteController";
private static final String HEARING_AID_ROUTE_ID_PREFIX = "HEARING_AID_";
private static final String LE_AUDIO_ROUTE_ID_PREFIX = "LE_AUDIO_";
@NonNull
private final AdapterStateChangedReceiver mAdapterStateChangedReceiver =
new AdapterStateChangedReceiver();
@NonNull
private final DeviceStateChangedReceiver mDeviceStateChangedReceiver =
new DeviceStateChangedReceiver();
@NonNull
private final Map<String, BluetoothRouteInfo> mBluetoothRoutes = new HashMap<>();
@NonNull
private final SparseIntArray mVolumeMap = new SparseIntArray();
@NonNull
private final Context mContext;
@NonNull
private final BluetoothAdapter mBluetoothAdapter;
@NonNull
private final BluetoothRouteController.BluetoothRoutesUpdatedListener mListener;
@NonNull
private final BluetoothProfileMonitor mBluetoothProfileMonitor;
@NonNull
private final AudioManager mAudioManager;
@Nullable
private BluetoothRouteInfo mSelectedBluetoothRoute;
AudioPoliciesBluetoothRouteController(@NonNull Context context,
@NonNull BluetoothAdapter bluetoothAdapter,
@NonNull BluetoothRouteController.BluetoothRoutesUpdatedListener listener) {
this(context, bluetoothAdapter,
new BluetoothProfileMonitor(context, bluetoothAdapter), listener);
}
@VisibleForTesting
AudioPoliciesBluetoothRouteController(@NonNull Context context,
@NonNull BluetoothAdapter bluetoothAdapter,
@NonNull BluetoothProfileMonitor bluetoothProfileMonitor,
@NonNull BluetoothRouteController.BluetoothRoutesUpdatedListener listener) {
Objects.requireNonNull(context);
Objects.requireNonNull(bluetoothAdapter);
Objects.requireNonNull(bluetoothProfileMonitor);
Objects.requireNonNull(listener);
mContext = context;
mBluetoothAdapter = bluetoothAdapter;
mBluetoothProfileMonitor = bluetoothProfileMonitor;
mAudioManager = mContext.getSystemService(AudioManager.class);
mListener = listener;
updateBluetoothRoutes();
}
@Override
public void start(UserHandle user) {
mBluetoothProfileMonitor.start();
IntentFilter adapterStateChangedIntentFilter = new IntentFilter();
adapterStateChangedIntentFilter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED);
mContext.registerReceiverAsUser(mAdapterStateChangedReceiver, user,
adapterStateChangedIntentFilter, null, null);
IntentFilter deviceStateChangedIntentFilter = new IntentFilter();
deviceStateChangedIntentFilter.addAction(BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED);
deviceStateChangedIntentFilter.addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED);
deviceStateChangedIntentFilter.addAction(BluetoothHearingAid.ACTION_ACTIVE_DEVICE_CHANGED);
deviceStateChangedIntentFilter.addAction(
BluetoothHearingAid.ACTION_CONNECTION_STATE_CHANGED);
deviceStateChangedIntentFilter.addAction(
BluetoothLeAudio.ACTION_LE_AUDIO_CONNECTION_STATE_CHANGED);
deviceStateChangedIntentFilter.addAction(
BluetoothLeAudio.ACTION_LE_AUDIO_ACTIVE_DEVICE_CHANGED);
mContext.registerReceiverAsUser(mDeviceStateChangedReceiver, user,
deviceStateChangedIntentFilter, null, null);
}
@Override
public void stop() {
mContext.unregisterReceiver(mAdapterStateChangedReceiver);
mContext.unregisterReceiver(mDeviceStateChangedReceiver);
}
@Override
public boolean selectRoute(@Nullable String deviceAddress) {
synchronized (this) {
// Fetch all available devices in order to avoid race conditions with Bluetooth stack.
updateBluetoothRoutes();
if (deviceAddress == null) {
mSelectedBluetoothRoute = null;
return true;
}
BluetoothRouteInfo bluetoothRouteInfo = mBluetoothRoutes.get(deviceAddress);
if (bluetoothRouteInfo == null) {
Slog.w(TAG, "Cannot find bluetooth route for " + deviceAddress);
return false;
}
mSelectedBluetoothRoute = bluetoothRouteInfo;
setRouteConnectionState(mSelectedBluetoothRoute, STATE_CONNECTED);
updateConnectivityStateForDevicesInTheSameGroup();
return true;
}
}
/**
* Updates connectivity state for devices in the same devices group.
*
* <p>{@link BluetoothProfile#LE_AUDIO} and {@link BluetoothProfile#HEARING_AID} support
* grouping devices. Devices that belong to the same group should have the same routeId but
* different physical address.
*
* <p>In case one of the devices from the group is selected then other devices should also
* reflect this by changing their connectivity status to
* {@link MediaRoute2Info#CONNECTION_STATE_CONNECTED}.
*/
private void updateConnectivityStateForDevicesInTheSameGroup() {
synchronized (this) {
for (BluetoothRouteInfo btRoute : mBluetoothRoutes.values()) {
if (TextUtils.equals(btRoute.mRoute.getId(), mSelectedBluetoothRoute.mRoute.getId())
&& !TextUtils.equals(btRoute.mBtDevice.getAddress(),
mSelectedBluetoothRoute.mBtDevice.getAddress())) {
setRouteConnectionState(btRoute, STATE_CONNECTED);
}
}
}
}
@Override
public void transferTo(@Nullable String routeId) {
if (routeId == null) {
mBluetoothAdapter.removeActiveDevice(ACTIVE_DEVICE_AUDIO);
return;
}
BluetoothRouteInfo btRouteInfo = findBluetoothRouteWithRouteId(routeId);
if (btRouteInfo == null) {
Slog.w(TAG, "transferTo: Unknown route. ID=" + routeId);
return;
}
mBluetoothAdapter.setActiveDevice(btRouteInfo.mBtDevice, ACTIVE_DEVICE_AUDIO);
}
@Nullable
private BluetoothRouteInfo findBluetoothRouteWithRouteId(@Nullable String routeId) {
if (routeId == null) {
return null;
}
synchronized (this) {
for (BluetoothRouteInfo btRouteInfo : mBluetoothRoutes.values()) {
if (TextUtils.equals(btRouteInfo.mRoute.getId(), routeId)) {
return btRouteInfo;
}
}
}
return null;
}
private void updateBluetoothRoutes() {
Set<BluetoothDevice> bondedDevices = mBluetoothAdapter.getBondedDevices();
if (bondedDevices == null) {
return;
}
synchronized (this) {
mBluetoothRoutes.clear();
// We need to query all available to BT stack devices in order to avoid inconsistency
// between external services, like, AndroidManager, and BT stack.
for (BluetoothDevice device : bondedDevices) {
if (isDeviceConnected(device)) {
BluetoothRouteInfo newBtRoute = createBluetoothRoute(device);
if (newBtRoute.mConnectedProfiles.size() > 0) {
mBluetoothRoutes.put(device.getAddress(), newBtRoute);
}
}
}
}
}
@VisibleForTesting
/* package */ boolean isDeviceConnected(@NonNull BluetoothDevice device) {
return device.isConnected();
}
@Nullable
@Override
public MediaRoute2Info getSelectedRoute() {
synchronized (this) {
if (mSelectedBluetoothRoute == null) {
return null;
}
return mSelectedBluetoothRoute.mRoute;
}
}
@NonNull
@Override
public List<MediaRoute2Info> getTransferableRoutes() {
List<MediaRoute2Info> routes = getAllBluetoothRoutes();
synchronized (this) {
if (mSelectedBluetoothRoute != null) {
routes.remove(mSelectedBluetoothRoute.mRoute);
}
}
return routes;
}
@NonNull
@Override
public List<MediaRoute2Info> getAllBluetoothRoutes() {
List<MediaRoute2Info> routes = new ArrayList<>();
List<String> routeIds = new ArrayList<>();
MediaRoute2Info selectedRoute = getSelectedRoute();
if (selectedRoute != null) {
routes.add(selectedRoute);
routeIds.add(selectedRoute.getId());
}
synchronized (this) {
for (BluetoothRouteInfo btRoute : mBluetoothRoutes.values()) {
// A pair of hearing aid devices or having the same hardware address
if (routeIds.contains(btRoute.mRoute.getId())) {
continue;
}
routes.add(btRoute.mRoute);
routeIds.add(btRoute.mRoute.getId());
}
}
return routes;
}
@Override
public boolean updateVolumeForDevices(int devices, int volume) {
int routeType;
if ((devices & (AudioSystem.DEVICE_OUT_HEARING_AID)) != 0) {
routeType = MediaRoute2Info.TYPE_HEARING_AID;
} else if ((devices & (AudioManager.DEVICE_OUT_BLUETOOTH_A2DP
| AudioManager.DEVICE_OUT_BLUETOOTH_A2DP_HEADPHONES
| AudioManager.DEVICE_OUT_BLUETOOTH_A2DP_SPEAKER)) != 0) {
routeType = MediaRoute2Info.TYPE_BLUETOOTH_A2DP;
} else if ((devices & (AudioManager.DEVICE_OUT_BLE_HEADSET)) != 0) {
routeType = MediaRoute2Info.TYPE_BLE_HEADSET;
} else {
return false;
}
synchronized (this) {
mVolumeMap.put(routeType, volume);
if (mSelectedBluetoothRoute == null
|| mSelectedBluetoothRoute.mRoute.getType() != routeType) {
return false;
}
mSelectedBluetoothRoute.mRoute =
new MediaRoute2Info.Builder(mSelectedBluetoothRoute.mRoute)
.setVolume(volume)
.build();
}
notifyBluetoothRoutesUpdated();
return true;
}
private void notifyBluetoothRoutesUpdated() {
mListener.onBluetoothRoutesUpdated(getAllBluetoothRoutes());
}
private BluetoothRouteInfo createBluetoothRoute(BluetoothDevice device) {
BluetoothRouteInfo
newBtRoute = new BluetoothRouteInfo();
newBtRoute.mBtDevice = device;
String routeId = device.getAddress();
String deviceName = device.getName();
if (TextUtils.isEmpty(deviceName)) {
deviceName = mContext.getResources().getText(R.string.unknownName).toString();
}
int type = MediaRoute2Info.TYPE_BLUETOOTH_A2DP;
newBtRoute.mConnectedProfiles = new SparseBooleanArray();
if (mBluetoothProfileMonitor.isProfileSupported(BluetoothProfile.A2DP, device)) {
newBtRoute.mConnectedProfiles.put(BluetoothProfile.A2DP, true);
}
if (mBluetoothProfileMonitor.isProfileSupported(BluetoothProfile.HEARING_AID, device)) {
newBtRoute.mConnectedProfiles.put(BluetoothProfile.HEARING_AID, true);
// Intentionally assign the same ID for a pair of devices to publish only one of them.
routeId = HEARING_AID_ROUTE_ID_PREFIX
+ mBluetoothProfileMonitor.getGroupId(BluetoothProfile.HEARING_AID, device);
type = MediaRoute2Info.TYPE_HEARING_AID;
}
if (mBluetoothProfileMonitor.isProfileSupported(BluetoothProfile.LE_AUDIO, device)) {
newBtRoute.mConnectedProfiles.put(BluetoothProfile.LE_AUDIO, true);
routeId = LE_AUDIO_ROUTE_ID_PREFIX
+ mBluetoothProfileMonitor.getGroupId(BluetoothProfile.LE_AUDIO, device);
type = MediaRoute2Info.TYPE_BLE_HEADSET;
}
// Current volume will be set when connected.
newBtRoute.mRoute = new MediaRoute2Info.Builder(routeId, deviceName)
.addFeature(MediaRoute2Info.FEATURE_LIVE_AUDIO)
.addFeature(MediaRoute2Info.FEATURE_LOCAL_PLAYBACK)
.setConnectionState(MediaRoute2Info.CONNECTION_STATE_DISCONNECTED)
.setDescription(mContext.getResources().getText(
R.string.bluetooth_a2dp_audio_route_name).toString())
.setType(type)
.setVolumeHandling(MediaRoute2Info.PLAYBACK_VOLUME_VARIABLE)
.setVolumeMax(mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC))
.setAddress(device.getAddress())
.build();
return newBtRoute;
}
private void setRouteConnectionState(@NonNull BluetoothRouteInfo btRoute,
@MediaRoute2Info.ConnectionState int state) {
if (btRoute == null) {
Slog.w(TAG, "setRouteConnectionState: route shouldn't be null");
return;
}
if (btRoute.mRoute.getConnectionState() == state) {
return;
}
MediaRoute2Info.Builder builder = new MediaRoute2Info.Builder(btRoute.mRoute)
.setConnectionState(state);
builder.setType(btRoute.getRouteType());
if (state == MediaRoute2Info.CONNECTION_STATE_CONNECTED) {
int currentVolume;
synchronized (this) {
currentVolume = mVolumeMap.get(btRoute.getRouteType(), 0);
}
builder.setVolume(currentVolume);
}
btRoute.mRoute = builder.build();
}
private static class BluetoothRouteInfo {
private BluetoothDevice mBtDevice;
private MediaRoute2Info mRoute;
private SparseBooleanArray mConnectedProfiles;
@MediaRoute2Info.Type
int getRouteType() {
// Let hearing aid profile have a priority.
if (mConnectedProfiles.get(BluetoothProfile.HEARING_AID, false)) {
return MediaRoute2Info.TYPE_HEARING_AID;
}
if (mConnectedProfiles.get(BluetoothProfile.LE_AUDIO, false)) {
return MediaRoute2Info.TYPE_BLE_HEADSET;
}
return MediaRoute2Info.TYPE_BLUETOOTH_A2DP;
}
}
private class AdapterStateChangedReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1);
if (state == BluetoothAdapter.STATE_OFF
|| state == BluetoothAdapter.STATE_TURNING_OFF) {
synchronized (AudioPoliciesBluetoothRouteController.this) {
mBluetoothRoutes.clear();
}
notifyBluetoothRoutesUpdated();
} else if (state == BluetoothAdapter.STATE_ON) {
updateBluetoothRoutes();
boolean shouldCallListener;
synchronized (AudioPoliciesBluetoothRouteController.this) {
shouldCallListener = !mBluetoothRoutes.isEmpty();
}
if (shouldCallListener) {
notifyBluetoothRoutesUpdated();
}
}
}
}
private class DeviceStateChangedReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
switch (intent.getAction()) {
case BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED:
case BluetoothHearingAid.ACTION_ACTIVE_DEVICE_CHANGED:
case BluetoothLeAudio.ACTION_LE_AUDIO_ACTIVE_DEVICE_CHANGED:
case BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED:
case BluetoothHearingAid.ACTION_CONNECTION_STATE_CHANGED:
case BluetoothLeAudio.ACTION_LE_AUDIO_CONNECTION_STATE_CHANGED:
updateBluetoothRoutes();
notifyBluetoothRoutesUpdated();
}
}
}
}