summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Alex Dadukin <adadukin@google.com> 2023-02-23 11:51:59 +0000
committer Alex Dadukin <adadukin@google.com> 2023-03-01 11:15:30 +0000
commit28ee07d1124ef5f744c5c5620dc5bc3f16fff5fe (patch)
tree9d88982bd89885b622df70a27126e470b9855114
parent1ae78a473e179fc6a2c226695eadbabe66801d3f (diff)
Implement BluetoothRouteController that supports Audio Policies
Bug: b/255495104 Test: atest AudioPoliciesBluetoothRouteControllerTest Change-Id: I814a776dd80ff1f02b09db91a4174244bcd8ded0
-rw-r--r--services/core/java/com/android/server/media/AudioPoliciesBluetoothRouteController.java453
-rw-r--r--services/core/java/com/android/server/media/BluetoothProfileMonitor.java178
-rw-r--r--services/robotests/src/com/android/server/media/AudioPoliciesBluetoothRouteControllerTest.java293
3 files changed, 646 insertions, 278 deletions
diff --git a/services/core/java/com/android/server/media/AudioPoliciesBluetoothRouteController.java b/services/core/java/com/android/server/media/AudioPoliciesBluetoothRouteController.java
index 58fdb57af8fa..eb997badca52 100644
--- a/services/core/java/com/android/server/media/AudioPoliciesBluetoothRouteController.java
+++ b/services/core/java/com/android/server/media/AudioPoliciesBluetoothRouteController.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2020 The Android Open Source Project
+ * 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.
@@ -18,7 +18,6 @@ package com.android.server.media;
import static android.bluetooth.BluetoothAdapter.ACTIVE_DEVICE_AUDIO;
import static android.bluetooth.BluetoothAdapter.STATE_CONNECTED;
-import static android.bluetooth.BluetoothAdapter.STATE_DISCONNECTED;
import android.annotation.NonNull;
import android.annotation.Nullable;
@@ -37,18 +36,18 @@ import android.media.AudioSystem;
import android.media.MediaRoute2Info;
import android.os.UserHandle;
import android.text.TextUtils;
-import android.util.Log;
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.Iterator;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
/**
@@ -61,58 +60,70 @@ import java.util.Set;
* <p>Selected route override should be used by {@link AudioManager} which is aware of Audio
* Policies.
*/
-class AudioPoliciesBluetoothRouteController implements BluetoothRouteController {
+/* package */ class AudioPoliciesBluetoothRouteController
+ implements BluetoothRouteController {
private static final String TAG = "APBtRouteController";
- private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
private static final String HEARING_AID_ROUTE_ID_PREFIX = "HEARING_AID_";
private static final String LE_AUDIO_ROUTE_ID_PREFIX = "LE_AUDIO_";
- // Maps hardware address to BluetoothRouteInfo
+ @NonNull
+ private final AdapterStateChangedReceiver mAdapterStateChangedReceiver =
+ new AdapterStateChangedReceiver();
+
+ @NonNull
+ private final DeviceStateChangedReceiver mDeviceStateChangedReceiver =
+ new DeviceStateChangedReceiver();
+
+ @NonNull
private final Map<String, BluetoothRouteInfo> mBluetoothRoutes = new HashMap<>();
- private final List<BluetoothRouteInfo> mActiveRoutes = new ArrayList<>();
- // Route type -> volume map
+ @NonNull
private final SparseIntArray mVolumeMap = new SparseIntArray();
+ @NonNull
private final Context mContext;
+ @NonNull
private final BluetoothAdapter mBluetoothAdapter;
- private final BluetoothRoutesUpdatedListener mListener;
+ @NonNull
+ private final BluetoothRouteController.BluetoothRoutesUpdatedListener mListener;
+ @NonNull
+ private final BluetoothProfileMonitor mBluetoothProfileMonitor;
+ @NonNull
private final AudioManager mAudioManager;
- private final BluetoothProfileListener mProfileListener = new BluetoothProfileListener();
- private final AdapterStateChangedReceiver mAdapterStateChangedReceiver =
- new AdapterStateChangedReceiver();
- private final DeviceStateChangedReceiver mDeviceStateChangedReceiver =
- new DeviceStateChangedReceiver();
+ @Nullable
+ private BluetoothRouteInfo mSelectedBluetoothRoute;
- private BluetoothA2dp mA2dpProfile;
- private BluetoothHearingAid mHearingAidProfile;
- private BluetoothLeAudio mLeAudioProfile;
+ 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);
- AudioPoliciesBluetoothRouteController(Context context, BluetoothAdapter btAdapter,
- BluetoothRoutesUpdatedListener listener) {
mContext = context;
- mBluetoothAdapter = btAdapter;
+ mBluetoothAdapter = bluetoothAdapter;
+ mBluetoothProfileMonitor = bluetoothProfileMonitor;
+ mAudioManager = mContext.getSystemService(AudioManager.class);
mListener = listener;
- mAudioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
- buildBluetoothRoutes();
+
+ updateBluetoothRoutes();
}
- /**
- * Registers listener to bluetooth status changes as the provided user.
- *
- * The registered receiver listens to {@link BluetoothA2dp#ACTION_ACTIVE_DEVICE_CHANGED} and
- * {@link BluetoothA2dp#ACTION_CONNECTION_STATE_CHANGED } events for {@link BluetoothProfile#A2DP},
- * {@link BluetoothProfile#HEARING_AID}, and {@link BluetoothProfile#LE_AUDIO} bluetooth profiles.
- *
- * @param user {@code UserHandle} as which receiver is registered
- */
@Override
public void start(UserHandle user) {
- mBluetoothAdapter.getProfileProxy(mContext, mProfileListener, BluetoothProfile.A2DP);
- mBluetoothAdapter.getProfileProxy(mContext, mProfileListener, BluetoothProfile.HEARING_AID);
- mBluetoothAdapter.getProfileProxy(mContext, mProfileListener, BluetoothProfile.LE_AUDIO);
+ mBluetoothProfileMonitor.start();
IntentFilter adapterStateChangedIntentFilter = new IntentFilter();
@@ -143,22 +154,59 @@ class AudioPoliciesBluetoothRouteController implements BluetoothRouteController
}
@Override
- public boolean selectRoute(String deviceAddress) {
- // Temporary no-op.
- return false;
+ 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;
+ }
}
/**
- * Transfers to a given bluetooth route.
- * The dedicated BT device with the route would be activated.
+ * Updates connectivity state for devices in the same devices group.
*
- * @param routeId the id of the Bluetooth device. {@code null} denotes to clear the use of
- * BT routes.
+ * <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) {
- clearActiveDevices();
+ mBluetoothAdapter.removeActiveDevice(ACTIVE_DEVICE_AUDIO);
return;
}
@@ -169,38 +217,38 @@ class AudioPoliciesBluetoothRouteController implements BluetoothRouteController
return;
}
- if (mBluetoothAdapter != null) {
- mBluetoothAdapter.setActiveDevice(btRouteInfo.mBtDevice, ACTIVE_DEVICE_AUDIO);
- }
+ mBluetoothAdapter.setActiveDevice(btRouteInfo.mBtDevice, ACTIVE_DEVICE_AUDIO);
}
- private BluetoothRouteInfo findBluetoothRouteWithRouteId(String routeId) {
+ @Nullable
+ private BluetoothRouteInfo findBluetoothRouteWithRouteId(@Nullable String routeId) {
if (routeId == null) {
return null;
}
- for (BluetoothRouteInfo btRouteInfo : mBluetoothRoutes.values()) {
- if (TextUtils.equals(btRouteInfo.mRoute.getId(), routeId)) {
- return btRouteInfo;
+ synchronized (this) {
+ for (BluetoothRouteInfo btRouteInfo : mBluetoothRoutes.values()) {
+ if (TextUtils.equals(btRouteInfo.mRoute.getId(), routeId)) {
+ return btRouteInfo;
+ }
}
}
return null;
}
- /**
- * Clears the active device for all known profiles.
- */
- private void clearActiveDevices() {
- if (mBluetoothAdapter != null) {
- mBluetoothAdapter.removeActiveDevice(ACTIVE_DEVICE_AUDIO);
+ private void updateBluetoothRoutes() {
+ Set<BluetoothDevice> bondedDevices = mBluetoothAdapter.getBondedDevices();
+
+ if (bondedDevices == null) {
+ return;
}
- }
- private void buildBluetoothRoutes() {
- mBluetoothRoutes.clear();
- Set<BluetoothDevice> bondedDevices = mBluetoothAdapter.getBondedDevices();
- if (bondedDevices != null) {
+ 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 (device.isConnected()) {
+ if (isDeviceConnected(device)) {
BluetoothRouteInfo newBtRoute = createBluetoothRoute(device);
if (newBtRoute.mConnectedProfiles.size() > 0) {
mBluetoothRoutes.put(device.getAddress(), newBtRoute);
@@ -210,20 +258,31 @@ class AudioPoliciesBluetoothRouteController implements BluetoothRouteController
}
}
+ @VisibleForTesting
+ /* package */ boolean isDeviceConnected(@NonNull BluetoothDevice device) {
+ return device.isConnected();
+ }
+
@Nullable
@Override
public MediaRoute2Info getSelectedRoute() {
- // For now, active routes can be multiple only when a pair of hearing aid devices is active.
- // Let the first active device represent them.
- return (mActiveRoutes.isEmpty() ? null : mActiveRoutes.get(0).mRoute);
+ synchronized (this) {
+ if (mSelectedBluetoothRoute == null) {
+ return null;
+ }
+
+ return mSelectedBluetoothRoute.mRoute;
+ }
}
@NonNull
@Override
public List<MediaRoute2Info> getTransferableRoutes() {
List<MediaRoute2Info> routes = getAllBluetoothRoutes();
- for (BluetoothRouteInfo btRoute : mActiveRoutes) {
- routes.remove(btRoute.mRoute);
+ synchronized (this) {
+ if (mSelectedBluetoothRoute != null) {
+ routes.remove(mSelectedBluetoothRoute.mRoute);
+ }
}
return routes;
}
@@ -240,22 +299,19 @@ class AudioPoliciesBluetoothRouteController implements BluetoothRouteController
routeIds.add(selectedRoute.getId());
}
- for (BluetoothRouteInfo btRoute : mBluetoothRoutes.values()) {
- // A pair of hearing aid devices or having the same hardware address
- if (routeIds.contains(btRoute.mRoute.getId())) {
- continue;
+ 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());
}
- routes.add(btRoute.mRoute);
- routeIds.add(btRoute.mRoute.getId());
}
return routes;
}
- /**
- * Updates the volume for {@link AudioManager#getDevicesForStream(int) devices}.
- *
- * @return true if devices can be handled by the provider.
- */
@Override
public boolean updateVolumeForDevices(int devices, int volume) {
int routeType;
@@ -270,32 +326,31 @@ class AudioPoliciesBluetoothRouteController implements BluetoothRouteController
} else {
return false;
}
- mVolumeMap.put(routeType, volume);
- boolean shouldNotify = false;
- for (BluetoothRouteInfo btRoute : mActiveRoutes) {
- if (btRoute.mRoute.getType() != routeType) {
- continue;
+ synchronized (this) {
+ mVolumeMap.put(routeType, volume);
+ if (mSelectedBluetoothRoute == null
+ || mSelectedBluetoothRoute.mRoute.getType() != routeType) {
+ return false;
}
- btRoute.mRoute = new MediaRoute2Info.Builder(btRoute.mRoute)
- .setVolume(volume)
- .build();
- shouldNotify = true;
- }
- if (shouldNotify) {
- notifyBluetoothRoutesUpdated();
+
+ mSelectedBluetoothRoute.mRoute =
+ new MediaRoute2Info.Builder(mSelectedBluetoothRoute.mRoute)
+ .setVolume(volume)
+ .build();
}
+
+ notifyBluetoothRoutesUpdated();
return true;
}
private void notifyBluetoothRoutesUpdated() {
- if (mListener != null) {
- mListener.onBluetoothRoutesUpdated(getAllBluetoothRoutes());
- }
+ mListener.onBluetoothRoutesUpdated(getAllBluetoothRoutes());
}
private BluetoothRouteInfo createBluetoothRoute(BluetoothDevice device) {
- BluetoothRouteInfo newBtRoute = new BluetoothRouteInfo();
+ BluetoothRouteInfo
+ newBtRoute = new BluetoothRouteInfo();
newBtRoute.mBtDevice = device;
String routeId = device.getAddress();
@@ -305,20 +360,20 @@ class AudioPoliciesBluetoothRouteController implements BluetoothRouteController
}
int type = MediaRoute2Info.TYPE_BLUETOOTH_A2DP;
newBtRoute.mConnectedProfiles = new SparseBooleanArray();
- if (mA2dpProfile != null && mA2dpProfile.getConnectedDevices().contains(device)) {
+ if (mBluetoothProfileMonitor.isProfileSupported(BluetoothProfile.A2DP, device)) {
newBtRoute.mConnectedProfiles.put(BluetoothProfile.A2DP, true);
}
- if (mHearingAidProfile != null
- && mHearingAidProfile.getConnectedDevices().contains(device)) {
+ 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 + mHearingAidProfile.getHiSyncId(device);
+ routeId = HEARING_AID_ROUTE_ID_PREFIX
+ + mBluetoothProfileMonitor.getGroupId(BluetoothProfile.HEARING_AID, device);
type = MediaRoute2Info.TYPE_HEARING_AID;
}
- if (mLeAudioProfile != null
- && mLeAudioProfile.getConnectedDevices().contains(device)) {
+ if (mBluetoothProfileMonitor.isProfileSupported(BluetoothProfile.LE_AUDIO, device)) {
newBtRoute.mConnectedProfiles.put(BluetoothProfile.LE_AUDIO, true);
- routeId = LE_AUDIO_ROUTE_ID_PREFIX + mLeAudioProfile.getGroupId(device);
+ routeId = LE_AUDIO_ROUTE_ID_PREFIX
+ + mBluetoothProfileMonitor.getGroupId(BluetoothProfile.LE_AUDIO, device);
type = MediaRoute2Info.TYPE_BLE_HEADSET;
}
@@ -351,70 +406,17 @@ class AudioPoliciesBluetoothRouteController implements BluetoothRouteController
.setConnectionState(state);
builder.setType(btRoute.getRouteType());
- if (state == MediaRoute2Info.CONNECTION_STATE_CONNECTED) {
- builder.setVolume(mVolumeMap.get(btRoute.getRouteType(), 0));
- }
- btRoute.mRoute = builder.build();
- }
- private void addActiveRoute(BluetoothRouteInfo btRoute) {
- if (btRoute == null) {
- Slog.w(TAG, "addActiveRoute: btRoute is null");
- return;
- }
- if (DEBUG) {
- Log.d(TAG, "Adding active route: " + btRoute.mRoute);
- }
- if (mActiveRoutes.contains(btRoute)) {
- Slog.w(TAG, "addActiveRoute: btRoute is already added.");
- return;
- }
- setRouteConnectionState(btRoute, STATE_CONNECTED);
- mActiveRoutes.add(btRoute);
- }
-
- private void removeActiveRoute(BluetoothRouteInfo btRoute) {
- if (DEBUG) {
- Log.d(TAG, "Removing active route: " + btRoute.mRoute);
- }
- if (mActiveRoutes.remove(btRoute)) {
- setRouteConnectionState(btRoute, STATE_DISCONNECTED);
- }
- }
- private void clearActiveRoutesWithType(int type) {
- if (DEBUG) {
- Log.d(TAG, "Clearing active routes with type. type=" + type);
- }
- Iterator<BluetoothRouteInfo> iter = mActiveRoutes.iterator();
- while (iter.hasNext()) {
- BluetoothRouteInfo btRoute = iter.next();
- if (btRoute.mRoute.getType() == type) {
- iter.remove();
- setRouteConnectionState(btRoute, STATE_DISCONNECTED);
+ if (state == MediaRoute2Info.CONNECTION_STATE_CONNECTED) {
+ int currentVolume;
+ synchronized (this) {
+ currentVolume = mVolumeMap.get(btRoute.getRouteType(), 0);
}
+ builder.setVolume(currentVolume);
}
- }
- private void addActiveDevices(BluetoothDevice device) {
- // Let the given device be the first active device
- BluetoothRouteInfo activeBtRoute = mBluetoothRoutes.get(device.getAddress());
- // This could happen if ACTION_ACTIVE_DEVICE_CHANGED is sent before
- // ACTION_CONNECTION_STATE_CHANGED is sent.
- if (activeBtRoute == null) {
- activeBtRoute = createBluetoothRoute(device);
- mBluetoothRoutes.put(device.getAddress(), activeBtRoute);
- }
- addActiveRoute(activeBtRoute);
-
- // A bluetooth route with the same route ID should be added.
- for (BluetoothRouteInfo btRoute : mBluetoothRoutes.values()) {
- if (TextUtils.equals(btRoute.mRoute.getId(), activeBtRoute.mRoute.getId())
- && !TextUtils.equals(btRoute.mBtDevice.getAddress(),
- activeBtRoute.mBtDevice.getAddress())) {
- addActiveRoute(btRoute);
- }
- }
+ btRoute.mRoute = builder.build();
}
private static class BluetoothRouteInfo {
@@ -437,71 +439,25 @@ class AudioPoliciesBluetoothRouteController implements BluetoothRouteController
}
}
- // These callbacks run on the main thread.
- private final class BluetoothProfileListener implements BluetoothProfile.ServiceListener {
- @Override
- public void onServiceConnected(int profile, BluetoothProfile proxy) {
- List<BluetoothDevice> activeDevices;
- switch (profile) {
- case BluetoothProfile.A2DP:
- mA2dpProfile = (BluetoothA2dp) proxy;
- // It may contain null.
- activeDevices = mBluetoothAdapter.getActiveDevices(BluetoothProfile.A2DP);
- break;
- case BluetoothProfile.HEARING_AID:
- mHearingAidProfile = (BluetoothHearingAid) proxy;
- activeDevices = mBluetoothAdapter.getActiveDevices(
- BluetoothProfile.HEARING_AID);
- break;
- case BluetoothProfile.LE_AUDIO:
- mLeAudioProfile = (BluetoothLeAudio) proxy;
- activeDevices = mBluetoothAdapter.getActiveDevices(BluetoothProfile.LE_AUDIO);
- break;
- default:
- return;
- }
- for (BluetoothDevice device : proxy.getConnectedDevices()) {
- BluetoothRouteInfo btRoute = mBluetoothRoutes.get(device.getAddress());
- if (btRoute == null) {
- btRoute = createBluetoothRoute(device);
- mBluetoothRoutes.put(device.getAddress(), btRoute);
- }
- if (activeDevices.contains(device)) {
- addActiveRoute(btRoute);
- }
- }
- notifyBluetoothRoutesUpdated();
- }
-
- @Override
- public void onServiceDisconnected(int profile) {
- switch (profile) {
- case BluetoothProfile.A2DP:
- mA2dpProfile = null;
- break;
- case BluetoothProfile.HEARING_AID:
- mHearingAidProfile = null;
- break;
- case BluetoothProfile.LE_AUDIO:
- mLeAudioProfile = null;
- break;
- default:
- return;
- }
- }
- }
-
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) {
- mBluetoothRoutes.clear();
+ synchronized (AudioPoliciesBluetoothRouteController.this) {
+ mBluetoothRoutes.clear();
+ }
notifyBluetoothRoutesUpdated();
} else if (state == BluetoothAdapter.STATE_ON) {
- buildBluetoothRoutes();
- if (!mBluetoothRoutes.isEmpty()) {
+ updateBluetoothRoutes();
+
+ boolean shouldCallListener;
+ synchronized (AudioPoliciesBluetoothRouteController.this) {
+ shouldCallListener = !mBluetoothRoutes.isEmpty();
+ }
+
+ if (shouldCallListener) {
notifyBluetoothRoutesUpdated();
}
}
@@ -511,74 +467,15 @@ class AudioPoliciesBluetoothRouteController implements BluetoothRouteController
private class DeviceStateChangedReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
- BluetoothDevice device = intent.getParcelableExtra(
- BluetoothDevice.EXTRA_DEVICE, BluetoothDevice.class);
-
switch (intent.getAction()) {
case BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED:
- clearActiveRoutesWithType(MediaRoute2Info.TYPE_BLUETOOTH_A2DP);
- if (device != null) {
- addActiveRoute(mBluetoothRoutes.get(device.getAddress()));
- }
- notifyBluetoothRoutesUpdated();
- break;
case BluetoothHearingAid.ACTION_ACTIVE_DEVICE_CHANGED:
- clearActiveRoutesWithType(MediaRoute2Info.TYPE_HEARING_AID);
- if (device != null) {
- if (DEBUG) {
- Log.d(TAG, "Setting active hearing aid devices. device=" + device);
- }
-
- addActiveDevices(device);
- }
- notifyBluetoothRoutesUpdated();
- break;
case BluetoothLeAudio.ACTION_LE_AUDIO_ACTIVE_DEVICE_CHANGED:
- clearActiveRoutesWithType(MediaRoute2Info.TYPE_BLE_HEADSET);
- if (device != null) {
- if (DEBUG) {
- Log.d(TAG, "Setting active le audio devices. device=" + device);
- }
-
- addActiveDevices(device);
- }
- notifyBluetoothRoutesUpdated();
- break;
case BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED:
- handleConnectionStateChanged(BluetoothProfile.A2DP, intent, device);
- break;
case BluetoothHearingAid.ACTION_CONNECTION_STATE_CHANGED:
- handleConnectionStateChanged(BluetoothProfile.HEARING_AID, intent, device);
- break;
case BluetoothLeAudio.ACTION_LE_AUDIO_CONNECTION_STATE_CHANGED:
- handleConnectionStateChanged(BluetoothProfile.LE_AUDIO, intent, device);
- break;
- }
- }
-
- private void handleConnectionStateChanged(int profile, Intent intent,
- BluetoothDevice device) {
- int state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, -1);
- BluetoothRouteInfo btRoute = mBluetoothRoutes.get(device.getAddress());
- if (state == BluetoothProfile.STATE_CONNECTED) {
- if (btRoute == null) {
- btRoute = createBluetoothRoute(device);
- if (btRoute.mConnectedProfiles.size() > 0) {
- mBluetoothRoutes.put(device.getAddress(), btRoute);
- notifyBluetoothRoutesUpdated();
- }
- } else {
- btRoute.mConnectedProfiles.put(profile, true);
- }
- } else if (state == BluetoothProfile.STATE_DISCONNECTING
- || state == BluetoothProfile.STATE_DISCONNECTED) {
- if (btRoute != null) {
- btRoute.mConnectedProfiles.delete(profile);
- if (btRoute.mConnectedProfiles.size() == 0) {
- removeActiveRoute(mBluetoothRoutes.remove(device.getAddress()));
- notifyBluetoothRoutesUpdated();
- }
- }
+ updateBluetoothRoutes();
+ notifyBluetoothRoutesUpdated();
}
}
}
diff --git a/services/core/java/com/android/server/media/BluetoothProfileMonitor.java b/services/core/java/com/android/server/media/BluetoothProfileMonitor.java
new file mode 100644
index 000000000000..b129dd0f74c6
--- /dev/null
+++ b/services/core/java/com/android/server/media/BluetoothProfileMonitor.java
@@ -0,0 +1,178 @@
+/*
+ * 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 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.Context;
+
+import java.util.Objects;
+
+/* package */ class BluetoothProfileMonitor {
+
+ /* package */ static final long GROUP_ID_NO_GROUP = -1L;
+
+ @NonNull
+ private final ProfileListener mProfileListener = new ProfileListener();
+
+ @NonNull
+ private final Context mContext;
+ @NonNull
+ private final BluetoothAdapter mBluetoothAdapter;
+
+ @Nullable
+ private BluetoothA2dp mA2dpProfile;
+ @Nullable
+ private BluetoothHearingAid mHearingAidProfile;
+ @Nullable
+ private BluetoothLeAudio mLeAudioProfile;
+
+ @Nullable
+ private OnProfileChangedListener mOnProfileChangedListener;
+
+ BluetoothProfileMonitor(@NonNull Context context,
+ @NonNull BluetoothAdapter bluetoothAdapter) {
+ Objects.requireNonNull(context);
+ Objects.requireNonNull(bluetoothAdapter);
+
+ mContext = context;
+ mBluetoothAdapter = bluetoothAdapter;
+ }
+
+ /* package */ synchronized void setOnProfileChangedListener(
+ @NonNull OnProfileChangedListener listener) {
+ mOnProfileChangedListener = listener;
+ }
+
+ /* package */ void start() {
+ mBluetoothAdapter.getProfileProxy(mContext, mProfileListener, BluetoothProfile.A2DP);
+ mBluetoothAdapter.getProfileProxy(mContext, mProfileListener, BluetoothProfile.HEARING_AID);
+ mBluetoothAdapter.getProfileProxy(mContext, mProfileListener, BluetoothProfile.LE_AUDIO);
+ }
+
+ /* package */ boolean isProfileSupported(int profile, @NonNull BluetoothDevice device) {
+ BluetoothProfile bluetoothProfile;
+
+ synchronized (this) {
+ switch (profile) {
+ case BluetoothProfile.A2DP:
+ bluetoothProfile = mA2dpProfile;
+ break;
+ case BluetoothProfile.LE_AUDIO:
+ bluetoothProfile = mLeAudioProfile;
+ break;
+ case BluetoothProfile.HEARING_AID:
+ bluetoothProfile = mHearingAidProfile;
+ break;
+ default:
+ throw new IllegalArgumentException(profile
+ + " is not supported as Bluetooth profile");
+ }
+ }
+
+ if (bluetoothProfile == null) {
+ return false;
+ }
+
+ return bluetoothProfile.getConnectedDevices().contains(device);
+ }
+
+ /* package */ long getGroupId(int profile, @NonNull BluetoothDevice device) {
+ synchronized (this) {
+ switch (profile) {
+ case BluetoothProfile.A2DP:
+ return GROUP_ID_NO_GROUP;
+ case BluetoothProfile.LE_AUDIO:
+ return mLeAudioProfile == null ? GROUP_ID_NO_GROUP : mLeAudioProfile.getGroupId(
+ device);
+ case BluetoothProfile.HEARING_AID:
+ return mHearingAidProfile == null
+ ? GROUP_ID_NO_GROUP : mHearingAidProfile.getHiSyncId(device);
+ default:
+ throw new IllegalArgumentException(profile
+ + " is not supported as Bluetooth profile");
+ }
+ }
+ }
+
+ /* package */ interface OnProfileChangedListener {
+ void onProfileChange(int profile);
+ }
+
+ private final class ProfileListener implements BluetoothProfile.ServiceListener {
+ @Override
+ public void onServiceConnected(int profile, BluetoothProfile proxy) {
+ OnProfileChangedListener listener;
+
+ synchronized (BluetoothProfileMonitor.this) {
+ switch (profile) {
+ case BluetoothProfile.A2DP:
+ mA2dpProfile = (BluetoothA2dp) proxy;
+ break;
+ case BluetoothProfile.HEARING_AID:
+ mHearingAidProfile = (BluetoothHearingAid) proxy;
+ break;
+ case BluetoothProfile.LE_AUDIO:
+ mLeAudioProfile = (BluetoothLeAudio) proxy;
+ break;
+ default:
+ return;
+ }
+
+ listener = mOnProfileChangedListener;
+ }
+
+ if (listener != null) {
+ listener.onProfileChange(profile);
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected(int profile) {
+ OnProfileChangedListener listener;
+
+ synchronized (BluetoothProfileMonitor.this) {
+ switch (profile) {
+ case BluetoothProfile.A2DP:
+ mA2dpProfile = null;
+ break;
+ case BluetoothProfile.HEARING_AID:
+ mHearingAidProfile = null;
+ break;
+ case BluetoothProfile.LE_AUDIO:
+ mLeAudioProfile = null;
+ break;
+ default:
+ return;
+ }
+
+ listener = mOnProfileChangedListener;
+ }
+
+ if (listener != null) {
+ listener.onProfileChange(profile);
+ }
+ }
+ }
+
+}
diff --git a/services/robotests/src/com/android/server/media/AudioPoliciesBluetoothRouteControllerTest.java b/services/robotests/src/com/android/server/media/AudioPoliciesBluetoothRouteControllerTest.java
new file mode 100644
index 000000000000..0ad418427183
--- /dev/null
+++ b/services/robotests/src/com/android/server/media/AudioPoliciesBluetoothRouteControllerTest.java
@@ -0,0 +1,293 @@
+/*
+ * 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 com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.when;
+
+import android.app.Application;
+import android.bluetooth.BluetoothA2dp;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.content.Intent;
+import android.media.AudioManager;
+import android.media.MediaRoute2Info;
+import android.os.UserHandle;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.Shadows;
+import org.robolectric.shadows.ShadowBluetoothAdapter;
+import org.robolectric.shadows.ShadowBluetoothDevice;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+
+@RunWith(RobolectricTestRunner.class)
+public class AudioPoliciesBluetoothRouteControllerTest {
+
+ private static final String DEVICE_ADDRESS_UNKNOWN = ":unknown:ip:address:";
+ private static final String DEVICE_ADDRESS_SAMPLE_1 = "30:59:8B:E4:C6:35";
+ private static final String DEVICE_ADDRESS_SAMPLE_2 = "0D:0D:A6:FF:8D:B6";
+ private static final String DEVICE_ADDRESS_SAMPLE_3 = "2D:9B:0C:C2:6F:78";
+ private static final String DEVICE_ADDRESS_SAMPLE_4 = "66:88:F9:2D:A8:1E";
+
+ private Context mContext;
+
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+
+ @Mock
+ private BluetoothRouteController.BluetoothRoutesUpdatedListener mListener;
+
+ @Mock
+ private BluetoothProfileMonitor mBluetoothProfileMonitor;
+
+ private AudioPoliciesBluetoothRouteController mAudioPoliciesBluetoothRouteController;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+
+ Application application = ApplicationProvider.getApplicationContext();
+ mContext = application;
+
+ BluetoothManager bluetoothManager = (BluetoothManager)
+ mContext.getSystemService(Context.BLUETOOTH_SERVICE);
+
+ BluetoothAdapter bluetoothAdapter = bluetoothManager.getAdapter();
+ mShadowBluetoothAdapter = Shadows.shadowOf(bluetoothAdapter);
+
+ mAudioPoliciesBluetoothRouteController =
+ new AudioPoliciesBluetoothRouteController(mContext, bluetoothAdapter,
+ mBluetoothProfileMonitor, mListener) {
+ @Override
+ boolean isDeviceConnected(BluetoothDevice device) {
+ return true;
+ }
+ };
+
+ // Enable A2DP profile.
+ when(mBluetoothProfileMonitor.isProfileSupported(eq(BluetoothProfile.A2DP), any()))
+ .thenReturn(true);
+ mShadowBluetoothAdapter.setProfileConnectionState(BluetoothProfile.A2DP,
+ BluetoothProfile.STATE_CONNECTED);
+
+ mAudioPoliciesBluetoothRouteController.start(UserHandle.of(0));
+ }
+
+ @Test
+ public void getSelectedRoute_noBluetoothRoutesAvailable_returnsNull() {
+ assertThat(mAudioPoliciesBluetoothRouteController.getSelectedRoute()).isNull();
+ }
+
+ @Test
+ public void selectRoute_noBluetoothRoutesAvailable_returnsFalse() {
+ assertThat(mAudioPoliciesBluetoothRouteController
+ .selectRoute(DEVICE_ADDRESS_UNKNOWN)).isFalse();
+ }
+
+ @Test
+ public void selectRoute_noDeviceWithGivenAddress_returnsFalse() {
+ Set<BluetoothDevice> devices = generateFakeBluetoothDevicesSet(
+ DEVICE_ADDRESS_SAMPLE_1, DEVICE_ADDRESS_SAMPLE_3);
+
+ mShadowBluetoothAdapter.setBondedDevices(devices);
+
+ assertThat(mAudioPoliciesBluetoothRouteController
+ .selectRoute(DEVICE_ADDRESS_SAMPLE_2)).isFalse();
+ }
+
+ @Test
+ public void selectRoute_deviceIsInDevicesSet_returnsTrue() {
+ Set<BluetoothDevice> devices = generateFakeBluetoothDevicesSet(
+ DEVICE_ADDRESS_SAMPLE_1, DEVICE_ADDRESS_SAMPLE_2);
+
+ mShadowBluetoothAdapter.setBondedDevices(devices);
+
+ assertThat(mAudioPoliciesBluetoothRouteController
+ .selectRoute(DEVICE_ADDRESS_SAMPLE_1)).isTrue();
+ }
+
+ @Test
+ public void selectRoute_resetSelectedDevice_returnsTrue() {
+ Set<BluetoothDevice> devices = generateFakeBluetoothDevicesSet(
+ DEVICE_ADDRESS_SAMPLE_1, DEVICE_ADDRESS_SAMPLE_2);
+
+ mShadowBluetoothAdapter.setBondedDevices(devices);
+
+ mAudioPoliciesBluetoothRouteController.selectRoute(DEVICE_ADDRESS_SAMPLE_1);
+ assertThat(mAudioPoliciesBluetoothRouteController.selectRoute(null)).isTrue();
+ }
+
+ @Test
+ public void selectRoute_noSelectedDevice_returnsTrue() {
+ Set<BluetoothDevice> devices = generateFakeBluetoothDevicesSet(
+ DEVICE_ADDRESS_SAMPLE_1, DEVICE_ADDRESS_SAMPLE_2);
+
+ mShadowBluetoothAdapter.setBondedDevices(devices);
+
+ assertThat(mAudioPoliciesBluetoothRouteController.selectRoute(null)).isTrue();
+ }
+
+ @Test
+ public void getSelectedRoute_updateRouteFailed_returnsNull() {
+ Set<BluetoothDevice> devices = generateFakeBluetoothDevicesSet(
+ DEVICE_ADDRESS_SAMPLE_1, DEVICE_ADDRESS_SAMPLE_2);
+
+ mShadowBluetoothAdapter.setBondedDevices(devices);
+ mAudioPoliciesBluetoothRouteController
+ .selectRoute(DEVICE_ADDRESS_SAMPLE_3);
+
+ assertThat(mAudioPoliciesBluetoothRouteController.getSelectedRoute()).isNull();
+ }
+
+ @Test
+ public void getSelectedRoute_updateRouteSuccessful_returnsUpdateDevice() {
+ Set<BluetoothDevice> devices = generateFakeBluetoothDevicesSet(
+ DEVICE_ADDRESS_SAMPLE_1, DEVICE_ADDRESS_SAMPLE_2, DEVICE_ADDRESS_SAMPLE_4);
+
+ assertThat(mAudioPoliciesBluetoothRouteController.getSelectedRoute()).isNull();
+
+ mShadowBluetoothAdapter.setBondedDevices(devices);
+
+ assertThat(mAudioPoliciesBluetoothRouteController
+ .selectRoute(DEVICE_ADDRESS_SAMPLE_4)).isTrue();
+
+ MediaRoute2Info selectedRoute = mAudioPoliciesBluetoothRouteController.getSelectedRoute();
+ assertThat(selectedRoute.getAddress()).isEqualTo(DEVICE_ADDRESS_SAMPLE_4);
+ }
+
+ @Test
+ public void getSelectedRoute_resetSelectedRoute_returnsNull() {
+ Set<BluetoothDevice> devices = generateFakeBluetoothDevicesSet(
+ DEVICE_ADDRESS_SAMPLE_1, DEVICE_ADDRESS_SAMPLE_2, DEVICE_ADDRESS_SAMPLE_4);
+
+ mShadowBluetoothAdapter.setBondedDevices(devices);
+
+ // Device is not null now.
+ mAudioPoliciesBluetoothRouteController.selectRoute(DEVICE_ADDRESS_SAMPLE_4);
+ // Rest the device.
+ mAudioPoliciesBluetoothRouteController.selectRoute(null);
+
+ assertThat(mAudioPoliciesBluetoothRouteController.getSelectedRoute())
+ .isNull();
+ }
+
+ @Test
+ public void getTransferableRoutes_noSelectedRoute_returnsAllBluetoothDevices() {
+ String[] addresses = new String[] { DEVICE_ADDRESS_SAMPLE_1,
+ DEVICE_ADDRESS_SAMPLE_2, DEVICE_ADDRESS_SAMPLE_4 };
+ Set<BluetoothDevice> devices = generateFakeBluetoothDevicesSet(addresses);
+ mShadowBluetoothAdapter.setBondedDevices(devices);
+
+ // Force route controller to update bluetooth devices list.
+ sendBluetoothDevicesChangedBroadcast();
+
+ Set<String> transferableDevices = extractAddressesListFrom(
+ mAudioPoliciesBluetoothRouteController.getTransferableRoutes());
+ assertThat(transferableDevices).containsExactlyElementsIn(addresses);
+ }
+
+ @Test
+ public void getTransferableRoutes_hasSelectedRoute_returnsRoutesWithoutSelectedDevice() {
+ String[] addresses = new String[] { DEVICE_ADDRESS_SAMPLE_1,
+ DEVICE_ADDRESS_SAMPLE_2, DEVICE_ADDRESS_SAMPLE_4 };
+ Set<BluetoothDevice> devices = generateFakeBluetoothDevicesSet(addresses);
+ mShadowBluetoothAdapter.setBondedDevices(devices);
+
+ // Force route controller to update bluetooth devices list.
+ sendBluetoothDevicesChangedBroadcast();
+ mAudioPoliciesBluetoothRouteController.selectRoute(DEVICE_ADDRESS_SAMPLE_4);
+
+ Set<String> transferableDevices = extractAddressesListFrom(
+ mAudioPoliciesBluetoothRouteController.getTransferableRoutes());
+ assertThat(transferableDevices).containsExactly(DEVICE_ADDRESS_SAMPLE_1,
+ DEVICE_ADDRESS_SAMPLE_2);
+ }
+
+ @Test
+ public void getAllBluetoothRoutes_hasSelectedRoute_returnsAllRoutes() {
+ String[] addresses = new String[] { DEVICE_ADDRESS_SAMPLE_1,
+ DEVICE_ADDRESS_SAMPLE_2, DEVICE_ADDRESS_SAMPLE_4 };
+ Set<BluetoothDevice> devices = generateFakeBluetoothDevicesSet(addresses);
+ mShadowBluetoothAdapter.setBondedDevices(devices);
+
+ // Force route controller to update bluetooth devices list.
+ sendBluetoothDevicesChangedBroadcast();
+ mAudioPoliciesBluetoothRouteController.selectRoute(DEVICE_ADDRESS_SAMPLE_4);
+
+ Set<String> bluetoothDevices = extractAddressesListFrom(
+ mAudioPoliciesBluetoothRouteController.getAllBluetoothRoutes());
+ assertThat(bluetoothDevices).containsExactlyElementsIn(addresses);
+ }
+
+ @Test
+ public void updateVolumeForDevice_setVolumeForA2DPTo25_selectedRouteVolumeIsUpdated() {
+ String[] addresses = new String[] { DEVICE_ADDRESS_SAMPLE_1,
+ DEVICE_ADDRESS_SAMPLE_2, DEVICE_ADDRESS_SAMPLE_4 };
+ Set<BluetoothDevice> devices = generateFakeBluetoothDevicesSet(addresses);
+ mShadowBluetoothAdapter.setBondedDevices(devices);
+
+ // Force route controller to update bluetooth devices list.
+ sendBluetoothDevicesChangedBroadcast();
+ mAudioPoliciesBluetoothRouteController.selectRoute(DEVICE_ADDRESS_SAMPLE_4);
+
+ mAudioPoliciesBluetoothRouteController.updateVolumeForDevices(
+ AudioManager.DEVICE_OUT_BLUETOOTH_A2DP, 25);
+
+ MediaRoute2Info selectedRoute = mAudioPoliciesBluetoothRouteController.getSelectedRoute();
+ assertThat(selectedRoute.getVolume()).isEqualTo(25);
+ }
+
+ private void sendBluetoothDevicesChangedBroadcast() {
+ Intent intent = new Intent(BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED);
+ mContext.sendBroadcast(intent);
+ }
+
+ private static Set<String> extractAddressesListFrom(Collection<MediaRoute2Info> routes) {
+ Set<String> addresses = new HashSet<>();
+
+ for (MediaRoute2Info route: routes) {
+ addresses.add(route.getAddress());
+ }
+
+ return addresses;
+ }
+
+ private static Set<BluetoothDevice> generateFakeBluetoothDevicesSet(String... addresses) {
+ Set<BluetoothDevice> devices = new HashSet<>();
+
+ for (String address: addresses) {
+ devices.add(ShadowBluetoothDevice.newInstance(address));
+ }
+
+ return devices;
+ }
+}