/* * 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.telecom; import static com.android.server.telecom.AudioRoute.BT_AUDIO_ROUTE_TYPES; import static com.android.server.telecom.AudioRoute.TYPE_INVALID; import static com.android.server.telecom.AudioRoute.TYPE_SPEAKER; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; 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.AudioAttributes; import android.media.AudioDeviceAttributes; import android.media.AudioDeviceInfo; import android.media.AudioManager; import android.media.IAudioService; import android.media.audiopolicy.AudioProductStrategy; import android.os.Handler; import android.os.HandlerThread; import android.os.Message; import android.os.RemoteException; import android.telecom.CallAudioState; import android.telecom.Log; import android.telecom.Logging.Session; import android.telecom.VideoProfile; import android.util.ArrayMap; import android.util.Pair; import androidx.annotation.NonNull; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.os.SomeArgs; import com.android.internal.util.IndentingPrintWriter; import com.android.server.telecom.bluetooth.BluetoothRouteManager; import com.android.server.telecom.flags.FeatureFlags; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; public class CallAudioRouteController implements CallAudioRouteAdapter { private static final long TIMEOUT_LIMIT = 2000L; private static final AudioRoute DUMMY_ROUTE = new AudioRoute(TYPE_INVALID, null, null); private static final Map ROUTE_MAP; static { ROUTE_MAP = new ArrayMap<>(); ROUTE_MAP.put(AudioRoute.TYPE_EARPIECE, CallAudioState.ROUTE_EARPIECE); ROUTE_MAP.put(AudioRoute.TYPE_WIRED, CallAudioState.ROUTE_WIRED_HEADSET); ROUTE_MAP.put(AudioRoute.TYPE_SPEAKER, CallAudioState.ROUTE_SPEAKER); ROUTE_MAP.put(AudioRoute.TYPE_DOCK, CallAudioState.ROUTE_SPEAKER); ROUTE_MAP.put(AudioRoute.TYPE_BLUETOOTH_SCO, CallAudioState.ROUTE_BLUETOOTH); ROUTE_MAP.put(AudioRoute.TYPE_BLUETOOTH_HA, CallAudioState.ROUTE_BLUETOOTH); ROUTE_MAP.put(AudioRoute.TYPE_BLUETOOTH_LE, CallAudioState.ROUTE_BLUETOOTH); ROUTE_MAP.put(AudioRoute.TYPE_STREAMING, CallAudioState.ROUTE_STREAMING); } /** Valid values for the first argument for SWITCH_BASELINE_ROUTE */ public static final int INCLUDE_BLUETOOTH_IN_BASELINE = 1; private final CallsManager mCallsManager; private final Context mContext; private AudioManager mAudioManager; private CallAudioManager mCallAudioManager; private final BluetoothRouteManager mBluetoothRouteManager; private final CallAudioManager.AudioServiceFactory mAudioServiceFactory; private final Handler mHandler; private final WiredHeadsetManager mWiredHeadsetManager; private Set mAvailableRoutes; private AudioRoute mCurrentRoute; private AudioRoute mEarpieceWiredRoute; private AudioRoute mSpeakerDockRoute; private AudioRoute mStreamingRoute; private Set mStreamingRoutes; private Map mBluetoothRoutes; private Pair mActiveBluetoothDevice; private Map mActiveDeviceCache; private Map mTypeRoutes; private PendingAudioRoute mPendingAudioRoute; private AudioRoute.Factory mAudioRouteFactory; private StatusBarNotifier mStatusBarNotifier; private FeatureFlags mFeatureFlags; private int mFocusType; private boolean mIsScoAudioConnected; private final Object mLock = new Object(); private final TelecomSystem.SyncRoot mTelecomLock; private final BroadcastReceiver mSpeakerPhoneChangeReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { Log.startSession("CARC.mSPCR"); try { if (AudioManager.ACTION_SPEAKERPHONE_STATE_CHANGED.equals(intent.getAction())) { if (mAudioManager != null) { AudioDeviceInfo info = mAudioManager.getCommunicationDevice(); if ((info != null) && (info.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER)) { if (mCurrentRoute.getType() != AudioRoute.TYPE_SPEAKER) { sendMessageWithSessionInfo(SPEAKER_ON); } } else { sendMessageWithSessionInfo(SPEAKER_OFF); } } } else { Log.w(this, "Received non-speakerphone-change intent"); } } finally { Log.endSession(); } } }; private final BroadcastReceiver mMuteChangeReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { Log.startSession("CARC.mCR"); try { if (AudioManager.ACTION_MICROPHONE_MUTE_CHANGED.equals(intent.getAction())) { if (mCallsManager.isInEmergencyCall()) { Log.i(this, "Mute was externally changed when there's an emergency call. " + "Forcing mute back off."); sendMessageWithSessionInfo(MUTE_OFF); } else { sendMessageWithSessionInfo(MUTE_EXTERNALLY_CHANGED); } } else if (AudioManager.STREAM_MUTE_CHANGED_ACTION.equals(intent.getAction())) { int streamType = intent.getIntExtra(AudioManager.EXTRA_VOLUME_STREAM_TYPE, -1); boolean isStreamMuted = intent.getBooleanExtra( AudioManager.EXTRA_STREAM_VOLUME_MUTED, false); if (streamType == AudioManager.STREAM_RING && !isStreamMuted && mCallAudioManager != null) { Log.i(this, "Ring stream was un-muted."); mCallAudioManager.onRingerModeChange(); } } else { Log.w(this, "Received non-mute-change intent"); } } finally { Log.endSession(); } } }; private CallAudioState mCallAudioState; private boolean mIsMute; private boolean mIsPending; private boolean mIsActive; public CallAudioRouteController( Context context, CallsManager callsManager, CallAudioManager.AudioServiceFactory audioServiceFactory, AudioRoute.Factory audioRouteFactory, WiredHeadsetManager wiredHeadsetManager, BluetoothRouteManager bluetoothRouteManager, StatusBarNotifier statusBarNotifier, FeatureFlags featureFlags) { mContext = context; mCallsManager = callsManager; mAudioManager = context.getSystemService(AudioManager.class); mAudioServiceFactory = audioServiceFactory; mAudioRouteFactory = audioRouteFactory; mWiredHeadsetManager = wiredHeadsetManager; mIsMute = false; mBluetoothRouteManager = bluetoothRouteManager; mStatusBarNotifier = statusBarNotifier; mFeatureFlags = featureFlags; mFocusType = NO_FOCUS; mIsScoAudioConnected = false; mTelecomLock = callsManager.getLock(); HandlerThread handlerThread = new HandlerThread(this.getClass().getSimpleName()); handlerThread.start(); // Register broadcast receivers IntentFilter speakerChangedFilter = new IntentFilter( AudioManager.ACTION_SPEAKERPHONE_STATE_CHANGED); speakerChangedFilter.setPriority(IntentFilter.SYSTEM_HIGH_PRIORITY); context.registerReceiver(mSpeakerPhoneChangeReceiver, speakerChangedFilter); IntentFilter micMuteChangedFilter = new IntentFilter( AudioManager.ACTION_MICROPHONE_MUTE_CHANGED); micMuteChangedFilter.setPriority(IntentFilter.SYSTEM_HIGH_PRIORITY); context.registerReceiver(mMuteChangeReceiver, micMuteChangedFilter); IntentFilter muteChangedFilter = new IntentFilter(AudioManager.STREAM_MUTE_CHANGED_ACTION); muteChangedFilter.setPriority(IntentFilter.SYSTEM_HIGH_PRIORITY); context.registerReceiver(mMuteChangeReceiver, muteChangedFilter); // Create handler mHandler = new Handler(handlerThread.getLooper()) { @Override public void handleMessage(@NonNull Message msg) { synchronized (this) { preHandleMessage(msg); String address; BluetoothDevice bluetoothDevice; int focus; @AudioRoute.AudioRouteType int type; switch (msg.what) { case CONNECT_WIRED_HEADSET: handleWiredHeadsetConnected(); break; case DISCONNECT_WIRED_HEADSET: handleWiredHeadsetDisconnected(); break; case CONNECT_DOCK: handleDockConnected(); break; case DISCONNECT_DOCK: handleDockDisconnected(); break; case BLUETOOTH_DEVICE_LIST_CHANGED: break; case BT_ACTIVE_DEVICE_PRESENT: type = msg.arg1; address = (String) ((SomeArgs) msg.obj).arg2; handleBtActiveDevicePresent(type, address); break; case BT_ACTIVE_DEVICE_GONE: type = msg.arg1; handleBtActiveDeviceGone(type); break; case BT_DEVICE_ADDED: type = msg.arg1; bluetoothDevice = (BluetoothDevice) ((SomeArgs) msg.obj).arg2; handleBtConnected(type, bluetoothDevice); break; case BT_DEVICE_REMOVED: type = msg.arg1; bluetoothDevice = (BluetoothDevice) ((SomeArgs) msg.obj).arg2; handleBtDisconnected(type, bluetoothDevice); break; case SWITCH_EARPIECE: case USER_SWITCH_EARPIECE: handleSwitchEarpiece(); break; case SWITCH_BLUETOOTH: case USER_SWITCH_BLUETOOTH: address = (String) ((SomeArgs) msg.obj).arg2; handleSwitchBluetooth(address); break; case SWITCH_HEADSET: case USER_SWITCH_HEADSET: handleSwitchHeadset(); break; case SWITCH_SPEAKER: case USER_SWITCH_SPEAKER: handleSwitchSpeaker(); break; case SWITCH_BASELINE_ROUTE: address = (String) ((SomeArgs) msg.obj).arg2; handleSwitchBaselineRoute(msg.arg1 == INCLUDE_BLUETOOTH_IN_BASELINE, address); break; case USER_SWITCH_BASELINE_ROUTE: handleSwitchBaselineRoute(msg.arg1 == INCLUDE_BLUETOOTH_IN_BASELINE, null); break; case SPEAKER_ON: handleSpeakerOn(); break; case SPEAKER_OFF: handleSpeakerOff(); break; case STREAMING_FORCE_ENABLED: handleStreamingEnabled(); break; case STREAMING_FORCE_DISABLED: handleStreamingDisabled(); break; case BT_AUDIO_CONNECTED: bluetoothDevice = (BluetoothDevice) ((SomeArgs) msg.obj).arg2; handleBtAudioActive(bluetoothDevice); break; case BT_AUDIO_DISCONNECTED: bluetoothDevice = (BluetoothDevice) ((SomeArgs) msg.obj).arg2; handleBtAudioInactive(bluetoothDevice); break; case MUTE_ON: handleMuteChanged(true); break; case MUTE_OFF: handleMuteChanged(false); break; case MUTE_EXTERNALLY_CHANGED: handleMuteChanged(mAudioManager.isMicrophoneMute()); break; case SWITCH_FOCUS: focus = msg.arg1; handleSwitchFocus(focus); break; case EXIT_PENDING_ROUTE: handleExitPendingRoute(); break; default: break; } postHandleMessage(msg); } } }; } @Override public void initialize() { mAvailableRoutes = new HashSet<>(); mBluetoothRoutes = new LinkedHashMap<>(); mActiveDeviceCache = new HashMap<>(); mActiveDeviceCache.put(AudioRoute.TYPE_BLUETOOTH_SCO, null); mActiveDeviceCache.put(AudioRoute.TYPE_BLUETOOTH_HA, null); mActiveDeviceCache.put(AudioRoute.TYPE_BLUETOOTH_LE, null); mActiveBluetoothDevice = null; mTypeRoutes = new ArrayMap<>(); mStreamingRoutes = new HashSet<>(); mPendingAudioRoute = new PendingAudioRoute(this, mAudioManager, mBluetoothRouteManager); mStreamingRoute = new AudioRoute(AudioRoute.TYPE_STREAMING, null, null); mStreamingRoutes.add(mStreamingRoute); int supportMask = calculateSupportedRouteMaskInit(); if ((supportMask & CallAudioState.ROUTE_SPEAKER) != 0) { // Create speaker routes mSpeakerDockRoute = mAudioRouteFactory.create(AudioRoute.TYPE_SPEAKER, null, mAudioManager); if (mSpeakerDockRoute == null) { Log.w(this, "Can't find available audio device info for route TYPE_SPEAKER"); } else { mTypeRoutes.put(AudioRoute.TYPE_SPEAKER, mSpeakerDockRoute); mAvailableRoutes.add(mSpeakerDockRoute); } } if ((supportMask & CallAudioState.ROUTE_WIRED_HEADSET) != 0) { // Create wired headset routes mEarpieceWiredRoute = mAudioRouteFactory.create(AudioRoute.TYPE_WIRED, null, mAudioManager); if (mEarpieceWiredRoute == null) { Log.w(this, "Can't find available audio device info for route TYPE_WIRED_HEADSET"); } else { mTypeRoutes.put(AudioRoute.TYPE_WIRED, mEarpieceWiredRoute); mAvailableRoutes.add(mEarpieceWiredRoute); } } else if ((supportMask & CallAudioState.ROUTE_EARPIECE) != 0) { // Create earpiece routes mEarpieceWiredRoute = mAudioRouteFactory.create(AudioRoute.TYPE_EARPIECE, null, mAudioManager); if (mEarpieceWiredRoute == null) { Log.w(this, "Can't find available audio device info for route TYPE_EARPIECE"); } else { mTypeRoutes.put(AudioRoute.TYPE_EARPIECE, mEarpieceWiredRoute); mAvailableRoutes.add(mEarpieceWiredRoute); } } // set current route if (mEarpieceWiredRoute != null) { mCurrentRoute = mEarpieceWiredRoute; } else { mCurrentRoute = mSpeakerDockRoute; } mIsActive = false; mCallAudioState = new CallAudioState(mIsMute, ROUTE_MAP.get(mCurrentRoute.getType()), supportMask, null, new HashSet<>()); } @Override public void sendMessageWithSessionInfo(int message) { sendMessageWithSessionInfo(message, 0, (String) null); } @Override public void sendMessageWithSessionInfo(int message, int arg) { sendMessageWithSessionInfo(message, arg, (String) null); } @Override public void sendMessageWithSessionInfo(int message, int arg, String data) { SomeArgs args = SomeArgs.obtain(); args.arg1 = Log.createSubsession(); args.arg2 = data; sendMessage(message, arg, 0, args); } @Override public void sendMessageWithSessionInfo(int message, int arg, BluetoothDevice bluetoothDevice) { SomeArgs args = SomeArgs.obtain(); args.arg1 = Log.createSubsession(); args.arg2 = bluetoothDevice; sendMessage(message, arg, 0, args); } @Override public void sendMessage(int message, Runnable r) { r.run(); } private void sendMessage(int what, int arg1, int arg2, Object obj) { mHandler.sendMessage(Message.obtain(mHandler, what, arg1, arg2, obj)); } @Override public void setCallAudioManager(CallAudioManager callAudioManager) { mCallAudioManager = callAudioManager; } @Override public CallAudioState getCurrentCallAudioState() { return mCallAudioState; } @Override public boolean isHfpDeviceAvailable() { return !mBluetoothRoutes.isEmpty(); } @Override public Handler getAdapterHandler() { return mHandler; } @Override public PendingAudioRoute getPendingAudioRoute() { return mPendingAudioRoute; } @Override public void dump(IndentingPrintWriter pw) { } private void preHandleMessage(Message msg) { if (msg.obj instanceof SomeArgs) { Session session = (Session) ((SomeArgs) msg.obj).arg1; String messageCodeName = MESSAGE_CODE_TO_NAME.get(msg.what, "unknown"); Log.continueSession(session, "CARC.pM_" + messageCodeName); Log.i(this, "Message received: %s=%d, arg1=%d", messageCodeName, msg.what, msg.arg1); } } private void postHandleMessage(Message msg) { Log.endSession(); if (msg.obj instanceof SomeArgs) { ((SomeArgs) msg.obj).recycle(); } } public boolean isActive() { return mIsActive; } public boolean isPending() { return mIsPending; } private void routeTo(boolean active, AudioRoute destRoute) { if (!destRoute.equals(mStreamingRoute) && !getAvailableRoutes().contains(destRoute)) { Log.i(this, "Ignore routing to unavailable route: %s", destRoute); return; } if (mIsPending) { if (destRoute.equals(mPendingAudioRoute.getDestRoute()) && (mIsActive == active)) { return; } Log.i(this, "Override current pending route destination from %s(active=%b) to " + "%s(active=%b)", mPendingAudioRoute.getDestRoute(), mIsActive, destRoute, active); // Ensure we don't keep waiting for SPEAKER_ON if dest route gets overridden. if (active && mPendingAudioRoute.getDestRoute().getType() == TYPE_SPEAKER) { mPendingAudioRoute.clearPendingMessage(new Pair<>(SPEAKER_ON, null)); } // override pending route while keep waiting for still pending messages for the // previous pending route mPendingAudioRoute.setOrigRoute(mIsActive, mPendingAudioRoute.getDestRoute()); } else { if (mCurrentRoute.equals(destRoute) && (mIsActive == active)) { return; } Log.i(this, "Enter pending route, orig%s(active=%b), dest%s(active=%b)", mCurrentRoute, mIsActive, destRoute, active); // route to pending route if (getAvailableRoutes().contains(mCurrentRoute)) { mPendingAudioRoute.setOrigRoute(mIsActive, mCurrentRoute); } else { // Avoid waiting for pending messages for an unavailable route mPendingAudioRoute.setOrigRoute(mIsActive, DUMMY_ROUTE); } mIsPending = true; } mPendingAudioRoute.setDestRoute(active, destRoute, mBluetoothRoutes.get(destRoute), mIsScoAudioConnected); mIsActive = active; mPendingAudioRoute.evaluatePendingState(); postTimeoutMessage(); } private void postTimeoutMessage() { // reset timeout handler mHandler.removeMessages(PENDING_ROUTE_TIMEOUT); mHandler.postDelayed(() -> mHandler.sendMessage( Message.obtain(mHandler, PENDING_ROUTE_TIMEOUT)), TIMEOUT_LIMIT); } private void handleWiredHeadsetConnected() { AudioRoute wiredHeadsetRoute = null; try { wiredHeadsetRoute = mAudioRouteFactory.create(AudioRoute.TYPE_WIRED, null, mAudioManager); } catch (IllegalArgumentException e) { Log.e(this, e, "Can't find available audio device info for route type:" + AudioRoute.DEVICE_TYPE_STRINGS.get(AudioRoute.TYPE_WIRED)); } if (wiredHeadsetRoute != null) { mAvailableRoutes.add(wiredHeadsetRoute); mAvailableRoutes.remove(mEarpieceWiredRoute); mTypeRoutes.put(AudioRoute.TYPE_WIRED, wiredHeadsetRoute); mEarpieceWiredRoute = wiredHeadsetRoute; routeTo(mIsActive, wiredHeadsetRoute); onAvailableRoutesChanged(); } } public void handleWiredHeadsetDisconnected() { // Update audio route states AudioRoute wiredHeadsetRoute = mTypeRoutes.remove(AudioRoute.TYPE_WIRED); if (wiredHeadsetRoute != null) { mAvailableRoutes.remove(wiredHeadsetRoute); mEarpieceWiredRoute = null; } AudioRoute earpieceRoute = mTypeRoutes.get(AudioRoute.TYPE_EARPIECE); if (earpieceRoute != null) { mAvailableRoutes.add(earpieceRoute); mEarpieceWiredRoute = earpieceRoute; } onAvailableRoutesChanged(); // Route to expected state if (mCurrentRoute.equals(wiredHeadsetRoute)) { routeTo(mIsActive, getBaseRoute(true, null)); } } private void handleDockConnected() { AudioRoute dockRoute = null; try { dockRoute = mAudioRouteFactory.create(AudioRoute.TYPE_DOCK, null, mAudioManager); } catch (IllegalArgumentException e) { Log.e(this, e, "Can't find available audio device info for route type:" + AudioRoute.DEVICE_TYPE_STRINGS.get(AudioRoute.TYPE_WIRED)); } if (dockRoute != null) { mAvailableRoutes.add(dockRoute); mAvailableRoutes.remove(mSpeakerDockRoute); mTypeRoutes.put(AudioRoute.TYPE_DOCK, dockRoute); mSpeakerDockRoute = dockRoute; routeTo(mIsActive, dockRoute); onAvailableRoutesChanged(); } } public void handleDockDisconnected() { // Update audio route states AudioRoute dockRoute = mTypeRoutes.get(AudioRoute.TYPE_DOCK); if (dockRoute != null) { mAvailableRoutes.remove(dockRoute); mSpeakerDockRoute = null; } AudioRoute speakerRoute = mTypeRoutes.get(AudioRoute.TYPE_SPEAKER); if (speakerRoute != null) { mAvailableRoutes.add(speakerRoute); mSpeakerDockRoute = speakerRoute; } onAvailableRoutesChanged(); // Route to expected state if (mCurrentRoute.equals(dockRoute)) { routeTo(mIsActive, getBaseRoute(true, null)); } } private void handleStreamingEnabled() { if (!mCurrentRoute.equals(mStreamingRoute)) { routeTo(mIsActive, mStreamingRoute); } else { Log.i(this, "ignore enable streaming, already in streaming"); } } private void handleStreamingDisabled() { if (mCurrentRoute.equals(mStreamingRoute)) { mCurrentRoute = DUMMY_ROUTE; onAvailableRoutesChanged(); routeTo(mIsActive, getBaseRoute(true, null)); } else { Log.i(this, "ignore disable streaming, not in streaming"); } } /** * Handles the case when SCO audio is connected for the BT headset. This follows shortly after * the BT device has been established as an active device (BT_ACTIVE_DEVICE_PRESENT) and doesn't * apply to other BT device types. In this case, the pending audio route will process the * BT_AUDIO_CONNECTED message that will trigger routing to the pending destination audio route; * otherwise, routing will be ignored if there aren't pending routes to be processed. * * Message being handled: BT_AUDIO_CONNECTED */ private void handleBtAudioActive(BluetoothDevice bluetoothDevice) { if (mIsPending) { Log.i(this, "handleBtAudioActive: is pending path"); if (Objects.equals(mPendingAudioRoute.getDestRoute().getBluetoothAddress(), bluetoothDevice.getAddress())) { mPendingAudioRoute.onMessageReceived(new Pair<>(BT_AUDIO_CONNECTED, bluetoothDevice.getAddress()), null); } } else { // ignore, not triggered by telecom Log.i(this, "handleBtAudioActive: ignoring handling bt audio active."); } } /** * Handles the case when SCO audio is disconnected for the BT headset. In this case, the pending * audio route will process the BT_AUDIO_DISCONNECTED message which will trigger routing to the * pending destination audio route; otherwise, routing will be ignored if there aren't any * pending routes to be processed. * * Message being handled: BT_AUDIO_DISCONNECTED */ private void handleBtAudioInactive(BluetoothDevice bluetoothDevice) { if (mIsPending) { Log.i(this, "handleBtAudioInactive: is pending path"); if (Objects.equals(mPendingAudioRoute.getOrigRoute().getBluetoothAddress(), bluetoothDevice.getAddress())) { mPendingAudioRoute.onMessageReceived(new Pair<>(BT_AUDIO_DISCONNECTED, bluetoothDevice.getAddress()), null); } } else { // ignore, not triggered by telecom Log.i(this, "handleBtAudioInactive: ignoring handling bt audio inactive."); } } /** * This particular routing occurs when the BT device is trying to establish itself as a * connected device (refer to BluetoothStateReceiver#handleConnectionStateChanged). The device * is included as an available route and cached into the current BT routes. * * Message being handled: BT_DEVICE_ADDED */ private void handleBtConnected(@AudioRoute.AudioRouteType int type, BluetoothDevice bluetoothDevice) { if (containsHearingAidPair(type, bluetoothDevice)) { return; } AudioRoute bluetoothRoute = mAudioRouteFactory.create(type, bluetoothDevice.getAddress(), mAudioManager); if (bluetoothRoute == null) { Log.w(this, "Can't find available audio device info for route type:" + AudioRoute.DEVICE_TYPE_STRINGS.get(type)); } else { Log.i(this, "bluetooth route added: " + bluetoothRoute); mAvailableRoutes.add(bluetoothRoute); mBluetoothRoutes.put(bluetoothRoute, bluetoothDevice); onAvailableRoutesChanged(); } } /** * Handles the case when the BT device is in a disconnecting/disconnected state. In this case, * the audio route for the specified device is removed from the available BT routes and the * audio is routed to an available route if the current route is pointing to the device which * got disconnected. * * Message being handled: BT_DEVICE_REMOVED */ private void handleBtDisconnected(@AudioRoute.AudioRouteType int type, BluetoothDevice bluetoothDevice) { // Clean up unavailable routes AudioRoute bluetoothRoute = getBluetoothRoute(type, bluetoothDevice.getAddress()); if (bluetoothRoute != null) { Log.i(this, "bluetooth route removed: " + bluetoothRoute); mBluetoothRoutes.remove(bluetoothRoute); mAvailableRoutes.remove(bluetoothRoute); onAvailableRoutesChanged(); } // Fallback to an available route if (Objects.equals(mCurrentRoute, bluetoothRoute)) { routeTo(mIsActive, getBaseRoute(true, null)); } } /** * This particular routing occurs when the specified bluetooth device is marked as the active * device (refer to BluetoothStateReceiver#handleActiveDeviceChanged). This takes care of * moving the call audio route to the bluetooth route. * * Message being handled: BT_ACTIVE_DEVICE_PRESENT */ private void handleBtActiveDevicePresent(@AudioRoute.AudioRouteType int type, String deviceAddress) { AudioRoute bluetoothRoute = getBluetoothRoute(type, deviceAddress); if (bluetoothRoute != null) { Log.i(this, "request to route to bluetooth route: %s (active=%b)", bluetoothRoute, mIsActive); routeTo(mIsActive, bluetoothRoute); } else { Log.i(this, "request to route to unavailable bluetooth route - type (%s), address (%s)", type, deviceAddress); } } /** * Handles routing for when the active BT device is removed for a given audio route type. In * this case, the audio is routed to another available route if the current route hasn't been * adjusted yet or there is a pending destination route associated with the device type that * went inactive. Note that BT_DEVICE_REMOVED will be processed first in this case, which will * handle removing the BT route for the device that went inactive as well as falling back to * an available route. * * Message being handled: BT_ACTIVE_DEVICE_GONE */ private void handleBtActiveDeviceGone(@AudioRoute.AudioRouteType int type) { if ((mIsPending && mPendingAudioRoute.getDestRoute().getType() == type) || (!mIsPending && mCurrentRoute.getType() == type)) { // Fallback to an available route routeTo(mIsActive, getBaseRoute(true, null)); } } private void handleMuteChanged(boolean mute) { mIsMute = mute; if (mIsMute != mAudioManager.isMicrophoneMute() && mIsActive) { IAudioService audioService = mAudioServiceFactory.getAudioService(); Log.i(this, "changing microphone mute state to: %b [serviceIsNull=%b]", mute, audioService == null); if (audioService != null) { try { audioService.setMicrophoneMute(mute, mContext.getOpPackageName(), mCallsManager.getCurrentUserHandle().getIdentifier(), mContext.getAttributionTag()); } catch (RemoteException e) { Log.e(this, e, "Remote exception while toggling mute."); return; } } } onMuteStateChanged(mIsMute); } private void handleSwitchFocus(int focus) { Log.i(this, "handleSwitchFocus: focus (%s)", focus); mFocusType = focus; switch (focus) { case NO_FOCUS -> { if (mIsActive) { // Reset mute state after call ends. handleMuteChanged(false); // Route back to inactive route. routeTo(false, mCurrentRoute); // Clear pending messages mPendingAudioRoute.clearPendingMessages(); } } case ACTIVE_FOCUS -> { // Route to active baseline route (we may need to change audio route in the case // when a video call is put on hold). routeTo(true, getBaseRoute(true, null)); } case RINGING_FOCUS -> { if (!mIsActive) { AudioRoute route = getBaseRoute(true, null); BluetoothDevice device = mBluetoothRoutes.get(route); // Check if in-band ringtone is enabled for the device; if it isn't, move to // inactive route. if (device != null && !mBluetoothRouteManager.isInbandRingEnabled(device)) { routeTo(false, route); } else { routeTo(true, route); } } else { // Route is already active. BluetoothDevice device = mBluetoothRoutes.get(mCurrentRoute); if (device != null && !mBluetoothRouteManager.isInbandRingEnabled(device)) { routeTo(false, mCurrentRoute); } } } } } public void handleSwitchEarpiece() { AudioRoute earpieceRoute = mTypeRoutes.get(AudioRoute.TYPE_EARPIECE); if (earpieceRoute != null && getAvailableRoutes().contains(earpieceRoute)) { routeTo(mIsActive, earpieceRoute); } else { Log.i(this, "ignore switch earpiece request"); } } private void handleSwitchBluetooth(String address) { Log.i(this, "handle switch to bluetooth with address %s", address); AudioRoute bluetoothRoute = null; BluetoothDevice bluetoothDevice = null; if (address == null) { bluetoothRoute = getArbitraryBluetoothDevice(); bluetoothDevice = mBluetoothRoutes.get(bluetoothRoute); } else { for (AudioRoute route : getAvailableRoutes()) { if (Objects.equals(address, route.getBluetoothAddress())) { bluetoothRoute = route; bluetoothDevice = mBluetoothRoutes.get(route); break; } } } if (bluetoothRoute != null && bluetoothDevice != null) { if (mFocusType == RINGING_FOCUS) { routeTo(mBluetoothRouteManager.isInbandRingEnabled(bluetoothDevice) && mIsActive, bluetoothRoute); } else { routeTo(mIsActive, bluetoothRoute); } } else { Log.i(this, "ignore switch bluetooth request to unavailable address"); } } /** * Retrieve the active BT device, if available, otherwise return the most recently tracked * active device, or null if none are available. * @return {@link AudioRoute} of the BT device. */ private AudioRoute getArbitraryBluetoothDevice() { if (mActiveBluetoothDevice != null) { return getBluetoothRoute(mActiveBluetoothDevice.first, mActiveBluetoothDevice.second); } else if (!mBluetoothRoutes.isEmpty()) { return mBluetoothRoutes.keySet().stream().toList().get(mBluetoothRoutes.size() - 1); } return null; } private void handleSwitchHeadset() { AudioRoute headsetRoute = mTypeRoutes.get(AudioRoute.TYPE_WIRED); if (headsetRoute != null && getAvailableRoutes().contains(headsetRoute)) { routeTo(mIsActive, headsetRoute); } else { Log.i(this, "ignore switch headset request"); } } private void handleSwitchSpeaker() { if (mSpeakerDockRoute != null && getAvailableRoutes().contains(mSpeakerDockRoute)) { routeTo(mIsActive, mSpeakerDockRoute); } else { Log.i(this, "ignore switch speaker request"); } } private void handleSwitchBaselineRoute(boolean includeBluetooth, String btAddressToExclude) { routeTo(mIsActive, getBaseRoute(includeBluetooth, btAddressToExclude)); } private void handleSpeakerOn() { if (isPending()) { Log.i(this, "handleSpeakerOn: sending SPEAKER_ON to pending audio route"); mPendingAudioRoute.onMessageReceived(new Pair<>(SPEAKER_ON, null), null); // Update status bar notification if we are in a call. mStatusBarNotifier.notifySpeakerphone(mCallsManager.hasAnyCalls()); } else { if (mSpeakerDockRoute != null && getAvailableRoutes().contains(mSpeakerDockRoute)) { routeTo(mIsActive, mSpeakerDockRoute); // Since the route switching triggered by this message, we need to manually send it // again so that we won't stuck in the pending route if (mIsActive) { sendMessageWithSessionInfo(SPEAKER_ON); } } } } private void handleSpeakerOff() { if (isPending()) { Log.i(this, "handleSpeakerOff - sending SPEAKER_OFF to pending audio route"); mPendingAudioRoute.onMessageReceived(new Pair<>(SPEAKER_OFF, null), null); // Update status bar notification mStatusBarNotifier.notifySpeakerphone(false); } else if (mCurrentRoute.getType() == AudioRoute.TYPE_SPEAKER) { routeTo(mIsActive, getBaseRoute(true, null)); // Since the route switching triggered by this message, we need to manually send it // again so that we won't stuck in the pending route if (mIsActive) { sendMessageWithSessionInfo(SPEAKER_OFF); } onAvailableRoutesChanged(); } } /** * This is invoked when there are no more pending audio routes to be processed, which signals * a change for the current audio route and the call audio state to be updated accordingly. */ public void handleExitPendingRoute() { if (mIsPending) { mCurrentRoute = mPendingAudioRoute.getDestRoute(); Log.addEvent(mCallsManager.getForegroundCall(), LogUtils.Events.AUDIO_ROUTE, "Entering audio route: " + mCurrentRoute + " (active=" + mIsActive + ")"); mIsPending = false; mPendingAudioRoute.clearPendingMessages(); onCurrentRouteChanged(); } } private void onCurrentRouteChanged() { synchronized (mLock) { BluetoothDevice activeBluetoothDevice = null; int route = ROUTE_MAP.get(mCurrentRoute.getType()); if (route == CallAudioState.ROUTE_STREAMING) { updateCallAudioState(new CallAudioState(mIsMute, route, route)); return; } if (route == CallAudioState.ROUTE_BLUETOOTH) { activeBluetoothDevice = mBluetoothRoutes.get(mCurrentRoute); } updateCallAudioState(new CallAudioState(mIsMute, route, mCallAudioState.getRawSupportedRouteMask(), activeBluetoothDevice, mCallAudioState.getSupportedBluetoothDevices())); } } private void onAvailableRoutesChanged() { synchronized (mLock) { int routeMask = 0; Set availableBluetoothDevices = new HashSet<>(); for (AudioRoute route : getAvailableRoutes()) { routeMask |= ROUTE_MAP.get(route.getType()); if (BT_AUDIO_ROUTE_TYPES.contains(route.getType())) { BluetoothDevice deviceToAdd = mBluetoothRoutes.get(route); // Only include the lead device for LE audio (otherwise, the routes will show // two separate devices in the UI). if (route.getType() == AudioRoute.TYPE_BLUETOOTH_LE && getLeAudioService() != null) { int groupId = getLeAudioService().getGroupId(deviceToAdd); if (groupId != BluetoothLeAudio.GROUP_ID_INVALID) { deviceToAdd = getLeAudioService().getConnectedGroupLeadDevice(groupId); } } // This will only ever be null when the lead device (LE) is disconnected and // try to obtain the lead device for the 2nd bud. if (deviceToAdd != null) { availableBluetoothDevices.add(deviceToAdd); } } } updateCallAudioState(new CallAudioState(mIsMute, mCallAudioState.getRoute(), routeMask, mCallAudioState.getActiveBluetoothDevice(), availableBluetoothDevices)); } } private void onMuteStateChanged(boolean mute) { updateCallAudioState(new CallAudioState(mute, mCallAudioState.getRoute(), mCallAudioState.getSupportedRouteMask(), mCallAudioState.getActiveBluetoothDevice(), mCallAudioState.getSupportedBluetoothDevices())); } private void updateCallAudioState(CallAudioState newCallAudioState) { Log.i(this, "updateCallAudioState: updating call audio state to %s", newCallAudioState); CallAudioState oldState = mCallAudioState; mCallAudioState = newCallAudioState; // Update status bar notification mStatusBarNotifier.notifyMute(newCallAudioState.isMuted()); mCallsManager.onCallAudioStateChanged(oldState, mCallAudioState); updateAudioStateForTrackedCalls(mCallAudioState); } private void updateAudioStateForTrackedCalls(CallAudioState newCallAudioState) { Set calls = mCallsManager.getTrackedCalls(); for (Call call : calls) { if (call != null && call.getConnectionService() != null) { call.getConnectionService().onCallAudioStateChanged(call, newCallAudioState); } } } private AudioRoute getPreferredAudioRouteFromStrategy() { // Get audio produce strategy AudioProductStrategy strategy = null; final AudioAttributes attr = new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) .build(); List strategies = AudioManager.getAudioProductStrategies(); for (AudioProductStrategy s : strategies) { if (s.supportsAudioAttributes(attr)) { strategy = s; } } if (strategy == null) { return null; } // Get preferred device AudioDeviceAttributes deviceAttr = mAudioManager.getPreferredDeviceForStrategy(strategy); Log.i(this, "getPreferredAudioRouteFromStrategy: preferred device is %s", deviceAttr); if (deviceAttr == null) { return null; } // Get corresponding audio route @AudioRoute.AudioRouteType int type = AudioRoute.DEVICE_INFO_TYPETO_AUDIO_ROUTE_TYPE.get( deviceAttr.getType()); if (BT_AUDIO_ROUTE_TYPES.contains(type)) { return getBluetoothRoute(type, deviceAttr.getAddress()); } else { return mTypeRoutes.get(deviceAttr.getType()); } } private AudioRoute getPreferredAudioRouteFromDefault(boolean includeBluetooth, String btAddressToExclude) { boolean skipEarpiece; Call foregroundCall = mCallAudioManager.getForegroundCall(); synchronized (mTelecomLock) { skipEarpiece = foregroundCall != null && VideoProfile.isVideo(foregroundCall.getVideoState()); } // Route to earpiece, wired, or speaker route if there are not bluetooth routes or if there // are only wearables available. AudioRoute activeWatchOrNonWatchDeviceRoute = getActiveWatchOrNonWatchDeviceRoute(btAddressToExclude); if (mBluetoothRoutes.isEmpty() || !includeBluetooth || activeWatchOrNonWatchDeviceRoute == null) { Log.i(this, "getPreferredAudioRouteFromDefault: Audio routing defaulting to " + "available non-BT route."); AudioRoute defaultRoute = mEarpieceWiredRoute != null ? mEarpieceWiredRoute : mSpeakerDockRoute; // Ensure that we default to speaker route if we're in a video call, but disregard it if // a wired headset is plugged in. if (skipEarpiece && defaultRoute.getType() == AudioRoute.TYPE_EARPIECE) { Log.i(this, "getPreferredAudioRouteFromDefault: Audio routing defaulting to " + "speaker route for video call."); defaultRoute = mSpeakerDockRoute; } return defaultRoute; } else { // Most recent active route will always be the last in the array (ensure that we don't // auto route to a wearable device unless it's already active). String autoRoutingToWatchExcerpt = mFeatureFlags.ignoreAutoRouteToWatchDevice() ? " (except watch)" : ""; Log.i(this, "getPreferredAudioRouteFromDefault: Audio routing defaulting to " + "most recently active BT route" + autoRoutingToWatchExcerpt + "."); return activeWatchOrNonWatchDeviceRoute; } } private int calculateSupportedRouteMaskInit() { Log.i(this, "calculateSupportedRouteMaskInit: is wired headset plugged in - %s", mWiredHeadsetManager.isPluggedIn()); int routeMask = CallAudioState.ROUTE_SPEAKER; if (mWiredHeadsetManager.isPluggedIn()) { routeMask |= CallAudioState.ROUTE_WIRED_HEADSET; } else { AudioDeviceInfo[] deviceList = mAudioManager.getDevices( AudioManager.GET_DEVICES_OUTPUTS); for (AudioDeviceInfo device: deviceList) { if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE) { routeMask |= CallAudioState.ROUTE_EARPIECE; break; } } } return routeMask; } @VisibleForTesting public Set getAvailableRoutes() { if (mCurrentRoute.equals(mStreamingRoute)) { return mStreamingRoutes; } else { return mAvailableRoutes; } } public AudioRoute getCurrentRoute() { return mCurrentRoute; } public AudioRoute getBluetoothRoute(@AudioRoute.AudioRouteType int audioRouteType, String address) { for (AudioRoute route : mBluetoothRoutes.keySet()) { if (route.getType() == audioRouteType && route.getBluetoothAddress().equals(address)) { return route; } } return null; } public AudioRoute getBaseRoute(boolean includeBluetooth, String btAddressToExclude) { AudioRoute destRoute = getPreferredAudioRouteFromStrategy(); if (destRoute == null || (destRoute.getBluetoothAddress() != null && !includeBluetooth)) { destRoute = getPreferredAudioRouteFromDefault(includeBluetooth, btAddressToExclude); } if (destRoute != null && !getAvailableRoutes().contains(destRoute)) { destRoute = null; } Log.i(this, "getBaseRoute - audio routing to %s", destRoute); return destRoute; } /** * Don't add additional AudioRoute when a hearing aid pair is detected. The devices have * separate addresses, so we need to perform explicit handling to ensure we don't * treat them as two separate devices. */ private boolean containsHearingAidPair(@AudioRoute.AudioRouteType int type, BluetoothDevice bluetoothDevice) { // Check if it is a hearing aid pair and skip connecting to the other device in this case. // Traverse mBluetoothRoutes backwards as the most recently active device will be inserted // last. String existingHearingAidAddress = null; List bluetoothRoutes = mBluetoothRoutes.keySet().stream().toList(); for (int i = bluetoothRoutes.size() - 1; i >= 0; i--) { AudioRoute audioRoute = bluetoothRoutes.get(i); if (audioRoute.getType() == AudioRoute.TYPE_BLUETOOTH_HA) { existingHearingAidAddress = audioRoute.getBluetoothAddress(); break; } } // Check that route is for hearing aid and that there exists another hearing aid route // created for the first device (of the pair) that was connected. if (type == AudioRoute.TYPE_BLUETOOTH_HA && existingHearingAidAddress != null) { BluetoothAdapter bluetoothAdapter = mBluetoothRouteManager.getDeviceManager() .getBluetoothAdapter(); if (bluetoothAdapter != null) { List activeHearingAids = bluetoothAdapter.getActiveDevices(BluetoothProfile.HEARING_AID); for (BluetoothDevice hearingAid : activeHearingAids) { if (hearingAid != null && hearingAid.getAddress() != null) { String address = hearingAid.getAddress(); if (address.equals(bluetoothDevice.getAddress()) || address.equals(existingHearingAidAddress)) { Log.i(this, "containsHearingAidPair: Detected a hearing aid " + "pair, ignoring creating a new AudioRoute"); return true; } } } } } return false; } /** * Prevent auto routing to a wearable device when calculating the default bluetooth audio route * to move to. This function ensures that the most recently active non-wearable device is * selected for routing unless a wearable device has already been identified as an active * device. */ private AudioRoute getActiveWatchOrNonWatchDeviceRoute(String btAddressToExclude) { if (!mFeatureFlags.ignoreAutoRouteToWatchDevice()) { Log.i(this, "getActiveWatchOrNonWatchDeviceRoute: ignore_auto_route_to_watch_device " + "flag is disabled. Routing to most recently reported active device."); return getMostRecentlyActiveBtRoute(btAddressToExclude); } List bluetoothRoutes = mBluetoothRoutes.keySet().stream().toList(); // Traverse the routes from the most recently active recorded devices first. AudioRoute nonWatchDeviceRoute = null; for (int i = bluetoothRoutes.size() - 1; i >= 0; i--) { AudioRoute route = bluetoothRoutes.get(i); BluetoothDevice device = mBluetoothRoutes.get(route); // Skip excluded BT address and LE audio if it's not the lead device. if (route.getBluetoothAddress().equals(btAddressToExclude) || isLeAudioNonLeadDeviceOrServiceUnavailable(route.getType(), device)) { continue; } // Check if the most recently active device is a watch device. if (i == (bluetoothRoutes.size() - 1) && device.equals(mCallAudioState .getActiveBluetoothDevice()) && mBluetoothRouteManager.isWatch(device)) { Log.i(this, "getActiveWatchOrNonWatchDeviceRoute: Routing to active watch - %s", bluetoothRoutes.get(0)); return bluetoothRoutes.get(0); } // Record the first occurrence of a non-watch device route if found. if (!mBluetoothRouteManager.isWatch(device) && nonWatchDeviceRoute == null) { nonWatchDeviceRoute = route; break; } } Log.i(this, "Routing to a non-watch device - %s", nonWatchDeviceRoute); return nonWatchDeviceRoute; } /** * Returns the most actively reported bluetooth route excluding the passed in route. */ private AudioRoute getMostRecentlyActiveBtRoute(String btAddressToExclude) { List bluetoothRoutes = mBluetoothRoutes.keySet().stream().toList(); for (int i = bluetoothRoutes.size() - 1; i >= 0; i--) { AudioRoute route = bluetoothRoutes.get(i); // Skip LE route if it's not the lead device. if (isLeAudioNonLeadDeviceOrServiceUnavailable( route.getType(), mBluetoothRoutes.get(route))) { continue; } if (!route.getBluetoothAddress().equals(btAddressToExclude)) { return route; } } return null; } private boolean isLeAudioNonLeadDeviceOrServiceUnavailable(@AudioRoute.AudioRouteType int type, BluetoothDevice device) { if (type != AudioRoute.TYPE_BLUETOOTH_LE) { return false; } else if (getLeAudioService() == null) { return true; } int groupId = getLeAudioService().getGroupId(device); if (groupId != BluetoothLeAudio.GROUP_ID_INVALID) { BluetoothDevice leadDevice = getLeAudioService().getConnectedGroupLeadDevice(groupId); Log.i(this, "Lead device for device (%s) is %s.", device, leadDevice); return leadDevice == null || !device.getAddress().equals(leadDevice.getAddress()); } return false; } private BluetoothLeAudio getLeAudioService() { return mBluetoothRouteManager.getDeviceManager().getLeAudioService(); } @VisibleForTesting public void setAudioManager(AudioManager audioManager) { mAudioManager = audioManager; } @VisibleForTesting public void setAudioRouteFactory(AudioRoute.Factory audioRouteFactory) { mAudioRouteFactory = audioRouteFactory; } public Map getBluetoothRoutes() { return mBluetoothRoutes; } public void overrideIsPending(boolean isPending) { mIsPending = isPending; } public void setIsScoAudioConnected(boolean value) { mIsScoAudioConnected = value; } /** * Update the active bluetooth device being tracked (as well as for individual profiles). * We need to keep track of active devices for individual profiles because of potential * inconsistencies found in BluetoothStateReceiver#handleActiveDeviceChanged. When multiple * profiles are paired, we could have a scenario where an active device A is replaced * with an active device B (from a different profile), which is then removed as an active * device shortly after, causing device A to be reactive. It's possible that the active device * changed intent is never received again for device A so an active device cache is necessary * to track these devices at a profile level. * @param device {@link Pair} containing the BT audio route type (i.e. SCO/HA/LE) and the * address of the device. */ public void updateActiveBluetoothDevice(Pair device) { mActiveDeviceCache.put(device.first, device.second); // Update most recently active device if address isn't null (meaning some device is active). if (device.second != null) { mActiveBluetoothDevice = device; } else { // If a device was removed, check to ensure that no other device is still considered // active. boolean hasActiveDevice = false; for (String address : mActiveDeviceCache.values()) { if (address != null) { hasActiveDevice = true; break; } } if (!hasActiveDevice) { mActiveBluetoothDevice = null; } } } @VisibleForTesting public void setActive(boolean active) { if (active) { mFocusType = ACTIVE_FOCUS; } else { mFocusType = NO_FOCUS; } mIsActive = active; } }