| /* |
| * Copyright 2020 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 static android.bluetooth.BluetoothAdapter.STATE_DISCONNECTED; |
| |
| 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.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.text.TextUtils; |
| import android.util.Log; |
| import android.util.Slog; |
| import android.util.SparseBooleanArray; |
| import android.util.SparseIntArray; |
| |
| import com.android.internal.R; |
| |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.HashMap; |
| import java.util.Iterator; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Objects; |
| |
| class BluetoothRouteProvider { |
| private static final String TAG = "BTRouteProvider"; |
| private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); |
| |
| private static final String HEARING_AID_ROUTE_ID_PREFIX = "HEARING_AID_"; |
| private static BluetoothRouteProvider sInstance; |
| |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| // Maps hardware address to BluetoothRouteInfo |
| final Map<String, BluetoothRouteInfo> mBluetoothRoutes = new HashMap<>(); |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| final List<BluetoothRouteInfo> mActiveRoutes = new ArrayList<>(); |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| BluetoothA2dp mA2dpProfile; |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| BluetoothHearingAid mHearingAidProfile; |
| |
| // Route type -> volume map |
| private final SparseIntArray mVolumeMap = new SparseIntArray(); |
| |
| private final Context mContext; |
| private final BluetoothAdapter mBluetoothAdapter; |
| private final BluetoothRoutesUpdatedListener mListener; |
| private final AudioManager mAudioManager; |
| private final Map<String, BluetoothEventReceiver> mEventReceiverMap = new HashMap<>(); |
| private final IntentFilter mIntentFilter = new IntentFilter(); |
| private final BroadcastReceiver mBroadcastReceiver = new BluetoothBroadcastReceiver(); |
| private final BluetoothProfileListener mProfileListener = new BluetoothProfileListener(); |
| |
| static synchronized BluetoothRouteProvider getInstance(@NonNull Context context, |
| @NonNull BluetoothRoutesUpdatedListener listener) { |
| Objects.requireNonNull(context); |
| Objects.requireNonNull(listener); |
| |
| if (sInstance == null) { |
| BluetoothAdapter btAdapter = BluetoothAdapter.getDefaultAdapter(); |
| if (btAdapter == null) { |
| return null; |
| } |
| sInstance = new BluetoothRouteProvider(context, btAdapter, listener); |
| } |
| return sInstance; |
| } |
| |
| private BluetoothRouteProvider(Context context, BluetoothAdapter btAdapter, |
| BluetoothRoutesUpdatedListener listener) { |
| mContext = context; |
| mBluetoothAdapter = btAdapter; |
| mListener = listener; |
| mAudioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); |
| buildBluetoothRoutes(); |
| } |
| |
| public void start() { |
| mBluetoothAdapter.getProfileProxy(mContext, mProfileListener, BluetoothProfile.A2DP); |
| mBluetoothAdapter.getProfileProxy(mContext, mProfileListener, BluetoothProfile.HEARING_AID); |
| |
| // Bluetooth on/off broadcasts |
| addEventReceiver(BluetoothAdapter.ACTION_STATE_CHANGED, new AdapterStateChangedReceiver()); |
| |
| DeviceStateChangedReceiver deviceStateChangedReceiver = new DeviceStateChangedReceiver(); |
| addEventReceiver(BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED, deviceStateChangedReceiver); |
| addEventReceiver(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED, deviceStateChangedReceiver); |
| addEventReceiver(BluetoothHearingAid.ACTION_ACTIVE_DEVICE_CHANGED, |
| deviceStateChangedReceiver); |
| addEventReceiver(BluetoothHearingAid.ACTION_CONNECTION_STATE_CHANGED, |
| deviceStateChangedReceiver); |
| |
| mContext.registerReceiver(mBroadcastReceiver, mIntentFilter, null, null); |
| } |
| |
| /** |
| * Transfers to a given bluetooth route. |
| * The dedicated BT device with the route would be activated. |
| * |
| * @param routeId the id of the Bluetooth device. {@code null} denotes to clear the use of |
| * BT routes. |
| */ |
| public void transferTo(@Nullable String routeId) { |
| if (routeId == null) { |
| clearActiveDevices(); |
| return; |
| } |
| |
| BluetoothRouteInfo btRouteInfo = findBluetoothRouteWithRouteId(routeId); |
| |
| if (btRouteInfo == null) { |
| Slog.w(TAG, "transferTo: Unknown route. ID=" + routeId); |
| return; |
| } |
| |
| if (mBluetoothAdapter != null) { |
| mBluetoothAdapter.setActiveDevice(btRouteInfo.btDevice, ACTIVE_DEVICE_AUDIO); |
| } |
| } |
| |
| /** |
| * Clears the active device for all known profiles. |
| */ |
| private void clearActiveDevices() { |
| if (mBluetoothAdapter != null) { |
| mBluetoothAdapter.removeActiveDevice(ACTIVE_DEVICE_AUDIO); |
| } |
| } |
| |
| private void addEventReceiver(String action, BluetoothEventReceiver eventReceiver) { |
| mEventReceiverMap.put(action, eventReceiver); |
| mIntentFilter.addAction(action); |
| } |
| |
| private void buildBluetoothRoutes() { |
| mBluetoothRoutes.clear(); |
| for (BluetoothDevice device : mBluetoothAdapter.getBondedDevices()) { |
| if (device.isConnected()) { |
| BluetoothRouteInfo newBtRoute = createBluetoothRoute(device); |
| if (newBtRoute.connectedProfiles.size() > 0) { |
| mBluetoothRoutes.put(device.getAddress(), newBtRoute); |
| } |
| } |
| } |
| } |
| |
| @Nullable |
| 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).route); |
| } |
| |
| @NonNull |
| List<MediaRoute2Info> getTransferableRoutes() { |
| List<MediaRoute2Info> routes = getAllBluetoothRoutes(); |
| for (BluetoothRouteInfo btRoute : mActiveRoutes) { |
| routes.remove(btRoute.route); |
| } |
| return routes; |
| } |
| |
| @NonNull |
| 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()); |
| } |
| |
| for (BluetoothRouteInfo btRoute : mBluetoothRoutes.values()) { |
| // A pair of hearing aid devices or having the same hardware address |
| if (routeIds.contains(btRoute.route.getId())) { |
| continue; |
| } |
| routes.add(btRoute.route); |
| routeIds.add(btRoute.route.getId()); |
| } |
| return routes; |
| } |
| |
| BluetoothRouteInfo findBluetoothRouteWithRouteId(String routeId) { |
| if (routeId == null) { |
| return null; |
| } |
| for (BluetoothRouteInfo btRouteInfo : mBluetoothRoutes.values()) { |
| if (TextUtils.equals(btRouteInfo.route.getId(), routeId)) { |
| return btRouteInfo; |
| } |
| } |
| return null; |
| } |
| |
| /** |
| * Updates the volume for {@link AudioManager#getDevicesForStream(int) devices}. |
| * |
| * @return true if devices can be handled by the provider. |
| */ |
| 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 { |
| return false; |
| } |
| mVolumeMap.put(routeType, volume); |
| |
| boolean shouldNotify = false; |
| for (BluetoothRouteInfo btRoute : mActiveRoutes) { |
| if (btRoute.route.getType() != routeType) { |
| continue; |
| } |
| btRoute.route = new MediaRoute2Info.Builder(btRoute.route) |
| .setVolume(volume) |
| .build(); |
| shouldNotify = true; |
| } |
| if (shouldNotify) { |
| notifyBluetoothRoutesUpdated(); |
| } |
| return true; |
| } |
| |
| private void notifyBluetoothRoutesUpdated() { |
| if (mListener != null) { |
| mListener.onBluetoothRoutesUpdated(getAllBluetoothRoutes()); |
| } |
| } |
| |
| private BluetoothRouteInfo createBluetoothRoute(BluetoothDevice device) { |
| BluetoothRouteInfo newBtRoute = new BluetoothRouteInfo(); |
| newBtRoute.btDevice = 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.connectedProfiles = new SparseBooleanArray(); |
| if (mA2dpProfile != null && mA2dpProfile.getConnectedDevices().contains(device)) { |
| newBtRoute.connectedProfiles.put(BluetoothProfile.A2DP, true); |
| } |
| if (mHearingAidProfile != null |
| && mHearingAidProfile.getConnectedDevices().contains(device)) { |
| newBtRoute.connectedProfiles.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); |
| type = MediaRoute2Info.TYPE_HEARING_AID; |
| } |
| |
| // Current volume will be set when connected. |
| newBtRoute.route = 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.route.getConnectionState() == state) { |
| return; |
| } |
| |
| MediaRoute2Info.Builder builder = new MediaRoute2Info.Builder(btRoute.route) |
| .setConnectionState(state); |
| builder.setType(btRoute.getRouteType()); |
| |
| if (state == MediaRoute2Info.CONNECTION_STATE_CONNECTED) { |
| builder.setVolume(mVolumeMap.get(btRoute.getRouteType(), 0)); |
| } |
| btRoute.route = builder.build(); |
| } |
| |
| private void addActiveRoute(BluetoothRouteInfo btRoute) { |
| if (DEBUG) { |
| Log.d(TAG, "Adding active route: " + btRoute.route); |
| } |
| if (btRoute == null || mActiveRoutes.contains(btRoute)) { |
| return; |
| } |
| setRouteConnectionState(btRoute, STATE_CONNECTED); |
| mActiveRoutes.add(btRoute); |
| } |
| |
| private void removeActiveRoute(BluetoothRouteInfo btRoute) { |
| if (DEBUG) { |
| Log.d(TAG, "Removing active route: " + btRoute.route); |
| } |
| 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.route.getType() == type) { |
| iter.remove(); |
| setRouteConnectionState(btRoute, STATE_DISCONNECTED); |
| } |
| } |
| } |
| |
| private void addActiveHearingAidDevices(BluetoothDevice device) { |
| if (DEBUG) { |
| Log.d(TAG, "Setting active hearing aid devices. device=" + device); |
| } |
| |
| // Let the given device be the first active device |
| BluetoothRouteInfo activeBtRoute = mBluetoothRoutes.get(device.getAddress()); |
| addActiveRoute(activeBtRoute); |
| |
| // A bluetooth route with the same route ID should be added. |
| for (BluetoothRouteInfo btRoute : mBluetoothRoutes.values()) { |
| if (TextUtils.equals(btRoute.route.getId(), activeBtRoute.route.getId()) |
| && !TextUtils.equals(btRoute.btDevice.getAddress(), |
| activeBtRoute.btDevice.getAddress())) { |
| addActiveRoute(btRoute); |
| } |
| } |
| } |
| |
| interface BluetoothRoutesUpdatedListener { |
| void onBluetoothRoutesUpdated(@NonNull List<MediaRoute2Info> routes); |
| } |
| |
| private class BluetoothRouteInfo { |
| public BluetoothDevice btDevice; |
| public MediaRoute2Info route; |
| public SparseBooleanArray connectedProfiles; |
| |
| @MediaRoute2Info.Type |
| int getRouteType() { |
| // Let hearing aid profile have a priority. |
| if (connectedProfiles.get(BluetoothProfile.HEARING_AID, false)) { |
| return MediaRoute2Info.TYPE_HEARING_AID; |
| } |
| return MediaRoute2Info.TYPE_BLUETOOTH_A2DP; |
| } |
| } |
| |
| // These callbacks run on the main thread. |
| private final class BluetoothProfileListener implements BluetoothProfile.ServiceListener { |
| public void onServiceConnected(int profile, BluetoothProfile proxy) { |
| List<BluetoothDevice> activeDevices; |
| switch (profile) { |
| case BluetoothProfile.A2DP: |
| mA2dpProfile = (BluetoothA2dp) proxy; |
| // It may contain null. |
| activeDevices = Collections.singletonList(mA2dpProfile.getActiveDevice()); |
| break; |
| case BluetoothProfile.HEARING_AID: |
| mHearingAidProfile = (BluetoothHearingAid) proxy; |
| activeDevices = mHearingAidProfile.getActiveDevices(); |
| 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(); |
| } |
| |
| public void onServiceDisconnected(int profile) { |
| switch (profile) { |
| case BluetoothProfile.A2DP: |
| mA2dpProfile = null; |
| break; |
| case BluetoothProfile.HEARING_AID: |
| mHearingAidProfile = null; |
| break; |
| default: |
| return; |
| } |
| } |
| } |
| private class BluetoothBroadcastReceiver extends BroadcastReceiver { |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| String action = intent.getAction(); |
| BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); |
| |
| BluetoothEventReceiver receiver = mEventReceiverMap.get(action); |
| if (receiver != null) { |
| receiver.onReceive(context, intent, device); |
| } |
| } |
| } |
| |
| private interface BluetoothEventReceiver { |
| void onReceive(Context context, Intent intent, BluetoothDevice device); |
| } |
| |
| private class AdapterStateChangedReceiver implements BluetoothEventReceiver { |
| public void onReceive(Context context, Intent intent, BluetoothDevice device) { |
| int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1); |
| if (state == BluetoothAdapter.STATE_OFF |
| || state == BluetoothAdapter.STATE_TURNING_OFF) { |
| mBluetoothRoutes.clear(); |
| notifyBluetoothRoutesUpdated(); |
| } else if (state == BluetoothAdapter.STATE_ON) { |
| buildBluetoothRoutes(); |
| if (!mBluetoothRoutes.isEmpty()) { |
| notifyBluetoothRoutesUpdated(); |
| } |
| } |
| } |
| } |
| |
| private class DeviceStateChangedReceiver implements BluetoothEventReceiver { |
| @Override |
| public void onReceive(Context context, Intent intent, BluetoothDevice device) { |
| 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) { |
| addActiveHearingAidDevices(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; |
| } |
| } |
| |
| 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.connectedProfiles.size() > 0) { |
| mBluetoothRoutes.put(device.getAddress(), btRoute); |
| notifyBluetoothRoutesUpdated(); |
| } |
| } else { |
| btRoute.connectedProfiles.put(profile, true); |
| } |
| } else if (state == BluetoothProfile.STATE_DISCONNECTING |
| || state == BluetoothProfile.STATE_DISCONNECTED) { |
| if (btRoute != null) { |
| btRoute.connectedProfiles.delete(profile); |
| if (btRoute.connectedProfiles.size() == 0) { |
| removeActiveRoute(mBluetoothRoutes.remove(device.getAddress())); |
| notifyBluetoothRoutesUpdated(); |
| } |
| } |
| } |
| } |
| } |
| } |