Merge "Prevent reset of EmergencyCallDiagnosticLogger active call timestamp" into main
diff --git a/flags/telecom_api_flags.aconfig b/flags/telecom_api_flags.aconfig
index 40b75a2..c44bea4 100644
--- a/flags/telecom_api_flags.aconfig
+++ b/flags/telecom_api_flags.aconfig
@@ -30,6 +30,20 @@
 }
 
 flag{
+  name: "get_registered_phone_accounts"
+  namespace: "telecom"
+  description: "When set, self-managed clients can get their own phone accounts"
+  bug: "317132586"
+}
+
+flag{
+  name: "transactional_video_state"
+  namespace: "telecom"
+  description: "when set, clients using transactional implementations will be able to set & get the video state"
+  bug: "311265260"
+}
+
+flag{
   name: "business_call_composer"
   namespace: "telecom"
   description: "Enables enriched calling features (e.g. Business name will show for a call)"
diff --git a/flags/telecom_calls_manager_flags.aconfig b/flags/telecom_calls_manager_flags.aconfig
index cdfcc30..de17eee 100644
--- a/flags/telecom_calls_manager_flags.aconfig
+++ b/flags/telecom_calls_manager_flags.aconfig
@@ -14,3 +14,10 @@
   description: "This fix ensures the MO calls won't switch from Active to Quite b/c setDialing was not called"
   bug: "309540769"
 }
+
+flag {
+  name: "enable_call_sequencing"
+  namespace: "telecom"
+  description: "Enables simultaneous call sequencing for SIM PhoneAccounts"
+  bug: "297446980"
+}
diff --git a/src/com/android/server/telecom/AsyncRingtonePlayer.java b/src/com/android/server/telecom/AsyncRingtonePlayer.java
index 912305b..3b5e342 100644
--- a/src/com/android/server/telecom/AsyncRingtonePlayer.java
+++ b/src/com/android/server/telecom/AsyncRingtonePlayer.java
@@ -26,6 +26,8 @@
 import android.os.Message;
 import android.telecom.Log;
 import android.telecom.Logging.Session;
+import android.util.Pair;
+
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.os.SomeArgs;
 import com.android.internal.util.Preconditions;
@@ -81,16 +83,17 @@
      * If {@link VolumeShaper.Configuration} is specified, it is applied to the ringtone to change
      * the volume of the ringtone as it plays.
      *
-     * @param ringtoneSupplier The {@link Ringtone} factory.
+     * @param ringtoneInfoSupplier The {@link Ringtone} factory.
      * @param ringtoneConsumer The {@link Ringtone} post-creation callback (to start the vibration).
      * @param isHfpDeviceConnected True if there is a HFP BT device connected, false otherwise.
      */
-    public void play(@NonNull Supplier<Ringtone> ringtoneSupplier,
-            BiConsumer<Ringtone, Boolean> ringtoneConsumer,  boolean isHfpDeviceConnected) {
+    public void play(@NonNull Supplier<Pair<Uri, Ringtone>> ringtoneInfoSupplier,
+            BiConsumer<Pair<Uri, Ringtone>, Boolean> ringtoneConsumer,
+            boolean isHfpDeviceConnected) {
         Log.d(this, "Posting play.");
         mIsPlaying = true;
         SomeArgs args = SomeArgs.obtain();
-        args.arg1 = ringtoneSupplier;
+        args.arg1 = ringtoneInfoSupplier;
         args.arg2 = ringtoneConsumer;
         args.arg3 = Log.createSubsession();
         args.arg4 = prepareRingingReadyLatch(isHfpDeviceConnected);
@@ -209,8 +212,10 @@
      * Starts the actual playback of the ringtone. Executes on ringtone-thread.
      */
     private void handlePlay(SomeArgs args) {
-        Supplier<Ringtone> ringtoneSupplier = (Supplier<Ringtone>) args.arg1;
-        BiConsumer<Ringtone, Boolean> ringtoneConsumer = (BiConsumer<Ringtone, Boolean>) args.arg2;
+        Supplier<Pair<Uri, Ringtone>> ringtoneInfoSupplier =
+                (Supplier<Pair<Uri, Ringtone>>) args.arg1;
+        BiConsumer<Pair<Uri, Ringtone>, Boolean> ringtoneConsumer =
+                (BiConsumer<Pair<Uri, Ringtone>, Boolean>) args.arg2;
         Session session = (Session) args.arg3;
         CountDownLatch ringingReadyLatch = (CountDownLatch) args.arg4;
         args.recycle();
@@ -226,6 +231,7 @@
                 return;
             }
             Ringtone ringtone = null;
+            Uri ringtoneUri = null;
             boolean hasStopped = false;
             try {
                 try {
@@ -236,7 +242,11 @@
                 } catch (InterruptedException e) {
                     Log.w(this, "handlePlay: latch exception: " + e);
                 }
-                ringtone = ringtoneSupplier.get();
+                if (ringtoneInfoSupplier != null && ringtoneInfoSupplier.get() != null) {
+                    ringtoneUri = ringtoneInfoSupplier.get().first;
+                    ringtone = ringtoneInfoSupplier.get().second;
+                }
+
                 // Ringtone supply can be slow or stop command could have been issued while waiting
                 // for BT to move to CONNECTED state. Re-check for stop event.
                 if (mHandler.hasMessages(EVENT_STOP)) {
@@ -253,8 +263,7 @@
                     Log.w(this, "No ringtone was found bail out from playing.");
                     return;
                 }
-                Uri uri = mRingtone.getUri();
-                String uriString = (uri != null ? uri.toSafeString() : "");
+                String uriString = ringtoneUri != null ? ringtoneUri.toSafeString() : "";
                 Log.i(this, "handlePlay: Play ringtone. Uri: " + uriString);
                 mRingtone.setLooping(true);
                 if (mRingtone.isPlaying()) {
@@ -265,7 +274,7 @@
                 Log.i(this, "Play ringtone, looping.");
             } finally {
                 removePendingRingingReadyLatch(ringingReadyLatch);
-                ringtoneConsumer.accept(ringtone, hasStopped);
+                ringtoneConsumer.accept(new Pair(ringtoneUri, ringtone), hasStopped);
             }
         } finally {
             Log.cancelSubsession(session);
diff --git a/src/com/android/server/telecom/AudioRoute.java b/src/com/android/server/telecom/AudioRoute.java
index 5037cf5..cdf44a8 100644
--- a/src/com/android/server/telecom/AudioRoute.java
+++ b/src/com/android/server/telecom/AudioRoute.java
@@ -82,13 +82,9 @@
                 }
             }
             if (routeInfo == null) {
-                CompletableFuture<Boolean> future = new CompletableFuture<>();
-                mScheduledExecutorService.schedule(new Runnable() {
-                    @Override
-                    public void run() {
-                        createRetry(type, bluetoothAddress, audioManager, retryCount - 1);
-                    }
-                }, RETRY_TIME_DELAY, TimeUnit.MILLISECONDS);
+                mScheduledExecutorService.schedule(
+                        () -> createRetry(type, bluetoothAddress, audioManager, retryCount - 1),
+                        RETRY_TIME_DELAY, TimeUnit.MILLISECONDS);
             } else {
                 mAudioRouteFuture.complete(new AudioRoute(type, bluetoothAddress, routeInfo));
             }
@@ -105,6 +101,7 @@
     public static final int TYPE_BLUETOOTH_SCO = 5;
     public static final int TYPE_BLUETOOTH_HA = 6;
     public static final int TYPE_BLUETOOTH_LE = 7;
+    public static final int TYPE_STREAMING = 8;
     @IntDef(prefix = "TYPE", value = {
             TYPE_INVALID,
             TYPE_EARPIECE,
@@ -113,7 +110,8 @@
             TYPE_DOCK,
             TYPE_BLUETOOTH_SCO,
             TYPE_BLUETOOTH_HA,
-            TYPE_BLUETOOTH_LE
+            TYPE_BLUETOOTH_LE,
+            TYPE_STREAMING
     })
     @Retention(RetentionPolicy.SOURCE)
     public @interface AudioRouteType {}
@@ -145,6 +143,7 @@
         DEVICE_TYPE_STRINGS.put(TYPE_BLUETOOTH_SCO, "TYPE_BLUETOOTH_SCO");
         DEVICE_TYPE_STRINGS.put(TYPE_BLUETOOTH_HA, "TYPE_BLUETOOTH_HA");
         DEVICE_TYPE_STRINGS.put(TYPE_BLUETOOTH_LE, "TYPE_BLUETOOTH_LE");
+        DEVICE_TYPE_STRINGS.put(TYPE_STREAMING, "TYPE_STREAMING");
     }
 
     public static final HashMap<Integer, Integer> DEVICE_INFO_TYPETO_AUDIO_ROUTE_TYPE;
@@ -225,6 +224,7 @@
     void onDestRouteAsPendingRoute(boolean active, PendingAudioRoute pendingAudioRoute,
                                    AudioManager audioManager) {
         if (pendingAudioRoute.isActive() && !active) {
+            Log.i(this, "clearCommunicationDevice");
             audioManager.clearCommunicationDevice();
         } else if (active) {
             if (mAudioRouteType == TYPE_BLUETOOTH_SCO) {
diff --git a/src/com/android/server/telecom/Call.java b/src/com/android/server/telecom/Call.java
index 624399b..f7ad93f 100644
--- a/src/com/android/server/telecom/Call.java
+++ b/src/com/android/server/telecom/Call.java
@@ -19,6 +19,8 @@
 import static android.provider.CallLog.Calls.MISSED_REASON_NOT_MISSED;
 import static android.telephony.TelephonyManager.EVENT_DISPLAY_EMERGENCY_MESSAGE;
 
+import static com.android.server.telecom.voip.VideoStateTranslation.VideoProfileStateToTransactionalVideoState;
+
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.content.Context;
@@ -652,6 +654,36 @@
     private boolean mIsVideoCallingSupportedByPhoneAccount = false;
 
     /**
+     * Indicates whether this individual calls video state can be changed as opposed to be gated
+     * by the {@link PhoneAccount}.
+     *
+     * {@code True} if the call is Transactional && has the CallAttributes.SUPPORTS_VIDEO_CALLING
+     * capability {@code false} otherwise.
+     */
+    private boolean mTransactionalCallSupportsVideoCalling = false;
+
+    public void setTransactionalCallSupportsVideoCalling(CallAttributes callAttributes) {
+        if (!mIsTransactionalCall) {
+            Log.i(this, "setTransactionalCallSupportsVideoCalling: call is not transactional");
+            return;
+        }
+        if (callAttributes == null) {
+            Log.i(this, "setTransactionalCallSupportsVideoCalling: callAttributes is null");
+            return;
+        }
+        if ((callAttributes.getCallCapabilities() & CallAttributes.SUPPORTS_VIDEO_CALLING)
+                == CallAttributes.SUPPORTS_VIDEO_CALLING) {
+            mTransactionalCallSupportsVideoCalling = true;
+        } else {
+            mTransactionalCallSupportsVideoCalling = false;
+        }
+    }
+
+    public boolean isTransactionalCallSupportsVideoCalling() {
+        return mTransactionalCallSupportsVideoCalling;
+    }
+
+    /**
      * Indicates whether or not this call can be pulled if it is an external call. If true, respect
      * the Connection Capability set by the ConnectionService. If false, override the capability
      * set and always remove the ability to pull this external call.
@@ -3176,8 +3208,7 @@
             } else if (mConnectionService != null) {
                 mConnectionService.onExtrasChanged(this, mExtras);
             } else {
-                Log.e(this, new NullPointerException(),
-                        "putExtras failed due to null CS callId=%s", getId());
+                Log.w(this, "putExtras failed due to null CS callId=%s", getId());
             }
         }
     }
@@ -4023,6 +4054,15 @@
             videoState = VideoProfile.STATE_AUDIO_ONLY;
         }
 
+        // Transactional calls have the ability to change video calling capabilities on a per-call
+        // basis as opposed to ConnectionService calls which are only based on the PhoneAccount.
+        if (mFlags.transactionalVideoState()
+                && mIsTransactionalCall && !mTransactionalCallSupportsVideoCalling) {
+            Log.i(this, "setVideoState: The transactional does NOT support video calling."
+                    + " defaulted to audio (video not supported)");
+            videoState = VideoProfile.STATE_AUDIO_ONLY;
+        }
+
         // Track Video State history during the duration of the call.
         // Only update the history when the call is active or disconnected. This ensures we do
         // not include the video state history when:
@@ -4045,6 +4085,12 @@
             }
         }
 
+        if (mFlags.transactionalVideoState()
+                && mIsTransactionalCall && mTransactionalService != null) {
+            int transactionalVS = VideoProfileStateToTransactionalVideoState(mVideoState);
+            mTransactionalService.onVideoStateChanged(this, transactionalVS);
+        }
+
         if (VideoProfile.isVideo(videoState)) {
             mAnalytics.setCallIsVideo(true);
         }
diff --git a/src/com/android/server/telecom/CallAudioRouteAdapter.java b/src/com/android/server/telecom/CallAudioRouteAdapter.java
index f76d47d..5585d09 100644
--- a/src/com/android/server/telecom/CallAudioRouteAdapter.java
+++ b/src/com/android/server/telecom/CallAudioRouteAdapter.java
@@ -97,6 +97,9 @@
         put(SPEAKER_ON, "SPEAKER_ON");
         put(SPEAKER_OFF, "SPEAKER_OFF");
 
+        put(STREAMING_FORCE_ENABLED, "STREAMING_FORCE_ENABLED");
+        put(STREAMING_FORCE_DISABLED, "STREAMING_FORCE_DISABLED");
+
         put(USER_SWITCH_EARPIECE, "USER_SWITCH_EARPIECE");
         put(USER_SWITCH_BLUETOOTH, "USER_SWITCH_BLUETOOTH");
         put(USER_SWITCH_HEADSET, "USER_SWITCH_HEADSET");
diff --git a/src/com/android/server/telecom/CallAudioRouteController.java b/src/com/android/server/telecom/CallAudioRouteController.java
index c1d7d0c..091c8fc 100644
--- a/src/com/android/server/telecom/CallAudioRouteController.java
+++ b/src/com/android/server/telecom/CallAudioRouteController.java
@@ -19,24 +19,36 @@
 import static com.android.server.telecom.AudioRoute.BT_AUDIO_ROUTE_TYPES;
 import static com.android.server.telecom.AudioRoute.TYPE_INVALID;
 
+import android.app.ActivityManager;
 import android.bluetooth.BluetoothDevice;
+import android.content.BroadcastReceiver;
 import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.UserInfo;
 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.Binder;
 import android.os.Handler;
 import android.os.HandlerThread;
 import android.os.Message;
+import android.os.RemoteException;
+import android.os.UserHandle;
 import android.telecom.CallAudioState;
 import android.telecom.Log;
 import android.telecom.Logging.Session;
 import android.util.ArrayMap;
 
+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 java.util.HashSet;
 import java.util.List;
@@ -57,20 +69,83 @@
         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);
     }
 
     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<AudioRoute> mAvailableRoutes;
     private AudioRoute mCurrentRoute;
     private AudioRoute mEarpieceWiredRoute;
     private AudioRoute mSpeakerDockRoute;
+    private AudioRoute mStreamingRoute;
+    private Set<AudioRoute> mStreamingRoutes;
     private Map<AudioRoute, BluetoothDevice> mBluetoothRoutes;
     private Map<Integer, AudioRoute> mTypeRoutes;
     private PendingAudioRoute mPendingAudioRoute;
     private AudioRoute.Factory mAudioRouteFactory;
+    private int mFocusType;
+    private final Object mLock = new Object();
+    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)) {
+                            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;
@@ -79,59 +154,142 @@
     public CallAudioRouteController(
             Context context,
             CallsManager callsManager,
+            CallAudioManager.AudioServiceFactory audioServiceFactory,
             AudioRoute.Factory audioRouteFactory,
-            WiredHeadsetManager wiredHeadsetManager) {
+            WiredHeadsetManager wiredHeadsetManager,
+            BluetoothRouteManager bluetoothRouteManager) {
+        mContext = context;
         mCallsManager = callsManager;
         mAudioManager = context.getSystemService(AudioManager.class);
+        mAudioServiceFactory = audioServiceFactory;
         mAudioRouteFactory = audioRouteFactory;
         mWiredHeadsetManager = wiredHeadsetManager;
         mIsMute = false;
+        mBluetoothRouteManager = bluetoothRouteManager;
+        mFocusType = NO_FOCUS;
         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(Message msg) {
-                preHandleMessage(msg);
-                String address;
-                BluetoothDevice bluetoothDevice;
-                @AudioRoute.AudioRouteType int type;
-                switch (msg.what) {
-                    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 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 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 EXIT_PENDING_ROUTE:
-                        handleExitPendingRoute();
-                        break;
-                    default:
-                        break;
+            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 USER_SWITCH_BASELINE_ROUTE:
+                            handleSwitchBaselineRoute();
+                            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.isMasterMute());
+                            break;
+                        case SWITCH_FOCUS:
+                            focus = msg.arg1;
+                            handleSwitchFocus(focus);
+                            break;
+                        case EXIT_PENDING_ROUTE:
+                            handleExitPendingRoute();
+                            break;
+                        default:
+                            break;
+                    }
+                    postHandleMessage(msg);
                 }
-                postHandleMessage(msg);
             }
         };
     }
@@ -140,7 +298,10 @@
         mAvailableRoutes = new HashSet<>();
         mBluetoothRoutes = new ArrayMap<>();
         mTypeRoutes = new ArrayMap<>();
+        mStreamingRoutes = new HashSet<>();
         mPendingAudioRoute = new PendingAudioRoute(this, mAudioManager);
+        mStreamingRoute = new AudioRoute(AudioRoute.TYPE_STREAMING, null, null);
+        mStreamingRoutes.add(mStreamingRoute);
 
         int supportMask = calculateSupportedRouteMask();
         if ((supportMask & CallAudioState.ROUTE_SPEAKER) != 0) {
@@ -225,6 +386,7 @@
 
     @Override
     public void setCallAudioManager(CallAudioManager callAudioManager) {
+        mCallAudioManager = callAudioManager;
     }
 
     @Override
@@ -271,6 +433,9 @@
     }
 
     private void routeTo(boolean active, AudioRoute destRoute) {
+        if (!destRoute.equals(mStreamingRoute) && !getAvailableRoutes().contains(destRoute)) {
+            return;
+        }
         if (mIsPending) {
             if (destRoute.equals(mPendingAudioRoute.getDestRoute()) && (mIsActive == active)) {
                 return;
@@ -280,22 +445,24 @@
                     mPendingAudioRoute.getDestRoute(), mIsActive, destRoute, active);
             // override pending route while keep waiting for still pending messages for the
             // previous pending route
+            mIsActive = active;
             mPendingAudioRoute.setOrigRoute(mIsActive, mPendingAudioRoute.getDestRoute());
             mPendingAudioRoute.setDestRoute(active, destRoute);
         } else {
-            if (mCurrentRoute.equals(destRoute) && (mIsActive = active)) {
+            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 (mAvailableRoutes.contains(mCurrentRoute)) {
+            if (getAvailableRoutes().contains(mCurrentRoute)) {
                 mPendingAudioRoute.setOrigRoute(mIsActive, mCurrentRoute);
             } else {
                 // Avoid waiting for pending messages for an unavailable route
                 mPendingAudioRoute.setOrigRoute(mIsActive, DUMMY_ROUTE);
             }
             mPendingAudioRoute.setDestRoute(active, destRoute);
+            mIsActive = active;
             mIsPending = true;
         }
         mPendingAudioRoute.evaluatePendingState();
@@ -309,6 +476,103 @@
                 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));
+        }
+    }
+
+    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));
+        }
+    }
+
+    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));
+        } else {
+            Log.i(this, "ignore disable streaming, not in streaming");
+        }
+    }
+
     private void handleBtAudioActive(BluetoothDevice bluetoothDevice) {
         if (mIsPending) {
             if (Objects.equals(mPendingAudioRoute.getDestRoute().getBluetoothAddress(),
@@ -360,13 +624,7 @@
 
         // Fallback to an available route
         if (Objects.equals(mCurrentRoute, bluetoothRoute)) {
-            // fallback policy
-            AudioRoute destRoute = getPreferredAudioRouteFromStrategy();
-            if (destRoute != null && mAvailableRoutes.contains(destRoute)) {
-                routeTo(mIsActive, destRoute);
-            } else {
-                routeTo(mIsActive, getPreferredAudioRouteFromDefault(true/* includeBluetooth */));
-            }
+            routeTo(mIsActive, getBaseRoute(false));
         }
     }
 
@@ -384,11 +642,142 @@
         if ((mIsPending && mPendingAudioRoute.getDestRoute().getType() == type)
                 || (!mIsPending && mCurrentRoute.getType() == type)) {
             // Fallback to an available route
-            AudioRoute destRoute = getPreferredAudioRouteFromStrategy();
-            if (destRoute != null && mAvailableRoutes.contains(destRoute)) {
-                routeTo(mIsActive, destRoute);
+            routeTo(mIsActive, getBaseRoute(true));
+        }
+    }
+
+    private void handleMuteChanged(boolean mute) {
+        mIsMute = mute;
+        if (mIsMute != mAudioManager.isMasterMute() && 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) {
+        mFocusType = focus;
+        switch (focus) {
+            case NO_FOCUS -> {
+                if (mIsActive) {
+                    handleMuteChanged(false);
+                    routeTo(false, mCurrentRoute);
+                }
+            }
+            case ACTIVE_FOCUS -> {
+                if (!mIsActive) {
+                    routeTo(true, getBaseRoute(true));
+                }
+            }
+            case RINGING_FOCUS -> {
+                if (!mIsActive) {
+                    AudioRoute route = getBaseRoute(true);
+                    BluetoothDevice device = mBluetoothRoutes.get(route);
+                    if (device != null && !mBluetoothRouteManager.isInbandRingEnabled(device)) {
+                        routeTo(false, route);
+                    } else {
+                        routeTo(true, route);
+                    }
+                } else {
+                    // 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;
+        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, getPreferredAudioRouteFromDefault(false/* includeBluetooth */));
+                routeTo(mIsActive, bluetoothRoute);
+            }
+        } else {
+            Log.i(this, "ignore switch bluetooth request");
+        }
+    }
+
+    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 speaker request");
+        }
+    }
+
+    private void handleSwitchSpeaker() {
+        if (mSpeakerDockRoute != null && getAvailableRoutes().contains(mSpeakerDockRoute)) {
+            routeTo(mIsActive, mSpeakerDockRoute);
+        } else {
+            Log.i(this, "ignore switch speaker request");
+        }
+    }
+
+    private void handleSwitchBaselineRoute() {
+        routeTo(mIsActive, getBaseRoute(true));
+    }
+
+    private void handleSpeakerOn() {
+        if (isPending()) {
+            mPendingAudioRoute.onMessageReceived(SPEAKER_ON);
+        } 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()) {
+            mPendingAudioRoute.onMessageReceived(SPEAKER_OFF);
+        } else if (mCurrentRoute.getType() == AudioRoute.TYPE_SPEAKER) {
+            routeTo(mIsActive, getBaseRoute(true));
+            // 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();
         }
@@ -397,39 +786,53 @@
     public void handleExitPendingRoute() {
         if (mIsPending) {
             Log.i(this, "Exit pending route and enter %s(active=%b)",
-                    mPendingAudioRoute.getDestRoute(), mPendingAudioRoute.isActive());
+                    mPendingAudioRoute.getDestRoute(), mIsActive);
             mCurrentRoute = mPendingAudioRoute.getDestRoute();
-            mIsActive = mPendingAudioRoute.isActive();
             mIsPending = false;
             onCurrentRouteChanged();
         }
     }
 
     private void onCurrentRouteChanged() {
-        BluetoothDevice activeBluetoothDevice = null;
-        int route = ROUTE_MAP.get(mCurrentRoute.getType());
-        if (route == CallAudioState.ROUTE_BLUETOOTH) {
-            activeBluetoothDevice = mBluetoothRoutes.get(mCurrentRoute);
+        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()));
         }
-        updateCallAudioState(new CallAudioState(mIsMute, route,
-                mCallAudioState.getSupportedRouteMask(), activeBluetoothDevice,
-                mCallAudioState.getSupportedBluetoothDevices()));
     }
 
     private void onAvailableRoutesChanged() {
-        int routeMask = 0;
-        Set<BluetoothDevice> availableBluetoothDevices = new HashSet<>();
-        for (AudioRoute route : mAvailableRoutes) {
-            routeMask |= ROUTE_MAP.get(route.getType());
-            if (BT_AUDIO_ROUTE_TYPES.contains(route.getType())) {
-                availableBluetoothDevices.add(mBluetoothRoutes.get(route));
+        synchronized (mLock) {
+            int routeMask = 0;
+            Set<BluetoothDevice> availableBluetoothDevices = new HashSet<>();
+            for (AudioRoute route : getAvailableRoutes()) {
+                routeMask |= ROUTE_MAP.get(route.getType());
+                if (BT_AUDIO_ROUTE_TYPES.contains(route.getType())) {
+                    availableBluetoothDevices.add(mBluetoothRoutes.get(route));
+                }
             }
+            updateCallAudioState(new CallAudioState(mIsMute, mCallAudioState.getRoute(), routeMask,
+                    mCallAudioState.getActiveBluetoothDevice(), availableBluetoothDevices));
         }
-        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 callAudioState) {
+        Log.i(this, "updateCallAudioState: " + callAudioState);
         CallAudioState oldState = mCallAudioState;
         mCallAudioState = callAudioState;
         mCallsManager.onCallAudioStateChanged(oldState, mCallAudioState);
@@ -468,7 +871,7 @@
         }
     }
 
-    public AudioRoute getPreferredAudioRouteFromDefault(boolean includeBluetooth) {
+    private AudioRoute getPreferredAudioRouteFromDefault(boolean includeBluetooth) {
         if (mBluetoothRoutes.isEmpty() || !includeBluetooth) {
             return mEarpieceWiredRoute != null ? mEarpieceWiredRoute : mSpeakerDockRoute;
         } else {
@@ -495,8 +898,13 @@
         return routeMask;
     }
 
+    @VisibleForTesting
     public Set<AudioRoute> getAvailableRoutes() {
-        return mAvailableRoutes;
+        if (mCurrentRoute.equals(mStreamingRoute)) {
+            return mStreamingRoutes;
+        } else {
+            return mAvailableRoutes;
+        }
     }
 
     public AudioRoute getCurrentRoute() {
@@ -513,6 +921,17 @@
         return null;
     }
 
+    public AudioRoute getBaseRoute(boolean includeBluetooth) {
+        AudioRoute destRoute = getPreferredAudioRouteFromStrategy();
+        if (destRoute == null) {
+            destRoute = getPreferredAudioRouteFromDefault(includeBluetooth);
+        }
+        if (destRoute != null && !getAvailableRoutes().contains(destRoute)) {
+            destRoute = null;
+        }
+        return destRoute;
+    }
+
     @VisibleForTesting
     public void setAudioManager(AudioManager audioManager) {
         mAudioManager = audioManager;
@@ -525,6 +944,11 @@
 
     @VisibleForTesting
     public void setActive(boolean active) {
+        if (active) {
+            mFocusType = ACTIVE_FOCUS;
+        } else {
+            mFocusType = NO_FOCUS;
+        }
         mIsActive = active;
     }
 }
diff --git a/src/com/android/server/telecom/CallsManager.java b/src/com/android/server/telecom/CallsManager.java
index de7d84a..ae096af 100644
--- a/src/com/android/server/telecom/CallsManager.java
+++ b/src/com/android/server/telecom/CallsManager.java
@@ -118,6 +118,7 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.app.IntentForwarderActivity;
 import com.android.internal.util.IndentingPrintWriter;
+import com.android.server.telecom.bluetooth.BluetoothDeviceManager;
 import com.android.server.telecom.bluetooth.BluetoothRouteManager;
 import com.android.server.telecom.bluetooth.BluetoothStateReceiver;
 import com.android.server.telecom.callfiltering.BlockCheckerAdapter;
@@ -607,6 +608,7 @@
             EmergencyCallDiagnosticLogger emergencyCallDiagnosticLogger,
             CallAudioCommunicationDeviceTracker communicationDeviceTracker,
             CallStreamingNotification callStreamingNotification,
+            BluetoothDeviceManager bluetoothDeviceManager,
             FeatureFlags featureFlags,
             IncomingCallFilterGraphProvider incomingCallFilterGraphProvider) {
 
@@ -632,6 +634,9 @@
         mDtmfLocalTonePlayer =
                 new DtmfLocalTonePlayer(new DtmfLocalTonePlayer.ToneGeneratorProxy());
         CallAudioRouteAdapter callAudioRouteAdapter;
+        // TODO: add another flag check when
+        // bluetoothDeviceManager.getBluetoothHeadset().isScoManagedByAudio()
+        // available and return true
         if (!featureFlags.useRefactoredAudioRouteSwitching()) {
             callAudioRouteAdapter = callAudioRouteStateMachineFactory.create(
                     context,
@@ -646,8 +651,8 @@
                     featureFlags
             );
         } else {
-            callAudioRouteAdapter = new CallAudioRouteController(
-                    context, this, new AudioRoute.Factory(), wiredHeadsetManager);
+            callAudioRouteAdapter = new CallAudioRouteController(context, this, audioServiceFactory,
+                    new AudioRoute.Factory(), wiredHeadsetManager, mBluetoothRouteManager);
         }
         callAudioRouteAdapter.initialize();
         bluetoothStateReceiver.setCallAudioRouteAdapter(callAudioRouteAdapter);
@@ -700,7 +705,8 @@
         mCallLogManager = new CallLogManager(context, phoneAccountRegistrar, mMissedCallNotifier,
                 mAnomalyReporter, featureFlags);
         mConnectionServiceRepository =
-                new ConnectionServiceRepository(mPhoneAccountRegistrar, mContext, mLock, this);
+                new ConnectionServiceRepository(mPhoneAccountRegistrar, mContext, mLock, this,
+                        featureFlags);
         mInCallWakeLockController = inCallWakeLockControllerFactory.create(context, this);
         mClockProxy = clockProxy;
         mToastFactory = toastFactory;
@@ -5880,8 +5886,7 @@
             return;
         }
         ConnectionServiceWrapper service = mConnectionServiceRepository.getService(
-                phoneAccountHandle.getComponentName(), phoneAccountHandle.getUserHandle(),
-                mFeatureFlags);
+                phoneAccountHandle.getComponentName(), phoneAccountHandle.getUserHandle());
         if (service == null) {
             Log.i(this, "Found no connection service.");
             return;
@@ -5906,8 +5911,7 @@
             return;
         }
         ConnectionServiceWrapper service = mConnectionServiceRepository.getService(
-                phoneAccountHandle.getComponentName(), phoneAccountHandle.getUserHandle(),
-                mFeatureFlags);
+                phoneAccountHandle.getComponentName(), phoneAccountHandle.getUserHandle());
         if (service == null) {
             Log.i(this, "Found no connection service.");
             return;
diff --git a/src/com/android/server/telecom/ConnectionServiceRepository.java b/src/com/android/server/telecom/ConnectionServiceRepository.java
index d6a78d0..e4ed220 100644
--- a/src/com/android/server/telecom/ConnectionServiceRepository.java
+++ b/src/com/android/server/telecom/ConnectionServiceRepository.java
@@ -38,6 +38,7 @@
     private final Context mContext;
     private final TelecomSystem.SyncRoot mLock;
     private final CallsManager mCallsManager;
+    private final FeatureFlags mFeatureFlags;
 
     private final ServiceBinder.Listener<ConnectionServiceWrapper> mUnbindListener =
             new ServiceBinder.Listener<ConnectionServiceWrapper>() {
@@ -54,18 +55,19 @@
             PhoneAccountRegistrar phoneAccountRegistrar,
             Context context,
             TelecomSystem.SyncRoot lock,
-            CallsManager callsManager) {
+            CallsManager callsManager,
+            FeatureFlags featureFlags) {
         mPhoneAccountRegistrar = phoneAccountRegistrar;
         mContext = context;
         mLock = lock;
         mCallsManager = callsManager;
+        mFeatureFlags = featureFlags;
     }
 
     @VisibleForTesting
     public ConnectionServiceWrapper getService(
             ComponentName componentName,
-            UserHandle userHandle,
-            FeatureFlags featureFlags) {
+            UserHandle userHandle) {
         Pair<ComponentName, UserHandle> cacheKey = Pair.create(componentName, userHandle);
         ConnectionServiceWrapper service = mServiceCache.get(cacheKey);
         if (service == null) {
@@ -77,7 +79,7 @@
                     mContext,
                     mLock,
                     userHandle,
-                    featureFlags);
+                    mFeatureFlags);
             service.addListener(mUnbindListener);
             mServiceCache.put(cacheKey, service);
         }
diff --git a/src/com/android/server/telecom/ConnectionServiceWrapper.java b/src/com/android/server/telecom/ConnectionServiceWrapper.java
index 43ceff3..53da8ff 100644
--- a/src/com/android/server/telecom/ConnectionServiceWrapper.java
+++ b/src/com/android/server/telecom/ConnectionServiceWrapper.java
@@ -1351,7 +1351,6 @@
     private final CallsManager mCallsManager;
     private final AppOpsManager mAppOpsManager;
     private final Context mContext;
-    private final FeatureFlags mFlags;
 
     private ConnectionServiceFocusManager.ConnectionServiceFocusListener mConnSvrFocusListener;
 
@@ -1386,7 +1385,6 @@
         mCallsManager = callsManager;
         mAppOpsManager = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
         mContext = context;
-        mFlags = featureFlags;
     }
 
     /** See {@link IConnectionService#addConnectionServiceAdapter}. */
@@ -2540,7 +2538,7 @@
                 isCallerConnectionManager = true;
             }
             ConnectionServiceWrapper service = mConnectionServiceRepository.getService(
-                    handle.getComponentName(), handle.getUserHandle(), mFlags);
+                    handle.getComponentName(), handle.getUserHandle());
             if (service != null && service != this) {
                 simServices.add(service);
             } else {
diff --git a/src/com/android/server/telecom/CreateConnectionProcessor.java b/src/com/android/server/telecom/CreateConnectionProcessor.java
index f5b257d..bcb2d2f 100644
--- a/src/com/android/server/telecom/CreateConnectionProcessor.java
+++ b/src/com/android/server/telecom/CreateConnectionProcessor.java
@@ -244,7 +244,7 @@
             Log.i(this, "Trying attempt %s", attempt);
             PhoneAccountHandle phoneAccount = attempt.connectionManagerPhoneAccount;
             mService = mRepository.getService(phoneAccount.getComponentName(),
-                    phoneAccount.getUserHandle(), mFlags);
+                    phoneAccount.getUserHandle());
             if (mService == null) {
                 Log.i(this, "Found no connection service for attempt %s", attempt);
                 attemptNextPhoneAccount();
@@ -260,7 +260,7 @@
                         PhoneAccountHandle remotePhoneAccount = attempt.targetPhoneAccount;
                         ConnectionServiceWrapper mRemoteService =
                                 mRepository.getService(remotePhoneAccount.getComponentName(),
-                                remotePhoneAccount.getUserHandle(), mFlags);
+                                remotePhoneAccount.getUserHandle());
                         if (mRemoteService == null) {
                             mCall.setConnectionService(mService);
                         } else {
diff --git a/src/com/android/server/telecom/EmergencyCallDiagnosticLogger.java b/src/com/android/server/telecom/EmergencyCallDiagnosticLogger.java
index f0f8bb4..cce8c66 100644
--- a/src/com/android/server/telecom/EmergencyCallDiagnosticLogger.java
+++ b/src/com/android/server/telecom/EmergencyCallDiagnosticLogger.java
@@ -403,7 +403,12 @@
             Log.i(this, "skipped dumping diagnostic data");
             return;
         }
-        dumpDiagnosticDataFromDropbox(pw);
+        try {
+            dumpDiagnosticDataFromDropbox(pw);
+        } catch (Exception e) {
+            pw.println("Exception was thrown while dumping diagnostic data from DropBox");
+            e.printStackTrace();
+        }
     }
 
     private static class CallEventTimestamps {
diff --git a/src/com/android/server/telecom/PendingAudioRoute.java b/src/com/android/server/telecom/PendingAudioRoute.java
index 5fa3048..8de62ed 100644
--- a/src/com/android/server/telecom/PendingAudioRoute.java
+++ b/src/com/android/server/telecom/PendingAudioRoute.java
@@ -76,9 +76,7 @@
     public void onMessageReceived(int message) {
         if (message == PENDING_ROUTE_FAILED) {
             // Fallback to base route
-            //TODO: Replace getPreferredAudioRouteFromDefault by getBaseRoute when available and
-            // make the replaced one private
-            mDestRoute = mCallAudioRouteController.getPreferredAudioRouteFromDefault(true);
+            mDestRoute = mCallAudioRouteController.getBaseRoute(true);
             mCallAudioRouteController.sendMessageWithSessionInfo(
                     CallAudioRouteAdapter.EXIT_PENDING_ROUTE);
         }
diff --git a/src/com/android/server/telecom/Ringer.java b/src/com/android/server/telecom/Ringer.java
index 3ec4ebe..e148ef5 100644
--- a/src/com/android/server/telecom/Ringer.java
+++ b/src/com/android/server/telecom/Ringer.java
@@ -44,6 +44,7 @@
 import android.os.vibrator.persistence.VibrationXmlParser;
 import android.telecom.Log;
 import android.telecom.TelecomManager;
+import android.util.Pair;
 import android.view.accessibility.AccessibilityManager;
 
 import com.android.internal.annotations.VisibleForTesting;
@@ -413,18 +414,18 @@
                         isVibratorEnabled, mIsHapticPlaybackSupportedByDevice);
             }
             // Defer ringtone creation to the async player thread.
-            Supplier<Ringtone> ringtoneSupplier;
+            Supplier<Pair<Uri, Ringtone>> ringtoneInfoSupplier;
             final boolean finalHapticChannelsMuted = hapticChannelsMuted;
             if (isHapticOnly) {
                 if (hapticChannelsMuted) {
                     Log.i(this,
                             "want haptic only ringtone but haptics are muted, skip ringtone play");
-                    ringtoneSupplier = null;
+                    ringtoneInfoSupplier = null;
                 } else {
-                    ringtoneSupplier = mRingtoneFactory::getHapticOnlyRingtone;
+                    ringtoneInfoSupplier = mRingtoneFactory::getHapticOnlyRingtone;
                 }
             } else {
-                ringtoneSupplier = () -> mRingtoneFactory.getRingtone(
+                ringtoneInfoSupplier = () -> mRingtoneFactory.getRingtone(
                         foregroundCall, mVolumeShaperConfig, finalHapticChannelsMuted);
             }
 
@@ -447,9 +448,18 @@
             // if the loaded ringtone is null. However if a stop event arrives before the ringtone
             // creation finishes, then this consumer can be skipped.
             final boolean finalUseCustomVibrationEffect = useCustomVibrationEffect;
-            BiConsumer<Ringtone, Boolean> afterRingtoneLogic =
-                    (Ringtone ringtone, Boolean stopped) -> {
+            BiConsumer<Pair<Uri, Ringtone>, Boolean> afterRingtoneLogic =
+                    (Pair<Uri, Ringtone> ringtoneInfo, Boolean stopped) -> {
                 try {
+                    Uri ringtoneUri = null;
+                    Ringtone ringtone = null;
+                    if (ringtoneInfo != null) {
+                        ringtoneUri = ringtoneInfo.first;
+                        ringtone = ringtoneInfo.second;
+                    } else {
+                        Log.w(this, "The ringtone could not be loaded.");
+                    }
+
                     if (stopped.booleanValue() || !vibratorReserved) {
                         // don't start vibration if the ringing is already abandoned, or the
                         // vibrator wasn't reserved. This still triggers the mBlockOnRingingFuture.
@@ -460,7 +470,7 @@
                         if (DEBUG_RINGER) {
                             Log.d(this, "Using ringtone defined vibration effect.");
                         }
-                        vibrationEffect = getVibrationEffectForRingtone(ringtone);
+                        vibrationEffect = getVibrationEffectForRingtone(ringtoneUri);
                     } else {
                         vibrationEffect = mDefaultVibrationEffect;
                     }
@@ -477,10 +487,10 @@
                 }
             };
             deferBlockOnRingingFuture = true;  // Run in vibrationLogic.
-            if (ringtoneSupplier != null) {
-                mRingtonePlayer.play(ringtoneSupplier, afterRingtoneLogic, isHfpDeviceAttached);
+            if (ringtoneInfoSupplier != null) {
+                mRingtonePlayer.play(ringtoneInfoSupplier, afterRingtoneLogic, isHfpDeviceAttached);
             } else {
-                afterRingtoneLogic.accept(/* ringtone= */ null, /* stopped= */ false);
+                afterRingtoneLogic.accept(/* ringtoneUri, ringtone = */ null, /* stopped= */ false);
             }
 
             // shouldAcquireAudioFocus is meant to be true, but that check is deferred to here
@@ -542,8 +552,7 @@
         }
     }
 
-    private VibrationEffect getVibrationEffectForRingtone(@NonNull Ringtone ringtone) {
-        Uri ringtoneUri = ringtone.getUri();
+    private VibrationEffect getVibrationEffectForRingtone(Uri ringtoneUri) {
         if (ringtoneUri == null) {
             return mDefaultVibrationEffect;
         }
diff --git a/src/com/android/server/telecom/RingtoneFactory.java b/src/com/android/server/telecom/RingtoneFactory.java
index 6bcfb4c..0e0b99f 100644
--- a/src/com/android/server/telecom/RingtoneFactory.java
+++ b/src/com/android/server/telecom/RingtoneFactory.java
@@ -34,6 +34,7 @@
 
 import com.android.internal.annotations.VisibleForTesting;
 import android.telecom.CallerInfo;
+import android.util.Pair;
 
 import java.util.List;
 
@@ -53,18 +54,7 @@
         mCallsManager = callsManager;
     }
 
-    /**
-     * Determines if a ringtone has haptic channels.
-     * @param ringtone The ringtone URI.
-     * @return {@code true} if there is a haptic channel, {@code false} otherwise.
-     */
-    public boolean hasHapticChannels(Ringtone ringtone) {
-        boolean hasHapticChannels = RingtoneManager.hasHapticChannels(ringtone.getUri());
-        Log.i(this, "hasHapticChannels %s -> %b", ringtone.getUri(), hasHapticChannels);
-        return hasHapticChannels;
-    }
-
-    public Ringtone getRingtone(Call incomingCall,
+    public Pair<Uri, Ringtone> getRingtone(Call incomingCall,
             @Nullable VolumeShaper.Configuration volumeShaperConfig, boolean hapticChannelsMuted) {
         // Initializing ringtones on the main thread can deadlock
         ThreadUtil.checkNotOnMainThread();
@@ -106,18 +96,19 @@
                 }
             }
 
-            if (defaultRingtoneUri == null) {
+            ringtoneUri = defaultRingtoneUri;
+            if (ringtoneUri == null) {
                 return null;
             }
 
             try {
                 ringtone = RingtoneManager.getRingtone(
-                        contextToUse, defaultRingtoneUri, volumeShaperConfig, audioAttrs);
+                        contextToUse, ringtoneUri, volumeShaperConfig, audioAttrs);
             } catch (Exception e) {
                 Log.e(this, e, "getRingtone: exception while getting ringtone.");
             }
         }
-        return ringtone;
+        return new Pair(ringtoneUri, ringtone);
     }
 
     private AudioAttributes getDefaultRingtoneAudioAttributes(boolean hapticChannelsMuted) {
@@ -130,7 +121,7 @@
 
     /** Returns a ringtone to be used when ringer is not audible for the incoming call. */
     @Nullable
-    public Ringtone getHapticOnlyRingtone() {
+    public Pair<Uri, Ringtone> getHapticOnlyRingtone() {
         // Initializing ringtones on the main thread can deadlock
         ThreadUtil.checkNotOnMainThread();
         Uri ringtoneUri = Uri.parse("file://" + mContext.getString(
@@ -138,12 +129,12 @@
         AudioAttributes audioAttrs = getDefaultRingtoneAudioAttributes(
             /* hapticChannelsMuted */ false);
         Ringtone ringtone = RingtoneManager.getRingtone(
-            mContext, ringtoneUri, /* volumeShaperConfig */ null, audioAttrs);
+                mContext, ringtoneUri, /* volumeShaperConfig */ null, audioAttrs);
         if (ringtone != null) {
             // Make sure the sound is muted.
             ringtone.setVolume(0);
         }
-        return ringtone;
+        return new Pair(ringtoneUri, ringtone);
     }
 
     private Context getWorkProfileContextForUser(UserHandle userHandle) {
diff --git a/src/com/android/server/telecom/TelecomServiceImpl.java b/src/com/android/server/telecom/TelecomServiceImpl.java
index 4bda96a..aa721d4 100644
--- a/src/com/android/server/telecom/TelecomServiceImpl.java
+++ b/src/com/android/server/telecom/TelecomServiceImpl.java
@@ -77,7 +77,6 @@
 import com.android.internal.telecom.ICallControl;
 import com.android.internal.telecom.ICallEventCallback;
 import com.android.internal.telecom.ITelecomService;
-import com.android.internal.telephony.flags.Flags;
 import com.android.internal.util.IndentingPrintWriter;
 import com.android.server.telecom.components.UserCallIntentProcessorFactory;
 import com.android.server.telecom.flags.FeatureFlags;
@@ -239,6 +238,9 @@
                                                 callEventCallback, mCallsManager, call);
 
                         call.setTransactionServiceWrapper(serviceWrapper);
+                        if (mFeatureFlags.transactionalVideoState()) {
+                            call.setTransactionalCallSupportsVideoCalling(callAttributes);
+                        }
                         ICallControl clientCallControl = serviceWrapper.getICallControl();
 
                         if (clientCallControl == null) {
@@ -600,6 +602,53 @@
         }
 
         @Override
+        public ParceledListSlice<PhoneAccount> getRegisteredPhoneAccounts(String callingPackage,
+                String callingFeatureId) {
+            try {
+                Log.startSession("TSI.gRPA", Log.getPackageAbbreviation(callingPackage));
+                try {
+                    enforceCallingPackage(callingPackage, "getRegisteredPhoneAccounts");
+                } catch (SecurityException se) {
+                    EventLog.writeEvent(0x534e4554, "307609763", Binder.getCallingUid(),
+                            "getRegisteredPhoneAccounts: invalid calling package");
+                    throw se;
+                }
+
+                boolean hasCrossUserAccess = false;
+                try {
+                    enforceInAppCrossUserPermission();
+                    hasCrossUserAccess = true;
+                } catch (SecurityException e) {
+                    // pass through
+                }
+
+                synchronized (mLock) {
+                    final UserHandle callingUserHandle = Binder.getCallingUserHandle();
+                    long token = Binder.clearCallingIdentity();
+                    try {
+                        return new ParceledListSlice<>(
+                                mPhoneAccountRegistrar.getPhoneAccounts(
+                                        0 /* capabilities */,
+                                        0 /* excludedCapabilities */,
+                                        null /* UriScheme */,
+                                        callingPackage,
+                                        true /* includeDisabledAccounts */,
+                                        callingUserHandle,
+                                        hasCrossUserAccess /* crossUserAccess */,
+                                        false /* includeAll */));
+                    } catch (Exception e) {
+                        Log.e(this, e, "getRegisteredPhoneAccounts");
+                        throw e;
+                    } finally {
+                        Binder.restoreCallingIdentity(token);
+                    }
+                }
+            } finally {
+                Log.endSession();
+            }
+        }
+
+        @Override
         public int getAllPhoneAccountsCount() {
             try {
                 Log.startSession("TSI.gAPAC");
@@ -2030,6 +2079,11 @@
                 pw.increaseIndent();
                 reflectAndPrintFlagConfigs(pw);
                 pw.decreaseIndent();
+
+                pw.println("TransactionManager: ");
+                pw.increaseIndent();
+                TransactionManager.getInstance().dump(pw);
+                pw.decreaseIndent();
             }
             if (isTimeLineView) {
                 Log.dumpEventsTimeline(pw);
@@ -3117,7 +3171,6 @@
                 + " does not meet the requirements to access the phone number");
     }
 
-
     private boolean canReadPrivilegedPhoneState(String callingPackage, String message) {
         // The system/default dialer can always read phone state - so that emergency calls will
         // still work.
diff --git a/src/com/android/server/telecom/TelecomSystem.java b/src/com/android/server/telecom/TelecomSystem.java
index 9f6fcba..b4c3a4d 100644
--- a/src/com/android/server/telecom/TelecomSystem.java
+++ b/src/com/android/server/telecom/TelecomSystem.java
@@ -425,6 +425,7 @@
                     emergencyCallDiagnosticLogger,
                     communicationDeviceTracker,
                     callStreamingNotification,
+                    bluetoothDeviceManager,
                     featureFlags,
                     IncomingCallFilterGraph::new);
 
diff --git a/src/com/android/server/telecom/TransactionalServiceRepository.java b/src/com/android/server/telecom/TransactionalServiceRepository.java
index 15278e1..793840e 100644
--- a/src/com/android/server/telecom/TransactionalServiceRepository.java
+++ b/src/com/android/server/telecom/TransactionalServiceRepository.java
@@ -61,11 +61,11 @@
         return service;
     }
 
-    public TransactionalServiceWrapper getTransactionalServiceWrapper(PhoneAccountHandle pah) {
+    private TransactionalServiceWrapper getTransactionalServiceWrapper(PhoneAccountHandle pah) {
         return mServiceLookupTable.get(pah);
     }
 
-    public boolean hasExistingServiceWrapper(PhoneAccountHandle pah) {
+    private boolean hasExistingServiceWrapper(PhoneAccountHandle pah) {
         return mServiceLookupTable.containsKey(pah);
     }
 
diff --git a/src/com/android/server/telecom/TransactionalServiceWrapper.java b/src/com/android/server/telecom/TransactionalServiceWrapper.java
index 938ee58..d497c6a 100644
--- a/src/com/android/server/telecom/TransactionalServiceWrapper.java
+++ b/src/com/android/server/telecom/TransactionalServiceWrapper.java
@@ -43,10 +43,10 @@
 import com.android.server.telecom.voip.HoldCallTransaction;
 import com.android.server.telecom.voip.EndCallTransaction;
 import com.android.server.telecom.voip.MaybeHoldCallForNewCallTransaction;
-import com.android.server.telecom.voip.ParallelTransaction;
 import com.android.server.telecom.voip.RequestNewActiveCallTransaction;
 import com.android.server.telecom.voip.SerialTransaction;
 import com.android.server.telecom.voip.SetMuteStateTransaction;
+import com.android.server.telecom.voip.RequestVideoStateTransaction;
 import com.android.server.telecom.voip.TransactionManager;
 import com.android.server.telecom.voip.VoipCallTransaction;
 import com.android.server.telecom.voip.VoipCallTransactionResult;
@@ -71,6 +71,7 @@
     public static final String ANSWER = "Answer";
     public static final String DISCONNECT = "Disconnect";
     public static final String START_STREAMING = "StartStreaming";
+    public static final String REQUEST_VIDEO_STATE = "RequestVideoState";
 
     // CallEventCallback : Telecom --> Client (ex. voip app)
     public static final String ON_SET_ACTIVE = "onSetActive";
@@ -130,6 +131,7 @@
         return mTransactionManager;
     }
 
+    @VisibleForTesting
     public PhoneAccountHandle getPhoneAccountHandle() {
         return mPhoneAccountHandle;
     }
@@ -166,7 +168,7 @@
         return callCount;
     }
 
-    public void cleanupTransactionalServiceWrapper() {
+    private void cleanupTransactionalServiceWrapper() {
         for (Call call : mTrackedCalls.values()) {
             mCallsManager.markCallAsDisconnected(call,
                     new DisconnectCause(DisconnectCause.ERROR, "process died"));
@@ -179,7 +181,7 @@
      **                        ICallControl: Client --> Server                                **
      **********************************************************************************************
      */
-    public final ICallControl mICallControl = new ICallControl.Stub() {
+    private final ICallControl mICallControl = new ICallControl.Stub() {
         @Override
         public void setActive(String callId, android.os.ResultReceiver callback)
                 throws RemoteException {
@@ -248,6 +250,17 @@
             }
         }
 
+        @Override
+        public void requestVideoState(int videoState, String callId, ResultReceiver callback)
+                throws RemoteException {
+            try {
+                Log.startSession("TSW.rVS");
+                createTransactions(callId, callback, REQUEST_VIDEO_STATE, videoState);
+            } finally {
+                Log.endSession();
+            }
+        }
+
         private void createTransactions(String callId, ResultReceiver callback, String action,
                 Object... objects) {
             Log.d(TAG, "createTransactions: callId=" + callId);
@@ -274,6 +287,11 @@
                         addTransactionsToManager(mStreamingController.getStartStreamingTransaction(mCallsManager,
                                 TransactionalServiceWrapper.this, call, mLock), callback);
                         break;
+                    case REQUEST_VIDEO_STATE:
+                        addTransactionsToManager(
+                                new RequestVideoStateTransaction(mCallsManager, call,
+                                        (int) objects[0]), callback);
+                        break;
                 }
             } else {
                 Bundle exceptionBundle = new Bundle();
@@ -346,7 +364,7 @@
         }
     };
 
-    public void addTransactionsToManager(VoipCallTransaction transaction,
+    private void addTransactionsToManager(VoipCallTransaction transaction,
             ResultReceiver callback) {
         Log.d(TAG, "addTransactionsToManager");
 
@@ -562,6 +580,15 @@
         }
     }
 
+    public void onVideoStateChanged(Call call, int videoState) {
+        if (call != null) {
+            try {
+                mICallEventCallback.onVideoStateChanged(call.getId(), videoState);
+            } catch (RemoteException e) {
+            }
+        }
+    }
+
     public void removeCallFromWrappers(Call call) {
         if (call != null) {
             try {
diff --git a/src/com/android/server/telecom/bluetooth/BluetoothDeviceManager.java b/src/com/android/server/telecom/bluetooth/BluetoothDeviceManager.java
index 27e5a7d..a0ffe63 100644
--- a/src/com/android/server/telecom/bluetooth/BluetoothDeviceManager.java
+++ b/src/com/android/server/telecom/bluetooth/BluetoothDeviceManager.java
@@ -25,9 +25,8 @@
 import android.bluetooth.BluetoothProfile;
 import android.bluetooth.BluetoothStatusCodes;
 import android.content.Context;
-import android.media.AudioManager;
 import android.media.AudioDeviceInfo;
-import android.media.audio.common.AudioDevice;
+import android.media.AudioManager;
 import android.os.Bundle;
 import android.telecom.Log;
 import android.util.ArraySet;
@@ -41,13 +40,17 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.concurrent.Executor;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 
 public class BluetoothDeviceManager {
 
@@ -98,6 +101,9 @@
                         synchronized (mLock) {
                             String logString;
                             if (profile == BluetoothProfile.HEADSET) {
+                                if (mFeatureFlags.useRefactoredAudioRouteSwitching()) {
+                                    mBluetoothHeadsetFuture.complete((BluetoothHeadset) proxy);
+                                }
                                 mBluetoothHeadset = (BluetoothHeadset) proxy;
                                 logString = "Got BluetoothHeadset: " + mBluetoothHeadset;
                             } else if (profile == BluetoothProfile.HEARING_AID) {
@@ -137,6 +143,9 @@
                             LinkedHashMap<String, BluetoothDevice> lostServiceDevices;
                             String logString;
                             if (profile == BluetoothProfile.HEADSET) {
+                                if (mFeatureFlags.useRefactoredAudioRouteSwitching()) {
+                                    mBluetoothHeadsetFuture.complete(null);
+                                }
                                 mBluetoothHeadset = null;
                                 lostServiceDevices = mHfpDevicesByAddress;
                                 mBluetoothRouteManager.onActiveDeviceChanged(null,
@@ -201,6 +210,7 @@
 
     private BluetoothRouteManager mBluetoothRouteManager;
     private BluetoothHeadset mBluetoothHeadset;
+    private CompletableFuture<BluetoothHeadset> mBluetoothHeadsetFuture;
     private BluetoothHearingAid mBluetoothHearingAid;
     private boolean mLeAudioCallbackRegistered = false;
     private BluetoothLeAudio mBluetoothLeAudioService;
@@ -218,8 +228,12 @@
     public BluetoothDeviceManager(Context context, BluetoothAdapter bluetoothAdapter,
             CallAudioCommunicationDeviceTracker communicationDeviceTracker,
             FeatureFlags featureFlags) {
+        mFeatureFlags = featureFlags;
         if (bluetoothAdapter != null) {
             mBluetoothAdapter = bluetoothAdapter;
+            if (mFeatureFlags.useRefactoredAudioRouteSwitching()) {
+                mBluetoothHeadsetFuture = new CompletableFuture<>();
+            }
             bluetoothAdapter.getProfileProxy(context, mBluetoothProfileServiceListener,
                     BluetoothProfile.HEADSET);
             bluetoothAdapter.getProfileProxy(context, mBluetoothProfileServiceListener,
@@ -229,7 +243,6 @@
             mAudioManager = context.getSystemService(AudioManager.class);
             mExecutor = context.getMainExecutor();
             mCommunicationDeviceTracker = communicationDeviceTracker;
-            mFeatureFlags = featureFlags;
         }
     }
 
@@ -333,7 +346,19 @@
     }
 
     public BluetoothHeadset getBluetoothHeadset() {
-        return mBluetoothHeadset;
+        if (mFeatureFlags.useRefactoredAudioRouteSwitching()) {
+            try {
+                mBluetoothHeadset = mBluetoothHeadsetFuture.get(500L,
+                        TimeUnit.MILLISECONDS);
+                return mBluetoothHeadset;
+            } catch (TimeoutException | InterruptedException | ExecutionException e) {
+                // ignore
+                Log.w(this, "Acquire BluetoothHeadset service failed due to: " + e);
+                return null;
+            }
+        } else {
+            return mBluetoothHeadset;
+        }
     }
 
     public BluetoothAdapter getBluetoothAdapter() {
@@ -402,7 +427,7 @@
                 mHearingAidDeviceSyncIds.put(device, hiSyncId);
                 targetDeviceMap = mHearingAidDevicesByAddress;
             } else if (deviceType == DEVICE_TYPE_HEADSET) {
-                if (mBluetoothHeadset == null) {
+                if (getBluetoothHeadset() == null) {
                     Log.w(this, "Headset service null when receiving device added broadcast");
                     return;
                 }
@@ -465,7 +490,7 @@
     }
 
     public void disconnectSco() {
-        if (mBluetoothHeadset == null) {
+        if (getBluetoothHeadset() == null) {
             Log.w(this, "Trying to disconnect audio but no headset service exists.");
         } else {
             mBluetoothHeadset.disconnectAudio();
@@ -650,7 +675,7 @@
             callProfile = BluetoothProfile.HEARING_AID;
         } else if (mHfpDevicesByAddress.containsKey(address)) {
             Log.i(this, "Telecomm found HFP device for address: " + address);
-            if (mBluetoothHeadset == null) {
+            if (getBluetoothHeadset() == null) {
                 Log.w(this, "Attempting to turn on audio when the headset service is null");
                 return false;
             }
@@ -707,9 +732,15 @@
                 Log.w(this, "Couldn't set active device to %s", address);
                 return false;
             }
-            int scoConnectionRequest = mBluetoothHeadset.connectAudio();
-            return scoConnectionRequest == BluetoothStatusCodes.SUCCESS ||
-                scoConnectionRequest == BluetoothStatusCodes.ERROR_AUDIO_DEVICE_ALREADY_CONNECTED;
+            if (getBluetoothHeadset() != null) {
+                int scoConnectionRequest = mBluetoothHeadset.connectAudio();
+                return scoConnectionRequest == BluetoothStatusCodes.SUCCESS ||
+                        scoConnectionRequest
+                                == BluetoothStatusCodes.ERROR_AUDIO_DEVICE_ALREADY_CONNECTED;
+            } else {
+                Log.w(this, "Couldn't find bluetooth headset service");
+                return false;
+            }
         } else {
             Log.w(this, "Attempting to turn on audio for a disconnected device");
             return false;
@@ -739,16 +770,20 @@
         // Get the inband ringing enabled status of expected BT device to route call audio instead
         // of using the address of currently connected device.
         BluetoothDevice activeDevice = mBluetoothRouteManager.getMostRecentlyReportedActiveDevice();
-        Log.i(this, "isInbandRingingEnabled: activeDevice: " + activeDevice);
-        if (mBluetoothRouteManager.isCachedLeAudioDevice(activeDevice)) {
+        return isInbandRingEnabled(activeDevice);
+    }
+
+    public boolean isInbandRingEnabled(BluetoothDevice bluetoothDevice) {
+        Log.i(this, "isInbandRingEnabled: device: " + bluetoothDevice);
+        if (mBluetoothRouteManager.isCachedLeAudioDevice(bluetoothDevice)) {
             if (mBluetoothLeAudioService == null) {
                 Log.i(this, "isInbandRingingEnabled: no leaudio service available.");
                 return false;
             }
-            int groupId = mBluetoothLeAudioService.getGroupId(activeDevice);
+            int groupId = mBluetoothLeAudioService.getGroupId(bluetoothDevice);
             return mBluetoothLeAudioService.isInbandRingtoneEnabled(groupId);
         } else {
-            if (mBluetoothHeadset == null) {
+            if (getBluetoothHeadset() == null) {
                 Log.i(this, "isInbandRingingEnabled: no headset service available.");
                 return false;
             }
diff --git a/src/com/android/server/telecom/bluetooth/BluetoothRouteManager.java b/src/com/android/server/telecom/bluetooth/BluetoothRouteManager.java
index 235ba56..7da5339 100644
--- a/src/com/android/server/telecom/bluetooth/BluetoothRouteManager.java
+++ b/src/com/android/server/telecom/bluetooth/BluetoothRouteManager.java
@@ -957,6 +957,11 @@
         return mDeviceManager.isInbandRingingEnabled();
     }
 
+    @VisibleForTesting
+    public boolean isInbandRingEnabled(BluetoothDevice bluetoothDevice) {
+        return mDeviceManager.isInbandRingEnabled(bluetoothDevice);
+    }
+
     private boolean addDevice(String address) {
         if (mAudioConnectingStates.containsKey(address)) {
             Log.i(this, "Attempting to add device %s twice.", address);
diff --git a/src/com/android/server/telecom/voip/ParallelTransaction.java b/src/com/android/server/telecom/voip/ParallelTransaction.java
index 6176087..621892a 100644
--- a/src/com/android/server/telecom/voip/ParallelTransaction.java
+++ b/src/com/android/server/telecom/voip/ParallelTransaction.java
@@ -34,6 +34,7 @@
 
     @Override
     public void start() {
+        if (mStats != null) mStats.markStarted();
         // post timeout work
         CompletableFuture<Void> future = new CompletableFuture<>();
         mHandler.postDelayed(() -> future.complete(null), TIMEOUT_LIMIT);
@@ -44,7 +45,7 @@
             if (mCompleteListener != null) {
                 mCompleteListener.onTransactionTimeout(mTransactionName);
             }
-            finish();
+            timeout();
             return null;
         }, new LoggedHandlerExecutor(mHandler, mTransactionName + "@" + hashCode()
                 + ".s", mLock));
@@ -68,7 +69,7 @@
                                                                     transactionName));
                                             mCompleteListener.onTransactionCompleted(mainResult,
                                                     mTransactionName);
-                                            finish();
+                                            finish(mainResult);
                                             return null;
                                         }, new LoggedHandlerExecutor(mHandler,
                                                 mTransactionName + "@" + hashCode()
@@ -91,7 +92,7 @@
                                                         transactionName));
                                         mCompleteListener.onTransactionCompleted(mainResult,
                                                 mTransactionName);
-                                        finish();
+                                        finish(mainResult);
                                         return null;
                                     }, new LoggedHandlerExecutor(mHandler,
                                             mTransactionName + "@" + hashCode()
diff --git a/src/com/android/server/telecom/voip/RequestVideoStateTransaction.java b/src/com/android/server/telecom/voip/RequestVideoStateTransaction.java
new file mode 100644
index 0000000..64596b1
--- /dev/null
+++ b/src/com/android/server/telecom/voip/RequestVideoStateTransaction.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2024 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.voip;
+
+import static com.android.server.telecom.voip.VideoStateTranslation.TransactionalVideoStateToVideoProfileState;
+
+import android.telecom.VideoProfile;
+import android.util.Log;
+
+import com.android.server.telecom.CallsManager;
+import com.android.server.telecom.Call;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+
+public class RequestVideoStateTransaction extends VoipCallTransaction {
+
+    private static final String TAG = RequestVideoStateTransaction.class.getSimpleName();
+    private final Call mCall;
+    private final int mVideoProfileState;
+
+    public RequestVideoStateTransaction(CallsManager callsManager, Call call,
+            int transactionalVideoState) {
+        super(callsManager.getLock());
+        mCall = call;
+        mVideoProfileState = TransactionalVideoStateToVideoProfileState(transactionalVideoState);
+    }
+
+    @Override
+    public CompletionStage<VoipCallTransactionResult> processTransaction(Void v) {
+        Log.d(TAG, "processTransaction");
+        CompletableFuture<VoipCallTransactionResult> future = new CompletableFuture<>();
+
+        if (isRequestingVideoTransmission(mVideoProfileState) &&
+                !mCall.isVideoCallingSupportedByPhoneAccount()) {
+            future.complete(new VoipCallTransactionResult(
+                    VoipCallTransactionResult.RESULT_FAILED,
+                    "Video calling is not supported by the target account"));
+        } else if (isRequestingVideoTransmission(mVideoProfileState) &&
+                !mCall.isTransactionalCallSupportsVideoCalling()) {
+            future.complete(new VoipCallTransactionResult(
+                    VoipCallTransactionResult.RESULT_FAILED,
+                    "Video calling is not supported according to the callAttributes"));
+        } else {
+            mCall.setVideoState(mVideoProfileState);
+            future.complete(new VoipCallTransactionResult(
+                    VoipCallTransactionResult.RESULT_SUCCEED,
+                    "The Video State was changed successfully"));
+        }
+        return future;
+    }
+
+    private boolean isRequestingVideoTransmission(int targetVideoState) {
+        return targetVideoState != VideoProfile.STATE_AUDIO_ONLY;
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/server/telecom/voip/SerialTransaction.java b/src/com/android/server/telecom/voip/SerialTransaction.java
index b35b471..7d5a178 100644
--- a/src/com/android/server/telecom/voip/SerialTransaction.java
+++ b/src/com/android/server/telecom/voip/SerialTransaction.java
@@ -21,6 +21,7 @@
 
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
 
 /**
  * A VoipCallTransaction implementation that its sub transactions will be executed in serial
@@ -37,6 +38,7 @@
 
     @Override
     public void start() {
+        if (mStats != null) mStats.markStarted();
         // post timeout work
         CompletableFuture<Void> future = new CompletableFuture<>();
         mHandler.postDelayed(() -> future.complete(null), TIMEOUT_LIMIT);
@@ -47,7 +49,7 @@
             if (mCompleteListener != null) {
                 mCompleteListener.onTransactionTimeout(mTransactionName);
             }
-            finish();
+            timeout();
             return null;
         }, new LoggedHandlerExecutor(mHandler, mTransactionName + "@" + hashCode()
                 + ".s", mLock));
@@ -55,6 +57,7 @@
         if (mSubTransactions != null && mSubTransactions.size() > 0) {
             TransactionManager.TransactionCompleteListener subTransactionListener =
                     new TransactionManager.TransactionCompleteListener() {
+                        private final AtomicInteger mTransactionIndex = new AtomicInteger(0);
 
                         @Override
                         public void onTransactionCompleted(VoipCallTransactionResult result,
@@ -71,14 +74,16 @@
                                                                     transactionName));
                                             mCompleteListener.onTransactionCompleted(mainResult,
                                                     mTransactionName);
-                                            finish();
+                                            finish(mainResult);
                                             return null;
                                         }, new LoggedHandlerExecutor(mHandler,
                                                 mTransactionName + "@" + hashCode()
                                                         + ".oTC", mLock));
                             } else {
-                                if (mSubTransactions.size() > 0) {
-                                    VoipCallTransaction transaction = mSubTransactions.remove(0);
+                                int currTransactionIndex = mTransactionIndex.incrementAndGet();
+                                if (currTransactionIndex < mSubTransactions.size()) {
+                                    VoipCallTransaction transaction = mSubTransactions.get(
+                                            currTransactionIndex);
                                     transaction.setCompleteListener(this);
                                     transaction.start();
                                 } else {
@@ -99,14 +104,14 @@
                                                         transactionName));
                                         mCompleteListener.onTransactionCompleted(mainResult,
                                                 mTransactionName);
-                                        finish();
+                                        finish(mainResult);
                                         return null;
                                     }, new LoggedHandlerExecutor(mHandler,
                                             mTransactionName + "@" + hashCode()
                                                     + ".oTT", mLock));
                         }
                     };
-            VoipCallTransaction transaction = mSubTransactions.remove(0);
+            VoipCallTransaction transaction = mSubTransactions.get(0);
             transaction.setCompleteListener(subTransactionListener);
             transaction.start();
         } else {
diff --git a/src/com/android/server/telecom/voip/TransactionManager.java b/src/com/android/server/telecom/voip/TransactionManager.java
index 228bdde..76d83cc 100644
--- a/src/com/android/server/telecom/voip/TransactionManager.java
+++ b/src/com/android/server/telecom/voip/TransactionManager.java
@@ -21,20 +21,25 @@
 import android.os.OutcomeReceiver;
 import android.telecom.TelecomManager;
 import android.telecom.CallException;
+import android.util.IndentingPrintWriter;
 import android.util.Log;
 
 import com.android.internal.annotations.VisibleForTesting;
-
+import com.android.server.telecom.flags.Flags;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
+import java.util.Deque;
 import java.util.List;
+import java.util.Locale;
 import java.util.Queue;
 
 public class TransactionManager {
     private static final String TAG = "VoipCallTransactionManager";
+    private static final int TRANSACTION_HISTORY_SIZE = 20;
     private static TransactionManager INSTANCE = null;
     private static final Object sLock = new Object();
-    private Queue<VoipCallTransaction> mTransactions;
+    private final Queue<VoipCallTransaction> mTransactions;
+    private final Deque<VoipCallTransaction> mCompletedTransactions;
     private VoipCallTransaction mCurrentTransaction;
 
     public interface TransactionCompleteListener {
@@ -45,6 +50,10 @@
     private TransactionManager() {
         mTransactions = new ArrayDeque<>();
         mCurrentTransaction = null;
+        if (Flags.enableCallSequencing()) {
+            mCompletedTransactions = new ArrayDeque<>();
+        } else
+            mCompletedTransactions = null;
     }
 
     public static TransactionManager getInstance() {
@@ -69,7 +78,7 @@
         transaction.setCompleteListener(new TransactionCompleteListener() {
             @Override
             public void onTransactionCompleted(VoipCallTransactionResult result,
-                    String transactionName){
+                    String transactionName) {
                 Log.i(TAG, String.format("transaction %s completed: with result=[%d]",
                         transactionName, result.getResult()));
                 if (result.getResult() == TelecomManager.TELECOM_TRANSACTION_SUCCESS) {
@@ -112,7 +121,10 @@
 
     private void finishTransaction() {
         synchronized (sLock) {
-            mCurrentTransaction = null;
+            if (mCurrentTransaction != null) {
+                addTransactionToHistory(mCurrentTransaction);
+                mCurrentTransaction = null;
+            }
         }
         startTransactions();
     }
@@ -123,8 +135,115 @@
         synchronized (sLock) {
             pendingTransactions = new ArrayList<>(mTransactions);
         }
-        for (VoipCallTransaction transaction : pendingTransactions) {
-            transaction.finish();
+        for (VoipCallTransaction t : pendingTransactions) {
+            t.finish(new VoipCallTransactionResult(VoipCallTransactionResult.RESULT_FAILED,
+                    "clear called"));
         }
     }
+
+    private void addTransactionToHistory(VoipCallTransaction t) {
+        if (!Flags.enableCallSequencing()) return;
+
+        mCompletedTransactions.add(t);
+        if (mCompletedTransactions.size() > TRANSACTION_HISTORY_SIZE) {
+            mCompletedTransactions.poll();
+        }
+    }
+
+    /**
+     * Called when the dumpsys is created for telecom to capture the current state.
+     */
+    public void dump(IndentingPrintWriter pw) {
+        if (!Flags.enableCallSequencing()) {
+            pw.println("<<Flag not enabled>>");
+            return;
+        }
+        synchronized (sLock) {
+            pw.println("Pending Transactions:");
+            pw.increaseIndent();
+            for (VoipCallTransaction t : mTransactions) {
+                printPendingTransactionStats(t, pw);
+            }
+            pw.decreaseIndent();
+
+            pw.println("Ongoing Transaction:");
+            pw.increaseIndent();
+            if (mCurrentTransaction != null) {
+                printPendingTransactionStats(mCurrentTransaction, pw);
+            }
+            pw.decreaseIndent();
+
+            pw.println("Completed Transactions:");
+            pw.increaseIndent();
+            for (VoipCallTransaction t : mCompletedTransactions) {
+                printCompleteTransactionStats(t, pw);
+            }
+            pw.decreaseIndent();
+        }
+    }
+
+    /**
+     * Recursively print the pending {@link VoipCallTransaction} stats for logging purposes.
+     * @param t The transaction that stats should be printed for
+     * @param pw The IndentingPrintWriter to print the result to
+     */
+    private void printPendingTransactionStats(VoipCallTransaction t, IndentingPrintWriter pw) {
+        VoipCallTransaction.Stats s = t.getStats();
+        if (s == null) {
+            pw.println(String.format(Locale.getDefault(), "%s: <NO STATS>", t.mTransactionName));
+            return;
+        }
+        pw.println(String.format(Locale.getDefault(),
+                "[%s] %s: (result=[%s]), (created -> now : [%+d] mS),"
+                        + " (created -> started : [%+d] mS),"
+                        + " (started -> now : [%+d] mS)",
+                s.addedTimeStamp, t.mTransactionName, parseTransactionResult(s),
+                s.measureTimeSinceCreatedMs(), s.measureCreatedToStartedMs(),
+                s.measureTimeSinceStartedMs()));
+
+        if (t.mSubTransactions == null || t.mSubTransactions.isEmpty()) {
+            return;
+        }
+        pw.increaseIndent();
+        for (VoipCallTransaction subTransaction : t.mSubTransactions) {
+            printPendingTransactionStats(subTransaction, pw);
+        }
+        pw.decreaseIndent();
+    }
+
+    /**
+     * Recursively print the complete Transaction stats for logging purposes.
+     * @param t The transaction that stats should be printed for
+     * @param pw The IndentingPrintWriter to print the result to
+     */
+    private void printCompleteTransactionStats(VoipCallTransaction t, IndentingPrintWriter pw) {
+        VoipCallTransaction.Stats s = t.getStats();
+        if (s == null) {
+            pw.println(String.format(Locale.getDefault(), "%s: <NO STATS>", t.mTransactionName));
+            return;
+        }
+        pw.println(String.format(Locale.getDefault(),
+                "[%s] %s: (result=[%s]), (created -> started : [%+d] mS), "
+                        + "(started -> completed : [%+d] mS)",
+                s.addedTimeStamp, t.mTransactionName, parseTransactionResult(s),
+                s.measureCreatedToStartedMs(), s.measureStartedToCompletedMs()));
+
+        if (t.mSubTransactions == null || t.mSubTransactions.isEmpty()) {
+            return;
+        }
+        pw.increaseIndent();
+        for (VoipCallTransaction subTransaction : t.mSubTransactions) {
+            printCompleteTransactionStats(subTransaction, pw);
+        }
+        pw.decreaseIndent();
+    }
+
+    private String parseTransactionResult(VoipCallTransaction.Stats s) {
+        if (s.isTimedOut()) return "TIMED OUT";
+        if (s.getTransactionResult() == null) return "PENDING";
+        if (s.getTransactionResult().getResult() == VoipCallTransactionResult.RESULT_SUCCEED) {
+            return "SUCCESS";
+        }
+        return s.getTransactionResult().toString();
+    }
 }
diff --git a/src/com/android/server/telecom/voip/VideoStateTranslation.java b/src/com/android/server/telecom/voip/VideoStateTranslation.java
new file mode 100644
index 0000000..615e4bc
--- /dev/null
+++ b/src/com/android/server/telecom/voip/VideoStateTranslation.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2024 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.voip;
+
+import android.telecom.CallAttributes;
+import android.telecom.Log;
+import android.telecom.VideoProfile;
+
+/**
+ * This remapping class is needed because {@link VideoProfile} has more fine grain levels of video
+ * states as apposed to Transactional video states (defined in  {@link CallAttributes.CallType}.
+ * To be more specific, there are 3 video states (rx, tx, and bi-directional).
+ * {@link CallAttributes.CallType} only has 2 states (audio and video).
+ *
+ * The reason why Transactional calls have fewer states is due to the fact that the framework is
+ * only used by VoIP apps and Telecom only cares to know if the call is audio or video.
+ *
+ * Calls that are backed by a {@link android.telecom.ConnectionService} have the ability to be
+ * managed calls (non-VoIP) and Dialer needs more fine grain video states to update the UI. Thus,
+ * {@link VideoProfile} is used for {@link android.telecom.ConnectionService} backed calls.
+ */
+public class VideoStateTranslation {
+    private static final String TAG = VideoStateTranslation.class.getSimpleName();
+
+    /**
+     * Client --> Telecom
+     * This should be used when the client application is signaling they are changing the video
+     * state.
+     */
+    public static int TransactionalVideoStateToVideoProfileState(int transactionalVideo) {
+        if (transactionalVideo == CallAttributes.AUDIO_CALL) {
+            Log.i(TAG, "%s --> VideoProfile.STATE_AUDIO_ONLY",
+                    TransactionalVideoState_toString(transactionalVideo));
+            return VideoProfile.STATE_AUDIO_ONLY;
+        } else {
+            Log.i(TAG, "%s --> VideoProfile.STATE_BIDIRECTIONAL",
+                    TransactionalVideoState_toString(transactionalVideo));
+            return VideoProfile.STATE_BIDIRECTIONAL;
+        }
+    }
+
+    /**
+     * Telecom --> Client
+     * This should be used when Telecom is informing the client of a video state change.
+     */
+    public static int VideoProfileStateToTransactionalVideoState(int videoProfileState) {
+        if (videoProfileState == VideoProfile.STATE_AUDIO_ONLY) {
+            Log.i(TAG, "%s --> CallAttributes.AUDIO_CALL",
+                    VideoProfileState_toString(videoProfileState));
+            return CallAttributes.AUDIO_CALL;
+        } else {
+            Log.i(TAG, "%s --> CallAttributes.VIDEO_CALL",
+                    VideoProfileState_toString(videoProfileState));
+            return CallAttributes.VIDEO_CALL;
+        }
+    }
+
+    private static String TransactionalVideoState_toString(int transactionalVideoState) {
+        if (transactionalVideoState == CallAttributes.AUDIO_CALL) {
+            return "CallAttributes.AUDIO_CALL";
+        } else {
+            return "CallAttributes.VIDEO_CALL";
+        }
+    }
+
+    private static String VideoProfileState_toString(int videoProfileState) {
+        switch (videoProfileState) {
+            case VideoProfile.STATE_BIDIRECTIONAL -> {
+                return "VideoProfile.STATE_BIDIRECTIONAL";
+            }
+            case VideoProfile.STATE_RX_ENABLED -> {
+                return "VideoProfile.STATE_RX_ENABLED";
+            }
+            case VideoProfile.STATE_TX_ENABLED -> {
+                return "VideoProfile.STATE_TX_ENABLED";
+            }
+        }
+        return "VideoProfile.STATE_AUDIO_ONLY";
+    }
+}
diff --git a/src/com/android/server/telecom/voip/VoipCallTransaction.java b/src/com/android/server/telecom/voip/VoipCallTransaction.java
index a952eb1..3c91158 100644
--- a/src/com/android/server/telecom/voip/VoipCallTransaction.java
+++ b/src/com/android/server/telecom/voip/VoipCallTransaction.java
@@ -22,23 +22,119 @@
 
 import com.android.server.telecom.LoggedHandlerExecutor;
 import com.android.server.telecom.TelecomSystem;
+import com.android.server.telecom.flags.Flags;
 
+import java.time.LocalDateTime;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.Function;
 
 public class VoipCallTransaction {
     //TODO: add log events
     protected static final long TIMEOUT_LIMIT = 5000L;
+
+    /**
+     * Tracks stats about a transaction for logging purposes.
+     */
+    public static class Stats {
+        // the logging visible timestamp for ease of debugging
+        public final LocalDateTime addedTimeStamp;
+        // the time in nS that the transaction was first created
+        private final long mCreatedTimeNs;
+        // the time that the transaction was started.
+        private long mStartedTimeNs = -1L;
+        // the time that the transaction was finished.
+        private long mFinishedTimeNs = -1L;
+        // If finished, did this transaction finish because it timed out?
+        private boolean mIsTimedOut = false;
+        private VoipCallTransactionResult  mTransactionResult = null;
+
+        public Stats() {
+            addedTimeStamp = LocalDateTime.now();
+            mCreatedTimeNs = System.nanoTime();
+        }
+
+        /**
+         * Mark the transaction as started and record the time.
+         */
+        public void markStarted() {
+            if (mStartedTimeNs > -1) return;
+            mStartedTimeNs = System.nanoTime();
+        }
+
+        /**
+         * Mark the transaction as completed and record the time.
+         */
+        public void markComplete(boolean isTimedOut, VoipCallTransactionResult result) {
+            if (mFinishedTimeNs > -1) return;
+            mFinishedTimeNs = System.nanoTime();
+            mIsTimedOut = isTimedOut;
+            mTransactionResult = result;
+        }
+
+        /**
+         * @return Time in mS since the transaction was created.
+         */
+        public long measureTimeSinceCreatedMs() {
+            return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - mCreatedTimeNs);
+        }
+
+        /**
+         * @return Time in mS between when transaction was created and when it was marked as
+         * started. Returns -1 if the transaction was not started yet.
+         */
+        public long measureCreatedToStartedMs() {
+            return mStartedTimeNs > 0 ?
+                    TimeUnit.NANOSECONDS.toMillis(mStartedTimeNs - mCreatedTimeNs) : -1;
+        }
+
+        /**
+         * @return Time in mS since the transaction was marked started to the TransactionManager.
+         * Returns -1 if the transaction hasn't been started yet.
+         */
+        public long measureTimeSinceStartedMs() {
+            return mStartedTimeNs > 0 ?
+                    TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - mStartedTimeNs) : -1;
+        }
+
+        /**
+         * @return Time in mS between when the transaction was marked as started and when it was
+         * marked as completed. Returns -1 if the transaction hasn't started or finished yet.
+         */
+        public long measureStartedToCompletedMs() {
+            return (mStartedTimeNs > 0 && mFinishedTimeNs > 0) ?
+                    TimeUnit.NANOSECONDS.toMillis(mFinishedTimeNs - mStartedTimeNs) : -1;
+
+        }
+
+        /**
+         * @return true if this transaction completed due to timing out, false if the transaction
+         * hasn't completed yet or it completed and did not time out.
+         */
+        public boolean isTimedOut() {
+            return mIsTimedOut;
+        }
+
+        /**
+         * @return the result if the transaction completed, null if it timed out or hasn't completed
+         * yet.
+         */
+        public VoipCallTransactionResult getTransactionResult() {
+            return mTransactionResult;
+        }
+    }
+
     protected final AtomicBoolean mCompleted = new AtomicBoolean(false);
-    protected String mTransactionName = this.getClass().getSimpleName();
+    protected final String mTransactionName = this.getClass().getSimpleName();
     private HandlerThread mHandlerThread;
     protected Handler mHandler;
     protected TransactionManager.TransactionCompleteListener mCompleteListener;
     protected List<VoipCallTransaction> mSubTransactions;
     protected TelecomSystem.SyncRoot mLock;
+    protected final Stats mStats;
 
     public VoipCallTransaction(
             List<VoipCallTransaction> subTransactions, TelecomSystem.SyncRoot lock) {
@@ -47,6 +143,7 @@
         mHandlerThread.start();
         mHandler = new Handler(mHandlerThread.getLooper());
         mLock = lock;
+        mStats = Flags.enableCallSequencing() ? new Stats() : null;
     }
 
     public VoipCallTransaction(TelecomSystem.SyncRoot lock) {
@@ -54,6 +151,7 @@
     }
 
     public void start() {
+        if (mStats != null) mStats.markStarted();
         // post timeout work
         CompletableFuture<Void> future = new CompletableFuture<>();
         mHandler.postDelayed(() -> future.complete(null), TIMEOUT_LIMIT);
@@ -64,7 +162,7 @@
             if (mCompleteListener != null) {
                 mCompleteListener.onTransactionTimeout(mTransactionName);
             }
-            finish();
+            timeout();
             return null;
         }, new LoggedHandlerExecutor(mHandler, mTransactionName + "@" + hashCode()
                 + ".s", mLock));
@@ -82,7 +180,7 @@
                     if (mCompleteListener != null) {
                         mCompleteListener.onTransactionCompleted(result, mTransactionName);
                     }
-                    finish();
+                    finish(result);
                     return null;
                     }, executor)
                 .exceptionallyAsync((throwable -> {
@@ -100,11 +198,27 @@
         mCompleteListener = listener;
     }
 
-    public void finish() {
+    public void timeout() {
+        finish(true, null);
+    }
+
+    public void finish(VoipCallTransactionResult result) {
+        finish(false, result);
+    }
+
+    public void finish(boolean isTimedOut, VoipCallTransactionResult result) {
+        if (mStats != null) mStats.markComplete(isTimedOut, result);
         // finish all sub transactions
-        if (mSubTransactions != null && mSubTransactions.size() > 0) {
-            mSubTransactions.forEach(VoipCallTransaction::finish);
+        if (mSubTransactions != null && !mSubTransactions.isEmpty()) {
+            mSubTransactions.forEach( t -> t.finish(isTimedOut, result));
         }
         mHandlerThread.quit();
     }
+
+    /**
+     * @return Stats related to this transaction if stats are enabled, null otherwise.
+     */
+    public Stats getStats() {
+        return mStats;
+    }
 }
diff --git a/tests/src/com/android/server/telecom/tests/BluetoothDeviceManagerTest.java b/tests/src/com/android/server/telecom/tests/BluetoothDeviceManagerTest.java
index 648a831..c516c8e 100644
--- a/tests/src/com/android/server/telecom/tests/BluetoothDeviceManagerTest.java
+++ b/tests/src/com/android/server/telecom/tests/BluetoothDeviceManagerTest.java
@@ -403,7 +403,8 @@
         when(mAdapter.setActiveDevice(nullable(BluetoothDevice.class),
                     eq(BluetoothAdapter.ACTIVE_DEVICE_ALL))).thenReturn(true);
         mBluetoothDeviceManager.connectAudio(device1.getAddress(), false);
-        verify(mAdapter).setActiveDevice(device1, BluetoothAdapter.ACTIVE_DEVICE_PHONE_CALL);
+        verify(mAdapter).setActiveDevice(eq(device1),
+                eq(BluetoothAdapter.ACTIVE_DEVICE_PHONE_CALL));
         verify(mAdapter, never()).setActiveDevice(nullable(BluetoothDevice.class),
                 eq(BluetoothAdapter.ACTIVE_DEVICE_ALL));
         mBluetoothDeviceManager.disconnectAudio();
diff --git a/tests/src/com/android/server/telecom/tests/CallAudioRouteControllerTest.java b/tests/src/com/android/server/telecom/tests/CallAudioRouteControllerTest.java
index 08576fc..0a53eb0 100644
--- a/tests/src/com/android/server/telecom/tests/CallAudioRouteControllerTest.java
+++ b/tests/src/com/android/server/telecom/tests/CallAudioRouteControllerTest.java
@@ -16,18 +16,43 @@
 
 package com.android.server.telecom.tests;
 
+import static com.android.server.telecom.CallAudioRouteAdapter.ACTIVE_FOCUS;
 import static com.android.server.telecom.CallAudioRouteAdapter.BT_ACTIVE_DEVICE_GONE;
 import static com.android.server.telecom.CallAudioRouteAdapter.BT_ACTIVE_DEVICE_PRESENT;
 import static com.android.server.telecom.CallAudioRouteAdapter.BT_AUDIO_CONNECTED;
+import static com.android.server.telecom.CallAudioRouteAdapter.BT_AUDIO_DISCONNECTED;
 import static com.android.server.telecom.CallAudioRouteAdapter.BT_DEVICE_ADDED;
 import static com.android.server.telecom.CallAudioRouteAdapter.BT_DEVICE_REMOVED;
+import static com.android.server.telecom.CallAudioRouteAdapter.CONNECT_DOCK;
+import static com.android.server.telecom.CallAudioRouteAdapter.CONNECT_WIRED_HEADSET;
+import static com.android.server.telecom.CallAudioRouteAdapter.DISCONNECT_DOCK;
+import static com.android.server.telecom.CallAudioRouteAdapter.DISCONNECT_WIRED_HEADSET;
+import static com.android.server.telecom.CallAudioRouteAdapter.MUTE_OFF;
+import static com.android.server.telecom.CallAudioRouteAdapter.MUTE_ON;
+import static com.android.server.telecom.CallAudioRouteAdapter.NO_FOCUS;
+import static com.android.server.telecom.CallAudioRouteAdapter.RINGING_FOCUS;
+import static com.android.server.telecom.CallAudioRouteAdapter.SPEAKER_OFF;
+import static com.android.server.telecom.CallAudioRouteAdapter.SPEAKER_ON;
+import static com.android.server.telecom.CallAudioRouteAdapter.STREAMING_FORCE_DISABLED;
+import static com.android.server.telecom.CallAudioRouteAdapter.STREAMING_FORCE_ENABLED;
+import static com.android.server.telecom.CallAudioRouteAdapter.SWITCH_EARPIECE;
+import static com.android.server.telecom.CallAudioRouteAdapter.SWITCH_FOCUS;
+import static com.android.server.telecom.CallAudioRouteAdapter.USER_SWITCH_BLUETOOTH;
+import static com.android.server.telecom.CallAudioRouteAdapter.USER_SWITCH_EARPIECE;
+import static com.android.server.telecom.CallAudioRouteAdapter.USER_SWITCH_HEADSET;
+import static com.android.server.telecom.CallAudioRouteAdapter.USER_SWITCH_SPEAKER;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.nullable;
 import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.atLeast;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.timeout;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -35,15 +60,19 @@
 import android.bluetooth.BluetoothDevice;
 import android.media.AudioDeviceInfo;
 import android.media.AudioManager;
+import android.media.IAudioService;
 import android.media.audiopolicy.AudioProductStrategy;
+import android.os.UserHandle;
 import android.telecom.CallAudioState;
 
 import androidx.test.filters.SmallTest;
 
 import com.android.server.telecom.AudioRoute;
+import com.android.server.telecom.CallAudioManager;
 import com.android.server.telecom.CallAudioRouteController;
 import com.android.server.telecom.CallsManager;
 import com.android.server.telecom.WiredHeadsetManager;
+import com.android.server.telecom.bluetooth.BluetoothRouteManager;
 
 import org.junit.After;
 import org.junit.Before;
@@ -62,6 +91,9 @@
     @Mock AudioManager mAudioManager;
     @Mock AudioDeviceInfo mEarpieceDeviceInfo;
     @Mock CallsManager mCallsManager;
+    @Mock CallAudioManager.AudioServiceFactory mAudioServiceFactory;
+    @Mock IAudioService mAudioService;
+    @Mock BluetoothRouteManager mBluetoothRouteManager;
     private AudioRoute mEarpieceRoute;
     private AudioRoute mSpeakerRoute;
     private static final String BT_ADDRESS_1 = "00:00:00:00:00:01";
@@ -90,10 +122,16 @@
                 new AudioDeviceInfo[] {
                         mEarpieceDeviceInfo
                 });
+        when(mAudioManager.getPreferredDeviceForStrategy(nullable(AudioProductStrategy.class)))
+                .thenReturn(null);
+        when(mAudioServiceFactory.getAudioService()).thenReturn(mAudioService);
+        when(mContext.getAttributionTag()).thenReturn("");
         doNothing().when(mCallsManager).onCallAudioStateChanged(any(CallAudioState.class),
                 any(CallAudioState.class));
-        mController = new CallAudioRouteController(mContext, mCallsManager, mAudioRouteFactory,
-                mWiredHeadsetManager);
+        when(mCallsManager.getCurrentUserHandle()).thenReturn(
+                new UserHandle(UserHandle.USER_SYSTEM));
+        mController = new CallAudioRouteController(mContext, mCallsManager, mAudioServiceFactory,
+                mAudioRouteFactory, mWiredHeadsetManager, mBluetoothRouteManager);
         mController.setAudioRouteFactory(mAudioRouteFactory);
         mController.setAudioManager(mAudioManager);
         mEarpieceRoute = new AudioRoute(AudioRoute.TYPE_EARPIECE, null, null);
@@ -148,6 +186,7 @@
                 AudioRoute.TYPE_BLUETOOTH_SCO, BT_ADDRESS_1);
         verify(mAudioManager, timeout(TEST_TIMEOUT)).setCommunicationDevice(
                 nullable(AudioDeviceInfo.class));
+
         expectedState = new CallAudioState(false, CallAudioState.ROUTE_BLUETOOTH,
                 CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_BLUETOOTH
                         | CallAudioState.ROUTE_SPEAKER, BLUETOOTH_DEVICE_1, BLUETOOTH_DEVICES);
@@ -166,9 +205,6 @@
     @SmallTest
     @Test
     public void testActiveDeactivateBluetoothDevice() {
-        when(mAudioManager.getPreferredDeviceForStrategy(nullable(AudioProductStrategy.class)))
-                .thenReturn(null);
-
         mController.initialize();
         mController.sendMessageWithSessionInfo(BT_DEVICE_ADDED, AudioRoute.TYPE_BLUETOOTH_SCO,
                 BLUETOOTH_DEVICE_1);
@@ -192,7 +228,256 @@
 
     @SmallTest
     @Test
-    public void testSwitchFocusInBluetoothRoute() {
+    public void testSwitchFocusForBluetoothDeviceSupportInbandRinging() {
+        doAnswer(invocation -> {
+            mController.sendMessageWithSessionInfo(BT_AUDIO_CONNECTED, 0, BLUETOOTH_DEVICE_1);
+            return true;
+        }).when(mAudioManager).setCommunicationDevice(nullable(AudioDeviceInfo.class));
+        doAnswer(invocation -> {
+            mController.sendMessageWithSessionInfo(BT_AUDIO_DISCONNECTED, 0, BLUETOOTH_DEVICE_1);
+            return true;
+        }).when(mAudioManager).clearCommunicationDevice();
+        when(mBluetoothRouteManager.isInbandRingEnabled(eq(BLUETOOTH_DEVICE_1))).thenReturn(true);
 
+        mController.initialize();
+        mController.sendMessageWithSessionInfo(BT_DEVICE_ADDED, AudioRoute.TYPE_BLUETOOTH_SCO,
+                BLUETOOTH_DEVICE_1);
+
+        CallAudioState expectedState = new CallAudioState(false, CallAudioState.ROUTE_BLUETOOTH,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_BLUETOOTH
+                        | CallAudioState.ROUTE_SPEAKER, BLUETOOTH_DEVICE_1, BLUETOOTH_DEVICES);
+        mController.sendMessageWithSessionInfo(BT_ACTIVE_DEVICE_PRESENT,
+                AudioRoute.TYPE_BLUETOOTH_SCO, BT_ADDRESS_1);
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+        assertFalse(mController.isActive());
+
+        mController.sendMessageWithSessionInfo(SWITCH_FOCUS, RINGING_FOCUS);
+        verify(mAudioManager, timeout(TEST_TIMEOUT)).setCommunicationDevice(
+                nullable(AudioDeviceInfo.class));
+        assertTrue(mController.isActive());
+
+        mController.sendMessageWithSessionInfo(SWITCH_FOCUS, ACTIVE_FOCUS);
+        assertTrue(mController.isActive());
+
+        mController.sendMessageWithSessionInfo(SWITCH_FOCUS, NO_FOCUS);
+        verify(mAudioManager, timeout(TEST_TIMEOUT)).clearCommunicationDevice();
+        assertFalse(mController.isActive());
+    }
+
+    @SmallTest
+    @Test
+    public void testConnectAndDisconnectWiredHeadset() {
+        mController.initialize();
+        mController.sendMessageWithSessionInfo(CONNECT_WIRED_HEADSET);
+        CallAudioState expectedState = new CallAudioState(false, CallAudioState.ROUTE_WIRED_HEADSET,
+                CallAudioState.ROUTE_WIRED_HEADSET | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+
+        mController.sendMessageWithSessionInfo(DISCONNECT_WIRED_HEADSET);
+        expectedState = new CallAudioState(false, CallAudioState.ROUTE_EARPIECE,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+    }
+
+    @SmallTest
+    @Test
+    public void testConnectAndDisconnectDock() {
+        mController.initialize();
+        mController.sendMessageWithSessionInfo(CONNECT_DOCK);
+        CallAudioState expectedState = new CallAudioState(false, CallAudioState.ROUTE_SPEAKER,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+
+        mController.sendMessageWithSessionInfo(DISCONNECT_DOCK);
+        expectedState = new CallAudioState(false, CallAudioState.ROUTE_EARPIECE,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+    }
+
+    @SmallTest
+    @Test
+    public void testSpeakerToggle() {
+        mController.initialize();
+        mController.setActive(true);
+        mController.sendMessageWithSessionInfo(SPEAKER_ON);
+        CallAudioState expectedState = new CallAudioState(false, CallAudioState.ROUTE_SPEAKER,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+
+        mController.sendMessageWithSessionInfo(SPEAKER_OFF);
+        expectedState = new CallAudioState(false, CallAudioState.ROUTE_EARPIECE,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+    }
+
+    @SmallTest
+    @Test
+    public void testSpeakerToggleWhenDockConnected() {
+        mController.initialize();
+        mController.setActive(true);
+        mController.sendMessageWithSessionInfo(CONNECT_DOCK);
+        mController.sendMessageWithSessionInfo(SPEAKER_ON);
+        CallAudioState expectedState = new CallAudioState(false, CallAudioState.ROUTE_SPEAKER,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+
+        mController.sendMessageWithSessionInfo(SPEAKER_ON);
+        expectedState = new CallAudioState(false, CallAudioState.ROUTE_SPEAKER,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+
+        mController.sendMessageWithSessionInfo(SPEAKER_OFF);
+        expectedState = new CallAudioState(false, CallAudioState.ROUTE_EARPIECE,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+    }
+
+    @SmallTest
+    @Test
+    public void testSwitchEarpiece() {
+        mController.initialize();
+        mController.sendMessageWithSessionInfo(SPEAKER_ON);
+        CallAudioState expectedState = new CallAudioState(false, CallAudioState.ROUTE_SPEAKER,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+
+        mController.sendMessageWithSessionInfo(USER_SWITCH_EARPIECE);
+        mController.sendMessageWithSessionInfo(SPEAKER_OFF);
+        expectedState = new CallAudioState(false, CallAudioState.ROUTE_EARPIECE,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+    }
+
+    @SmallTest
+    @Test
+    public void testSwitchBluetooth() {
+        doAnswer(invocation -> {
+            mController.sendMessageWithSessionInfo(BT_AUDIO_CONNECTED, 0, BLUETOOTH_DEVICE_1);
+            return true;
+        }).when(mAudioManager).setCommunicationDevice(nullable(AudioDeviceInfo.class));
+
+        mController.initialize();
+        mController.setActive(true);
+        mController.sendMessageWithSessionInfo(BT_DEVICE_ADDED, AudioRoute.TYPE_BLUETOOTH_SCO,
+                BLUETOOTH_DEVICE_1);
+        CallAudioState expectedState = new CallAudioState(false, CallAudioState.ROUTE_EARPIECE,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_BLUETOOTH
+                        | CallAudioState.ROUTE_SPEAKER, null, BLUETOOTH_DEVICES);
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+
+        mController.sendMessageWithSessionInfo(USER_SWITCH_BLUETOOTH, 0,
+                BLUETOOTH_DEVICE_1.getAddress());
+        mController.sendMessageWithSessionInfo(BT_AUDIO_CONNECTED, 0, BLUETOOTH_DEVICE_1);
+        expectedState = new CallAudioState(false, CallAudioState.ROUTE_BLUETOOTH,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_BLUETOOTH
+                        | CallAudioState.ROUTE_SPEAKER, BLUETOOTH_DEVICE_1, BLUETOOTH_DEVICES);
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+    }
+
+    @SmallTest
+    @Test
+    public void tesetSwitchSpeakerAndHeadset() {
+        mController.initialize();
+        mController.sendMessageWithSessionInfo(CONNECT_WIRED_HEADSET);
+        CallAudioState expectedState = new CallAudioState(false, CallAudioState.ROUTE_WIRED_HEADSET,
+                CallAudioState.ROUTE_WIRED_HEADSET | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+
+        mController.sendMessageWithSessionInfo(USER_SWITCH_SPEAKER);
+        mController.sendMessageWithSessionInfo(SPEAKER_ON);
+        expectedState = new CallAudioState(false, CallAudioState.ROUTE_SPEAKER,
+                CallAudioState.ROUTE_WIRED_HEADSET | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+
+        mController.sendMessageWithSessionInfo(USER_SWITCH_HEADSET);
+        mController.sendMessageWithSessionInfo(SPEAKER_OFF);
+        expectedState = new CallAudioState(false, CallAudioState.ROUTE_WIRED_HEADSET,
+                CallAudioState.ROUTE_WIRED_HEADSET | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+    }
+
+    @SmallTest
+    @Test
+    public void testEnableAndDisableStreaming() {
+        mController.initialize();
+        mController.sendMessageWithSessionInfo(STREAMING_FORCE_ENABLED);
+        CallAudioState expectedState = new CallAudioState(false, CallAudioState.ROUTE_STREAMING,
+                CallAudioState.ROUTE_STREAMING, null, new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+
+        mController.sendMessageWithSessionInfo(SPEAKER_ON);
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+
+        mController.sendMessageWithSessionInfo(CONNECT_WIRED_HEADSET);
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+
+        mController.sendMessageWithSessionInfo(STREAMING_FORCE_DISABLED);
+        expectedState = new CallAudioState(false, CallAudioState.ROUTE_WIRED_HEADSET,
+                CallAudioState.ROUTE_WIRED_HEADSET | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+    }
+
+    @SmallTest
+    @Test
+    public void testToggleMute() throws Exception {
+        when(mAudioManager.isMasterMute()).thenReturn(false);
+
+        mController.initialize();
+        mController.setActive(true);
+
+        mController.sendMessageWithSessionInfo(MUTE_ON);
+        CallAudioState expectedState = new CallAudioState(true, CallAudioState.ROUTE_EARPIECE,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mAudioService, timeout(TEST_TIMEOUT)).setMicrophoneMute(eq(true), anyString(),
+                anyInt(), anyString());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
+
+        when(mAudioManager.isMasterMute()).thenReturn(true);
+        mController.sendMessageWithSessionInfo(MUTE_OFF);
+        expectedState = new CallAudioState(false, CallAudioState.ROUTE_EARPIECE,
+                CallAudioState.ROUTE_EARPIECE | CallAudioState.ROUTE_SPEAKER, null,
+                new HashSet<>());
+        verify(mAudioService, timeout(TEST_TIMEOUT)).setMicrophoneMute(eq(false), anyString(),
+                anyInt(), anyString());
+        verify(mCallsManager, timeout(TEST_TIMEOUT)).onCallAudioStateChanged(
+                any(CallAudioState.class), eq(expectedState));
     }
 }
diff --git a/tests/src/com/android/server/telecom/tests/CallsManagerTest.java b/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
index a7ccb0a..1529629 100644
--- a/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
+++ b/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
@@ -127,6 +127,7 @@
 import com.android.server.telecom.TelecomSystem;
 import com.android.server.telecom.Timeouts;
 import com.android.server.telecom.WiredHeadsetManager;
+import com.android.server.telecom.bluetooth.BluetoothDeviceManager;
 import com.android.server.telecom.bluetooth.BluetoothRouteManager;
 import com.android.server.telecom.bluetooth.BluetoothStateReceiver;
 import com.android.server.telecom.callfiltering.BlockedNumbersAdapter;
@@ -290,6 +291,7 @@
     @Mock private PhoneCapability mPhoneCapability;
     @Mock private CallAudioCommunicationDeviceTracker mCommunicationDeviceTracker;
     @Mock private CallStreamingNotification mCallStreamingNotification;
+    @Mock private BluetoothDeviceManager mBluetoothDeviceManager;
     @Mock private FeatureFlags mFeatureFlags;
     @Mock private IncomingCallFilterGraph mIncomingCallFilterGraph;
     private CallsManager mCallsManager;
@@ -366,6 +368,7 @@
                 mEmergencyCallDiagnosticLogger,
                 mCommunicationDeviceTracker,
                 mCallStreamingNotification,
+                mBluetoothDeviceManager,
                 mFeatureFlags,
                 (call, listener, context, timeoutsAdapter, lock) -> mIncomingCallFilterGraph);
 
diff --git a/tests/src/com/android/server/telecom/tests/CreateConnectionProcessorTest.java b/tests/src/com/android/server/telecom/tests/CreateConnectionProcessorTest.java
index e973992..2f27bb5 100644
--- a/tests/src/com/android/server/telecom/tests/CreateConnectionProcessorTest.java
+++ b/tests/src/com/android/server/telecom/tests/CreateConnectionProcessorTest.java
@@ -838,9 +838,10 @@
 
     private ConnectionServiceWrapper makeConnectionServiceWrapper() {
         ConnectionServiceWrapper wrapper = mock(ConnectionServiceWrapper.class);
+
         when(mMockConnectionServiceRepository.getService(
-                eq(makeQuickConnectionServiceComponentName()),
-                any(UserHandle.class), any(FeatureFlags.class))).thenReturn(wrapper);
+                eq(makeQuickConnectionServiceComponentName()), any(UserHandle.class)))
+                .thenReturn(wrapper);
         return wrapper;
     }
 
diff --git a/tests/src/com/android/server/telecom/tests/RingerTest.java b/tests/src/com/android/server/telecom/tests/RingerTest.java
index a0ec267..1215fd3 100644
--- a/tests/src/com/android/server/telecom/tests/RingerTest.java
+++ b/tests/src/com/android/server/telecom/tests/RingerTest.java
@@ -59,6 +59,7 @@
 import android.platform.test.flag.junit.DeviceFlagsValueProvider;
 import android.telecom.PhoneAccountHandle;
 import android.telecom.TelecomManager;
+import android.util.Pair;
 
 import androidx.test.filters.SmallTest;
 
@@ -83,6 +84,7 @@
 
 import java.time.Duration;
 import java.util.concurrent.CompletableFuture;
+import java.util.function.Supplier;
 
 @RunWith(JUnit4.class)
 public class RingerTest extends TelecomTestCase {
@@ -144,7 +146,6 @@
         mockNotificationManager =mContext.getSystemService(NotificationManager.class);
         when(mockTonePlayer.startTone()).thenReturn(true);
         when(mockNotificationManager.matchesCallFilter(any(Uri.class))).thenReturn(true);
-        when(mockRingtoneFactory.hasHapticChannels(any(Ringtone.class))).thenReturn(false);
         when(mockCall1.getState()).thenReturn(CallState.RINGING);
         when(mockCall2.getState()).thenReturn(CallState.RINGING);
         when(mockCall1.getAssociatedUser()).thenReturn(PA_HANDLE.getUserHandle());
@@ -426,7 +427,7 @@
         // Pretend we're using audio coupled haptics.
         setIsUsingHaptics(mockRingtone, true);
         assertTrue(startRingingAndWaitForAsync(mockCall1, false));
-        verify(mockRingtoneFactory, times(1))
+        verify(mockRingtoneFactory, atLeastOnce())
             .getRingtone(any(Call.class), nullable(VolumeShaper.Configuration.class), anyBoolean());
         verifyNoMoreInteractions(mockRingtoneFactory);
         verify(mockTonePlayer).stopTone();
@@ -468,14 +469,14 @@
 
         mRingerUnderTest.startCallWaiting(mockCall1);
         when(mockRingtoneFactory.getRingtone(any(Call.class), eq(null), anyBoolean()))
-            .thenReturn(mockRingtone);
+            .thenReturn(new Pair(FAKE_RINGTONE_URI, mockRingtone));
         when(mockAudioManager.getRingerMode()).thenReturn(AudioManager.RINGER_MODE_VIBRATE);
         when(mockAudioManager.getStreamVolume(AudioManager.STREAM_RING)).thenReturn(0);
         enableVibrationWhenRinging();
         assertFalse(startRingingAndWaitForAsync(mockCall2, false));
         verify(mockTonePlayer).stopTone();
         // Try to play a silent haptics ringtone
-        verify(mockRingtoneFactory, times(1)).getHapticOnlyRingtone();
+        verify(mockRingtoneFactory, atLeastOnce()).getHapticOnlyRingtone();
         verifyNoMoreInteractions(mockRingtoneFactory);
         verify(mockRingtone).play();
 
@@ -514,7 +515,7 @@
         enableVibrationWhenRinging();
         assertFalse(startRingingAndWaitForAsync(mockCall2, false));
 
-        verify(mockRingtoneFactory, times(1)).getHapticOnlyRingtone();
+        verify(mockRingtoneFactory, atLeastOnce()).getHapticOnlyRingtone();
         verifyNoMoreInteractions(mockRingtoneFactory);
         verify(mockTonePlayer).stopTone();
         // Try to play a silent haptics ringtone
@@ -534,7 +535,7 @@
         enableVibrationWhenRinging();
         assertTrue(startRingingAndWaitForAsync(mockCall2, false));
         verify(mockTonePlayer).stopTone();
-        verify(mockRingtoneFactory, times(1))
+        verify(mockRingtoneFactory, atLeastOnce())
             .getRingtone(any(Call.class), isNull(), anyBoolean());
         verifyNoMoreInteractions(mockRingtoneFactory);
         verify(mockRingtone).play();
@@ -551,7 +552,7 @@
         ensureRingerIsAudible();
         enableVibrationOnlyWhenNotRinging();
         assertTrue(startRingingAndWaitForAsync(mockCall2, false));
-        verify(mockRingtoneFactory, times(1))
+        verify(mockRingtoneFactory, atLeastOnce())
             .getRingtone(any(Call.class), nullable(VolumeShaper.Configuration.class), anyBoolean());
         verifyNoMoreInteractions(mockRingtoneFactory);
         verify(mockTonePlayer).stopTone();
@@ -570,7 +571,7 @@
         enableRampingRinger();
         enableVibrationWhenRinging();
         assertTrue(startRingingAndWaitForAsync(mockCall2, false));
-        verify(mockRingtoneFactory, times(1))
+        verify(mockRingtoneFactory, atLeastOnce())
             .getRingtone(any(Call.class), nullable(VolumeShaper.Configuration.class), anyBoolean());
         verifyNoMoreInteractions(mockRingtoneFactory);
         verify(mockTonePlayer).stopTone();
@@ -602,7 +603,7 @@
         when(mockAudioManager.getStreamVolume(AudioManager.STREAM_RING)).thenReturn(100);
         enableVibrationWhenRinging();
         assertTrue(startRingingAndWaitForAsync(mockCall2, true));
-        verify(mockRingtoneFactory, times(1))
+        verify(mockRingtoneFactory, atLeastOnce())
             .getRingtone(any(Call.class), isNull(), anyBoolean());
         verifyNoMoreInteractions(mockRingtoneFactory);
         verify(mockTonePlayer).stopTone();
@@ -623,7 +624,7 @@
 
         asyncRingtonePlayer.updateBtActiveState(true);
         mRingCompletionFuture.get();
-        verify(mockRingtoneFactory, times(1))
+        verify(mockRingtoneFactory, atLeastOnce())
                 .getRingtone(any(Call.class), nullable(VolumeShaper.Configuration.class),
                         anyBoolean());
         verifyNoMoreInteractions(mockRingtoneFactory);
@@ -753,7 +754,7 @@
                     } catch (InterruptedException e) {
                         Thread.currentThread().interrupt();
                     }
-                    return mockRingtone;
+                    return new Pair(FAKE_RINGTONE_URI, mockRingtone);
                 });
         // Start call waiting to make sure that it doesn't stop when we start ringing
         enableVibrationWhenRinging();
@@ -832,10 +833,12 @@
 
     private Ringtone ensureRingtoneMocked() {
         Ringtone mockRingtone = mock(Ringtone.class);
+        Pair<Uri, Ringtone> ringtoneInfo = new Pair(
+                FAKE_RINGTONE_URI, mockRingtone);
         when(mockRingtoneFactory.getRingtone(
                 any(Call.class), nullable(VolumeShaper.Configuration.class), anyBoolean()))
-                .thenReturn(mockRingtone);
-        when(mockRingtoneFactory.getHapticOnlyRingtone()).thenReturn(mockRingtone);
+                .thenReturn(ringtoneInfo);
+        when(mockRingtoneFactory.getHapticOnlyRingtone()).thenReturn(ringtoneInfo);
         return mockRingtone;
     }