Merge "Use new APIs from SIM phonebook provider"
diff --git a/res/values/config.xml b/res/values/config.xml
index 9f8cc81..a296254 100644
--- a/res/values/config.xml
+++ b/res/values/config.xml
@@ -317,4 +317,8 @@
     <!-- The package names which can request thermal mitigation. -->
     <string-array name="thermal_mitigation_allowlisted_packages" translatable="false">
     </string-array>
+
+    <!-- The package name of the app which hosts the
+         {@link TelecomManager#ACTION_SHOW_CALL_SETTINGS} settings screen. -->
+    <string name="call_settings_package_name">com.android.phone</string>
 </resources>
diff --git a/src/com/android/phone/CarrierConfigLoader.java b/src/com/android/phone/CarrierConfigLoader.java
index d5b697a..577d2c0 100644
--- a/src/com/android/phone/CarrierConfigLoader.java
+++ b/src/com/android/phone/CarrierConfigLoader.java
@@ -706,9 +706,6 @@
         mFromSystemUnlocked = new boolean[numPhones];
         mServiceConnectionForNoSimConfig = new CarrierServiceConnection[numPhones];
         mServiceBoundForNoSimConfig = new boolean[numPhones];
-        // Make this service available through ServiceManager.
-        TelephonyFrameworkInitializer
-                .getTelephonyServiceManager().getCarrierConfigServiceRegisterer().register(this);
         logd("CarrierConfigLoader has started");
         mSubscriptionInfoUpdater = subscriptionInfoUpdater;
         mHandler.sendEmptyMessage(EVENT_CHECK_SYSTEM_UPDATE);
@@ -724,6 +721,9 @@
             if (sInstance == null) {
                 sInstance = new CarrierConfigLoader(context,
                         PhoneFactory.getSubscriptionInfoUpdater(), Looper.myLooper());
+                // Make this service available through ServiceManager.
+                TelephonyFrameworkInitializer.getTelephonyServiceManager()
+                        .getCarrierConfigServiceRegisterer().register(sInstance);
             } else {
                 Log.wtf(LOG_TAG, "init() called multiple times!  sInstance = " + sInstance);
             }
diff --git a/src/com/android/phone/ImsRcsController.java b/src/com/android/phone/ImsRcsController.java
index bd6ba6b..bcc312c 100644
--- a/src/com/android/phone/ImsRcsController.java
+++ b/src/com/android/phone/ImsRcsController.java
@@ -17,7 +17,6 @@
 package com.android.phone;
 
 import android.Manifest;
-import android.app.ActivityManager;
 import android.content.pm.PackageManager;
 import android.net.Uri;
 import android.os.Binder;
@@ -276,9 +275,6 @@
             List<Uri> contactNumbers, IRcsUceControllerCallback c) {
         enforceAccessUserCapabilityExchangePermission("requestCapabilities");
         enforceReadContactsPermission("requestCapabilities");
-        if (!isCallingProcessInForeground(Binder.getCallingUid())) {
-            throw new SecurityException("The caller is not in the foreground.");
-        }
         final long token = Binder.clearCallingIdentity();
         try {
             UceControllerManager uceCtrlManager = getRcsFeatureController(subId).getFeature(
@@ -300,9 +296,6 @@
             String callingFeatureId, Uri contactNumber, IRcsUceControllerCallback c) {
         enforceAccessUserCapabilityExchangePermission("requestAvailability");
         enforceReadContactsPermission("requestAvailability");
-        if (!isCallingProcessInForeground(Binder.getCallingUid())) {
-            throw new SecurityException("The caller is not in the foreground.");
-        }
         final long token = Binder.clearCallingIdentity();
         try {
             UceControllerManager uceCtrlManager = getRcsFeatureController(subId).getFeature(
@@ -414,6 +407,20 @@
         return pidfXml == null ? "none" : pidfXml;
     }
 
+    /**
+     * Remove UCE requests cannot be sent to the network status.
+     * @return true if this command is successful.
+     */
+    // Used for SHELL command only right now.
+    public boolean removeUceRequestDisallowedStatus(int subId) throws ImsException {
+        UceControllerManager uceCtrlManager = getRcsFeatureController(subId).getFeature(
+                UceControllerManager.class);
+        if (uceCtrlManager == null) {
+            return false;
+        }
+        return uceCtrlManager.removeUceRequestDisallowedStatus();
+    }
+
     @Override
     public void registerUcePublishStateCallback(int subId, IRcsUcePublishStateCallback c) {
         enforceReadPrivilegedPermission("registerUcePublishStateCallback");
@@ -668,19 +675,6 @@
     }
 
     /**
-     * Check if the calling process is in the foreground.
-     *
-     * @return true if the caller is in the foreground.
-     */
-    private boolean isCallingProcessInForeground(int uid) {
-        ActivityManager am = mApp.getSystemService(ActivityManager.class);
-        boolean isCallingProcessForeground = am != null
-                && am.getUidImportance(uid)
-                        == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND;
-        return isCallingProcessForeground;
-    }
-
-    /**
      * Retrieve ImsPhone instance.
      *
      * @param subId the subscription ID
diff --git a/src/com/android/phone/NotificationMgr.java b/src/com/android/phone/NotificationMgr.java
index 4fb61f0..fb45f4c 100644
--- a/src/com/android/phone/NotificationMgr.java
+++ b/src/com/android/phone/NotificationMgr.java
@@ -565,9 +565,10 @@
                     .setChannelId(NotificationChannelController.CHANNEL_ID_CALL_FORWARD)
                     .setOnlyAlertOnce(isRefresh);
 
-            Intent intent = new Intent(Intent.ACTION_MAIN);
+            Intent intent = new Intent(TelecomManager.ACTION_SHOW_CALL_SETTINGS);
             intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
-            intent.setClassName("com.android.phone", "com.android.phone.CallFeaturesSetting");
+            intent.setPackage(mContext.getResources().getString(
+                    R.string.call_settings_package_name));
             SubscriptionInfoHelper.addExtrasToIntent(
                     intent, mSubscriptionManager.getActiveSubscriptionInfo(subId));
             builder.setContentIntent(PendingIntent.getActivity(mContext, subId /* requestCode */,
diff --git a/src/com/android/phone/PhoneGlobals.java b/src/com/android/phone/PhoneGlobals.java
index e341427..97eb5f5 100644
--- a/src/com/android/phone/PhoneGlobals.java
+++ b/src/com/android/phone/PhoneGlobals.java
@@ -71,6 +71,8 @@
 import com.android.internal.telephony.ims.ImsResolver;
 import com.android.internal.telephony.imsphone.ImsPhone;
 import com.android.internal.telephony.imsphone.ImsPhoneCallTracker;
+import com.android.internal.telephony.uicc.UiccCard;
+import com.android.internal.telephony.uicc.UiccProfile;
 import com.android.internal.util.IndentingPrintWriter;
 import com.android.phone.settings.SettingsConstants;
 import com.android.phone.vvm.CarrierVvmPackageInstalledReceiver;
@@ -219,6 +221,53 @@
         }
     }
 
+    // Some carrier config settings disable the network lock screen, so we call handleSimLock
+    // when either SIM_LOCK or CARRIER_CONFIG changes so that no matter which one happens first,
+    // we still do the right thing
+    private void handleSimLock(int subType, Phone phone) {
+        PersistableBundle cc = getCarrierConfigForSubId(phone.getSubId());
+        if (!CarrierConfigManager.isConfigForIdentifiedCarrier(cc)) {
+            // If we only have the default carrier config just return, to avoid popping up the
+            // the SIM lock screen when it's disabled by the carrier.
+            Log.i(LOG_TAG, "Not showing 'SIM network unlock' screen. Carrier config not loaded");
+            return;
+        }
+        if (cc.getBoolean(CarrierConfigManager.KEY_IGNORE_SIM_NETWORK_LOCKED_EVENTS_BOOL)) {
+            // Some products don't have the concept of a "SIM network lock"
+            Log.i(LOG_TAG, "Not showing 'SIM network unlock' screen. Disabled by carrier config");
+            return;
+        }
+
+        // if passed in subType is unknown, retrieve it here.
+        if (subType == -1) {
+            final UiccCard uiccCard = phone.getUiccCard();
+            if (uiccCard == null) {
+                Log.e(LOG_TAG,
+                        "handleSimLock: uiccCard for phone " + phone.getPhoneId() + " is null");
+                return;
+            }
+            final UiccProfile uiccProfile = uiccCard.getUiccProfile();
+            if (uiccProfile == null) {
+                Log.e(LOG_TAG,
+                        "handleSimLock: uiccProfile for phone " + phone.getPhoneId() + " is null");
+                return;
+            }
+            subType = uiccProfile.getApplication(
+                    uiccProfile.mCurrentAppType).getPersoSubState().ordinal();
+        }
+        // Normal case: show the "SIM network unlock" PIN entry screen.
+        // The user won't be able to do anything else until
+        // they enter a valid SIM network PIN.
+        Log.i(LOG_TAG, "show sim depersonal panel");
+        IccNetworkDepersonalizationPanel.showDialog(phone, subType);
+    }
+
+    private boolean isSimLocked(Phone phone) {
+        TelephonyManager tm = getSystemService(TelephonyManager.class);
+        return tm.createForSubscriptionId(phone.getSubId()).getSimState()
+                == TelephonyManager.SIM_STATE_NETWORK_LOCKED;
+    }
+
     Handler mHandler = new Handler() {
         @Override
         public void handleMessage(Message msg) {
@@ -228,20 +277,9 @@
                 // TODO: This event should be handled by the lock screen, just
                 // like the "SIM missing" and "Sim locked" cases (bug 1804111).
                 case EVENT_SIM_NETWORK_LOCKED:
-                    if (getCarrierConfig().getBoolean(
-                            CarrierConfigManager.KEY_IGNORE_SIM_NETWORK_LOCKED_EVENTS_BOOL)) {
-                        // Some products don't have the concept of a "SIM network lock"
-                        Log.i(LOG_TAG, "Ignoring EVENT_SIM_NETWORK_LOCKED event; "
-                              + "not showing 'SIM network unlock' PIN entry screen");
-                    } else {
-                        // Normal case: show the "SIM network unlock" PIN entry screen.
-                        // The user won't be able to do anything else until
-                        // they enter a valid SIM network PIN.
-                        Log.i(LOG_TAG, "show sim depersonal panel");
-                        Phone phone = (Phone) ((AsyncResult) msg.obj).userObj;
-                        int subType = (Integer)((AsyncResult)msg.obj).result;
-                        IccNetworkDepersonalizationPanel.showDialog(phone, subType);
-                    }
+                    int subType = (Integer) ((AsyncResult) msg.obj).result;
+                    Phone phone = (Phone) ((AsyncResult) msg.obj).userObj;
+                    handleSimLock(subType, phone);
                     break;
 
                 case EVENT_DATA_ROAMING_DISCONNECTED:
@@ -314,6 +352,12 @@
                     // The voicemail number could be overridden by carrier config, so need to
                     // refresh the message waiting (voicemail) indicator.
                     refreshMwiIndicator(subId);
+                    phone = getPhone(subId);
+                    if (phone != null && isSimLocked(phone)) {
+                        // pass in subType=-1 so handleSimLock can find the actual subType if
+                        // needed. This is safe as valid values for subType are >= 0
+                        handleSimLock(-1, phone);
+                    }
                     break;
             }
         }
diff --git a/src/com/android/phone/PhoneInterfaceManager.java b/src/com/android/phone/PhoneInterfaceManager.java
index 1de3fda..801c480 100755
--- a/src/com/android/phone/PhoneInterfaceManager.java
+++ b/src/com/android/phone/PhoneInterfaceManager.java
@@ -4345,8 +4345,8 @@
     @Override
     public void setImsProvisioningStatusForCapability(int subId, int capability, int tech,
             boolean isProvisioned) {
-        if (tech != ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN
-                && tech != ImsRegistrationImplBase.REGISTRATION_TECH_LTE) {
+        if (tech < ImsRegistrationImplBase.REGISTRATION_TECH_LTE
+                || tech > ImsRegistrationImplBase.REGISTRATION_TECH_NR) {
             throw new IllegalArgumentException("Registration technology '" + tech + "' is invalid");
         }
         checkModifyPhoneStatePermission(subId, "setImsProvisioningStatusForCapability");
@@ -4356,6 +4356,12 @@
             if (!isImsProvisioningRequired(subId, capability, true)) {
                 return;
             }
+            if (tech != ImsRegistrationImplBase.REGISTRATION_TECH_LTE
+                    && tech != ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN) {
+                loge("setImsProvisioningStatusForCapability: called for technology that does "
+                        + "not support provisioning - " + tech);
+                return;
+            }
 
             // this capability requires provisioning, route to the correct API.
             ImsManager ims = ImsManager.getInstance(mApp, getSlotIndex(subId));
@@ -4383,7 +4389,7 @@
                     }
                     cacheMmTelCapabilityProvisioning(subId, capability, tech, isProvisioned);
                     try {
-                        ims.changeMmTelCapability(capability, tech, isProvisioned);
+                        ims.changeMmTelCapability(isProvisioned, capability, tech);
                     } catch (com.android.ims.ImsException e) {
                         loge("setImsProvisioningStatusForCapability: couldn't change UT capability"
                                 + ", Exception" + e.getMessage());
@@ -4404,8 +4410,8 @@
 
     @Override
     public boolean getImsProvisioningStatusForCapability(int subId, int capability, int tech) {
-        if (tech != ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN
-                && tech != ImsRegistrationImplBase.REGISTRATION_TECH_LTE) {
+        if (tech < ImsRegistrationImplBase.REGISTRATION_TECH_LTE
+                || tech > ImsRegistrationImplBase.REGISTRATION_TECH_NR) {
             throw new IllegalArgumentException("Registration technology '" + tech + "' is invalid");
         }
         enforceReadPrivilegedPermission("getProvisioningStatusForCapability");
@@ -4416,6 +4422,13 @@
                 return true;
             }
 
+            if (tech != ImsRegistrationImplBase.REGISTRATION_TECH_LTE
+                    && tech != ImsRegistrationImplBase.REGISTRATION_TECH_IWLAN) {
+                loge("getImsProvisioningStatusForCapability: called for technology that does "
+                        + "not support provisioning - " + tech);
+                return true;
+            }
+
             ImsManager ims = ImsManager.getInstance(mApp, getSlotIndex(subId));
             switch (capability) {
                 case MmTelFeature.MmTelCapabilities.CAPABILITY_TYPE_VOICE: {
@@ -4587,7 +4600,7 @@
                         + subId + "' for key:" + key);
                 return ImsConfigImplBase.CONFIG_RESULT_UNKNOWN;
             }
-            return ImsManager.getInstance(mApp, slotId).getConfigInterface().getConfigInt(key);
+            return ImsManager.getInstance(mApp, slotId).getConfigInt(key);
         } catch (com.android.ims.ImsException e) {
             Log.w(LOG_TAG, "getImsProvisioningInt: ImsService is not available for subscription '"
                     + subId + "' for key:" + key);
@@ -4612,7 +4625,7 @@
                         + subId + "' for key:" + key);
                 return ProvisioningManager.STRING_QUERY_RESULT_ERROR_GENERIC;
             }
-            return ImsManager.getInstance(mApp, slotId).getConfigInterface().getConfigString(key);
+            return ImsManager.getInstance(mApp, slotId).getConfigString(key);
         } catch (com.android.ims.ImsException e) {
             Log.w(LOG_TAG, "getImsProvisioningString: ImsService is not available for sub '"
                     + subId + "' for key:" + key);
@@ -4638,10 +4651,10 @@
                         + subId + "' for key:" + key);
                 return ImsConfigImplBase.CONFIG_RESULT_FAILED;
             }
-            return ImsManager.getInstance(mApp, slotId).getConfigInterface().setConfig(key, value);
-        } catch (com.android.ims.ImsException e) {
+            return ImsManager.getInstance(mApp, slotId).setConfig(key, value);
+        } catch (com.android.ims.ImsException | RemoteException e) {
             Log.w(LOG_TAG, "setImsProvisioningInt: ImsService unavailable for sub '" + subId
-                    + "' for key:" + key);
+                    + "' for key:" + key, e);
             return ImsConfigImplBase.CONFIG_RESULT_FAILED;
         } finally {
             Binder.restoreCallingIdentity(identity);
@@ -4664,10 +4677,10 @@
                         + subId + "' for key:" + key);
                 return ImsConfigImplBase.CONFIG_RESULT_FAILED;
             }
-            return ImsManager.getInstance(mApp, slotId).getConfigInterface().setConfig(key, value);
-        } catch (com.android.ims.ImsException e) {
+            return ImsManager.getInstance(mApp, slotId).setConfig(key, value);
+        } catch (com.android.ims.ImsException | RemoteException e) {
             Log.w(LOG_TAG, "setImsProvisioningString: ImsService unavailable for sub '" + subId
-                    + "' for key:" + key);
+                    + "' for key:" + key, e);
             return ImsConfigImplBase.CONFIG_RESULT_FAILED;
         } finally {
             Binder.restoreCallingIdentity(identity);
@@ -5770,6 +5783,8 @@
                                 .setCallingUid(Binder.getCallingUid())
                                 .setMethod("getCellNetworkScanResults")
                                 .setMinSdkVersionForFine(Build.VERSION_CODES.Q)
+                                .setMinSdkVersionForCoarse(Build.VERSION_CODES.Q)
+                                .setMinSdkVersionForEnforcement(Build.VERSION_CODES.Q)
                                 .build());
         switch (locationResult) {
             case DENIED_HARD:
@@ -7449,6 +7464,8 @@
                                 .setMethod("getServiceStateForSubscriber")
                                 .setLogAsInfo(true)
                                 .setMinSdkVersionForFine(Build.VERSION_CODES.Q)
+                                .setMinSdkVersionForCoarse(Build.VERSION_CODES.Q)
+                                .setMinSdkVersionForEnforcement(Build.VERSION_CODES.Q)
                                 .build());
 
         LocationAccessPolicy.LocationPermissionResult coarseLocationResult =
@@ -7461,6 +7478,8 @@
                                 .setMethod("getServiceStateForSubscriber")
                                 .setLogAsInfo(true)
                                 .setMinSdkVersionForCoarse(Build.VERSION_CODES.Q)
+                                .setMinSdkVersionForFine(Integer.MAX_VALUE)
+                                .setMinSdkVersionForEnforcement(Build.VERSION_CODES.Q)
                                 .build());
         // We don't care about hard or soft here -- all we need to know is how much info to scrub.
         boolean hasFinePermission =
@@ -10122,6 +10141,23 @@
         }
     }
 
+    /**
+     * Remove UCE requests cannot be sent to the network status.
+     */
+    // Used for SHELL command only right now.
+    @Override
+    public boolean removeUceRequestDisallowedStatus(int subId) {
+        TelephonyPermissions.enforceShellOnly(Binder.getCallingUid(), "uceRemoveDisallowedStatus");
+        final long identity = Binder.clearCallingIdentity();
+        try {
+            return mApp.imsRcsController.removeUceRequestDisallowedStatus(subId);
+        } catch (ImsException e) {
+            throw new ServiceSpecificException(e.getCode(), e.getMessage());
+        } finally {
+            Binder.restoreCallingIdentity(identity);
+        }
+    }
+
 
     @Override
     public void setSignalStrengthUpdateRequest(int subId, SignalStrengthUpdateRequest request,
diff --git a/src/com/android/phone/TelephonyShellCommand.java b/src/com/android/phone/TelephonyShellCommand.java
index 36d539a..7b11c89 100644
--- a/src/com/android/phone/TelephonyShellCommand.java
+++ b/src/com/android/phone/TelephonyShellCommand.java
@@ -126,6 +126,8 @@
     private static final String UCE_SET_DEVICE_ENABLED = "set-device-enabled";
     private static final String UCE_OVERRIDE_PUBLISH_CAPS = "override-published-caps";
     private static final String UCE_GET_LAST_PIDF_XML = "get-last-publish-pidf";
+    private static final String UCE_REMOVE_REQUEST_DISALLOWED_STATUS =
+            "remove-request-disallowed-status";
 
     // Check if a package has carrier privileges on any SIM, regardless of subId/phoneId.
     private static final String HAS_CARRIER_PRIVILEGES_COMMAND = "has-carrier-privileges";
@@ -405,6 +407,8 @@
         pw.println("  uce get-last-publish-pidf [-s SLOT_ID]");
         pw.println("    Get the PIDF XML included in the last SIP PUBLISH, or \"none\" if no ");
         pw.println("    PUBLISH is active");
+        pw.println("  uce remove-request-disallowed-status [-s SLOT_ID]");
+        pw.println("    Remove the UCE is disallowed to execute UCE requests status");
     }
 
     private void onHelpNumberVerification() {
@@ -1809,6 +1813,8 @@
                 return handleUceOverridePublishCaps();
             case UCE_GET_LAST_PIDF_XML:
                 return handleUceGetPidfXml();
+            case UCE_REMOVE_REQUEST_DISALLOWED_STATUS:
+                return handleUceRemoveRequestDisallowedStatus();
         }
         return -1;
     }
@@ -1895,6 +1901,26 @@
         return 0;
     }
 
+    private int handleUceRemoveRequestDisallowedStatus() {
+        int subId = getSubId("uce remove-request-disallowed-status");
+        if (subId == SubscriptionManager.INVALID_SUBSCRIPTION_ID) {
+            Log.w(LOG_TAG, "uce remove-request-disallowed-status, Invalid subscription ID");
+            return -1;
+        }
+        boolean result;
+        try {
+            result = mInterface.removeUceRequestDisallowedStatus(subId);
+        } catch (RemoteException e) {
+            Log.w(LOG_TAG, "uce remove-request-disallowed-status, error " + e.getMessage());
+            return -1;
+        }
+        if (VDBG) {
+            Log.v(LOG_TAG, "uce remove-request-disallowed-status, returned: " + result);
+        }
+        getOutPrintWriter().println(result);
+        return 0;
+    }
+
     private int handleSrcSetTestEnabledCommand() {
         String enabledStr = getNextArg();
         if (enabledStr == null) {
diff --git a/src/com/android/phone/settings/RadioInfo.java b/src/com/android/phone/settings/RadioInfo.java
index 3f4ae58..302fc47 100644
--- a/src/com/android/phone/settings/RadioInfo.java
+++ b/src/com/android/phone/settings/RadioInfo.java
@@ -1322,7 +1322,7 @@
             new MenuItem.OnMenuItemClickListener() {
         public boolean onMenuItemClick(MenuItem item) {
             boolean isImsRegistered = mPhone.isImsRegistered();
-            boolean availableVolte = mPhone.isVolteEnabled();
+            boolean availableVolte = mPhone.isVoiceOverCellularImsEnabled();
             boolean availableWfc = mPhone.isWifiCallingEnabled();
             boolean availableVt = mPhone.isVideoEnabled();
             boolean availableUt = mPhone.isUtEnabled();
diff --git a/src/com/android/services/telephony/TelephonyConnectionService.java b/src/com/android/services/telephony/TelephonyConnectionService.java
index a1bea2a..581a23a 100644
--- a/src/com/android/services/telephony/TelephonyConnectionService.java
+++ b/src/com/android/services/telephony/TelephonyConnectionService.java
@@ -1598,7 +1598,7 @@
         Phone imsPhone = phone.getImsPhone();
 
         return imsPhone != null
-                && (imsPhone.isVolteEnabled() || imsPhone.isWifiCallingEnabled())
+                && (imsPhone.isVoiceOverCellularImsEnabled() || imsPhone.isWifiCallingEnabled())
                 && (imsPhone.getServiceState().getState() == ServiceState.STATE_IN_SERVICE);
     }
 
diff --git a/src/com/android/services/telephony/rcs/MessageTransportStateTracker.java b/src/com/android/services/telephony/rcs/MessageTransportWrapper.java
similarity index 61%
rename from src/com/android/services/telephony/rcs/MessageTransportStateTracker.java
rename to src/com/android/services/telephony/rcs/MessageTransportWrapper.java
index e640735..0d4265a 100644
--- a/src/com/android/services/telephony/rcs/MessageTransportStateTracker.java
+++ b/src/com/android/services/telephony/rcs/MessageTransportWrapper.java
@@ -31,69 +31,32 @@
 import android.util.LocalLog;
 import android.util.Log;
 
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.telephony.SipMessageParsingUtils;
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.phone.RcsProvisioningMonitor;
+import com.android.services.telephony.rcs.validator.ValidationResult;
+
 import java.io.PrintWriter;
 import java.util.Set;
 import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
 import java.util.function.Consumer;
 
 /**
- * Tracks the SIP message path both from the IMS application to the SipDelegate and from the
+ * Wraps the SIP message path both from the IMS application to the SipDelegate and from the
  * SipDelegate back to the IMS Application.
  * <p>
- * Responsibilities include:
- * 1) Queue incoming and outgoing SIP messages and deliver to IMS application and SipDelegate in
- *        order. If there is an error delivering the message, notify the caller.
- * 2) TODO Perform basic validation of outgoing messages.
- * 3) TODO Record the status of ongoing SIP Dialogs and trigger the completion of pending
- *         consumers when they are finished or call closeDialog to clean up the SIP
- *         dialogs that did not complete within the allotted timeout time.
+ * Queues incoming and outgoing SIP messages on an Executor and deliver to IMS application and
+ * SipDelegate in order. If there is an error delivering the message, the caller is notified.
+ * Uses {@link TransportSipMessageValidator} to track ongoing SIP dialogs and verify outgoing
+ * messages.
  * <p>
  * Note: This handles incoming binder calls, so all calls from other processes should be handled on
  * the provided Executor.
  */
-public class MessageTransportStateTracker implements DelegateBinderStateManager.StateCallback {
-    private static final String TAG = "MessageST";
-
-    /**
-     * Communicates the result of verifying whether a SIP message should be sent based on the
-     * contents of the SIP message as well as if the transport is in an available state for the
-     * intended recipient of the message.
-     */
-    private static class VerificationResult {
-        public static final VerificationResult SUCCESS = new VerificationResult();
-
-        /**
-         * If {@code true}, the requested SIP message has been verified to be sent to the remote. If
-         * {@code false}, the SIP message has failed verification and should not be sent to the
-         * result. The {@link #restrictedReason} field will contain the reason for the verification
-         * failure.
-         */
-        public final boolean isVerified;
-
-        /**
-         * The reason associated with why the SIP message was not verified and generated a
-         * {@code false} result for {@link #isVerified}.
-         */
-        public final int restrictedReason;
-
-        /**
-         * Communicates a verified result of success. Use {@link #SUCCESS} instead.
-         */
-        private VerificationResult() {
-            isVerified = true;
-            restrictedReason = SipDelegateManager.MESSAGE_FAILURE_REASON_UNKNOWN;
-        }
-
-        /**
-         * The result of verifying that the SIP Message should be sent.
-         * @param reason The reason associated with why the SIP message was not verified and
-         *               generated a {@code false} result for {@link #isVerified}.
-         */
-        VerificationResult(@SipDelegateManager.MessageFailureReason int reason) {
-            isVerified = false;
-            restrictedReason = reason;
-        }
-    }
+public class MessageTransportWrapper implements DelegateBinderStateManager.StateCallback {
+    private static final String TAG = "MessageTW";
 
     // SipDelegateConnection(IMS Application) -> SipDelegate(ImsService)
     private final ISipDelegate.Stub mSipDelegateConnection = new ISipDelegate.Stub() {
@@ -113,8 +76,7 @@
                         return;
                     }
                     try {
-                        // TODO track the SIP Dialogs created/destroyed on the associated
-                        // SipDelegate.
+                        mSipSessionTracker.acknowledgePendingMessage(viaTransactionId);
                         mSipDelegate.notifyMessageReceived(viaTransactionId);
                     } catch (RemoteException e) {
                         logw("SipDelegate not available when notifyMessageReceived was called "
@@ -142,8 +104,7 @@
                         return;
                     }
                     try {
-                        // TODO track the SIP Dialogs created/destroyed on the associated
-                        // SipDelegate.
+                        mSipSessionTracker.notifyPendingMessageFailed(viaTransactionId);
                         mSipDelegate.notifyMessageReceiveError(viaTransactionId, reason);
                     } catch (RemoteException e) {
                         logw("SipDelegate not available when notifyMessageReceiveError was called "
@@ -164,18 +125,24 @@
             long token = Binder.clearCallingIdentity();
             try {
                 mExecutor.execute(() -> {
-                    VerificationResult result = verifyOutgoingMessage(sipMessage);
-                    if (!result.isVerified) {
-                        notifyDelegateSendError("Outgoing messages restricted", sipMessage,
-                                result.restrictedReason);
+                    ValidationResult result =
+                            mSipSessionTracker.verifyOutgoingMessage(sipMessage, configVersion);
+                    result = maybeOverrideValidationForTesting(result);
+                    if (!result.isValidated) {
+                        notifyDelegateSendError("Outgoing - " + result.logReason,
+                                sipMessage, result.restrictedReason);
                         return;
                     }
                     try {
-                        // TODO track the SIP Dialogs created/destroyed on the associated
-                        // SipDelegate.
+                        if (mSipDelegate == null) {
+                            logw("sendMessage called when SipDelegate is not associated."
+                                    + sipMessage);
+                            notifyDelegateSendError("No SipDelegate", sipMessage,
+                                    SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD);
+
+                            return;
+                        }
                         mSipDelegate.sendMessage(sipMessage, configVersion);
-                        logi("sendMessage: message sent - " + sipMessage + ", configVersion: "
-                                + configVersion);
                     } catch (RemoteException e) {
                         notifyDelegateSendError("RemoteException: " + e, sipMessage,
                                 SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD);
@@ -194,21 +161,7 @@
         public void cleanupSession(String callId) {
             long token = Binder.clearCallingIdentity();
             try {
-                mExecutor.execute(() -> {
-                    if (mSipDelegate == null) {
-                        logw("closeDialog called when SipDelegate is not associated, callId: "
-                                + callId);
-                        return;
-                    }
-                    try {
-                        // TODO track the SIP Dialogs created/destroyed on the associated
-                        // SipDelegate.
-                        mSipDelegate.cleanupSession(callId);
-                    } catch (RemoteException e) {
-                        logw("SipDelegate not available when closeDialog was called "
-                                + "for call id: " + callId);
-                    }
-                });
+                mExecutor.execute(() -> cleanupSessionInternal(callId));
             } finally {
                 Binder.restoreCallingIdentity(token);
             }
@@ -230,17 +183,14 @@
             long token = Binder.clearCallingIdentity();
             try {
                 mExecutor.execute(() -> {
-                    VerificationResult result = verifyIncomingMessage(message);
-                    if (!result.isVerified) {
-                        notifyAppReceiveError("Incoming messages restricted", message,
+                    ValidationResult result = mSipSessionTracker.verifyIncomingMessage(message);
+                    if (!result.isValidated) {
+                        notifyAppReceiveError("Incoming - " + result.logReason, message,
                                 result.restrictedReason);
                         return;
                     }
                     try {
-                        // TODO track the SIP Dialogs created/destroyed on the associated
-                        //  SipDelegate.
                         mAppCallback.onMessageReceived(message);
-                        logi("onMessageReceived: received " + message);
                     } catch (RemoteException e) {
                         notifyAppReceiveError("RemoteException: " + e, message,
                                 SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD);
@@ -253,7 +203,7 @@
 
         /**
          * An outgoing SIP message sent previously by the SipDelegateConnection to the SipDelegate
-         * using {@link ISipDelegate#sendMessage(SipMessage, int)} as been successfully sent.
+         * using {@link ISipDelegate#sendMessage(SipMessage, long)} as been successfully sent.
          */
         @Override
         public void onMessageSent(String viaTransactionId) {
@@ -265,6 +215,7 @@
                                 + "associated");
                     }
                     try {
+                        mSipSessionTracker.acknowledgePendingMessage(viaTransactionId);
                         mAppCallback.onMessageSent(viaTransactionId);
                     } catch (RemoteException e) {
                         logw("Error sending onMessageSent to SipDelegateConnection, remote not"
@@ -278,7 +229,7 @@
 
         /**
          * An outgoing SIP message sent previously by the SipDelegateConnection to the SipDelegate
-         * using {@link ISipDelegate#sendMessage(SipMessage, int)} failed to be sent.
+         * using {@link ISipDelegate#sendMessage(SipMessage, long)} failed to be sent.
          */
         @Override
         public void onMessageSendFailure(String viaTransactionId, int reason) {
@@ -290,6 +241,7 @@
                                 + "associated");
                     }
                     try {
+                        mSipSessionTracker.notifyPendingMessageFailed(viaTransactionId);
                         mAppCallback.onMessageSendFailure(viaTransactionId, reason);
                     } catch (RemoteException e) {
                         logw("Error sending onMessageSendFailure to SipDelegateConnection, remote"
@@ -302,55 +254,98 @@
         }
     };
 
+    /**
+     * Interface for injecting validator override dependencies for testing.
+     */
+    @VisibleForTesting
+    public interface ValidatorOverride {
+        /**
+         * @return {@code null} if the validation result should not be overridden, {@code true} if
+         * the validation result should always pass, {@code false} if the validation result should
+         * always fail.
+         */
+        Boolean getValidatorOverrideState();
+    }
+
+    private final ValidatorOverride mValidatorOverride;
     private final ISipDelegateMessageCallback mAppCallback;
     private final Executor mExecutor;
     private final int mSubId;
+    private final TransportSipMessageValidator mSipSessionTracker;
     private final LocalLog mLocalLog = new LocalLog(SipTransportController.LOG_SIZE);
 
     private ISipDelegate mSipDelegate;
-    private Consumer<Boolean> mPendingClosedConsumer;
-    private int mDelegateClosingReason = -1;
-    private int mDelegateClosedReason = -1;
 
-    public MessageTransportStateTracker(int subId, Executor executor,
+    public MessageTransportWrapper(int subId, ScheduledExecutorService executor,
             ISipDelegateMessageCallback appMessageCallback) {
         mSubId = subId;
         mAppCallback = appMessageCallback;
         mExecutor = executor;
+        mSipSessionTracker = new TransportSipMessageValidator(subId, executor);
+        mValidatorOverride = () -> RcsProvisioningMonitor.getInstance()
+                .getImsFeatureValidationOverride(mSubId);
+    }
+
+    /**
+     * Mock out dependencies for unit testing.
+     */
+    @VisibleForTesting
+    public MessageTransportWrapper(int subId, ScheduledExecutorService executor,
+            ISipDelegateMessageCallback appMessageCallback,
+            TransportSipMessageValidator sipSessionTracker) {
+        mSubId = subId;
+        mAppCallback = appMessageCallback;
+        mExecutor = executor;
+        mSipSessionTracker = sipSessionTracker;
+        // Remove links to static methods calls querying overrides for testing.
+        mValidatorOverride = () -> null;
     }
 
     @Override
     public void onRegistrationStateChanged(DelegateRegistrationState registrationState) {
-        // TODO: integrate registration changes to SipMessage verification checks.
+        mSipSessionTracker.onRegistrationStateChanged((callIds) -> {
+            for (String id : callIds)  {
+                cleanupSessionInternal(id);
+            }
+        }, registrationState);
     }
 
     @Override
     public void onImsConfigurationChanged(SipDelegateImsConfiguration config) {
-        // Not needed for this Tracker
+        mSipSessionTracker.onImsConfigurationChanged(config);
     }
 
     @Override
     public void onConfigurationChanged(SipDelegateConfiguration config) {
-        // Not needed for this Tracker
+        mSipSessionTracker.onConfigurationChanged(config);
     }
 
     /**
      * Open the transport and allow SIP messages to be sent/received on the delegate specified.
      * @param delegate The delegate connection to send SIP messages to on the ImsService.
+     * @param supportedFeatureTags Feature tags that are supported. Outgoing SIP messages relating
+     *                             to these tags will be allowed.
      * @param deniedFeatureTags Feature tags that have been denied. Outgoing SIP messages relating
      *         to these tags will be denied.
      */
-    public void openTransport(ISipDelegate delegate, Set<FeatureTagState> deniedFeatureTags) {
+    public void openTransport(ISipDelegate delegate, Set<String> supportedFeatureTags,
+            Set<FeatureTagState> deniedFeatureTags) {
+        logi("openTransport: delegate=" + delegate + ", supportedTags=" + supportedFeatureTags
+                + ", deniedTags=" + deniedFeatureTags);
+        mSipSessionTracker.onTransportOpened(supportedFeatureTags, deniedFeatureTags);
         mSipDelegate = delegate;
-        mDelegateClosingReason = -1;
-        mDelegateClosedReason = -1;
-        // TODO: integrate denied tags to SipMessage verification checks.
     }
 
     /** Dump state about this tracker that should be included in the dumpsys */
     public void dump(PrintWriter printWriter) {
-        printWriter.println("Most recent logs:");
+        IndentingPrintWriter pw = new IndentingPrintWriter(printWriter, "  ");
+        pw.println("Most recent logs:");
         mLocalLog.dump(printWriter);
+        pw.println();
+        pw.println("Dialog Tracker:");
+        pw.increaseIndent();
+        mSipSessionTracker.dump(pw);
+        pw.decreaseIndent();
     }
 
     /**
@@ -375,13 +370,14 @@
     }
 
     /**
-     * Gradually close all SIP Dialogs by:
+     * Gradually close all SIP Sessions by:
      * 1) denying all new outgoing SIP Dialog requests with the reason specified and
-     * 2) only allowing existing SIP Dialogs to continue.
+     * 2) only allowing existing SIP Sessions to continue.
      * <p>
-     * This will allow traffic to continue on existing SIP Dialogs until a BYE is sent and the
-     * SIP Dialogs are closed or a timeout is hit and {@link SipDelegate#closeDialog(String)} is
-     * forcefully called on all open SIP Dialogs.
+     * This will allow traffic to continue on existing SIP Sessions until a BYE is sent and the
+     * corresponding SIP Dialogs are closed or a timeout is hit and
+     * {@link SipDelegate#cleanupSession(String)} (String)} is forcefully called on all open SIP
+     * sessions.
      * <p>
      * Any outgoing out-of-dialog traffic on this transport will be denied with the provided reason.
      * <p>
@@ -400,81 +396,86 @@
      */
     public void closeGracefully(int delegateClosingReason, int closedReason,
             Consumer<Boolean> resultConsumer) {
-        mDelegateClosingReason = delegateClosingReason;
-        mPendingClosedConsumer = resultConsumer;
-        mExecutor.execute(() -> {
-            // TODO: Track SIP Dialogs and complete when there are no SIP dialogs open anymore or
-            //  the timeout occurs.
-            mPendingClosedConsumer.accept(true);
-            mPendingClosedConsumer = null;
-            closeTransport(closedReason);
-        });
+        logi("closeGracefully: closingReason=" + delegateClosingReason + ", closedReason="
+                + closedReason + ", resultConsumer(" + resultConsumer.hashCode() + ")");
+        mSipSessionTracker.closeSessionsGracefully((openCallIds) -> {
+            logi("closeGracefully resultConsumer(" + resultConsumer.hashCode()
+                    + "): open call IDs:{" + openCallIds + "}");
+            closeTransport(openCallIds);
+            // propagate event to the consumer
+            resultConsumer.accept(openCallIds.isEmpty() /*successfullyClosed*/);
+        }, delegateClosingReason, closedReason);
     }
 
     /**
-     * Close all ongoing SIP Dialogs immediately and respond to any incoming/outgoing messages with
+     * Close all ongoing SIP sessions immediately and respond to any incoming/outgoing messages with
      * the provided reason.
      * @param closedReason The failure reason to provide to incoming/outgoing SIP messages
      *         if an attempt is made to send/receive a message after this method is called.
      */
     public void close(int closedReason) {
-        closeTransport(closedReason);
+        Set<String> openSessions = mSipSessionTracker.closeSessions(closedReason);
+        logi("close: closedReason=" + closedReason + "open call IDs:{" + openSessions + "}");
+        closeTransport(openSessions);
     }
 
     // Clean up all state related to the existing SipDelegate immediately.
-    private void closeTransport(int closedReason) {
-        // TODO: add logic to forcefully close open SIP dialogs once they are being tracked.
+    private void closeTransport(Set<String> openCallIds) {
+        for (String id : openCallIds) {
+            cleanupSessionInternal(id);
+        }
         mSipDelegate = null;
-        if (mPendingClosedConsumer != null) {
-            mExecutor.execute(() -> {
-                logw("closeTransport: transport close forced with pending consumer.");
-                mPendingClosedConsumer.accept(false /*closedGracefully*/);
-                mPendingClosedConsumer = null;
-            });
-        }
-        mDelegateClosingReason = -1;
-        mDelegateClosedReason = closedReason;
     }
 
-    private VerificationResult verifyOutgoingMessage(SipMessage message) {
-        if (mDelegateClosingReason > -1) {
-            return new VerificationResult(mDelegateClosingReason);
+    private void cleanupSessionInternal(String callId) {
+        logi("cleanupSessionInternal: clean up session with callId: " + callId);
+        try {
+            if (mSipDelegate == null) {
+                logw("cleanupSessionInternal: SipDelegate is not associated, callId: " + callId);
+            } else {
+                // This will close the transport, so call cleanup on ImsService first.
+                mSipDelegate.cleanupSession(callId);
+            }
+        } catch (RemoteException e) {
+            logw("cleanupSessionInternal: remote not available when cleanupSession was called "
+                    + "for call id: " + callId);
         }
-        if (mDelegateClosedReason > -1) {
-            return new VerificationResult(mDelegateClosedReason);
-        }
-        if (mSipDelegate == null) {
-            logw("sendMessage called when SipDelegate is not associated." + message);
-            return new VerificationResult(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD);
-        }
-        return VerificationResult.SUCCESS;
+        mSipSessionTracker.onSipSessionCleanup(callId);
     }
 
-    private VerificationResult verifyIncomingMessage(SipMessage message) {
-        // Do not restrict incoming based on closing reason.
-        if (mDelegateClosedReason > -1) {
-            return new VerificationResult(mDelegateClosedReason);
+    private ValidationResult maybeOverrideValidationForTesting(ValidationResult result) {
+        Boolean isValidatedOverride = mValidatorOverride.getValidatorOverrideState();
+        if (isValidatedOverride == null) {
+            return result;
         }
-        return VerificationResult.SUCCESS;
+        if (isValidatedOverride) {
+            return ValidationResult.SUCCESS;
+        } else if (result.isValidated) {
+            // if override is set to false and the original result was validated, return a new
+            // restricted result with UNKNOWN reason.
+            return new ValidationResult(SipDelegateManager.MESSAGE_FAILURE_REASON_UNKNOWN,
+                    "validation failed due to a testing override being set");
+        }
+        return result;
     }
 
     private void notifyDelegateSendError(String logReason, SipMessage message, int reasonCode) {
-        // TODO parse SipMessage header for viaTransactionId.
-        logw("Error sending SipMessage[id: " + null + ", code: " + reasonCode + "] -> SipDelegate "
-                + "for reason: " + logReason);
+        String transactionId = SipMessageParsingUtils.getTransactionId(message.getHeaderSection());
+        logi("Error sending SipMessage[id: " + transactionId + ", code: " + reasonCode
+                + "] -> SipDelegate for reason: " + logReason);
         try {
-            mAppCallback.onMessageSendFailure(null, reasonCode);
+            mAppCallback.onMessageSendFailure(transactionId, reasonCode);
         } catch (RemoteException e) {
             logw("notifyDelegateSendError, SipDelegate is not available: " + e);
         }
     }
 
     private void notifyAppReceiveError(String logReason, SipMessage message, int reasonCode) {
-        // TODO parse SipMessage header for viaTransactionId.
-        logw("Error sending SipMessage[id: " + null + ", code: " + reasonCode + "] -> "
+        String transactionId = SipMessageParsingUtils.getTransactionId(message.getHeaderSection());
+        logi("Error sending SipMessage[id: " + transactionId + ", code: " + reasonCode + "] -> "
                 + "SipDelegateConnection for reason: " + logReason);
         try {
-            mSipDelegate.notifyMessageReceiveError(null, reasonCode);
+            mSipDelegate.notifyMessageReceiveError(transactionId, reasonCode);
         } catch (RemoteException e) {
             logw("notifyAppReceiveError, SipDelegate is not available: " + e);
         }
diff --git a/src/com/android/services/telephony/rcs/RcsFeatureController.java b/src/com/android/services/telephony/rcs/RcsFeatureController.java
index 3eefdb0..7834903 100644
--- a/src/com/android/services/telephony/rcs/RcsFeatureController.java
+++ b/src/com/android/services/telephony/rcs/RcsFeatureController.java
@@ -20,9 +20,7 @@
 import android.content.Context;
 import android.net.Uri;
 import android.telephony.ims.ImsException;
-import android.telephony.ims.ImsRcsManager;
 import android.telephony.ims.ImsReasonInfo;
-import android.telephony.ims.RegistrationManager;
 import android.telephony.ims.aidl.IImsCapabilityCallback;
 import android.telephony.ims.aidl.IImsRegistrationCallback;
 import android.telephony.ims.stub.ImsRegistrationImplBase;
@@ -135,6 +133,7 @@
                         logw("connectionReady returned null RcsFeatureManager");
                         return;
                     }
+                    logd("connectionReady");
                     try {
                         // May throw ImsException if for some reason the connection to the
                         // ImsService is gone.
@@ -153,6 +152,7 @@
                     if (reason == FeatureConnector.UNAVAILABLE_REASON_SERVER_UNAVAILABLE) {
                         loge("unexpected - connectionUnavailable due to server unavailable");
                     }
+                    logd("connectionUnavailable");
                     // Call before disabling connection to manager.
                     removeConnectionToService();
                     updateConnectionStatus(null /*manager*/);
@@ -411,6 +411,7 @@
     }
 
     private void setupConnectionToService(RcsFeatureManager manager) throws ImsException {
+        logd("setupConnectionToService");
         // Open persistent listener connection, sends RcsFeature#onFeatureReady.
         manager.openConnection();
         manager.updateCapabilities(mAssociatedSubId);
@@ -418,6 +419,7 @@
     }
 
     private void removeConnectionToService() {
+        logd("removeConnectionToService");
         RcsFeatureManager manager = getFeatureManager();
         if (manager != null) {
             manager.unregisterImsRegistrationCallback(
@@ -472,6 +474,10 @@
         }
     }
 
+    private void logd(String log) {
+        Log.d(LOG_TAG, getLogPrefix().append(log).toString());
+    }
+
     private void logw(String log) {
         Log.w(LOG_TAG, getLogPrefix().append(log).toString());
     }
diff --git a/src/com/android/services/telephony/rcs/SipDelegateBinderConnection.java b/src/com/android/services/telephony/rcs/SipDelegateBinderConnection.java
index 5eb0558..168a432 100644
--- a/src/com/android/services/telephony/rcs/SipDelegateBinderConnection.java
+++ b/src/com/android/services/telephony/rcs/SipDelegateBinderConnection.java
@@ -98,7 +98,8 @@
                     long token = Binder.clearCallingIdentity();
                     try {
                         mExecutor.execute(() -> {
-                            logi("onImsConfigurationChanged");
+                            logi("onImsConfigurationChanged: version="
+                                    + registeredSipConfig.getVersion());
                             for (StateCallback c : mStateCallbacks) {
                                 c.onImsConfigurationChanged(registeredSipConfig);
                             }
diff --git a/src/com/android/services/telephony/rcs/SipDelegateController.java b/src/com/android/services/telephony/rcs/SipDelegateController.java
index 8cd4365..30edca1 100644
--- a/src/com/android/services/telephony/rcs/SipDelegateController.java
+++ b/src/com/android/services/telephony/rcs/SipDelegateController.java
@@ -28,6 +28,7 @@
 import android.telephony.ims.aidl.ISipTransport;
 import android.telephony.ims.stub.DelegateConnectionStateCallback;
 import android.telephony.ims.stub.SipDelegate;
+import android.util.ArraySet;
 import android.util.LocalLog;
 import android.util.Log;
 import android.util.Pair;
@@ -42,6 +43,7 @@
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.Executor;
 import java.util.concurrent.ScheduledExecutorService;
+import java.util.stream.Collectors;
 
 /**
  * Created when an IMS application wishes to open up a {@link SipDelegateConnection} and manages the
@@ -78,7 +80,7 @@
     private final String mPackageName;
     private final DelegateRequest mInitialRequest;
     private final ScheduledExecutorService mExecutorService;
-    private final MessageTransportStateTracker mMessageTransportStateTracker;
+    private final MessageTransportWrapper mMessageTransportWrapper;
     private final DelegateStateTracker mDelegateStateTracker;
     private final DelegateBinderStateManager.Factory mBinderConnectionFactory;
     private final LocalLog mLocalLog = new LocalLog(SipTransportController.LOG_SIZE);
@@ -97,10 +99,10 @@
         mExecutorService = executorService;
         mBinderConnectionFactory = new BinderConnectionFactory(transportImpl, registrationImpl);
 
-        mMessageTransportStateTracker = new MessageTransportStateTracker(mSubId, executorService,
+        mMessageTransportWrapper = new MessageTransportWrapper(mSubId, executorService,
                 messageCallback);
         mDelegateStateTracker = new DelegateStateTracker(mSubId, stateCallback,
-                mMessageTransportStateTracker.getDelegateConnection());
+                mMessageTransportWrapper.getDelegateConnection());
     }
 
     /**
@@ -109,14 +111,14 @@
     @VisibleForTesting
     public SipDelegateController(int subId, DelegateRequest initialRequest, String packageName,
             ScheduledExecutorService executorService,
-            MessageTransportStateTracker messageTransportStateTracker,
+            MessageTransportWrapper messageTransportWrapper,
             DelegateStateTracker delegateStateTracker,
             DelegateBinderStateManager.Factory connectionFactory) {
         mSubId = subId;
         mInitialRequest = initialRequest;
         mPackageName = packageName;
         mExecutorService = executorService;
-        mMessageTransportStateTracker = messageTransportStateTracker;
+        mMessageTransportWrapper = messageTransportWrapper;
         mDelegateStateTracker = delegateStateTracker;
         mBinderConnectionFactory = connectionFactory;
     }
@@ -140,14 +142,14 @@
      * @return The ImsService's SIP delegate binder impl associated with this controller.
      */
     public ISipDelegate getSipDelegateInterface() {
-        return mMessageTransportStateTracker.getDelegateConnection();
+        return mMessageTransportWrapper.getDelegateConnection();
     }
 
     /**
      * @return The IMS app's message callback binder.
      */
     public ISipDelegateMessageCallback getAppMessageCallback() {
-        return mMessageTransportStateTracker.getAppMessageCallback();
+        return mMessageTransportWrapper.getAppMessageCallback();
     }
 
     /**
@@ -183,7 +185,12 @@
             }
             mBinderConnection = connection;
             logi("create: created, delegate denied: " + resultPair.second);
-            mMessageTransportStateTracker.openTransport(resultPair.first, resultPair.second);
+            Set<String> allowedTags = new ArraySet<>(supportedSet);
+            // Start with the supported set and remove all tags that were denied.
+            allowedTags.removeAll(resultPair.second.stream().map(FeatureTagState::getFeatureTag)
+                    .collect(Collectors.toSet()));
+            mMessageTransportWrapper.openTransport(resultPair.first, allowedTags,
+                    resultPair.second);
             mDelegateStateTracker.sipDelegateConnected(resultPair.second);
             return true;
         });
@@ -262,7 +269,7 @@
                         DelegateRegistrationState.DEREGISTERING_REASON_DESTROY_PENDING,
                         destroyReason);
         return pendingOperationComplete.thenApplyAsync((reasonFromDelegate) -> {
-            logi("destroy, operation complete, notifying trackers, reason" + reasonFromDelegate);
+            logi("destroy, operation complete, notifying trackers, reason " + reasonFromDelegate);
             mDelegateStateTracker.sipDelegateDestroyed(reasonFromDelegate);
             return reasonFromDelegate;
         }, mExecutorService);
@@ -323,10 +330,10 @@
         CompletableFuture<Boolean> pendingTransportClosed = new CompletableFuture<>();
         if (force) {
             logi("destroySipDelegate, forced");
-            mMessageTransportStateTracker.close(messageDestroyedReason);
+            mMessageTransportWrapper.close(messageDestroyedReason);
             pendingTransportClosed.complete(true);
         } else {
-            mMessageTransportStateTracker.closeGracefully(messageDestroyingReason,
+            mMessageTransportWrapper.closeGracefully(messageDestroyingReason,
                     messageDestroyedReason, pendingTransportClosed::complete);
         }
 
@@ -356,7 +363,7 @@
             DelegateBinderStateManager connection) {
         CompletableFuture<Pair<ISipDelegate, Set<FeatureTagState>>> createdFuture =
                 new CompletableFuture<>();
-        boolean isStarted = connection.create(mMessageTransportStateTracker.getMessageCallback(),
+        boolean isStarted = connection.create(mMessageTransportWrapper.getMessageCallback(),
                 (delegate, delegateDeniedTags) ->
                         createdFuture.complete(new Pair<>(delegate, delegateDeniedTags)));
         if (!isStarted) {
@@ -372,7 +379,7 @@
 
         List<DelegateBinderStateManager.StateCallback> stateCallbacks = new ArrayList<>(2);
         stateCallbacks.add(mDelegateStateTracker);
-        stateCallbacks.add(mMessageTransportStateTracker);
+        stateCallbacks.add(mMessageTransportWrapper);
 
         return mBinderConnectionFactory.create(mSubId,
                 new DelegateRequest(supportedSet), deniedSet, mExecutorService, stateCallbacks);
@@ -400,7 +407,7 @@
         pw.println();
         pw.println("MessageStateTracker:");
         pw.increaseIndent();
-        mMessageTransportStateTracker.dump(pw);
+        mMessageTransportWrapper.dump(pw);
         pw.decreaseIndent();
 
         pw.decreaseIndent();
diff --git a/src/com/android/services/telephony/rcs/SipDialog.java b/src/com/android/services/telephony/rcs/SipDialog.java
new file mode 100644
index 0000000..508d515
--- /dev/null
+++ b/src/com/android/services/telephony/rcs/SipDialog.java
@@ -0,0 +1,254 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs;
+
+import android.telephony.ims.SipMessage;
+import android.text.TextUtils;
+
+import com.android.internal.telephony.SipMessageParsingUtils;
+
+import java.time.Instant;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Track the state of a SIP Dialog.
+ * <p>
+ * SIP Dialogs follow the following initialization flow:
+ * <pre>
+ * (INVITE) ---> EARLY -(2XX)-> CONFIRMED -(BYE)-----> CLOSED
+ *          ^     |   \                           ^
+ *          |--(1XX)   -(3XX-7XX) ----------------|
+ * </pre>
+ * <p> A special note on forking INVITE requests:
+ * During the EARLY phase, a 1XX or 2XX response can carry a To header tag param. This To header
+ * tag param will be set once a INVITE reaches the remote and responds. If the proxy has multiple
+ * endpoints available for the same contact, the INVITE may fork and multiple responses may be
+ * received for the same INVITE request, each with a different To header tag parameter (but the
+ * same call-id). This will generate another SIP dialog within the same SIP session.
+ */
+public class SipDialog {
+
+    /**
+     * The device has sent out a dialog starting event and is awaiting a confirmation.
+     */
+    public static final int STATE_EARLY = 0;
+
+    /**
+     * The device has received a 2XX response to the early dialog.
+     */
+    public static final int STATE_CONFIRMED = 1;
+
+    /**
+     * The device has received either a 3XX+ response to a pending dialog request or a BYE
+     * request has been sent on this dialog.
+     */
+    public static final int STATE_CLOSED = 2;
+
+    private final String mBranchId;
+    private final String mCallId;
+    private final String mFromTag;
+    private final Set<String> mAcceptContactFeatureTags;
+    private String mToTag;
+    private int mState = STATE_EARLY;
+    private Instant mLastInteraction;
+
+    /**
+     * @return A SipDialog instance representing the SIP request.
+     */
+    public static SipDialog fromSipMessage(SipMessage m) {
+        if (!SipMessageParsingUtils.isSipRequest(m.getStartLine())) return null;
+        String fromTag = SipMessageParsingUtils.getFromTag(m.getHeaderSection());
+        Set<String> acceptContactTags = SipMessageParsingUtils.getAcceptContactFeatureTags(
+                m.getHeaderSection());
+        return new SipDialog(m.getViaBranchParameter(), m.getCallIdParameter(), fromTag,
+                acceptContactTags);
+    }
+
+    /**
+     * Track a new SIP dialog, which will be starting in {@link #STATE_EARLY}.
+     *
+     * @param branchId The via branch param of the INVITE request, which is used to match
+     *                 responses.
+     * @param callId   The callId of the SIP dialog.
+     * @param fromTag  The from header's tag parameter.
+     */
+    private SipDialog(String branchId, String callId, String fromTag, Set<String> featureTags) {
+        mBranchId = branchId;
+        mCallId = callId;
+        mFromTag = fromTag;
+        mAcceptContactFeatureTags = featureTags;
+        mLastInteraction = Instant.now();
+    }
+
+    /**
+     * @return The call id associated with the SIP dialog.
+     */
+    public String getCallId() {
+        return mCallId;
+    }
+
+    /**
+     * @return the state of the SIP dialog, either {@link #STATE_EARLY},
+     * {@link #STATE_CONFIRMED}, {@link #STATE_CLOSED}.
+     */
+    public int getState() {
+        return mState;
+    }
+
+    /**
+     * @return The to header's tag parameter if this dialog has gotten a response from the remote
+     * party or {@code null} if it has not.
+     */
+    public String getToTag() {
+        return mToTag;
+    }
+
+    /**
+     * @return The feature tags contained in the "Accept-Contact" header.
+     */
+    public Set<String> getAcceptContactFeatureTags() {
+        return mAcceptContactFeatureTags;
+    }
+
+    /**
+     * @return A new instance with branch param, call-id value, and from tag param populated.
+     */
+    public SipDialog forkDialog() {
+        return new SipDialog(mBranchId, mCallId, mFromTag, mAcceptContactFeatureTags);
+    }
+
+    /**
+     * A early response has been received (101-199) and contains a to tag, which will create a
+     * dialog.
+     * @param toTag The to tag of the SIP response.
+     */
+    public void earlyResponse(String toTag) {
+        if (TextUtils.isEmpty(toTag) || mState != STATE_EARLY) {
+            // invalid state
+            return;
+        }
+        mLastInteraction = Instant.now();
+        // Only accept To tag if one has not been assigned yet.
+        if (mToTag == null) {
+            mToTag = toTag;
+        }
+    }
+
+    /**
+     * The early dialog has been confirmed and
+     * @param toTag The To header's tag parameter.
+     */
+    public void confirm(String toTag) {
+        if (mState != STATE_EARLY) {
+            // Invalid state
+            return;
+        }
+        mLastInteraction = Instant.now();
+        mState = STATE_CONFIRMED;
+        // Only accept a To tag if one has not been assigned yet.
+        if (mToTag == null) {
+            mToTag = toTag;
+        }
+    }
+
+    /**
+     * Close the SIP dialog
+     */
+    public void close() {
+        mLastInteraction = Instant.now();
+        mState = STATE_CLOSED;
+    }
+
+    /**
+     * @return {@code true} if a SIP response's branch, call-id, and from tags match this dialog,
+     * {@code false} if it does not. This may match multiple Dialogs in the case of SIP INVITE
+     * forking.
+     */
+    public boolean isResponseAssociatedWithDialog(SipMessage m) {
+        if (!mBranchId.equals(m.getViaBranchParameter())) return false;
+        if (!mCallId.equals(m.getCallIdParameter())) return false;
+        String fromTag = SipMessageParsingUtils.getFromTag(m.getHeaderSection());
+        return mFromTag.equals(fromTag);
+    }
+
+    /**
+     * @return {@code true} if the SIP request is part of the SIP Dialog, {@code false} if it is
+     * not.
+     */
+    public boolean isRequestAssociatedWithDialog(SipMessage m) {
+        if (!mCallId.equals(m.getCallIdParameter())) return false;
+        String fromTag = SipMessageParsingUtils.getFromTag(m.getHeaderSection());
+        String toTag = SipMessageParsingUtils.getToTag(m.getHeaderSection());
+        // Requests can only be associated if both to and from tag of message are populated. The
+        // dialog's to tag must also be non-null meaning we got a response from the remote.
+        if (fromTag == null || toTag == null || mToTag == null) return false;
+        // For outgoing requests, recorded from tag will match their from tag and for incoming
+        // requests recorded from tag will match their to tag. Same with our recorded to tag.
+        return (mFromTag.equals(fromTag) || mFromTag.equals(toTag))
+                && (mToTag.equals(toTag) || mToTag.equals(fromTag));
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder b = new StringBuilder("SipDialog[");
+        switch (mState) {
+            case STATE_EARLY:
+                b.append("early");
+                break;
+            case STATE_CONFIRMED:
+                b.append("confirmed");
+                break;
+            case STATE_CLOSED:
+                b.append("closed");
+                break;
+            default:
+                b.append(mState);
+        }
+        b.append("] bId=");
+        b.append(mBranchId);
+        b.append(", cId=");
+        b.append(mCallId);
+        b.append(", f=");
+        b.append(mFromTag);
+        b.append(", t=");
+        b.append(mToTag);
+        b.append(", Last Interaction: ");
+        b.append(mLastInteraction);
+        return b.toString();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        SipDialog sipDialog = (SipDialog) o;
+        // Does not include mState and last interaction time, as a dialog is only the same if
+        // its branch, callId, and to/from tags are equal.
+        return mBranchId.equals(sipDialog.mBranchId)
+                && Objects.equals(mCallId, sipDialog.mCallId)
+                && Objects.equals(mFromTag, sipDialog.mFromTag)
+                && Objects.equals(mToTag, sipDialog.mToTag);
+    }
+
+    @Override
+    public int hashCode() {
+        // Does not include mState and last interaction time, as a dialog is only the same if
+        // its branch, callId, and to/from tags are equal.
+        return Objects.hash(mBranchId, mCallId, mFromTag, mToTag);
+    }
+}
diff --git a/src/com/android/services/telephony/rcs/SipSessionTracker.java b/src/com/android/services/telephony/rcs/SipSessionTracker.java
new file mode 100644
index 0000000..77bf3f3
--- /dev/null
+++ b/src/com/android/services/telephony/rcs/SipSessionTracker.java
@@ -0,0 +1,368 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs;
+
+import android.telephony.ims.SipMessage;
+import android.util.ArrayMap;
+import android.util.ArraySet;
+import android.util.LocalLog;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.telephony.SipMessageParsingUtils;
+import com.android.internal.util.IndentingPrintWriter;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Tracks the state of SIP sessions started by a SIP INVITE (see RFC 3261)
+ * <p>
+ * Each SIP session created will consist of one or more SIP with, each dialog in the session
+ * having the same call-ID. Each SIP dialog will be in one of three states: EARLY, CONFIRMED, and
+ * CLOSED.
+ * <p>
+ * The SIP session will be closed once all of the associated dialogs are closed.
+ */
+public class SipSessionTracker {
+    private static final String TAG = "SessionT";
+
+    /**
+     * SIP request methods that will start a new SIP Dialog and move it into the PENDING state
+     * while we wait for a response. Note: INVITE is not the only SIP dialog that will create a
+     * dialog, however it is the only one that we wish to track for this use case.
+     */
+    public static final String[] SIP_REQUEST_DIALOG_START_METHODS = new String[] { "invite" };
+
+    /**
+     * The SIP request method that will close a SIP Dialog in the ACTIVE state with the same
+     * Call-Id.
+     */
+    private static final String SIP_CLOSE_DIALOG_REQUEST_METHOD = "bye";
+
+    private final LocalLog mLocalLog = new LocalLog(SipTransportController.LOG_SIZE);
+    private final ArrayList<SipDialog> mTrackedDialogs = new ArrayList<>();
+    // Operations that are pending an ack from the remote application processing the message before
+    // they can be applied here. Maps the via header branch parameter of the message to the
+    // associated pending operation.
+    private final ArrayMap<String, Runnable> mPendingAck = new ArrayMap<>();
+
+    /**
+     * Filter a SIP message to determine if it will result in a new SIP dialog. This will need to be
+     * successfully acknowledged by the remote IMS stack using
+     * {@link #acknowledgePendingMessage(String)} before we do any further processing.
+     *
+     * @param message The Incoming SIP message.
+     */
+    public void filterSipMessage(SipMessage message) {
+        final Runnable r;
+        if (startsEarlyDialog(message)) {
+            r = getCreateDialogRunnable(message);
+        } else if (closesDialog(message)) {
+            r = getCloseDialogRunnable(message);
+        } else if (SipMessageParsingUtils.isSipResponse(message.getStartLine())) {
+            r = getDialogStateChangeRunnable(message);
+        } else {
+            r = null;
+        }
+
+        if (r != null) {
+            if (mPendingAck.containsKey(message.getViaBranchParameter())) {
+                Runnable lastEvent = mPendingAck.get(message.getViaBranchParameter());
+                logw("Adding new message when there was already a pending event for branch: "
+                        + message.getViaBranchParameter());
+                Runnable concatRunnable = () -> {
+                    // No choice but to concatenate the Runnables together.
+                    if (lastEvent != null) lastEvent.run();
+                    r.run();
+                };
+                mPendingAck.put(message.getViaBranchParameter(), concatRunnable);
+            } else {
+                mPendingAck.put(message.getViaBranchParameter(), r);
+            }
+        }
+    }
+
+    /**
+     * The pending SIP message has been received by the remote IMS stack. We can now track dialogs
+     * associated with this message.
+     * message.
+     * @param viaBranchId The SIP message's Via header's branch parameter, which is used as a
+     *                    unique token.
+     */
+    public void acknowledgePendingMessage(String viaBranchId) {
+        Runnable r = mPendingAck.get(viaBranchId);
+        if (r != null) {
+            mPendingAck.remove(viaBranchId);
+            r.run();
+        }
+    }
+
+    /**
+     * The pending SIP message has failed to be sent to the remote so remove the pending task.
+     * @param viaBranchId The failed message's Via header's branch parameter.
+     */
+    public void pendingMessageFailed(String viaBranchId) {
+        mPendingAck.remove(viaBranchId);
+    }
+
+    /**
+     * A SIP session tracked by the remote application's IMS stack has been closed, so we can stop
+     * tracking it.
+     * @param callId The callId of the SIP session that has been closed.
+     */
+    public void cleanupSession(String callId) {
+        List<SipDialog> dialogsToCleanup = mTrackedDialogs.stream()
+                .filter(d -> d.getCallId().equals(callId))
+                .collect(Collectors.toList());
+        if (dialogsToCleanup.isEmpty()) return;
+        logi("Cleanup dialogs associated with call id: " + callId);
+        for (SipDialog d : dialogsToCleanup) {
+            d.close();
+            logi("Dialog closed: " + d);
+        }
+        mTrackedDialogs.removeAll(dialogsToCleanup);
+    }
+
+    /**
+     * @return the call IDs of the dialogs associated with the provided feature tags.
+     */
+    public Set<String> getCallIdsAssociatedWithFeatureTag(Set<String> featureTags) {
+        if (featureTags.isEmpty()) return Collections.emptySet();
+        Set<String> associatedIds = new ArraySet<>();
+        for (String featureTag : featureTags) {
+            for (SipDialog dialog : mTrackedDialogs) {
+                boolean isAssociated = dialog.getAcceptContactFeatureTags().stream().anyMatch(
+                        d -> d.equalsIgnoreCase(featureTag));
+                if (isAssociated) associatedIds.add(dialog.getCallId());
+            }
+        }
+        return associatedIds;
+    }
+
+    /**
+     * @return All dialogs that have not received a final response yet 2XX or 3XX+.
+     */
+    public Set<SipDialog> getEarlyDialogs() {
+        return mTrackedDialogs.stream().filter(d -> d.getState() == SipDialog.STATE_EARLY)
+                .collect(Collectors.toSet());
+    }
+
+    /**
+     * @return All confirmed dialogs that have received a 2XX response and are active.
+     */
+    public Set<SipDialog> getConfirmedDialogs() {
+        return mTrackedDialogs.stream().filter(d -> d.getState() == SipDialog.STATE_CONFIRMED)
+                .collect(Collectors.toSet());
+    }
+
+    /**
+     * @return Dialogs that have been closed via a BYE or 3XX+ response and
+     * {@link #cleanupSession(String)} has not been called yet.
+     */
+    @VisibleForTesting
+    public Set<SipDialog> getClosedDialogs() {
+        return mTrackedDialogs.stream().filter(d -> d.getState() == SipDialog.STATE_CLOSED)
+                .collect(Collectors.toSet());
+    }
+
+    /**
+     * @return All of the tracked dialogs, even the ones that have been closed but
+     * {@link #cleanupSession(String)} has not been called.
+     */
+    public Set<SipDialog> getTrackedDialogs() {
+        return new ArraySet<>(mTrackedDialogs);
+    }
+
+    /**
+     * Clears all tracked sessions.
+     */
+    public void clearAllSessions() {
+        mTrackedDialogs.clear();
+        mPendingAck.clear();
+    }
+
+    /**
+     * Dump the state of this tracker to the provided PrintWriter.
+     */
+    public void dump(PrintWriter printWriter) {
+        IndentingPrintWriter pw = new IndentingPrintWriter(printWriter, "  ");
+        pw.println("SipSessionTracker:");
+        pw.increaseIndent();
+        pw.print("Early Call IDs: ");
+        pw.println(getEarlyDialogs().stream().map(SipDialog::getCallId)
+                .collect(Collectors.toSet()));
+        pw.print("Confirmed Call IDs: ");
+        pw.println(getConfirmedDialogs().stream().map(SipDialog::getCallId)
+                .collect(Collectors.toSet()));
+        pw.print("Closed Call IDs: ");
+        pw.println(getClosedDialogs().stream().map(SipDialog::getCallId)
+                .collect(Collectors.toSet()));
+        pw.println("Tracked Dialogs:");
+        pw.increaseIndent();
+        for (SipDialog d : mTrackedDialogs) {
+            pw.println(d);
+        }
+        pw.decreaseIndent();
+        pw.println();
+        pw.println("Local Logs");
+        mLocalLog.dump(pw);
+        pw.decreaseIndent();
+    }
+
+    /**
+     * @return {@code true}, if the SipMessage passed in should start a new SIP dialog,
+     * {@code false} if it should not.
+     */
+    private boolean startsEarlyDialog(SipMessage m) {
+        if (!SipMessageParsingUtils.isSipRequest(m.getStartLine())) {
+            return false;
+        }
+        String[] startLineSegments = SipMessageParsingUtils.splitStartLineAndVerify(
+                m.getStartLine());
+        if (startLineSegments == null) {
+            return false;
+        }
+        return Arrays.stream(SIP_REQUEST_DIALOG_START_METHODS)
+                .anyMatch(r -> r.equalsIgnoreCase(startLineSegments[0]));
+    }
+
+    /**
+     * @return {@code true}, if the SipMessage passed in should close a confirmed dialog,
+     * {@code false} if it should not.
+     */
+    private boolean closesDialog(SipMessage m) {
+        if (!SipMessageParsingUtils.isSipRequest(m.getStartLine())) {
+            return false;
+        }
+        String[] startLineSegments = SipMessageParsingUtils.splitStartLineAndVerify(
+                m.getStartLine());
+        if (startLineSegments == null) {
+            return false;
+        }
+        return SIP_CLOSE_DIALOG_REQUEST_METHOD.equalsIgnoreCase(startLineSegments[0]);
+    }
+
+    private Runnable getCreateDialogRunnable(SipMessage m) {
+        return () -> {
+            List<SipDialog> duplicateDialogs = mTrackedDialogs.stream()
+                    .filter(d -> d.getCallId().equals(m.getCallIdParameter()))
+                    .collect(Collectors.toList());
+            if (duplicateDialogs.size() > 0) {
+                logi("trying to create a dialog for a call ID that already exists, skip: "
+                        + duplicateDialogs);
+                return;
+            }
+            SipDialog dialog = SipDialog.fromSipMessage(m);
+            logi("Starting new SipDialog: " + dialog);
+            mTrackedDialogs.add(dialog);
+        };
+    }
+
+    private Runnable getCloseDialogRunnable(SipMessage m) {
+        return () -> {
+            List<SipDialog> dialogsToClose = mTrackedDialogs.stream()
+                    .filter(d -> d.isRequestAssociatedWithDialog(m))
+                    .collect(Collectors.toList());
+            if (dialogsToClose.isEmpty()) return;
+            logi("Closing dialogs associated with: " + m);
+            for (SipDialog d : dialogsToClose) {
+                d.close();
+                logi("Dialog closed: " + d);
+            }
+        };
+    }
+
+    private Runnable getDialogStateChangeRunnable(SipMessage m) {
+        return () -> {
+            // This will return a dialog and all of its potential forks
+            List<SipDialog> associatedDialogs = mTrackedDialogs.stream()
+                    .filter(d -> d.isResponseAssociatedWithDialog(m))
+                    .collect(Collectors.toList());
+            if (associatedDialogs.isEmpty()) return;
+            String messageToTag = SipMessageParsingUtils.getToTag(m.getHeaderSection());
+            // If the to tag matches (or message to tag doesn't exist in dialog yet because this is
+            // the first response), then we are done.
+            SipDialog match = associatedDialogs.stream()
+                    .filter(d -> d.getToTag() == null || d.getToTag().equals(messageToTag))
+                    .findFirst().orElse(null);
+            if (match == null) {
+                // If it doesn't then we have a situation where we need to fork the existing dialog.
+                // The dialog used to fork doesn't matter, since the required params are the same,
+                // so simply use the first one in the returned list.
+                logi("Dialog forked");
+                match = associatedDialogs.get(0).forkDialog();
+                mTrackedDialogs.add(match);
+            }
+            if (match != null) {
+                logi("Dialog: " + match + " is associated with: " + m);
+                updateSipDialogState(match, m);
+                logi("Dialog state updated to " + match);
+            } else {
+                logi("No Dialogs are associated with: " + m);
+            }
+        };
+    }
+
+    private void updateSipDialogState(SipDialog d, SipMessage m) {
+        String[] startLineSegments = SipMessageParsingUtils.splitStartLineAndVerify(
+                m.getStartLine());
+        if (startLineSegments == null) {
+            logw("Could not parse start line for SIP message: " + m.getStartLine());
+            return;
+        }
+        int statusCode = 0;
+        try {
+            statusCode = Integer.parseInt(startLineSegments[1]);
+        } catch (NumberFormatException e) {
+            logw("Could not parse status code for SIP message: " + m.getStartLine());
+            return;
+        }
+        String toTag = SipMessageParsingUtils.getToTag(m.getHeaderSection());
+        logi("updateSipDialogState: message has statusCode: " + statusCode + ", and to tag: "
+                + toTag);
+        // If specifically 100 Trying, then do not do anything.
+        if (statusCode <= 100) return;
+        // If 300+, then this dialog has received an error response and should move to closed state.
+        if (statusCode >= 300) {
+            d.close();
+            return;
+        }
+        if (toTag == null) logw("updateSipDialogState: No to tag for message: " + m);
+        if (statusCode >= 200) {
+            d.confirm(toTag);
+            return;
+        }
+        // 1XX responses still require updates to dialogs.
+        d.earlyResponse(toTag);
+    }
+
+    private void logi(String log) {
+        Log.w(SipTransportController.LOG_TAG, TAG + ": " + log);
+        mLocalLog.log("[I] " + log);
+    }
+
+    private void logw(String log) {
+        Log.w(SipTransportController.LOG_TAG, TAG + ": " + log);
+        mLocalLog.log("[W] " + log);
+    }
+}
diff --git a/src/com/android/services/telephony/rcs/SipTransportController.java b/src/com/android/services/telephony/rcs/SipTransportController.java
index 6ffffaf..f37c360 100644
--- a/src/com/android/services/telephony/rcs/SipTransportController.java
+++ b/src/com/android/services/telephony/rcs/SipTransportController.java
@@ -85,8 +85,8 @@
  */
 public class SipTransportController implements RcsFeatureController.Feature,
         OnRoleHoldersChangedListener {
+    public static final String LOG_TAG = "SipTransportC";
     static final int LOG_SIZE = 50;
-    static final String LOG_TAG = "SipTransportC";
 
     /**See {@link TimerAdapter#getReevaluateThrottleTimerMilliseconds()}.*/
     private static final int REEVALUATE_THROTTLE_DEFAULT_MS = 1000;
diff --git a/src/com/android/services/telephony/rcs/TransportSipMessageValidator.java b/src/com/android/services/telephony/rcs/TransportSipMessageValidator.java
new file mode 100644
index 0000000..777026c
--- /dev/null
+++ b/src/com/android/services/telephony/rcs/TransportSipMessageValidator.java
@@ -0,0 +1,551 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs;
+
+import android.telephony.ims.DelegateRegistrationState;
+import android.telephony.ims.FeatureTagState;
+import android.telephony.ims.SipDelegateConfiguration;
+import android.telephony.ims.SipDelegateImsConfiguration;
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+import android.util.LocalLog;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.services.telephony.rcs.validator.IncomingTransportStateValidator;
+import com.android.services.telephony.rcs.validator.MalformedSipMessageValidator;
+import com.android.services.telephony.rcs.validator.OutgoingTransportStateValidator;
+import com.android.services.telephony.rcs.validator.RestrictedOutgoingSipRequestValidator;
+import com.android.services.telephony.rcs.validator.RestrictedOutgoingSubscribeValidator;
+import com.android.services.telephony.rcs.validator.SipMessageValidator;
+import com.android.services.telephony.rcs.validator.ValidationResult;
+
+import java.io.PrintWriter;
+import java.util.Collections;
+import java.util.Set;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Track incoming and outgoing SIP messages passing through this delegate and verify these messages
+ * by doing the following:
+ *  <ul>
+ *    <li>Track the SipDelegate's registration state to ensure that a registration event has
+ *    occurred before allowing outgoing messages. Once it has occurred, filter outgoing SIP messages
+ *    based on the open/restricted feature tag registration state.</li>
+ *    <li>Track the SipDelegate's IMS configuration version and deny any outgoing SipMessages
+ *    associated with a stale IMS configuration version.</li>
+ *    <li>Track the SipDelegate open/close state to allow/deny outgoing messages based on the
+ *    session's state.</li>
+ *    <li>Validate outgoing SIP messages for both restricted request methods as well as restricted/
+ *    malformed headers.</li>
+ * </ul>
+ */
+public class TransportSipMessageValidator {
+
+    private static final String LOG_TAG = "SipMessageV";
+
+    /**
+     * the time in milliseconds that we will wait for SIP sessions to close before we will timeout
+     * and force the sessions to be cleaned up.
+     */
+    private static final int PENDING_CLOSE_TIMEOUT_MS = 1000;
+    /**
+     * time in milliseconds that we will wait for SIP sessions to be closed before we timeout and
+     * force the sessions associated with the deregistering feature tags to be cleaned up.
+     */
+    private static final int PENDING_REGISTRATION_CHANGE_TIMEOUT_MS = 1000;
+
+    /**
+     * Timeouts used in this class that are visible for testing.
+     */
+    @VisibleForTesting
+    public interface Timeouts {
+        /**
+         * @return the time in milliseconds that we will wait for SIP sessions to close before we
+         * will timeout and force the sessions to be cleaned up.
+         */
+        int getPendingCloseTimeoutMs();
+
+        /**
+         * @return the time in milliseconds that we will wait for SIP sessions to be closed before
+         * we timeout and force the sessions associated with the deregistering feature tags to be
+         * cleaned up.
+         */
+        int getPendingRegistrationChangeTimeoutMs();
+    }
+
+    /**
+     * Tracks a pending task that has been scheduled on the associated Executor.
+     */
+    private abstract static class PendingTask implements Runnable {
+
+        private ScheduledFuture<?> mFuture;
+
+        public void scheduleDelayed(ScheduledExecutorService executor, int timeMs) {
+            mFuture = executor.schedule(this, timeMs, TimeUnit.MILLISECONDS);
+        }
+
+        public boolean isDone() {
+            return mFuture != null && mFuture.isDone();
+        }
+
+        public void cancel() {
+            if (mFuture == null) return;
+            mFuture.cancel(false /*interrupt*/);
+        }
+    }
+
+    /**
+     * Tracks a pending reg cleanup task that has been scheduled on the associated Executor.
+     */
+    private abstract static class PendingRegCleanupTask extends PendingTask {
+        public final Set<String> pendingCallIds;
+        public final Set<String> featureTags;
+
+        PendingRegCleanupTask(Set<String> tags, Set<String> callIds) {
+            featureTags = tags;
+            pendingCallIds = callIds;
+        }
+    }
+
+    private final int mSubId;
+    private final ScheduledExecutorService mExecutor;
+    private final LocalLog mLocalLog = new LocalLog(SipTransportController.LOG_SIZE);
+    private final SipSessionTracker mSipSessionTracker;
+    // Validators
+    private final IncomingTransportStateValidator mIncomingTransportStateValidator;
+    private final OutgoingTransportStateValidator mOutgoingTransportStateValidator;
+    private final SipMessageValidator mOutgoingMessageValidator;
+    private final SipMessageValidator mIncomingMessageValidator;
+
+    private Set<String> mSupportedFeatureTags;
+    private Set<FeatureTagState> mDeniedFeatureTags;
+    private long mConfigVersion = -1;
+    private Consumer<Set<String>> mClosingCompleteConsumer;
+    private PendingTask mPendingClose;
+    private PendingRegCleanupTask mPendingRegCleanup;
+    private Consumer<Set<String>> mRegistrationAppliedConsumer;
+
+    public TransportSipMessageValidator(int subId, ScheduledExecutorService executor) {
+        mSubId = subId;
+        mExecutor = executor;
+        mSipSessionTracker = new SipSessionTracker();
+        mOutgoingTransportStateValidator = new OutgoingTransportStateValidator(mSipSessionTracker);
+        mIncomingTransportStateValidator = new IncomingTransportStateValidator();
+        mOutgoingMessageValidator = new MalformedSipMessageValidator().andThen(
+                new RestrictedOutgoingSipRequestValidator()).andThen(
+                new RestrictedOutgoingSubscribeValidator()).andThen(
+                        mOutgoingTransportStateValidator);
+        mIncomingMessageValidator = mIncomingTransportStateValidator;
+    }
+
+    @VisibleForTesting
+    public TransportSipMessageValidator(int subId, ScheduledExecutorService executor,
+            SipSessionTracker sipSessionTracker,
+            OutgoingTransportStateValidator outgoingStateValidator,
+            IncomingTransportStateValidator incomingStateValidator) {
+        mSubId = subId;
+        mExecutor = executor;
+        mSipSessionTracker = sipSessionTracker;
+        mOutgoingTransportStateValidator = outgoingStateValidator;
+        mIncomingTransportStateValidator = incomingStateValidator;
+        mOutgoingMessageValidator = mOutgoingTransportStateValidator;
+        mIncomingMessageValidator = mIncomingTransportStateValidator;
+    }
+
+    /**
+     * Notify this tracker that a registration state change has occurred.
+     * <p>
+     * In some scenarios, this will require that existing SIP dialogs are closed (for example, when
+     * moving a feature tag from REGISTERED->DEREGISTERING). This method allows the caller to
+     * provide a Consumer that will be called when either there are no SIP dialogs active on
+     * DEREGISTERING feature tags, or a timeout has occurred. In the case that a timeout has
+     * occurred, this Consumer will accept a list of callIds that will be manually closed by the
+     * framework to unblock the IMS stack.
+     * <p>
+     * @param stateChangeComplete A one time Consumer that when completed, will contain a List of
+     *         callIds corresponding to SIP Dialogs that have not been closed yet. It is the callers
+     *         responsibility to close the dialogs associated with the provided callIds. If another
+     *         state update occurs before the previous was completed, the previous consumer will be
+     *         completed with an empty list and the new Consumer will be executed when the new state
+     *         changes.
+     * @param regState The new registration state.
+     */
+    public void onRegistrationStateChanged(Consumer<Set<String>> stateChangeComplete,
+            DelegateRegistrationState regState) {
+        if (mRegistrationAppliedConsumer != null) {
+            logw("onRegistrationStateChanged: pending registration change, completing now.");
+            // complete the pending consumer with no dialogs pending, this will be re-evaluated
+            // and new configuration will be applied.
+            cleanupAndNotifyRegistrationAppliedConsumer(Collections.emptySet());
+        }
+        Set<String> restrictedTags = Stream.concat(
+                regState.getDeregisteringFeatureTags().stream(),
+                regState.getDeregisteredFeatureTags().stream()).map(FeatureTagState::getFeatureTag)
+                .collect(Collectors.toSet());
+        mOutgoingTransportStateValidator.restrictFeatureTags(restrictedTags);
+        mRegistrationAppliedConsumer = stateChangeComplete;
+        if (mPendingClose == null || mPendingClose.isDone()) {
+            // Only update the pending registration cleanup task if we do not already have a pending
+            // close in progress.
+            updatePendingRegCleanupTask(restrictedTags);
+        } else {
+            logi("skipping update reg cleanup due to pending close task.");
+        }
+    }
+
+    /**
+     * Notify this tracker that the IMS configuration has changed.
+     *
+     * Parameters contained in the IMS configuration will be used to validate outgoing messages,
+     * such as the configuration version.
+     * @param c The newest IMS configuration.
+     */
+    public void onImsConfigurationChanged(SipDelegateImsConfiguration c) {
+        if (c.getVersion() == mConfigVersion) {
+            return;
+        }
+        logi("onImsConfigurationChanged: " + mConfigVersion + "->" + c.getVersion());
+        mConfigVersion = c.getVersion();
+    }
+
+    /**
+     * Notify this tracker that the IMS configuration has changed.
+     *
+     * Parameters contained in the IMS configuration will be used to validate outgoing messages,
+     * such as the configuration version.
+     * @param c The newest IMS configuration.
+     */
+    public void onConfigurationChanged(SipDelegateConfiguration c) {
+        if (c.getVersion() == mConfigVersion) {
+            return;
+        }
+        logi("onConfigurationChanged: " + mConfigVersion + "->" + c.getVersion());
+        mConfigVersion = c.getVersion();
+    }
+
+    /**
+     * A new message transport has been opened to a SipDelegate.
+     * <p>
+     * Initializes this tracker and resets any state required to process messages.
+     * @param supportedFeatureTags feature tags that are supported and should pass message
+     *                             verification.
+     * @param deniedFeatureTags feature tags that were denied and should fail message verification.
+     */
+    public void onTransportOpened(Set<String> supportedFeatureTags,
+            Set<FeatureTagState> deniedFeatureTags) {
+        logi("onTransportOpened: moving to open state");
+        mSupportedFeatureTags = supportedFeatureTags;
+        mDeniedFeatureTags = deniedFeatureTags;
+        mOutgoingTransportStateValidator.open(supportedFeatureTags, deniedFeatureTags.stream().map(
+                FeatureTagState::getFeatureTag).collect(Collectors.toSet()));
+        mIncomingTransportStateValidator.open();
+    }
+
+    /**
+     * A SIP session has been cleaned up and should no longer be tracked.
+     * @param callId The call ID associated with the SIP session.
+     */
+    public void onSipSessionCleanup(String callId) {
+        mSipSessionTracker.cleanupSession(callId);
+        onCallIdsChanged();
+    }
+
+    /**
+     * Move this tracker into a restricted state, where only outgoing SIP messages associated with
+     * an ongoing SIP Session may be sent. Any out-of-dialog outgoing SIP messages will be denied.
+     * This does not affect incoming SIP messages (for example, an incoming SIP INVITE).
+     * <p>
+     * This tracker will stay in this state until either all open SIP Sessions are closed by the
+     * remote application, or a timeout occurs. Once this happens, the provided Consumer will accept
+     * a List of call IDs associated with the open SIP Sessions that did not close before the
+     * timeout. The caller must then close all open SIP Sessions before closing the transport.
+     * @param closingCompleteConsumer A Consumer that will be called when the transport can be
+     *         closed and may contain a list of callIds associated with SIP sessions that have not
+     *         been closed.
+     * @param closingReason The reason that will be provided if an outgoing out-of-dialog SIP
+     *         message is sent while the transport is closing.
+     * @param closedReason The reason that will be provided if any outgoing SIP message is sent
+     *         once the transport is closed.
+     */
+    public void closeSessionsGracefully(Consumer<Set<String>> closingCompleteConsumer,
+            int closingReason, int closedReason) {
+        if (closingCompleteConsumer == null) {
+            logw("closeSessionsGracefully: unexpected - called with null consumer... closing now");
+            closeSessions(closedReason);
+            return;
+        }
+        if (mClosingCompleteConsumer != null) {
+            // In this case, all we can do is combine the consumers and wait for the other pending
+            // close to complete, finishing both.
+            logw("closeSessionsGracefully: unexpected - existing close pending, combining"
+                    + " consumers.");
+            mClosingCompleteConsumer = callIds -> {
+                mClosingCompleteConsumer.accept(callIds);
+                closingCompleteConsumer.accept(callIds);
+            };
+            return;
+        } else {
+            mClosingCompleteConsumer = closingCompleteConsumer;
+        }
+        if (getTrackedSipSessionCallIds().isEmpty()) {
+            logi("closeSessionsGracefully: moving to closed state now, reason=" + closedReason);
+            closeSessionsInternal(closedReason);
+            cancelClosingTimeoutAndSendComplete(Collections.emptySet());
+            return;
+        }
+        cancelPendingRegCleanupTask();
+        logi("closeSessionsGracefully: moving to restricted state, reason=" + closingReason);
+        mOutgoingTransportStateValidator.restrict(closingReason);
+        mPendingClose = new PendingTask() {
+            @Override
+            public void run() {
+                closeSessions(closingReason);
+            }
+        };
+        mPendingClose.scheduleDelayed(mExecutor, PENDING_CLOSE_TIMEOUT_MS);
+    }
+
+    /**
+     * Close the transport now. If there are any open SIP sessions and this is closed due to a
+     * configuration change (SIM subscription change, user disabled RCS, the service is dead,
+     * etc...) then we will return the call IDs of all open sessions and ask them to be closed.
+     * @param closedReason The error reason for why the message transport was closed that will be
+     *         sent back to the caller if a new SIP message is sent.
+     * @return A List of call IDs associated with sessions that were still open at the time that the
+     * tracker closed the transport.
+     */
+    public Set<String> closeSessions(int closedReason) {
+        Set<String> openCallIds = getTrackedSipSessionCallIds();
+        logi("closeSessions: moving to closed state, reason=" + closedReason + ", open call ids: "
+                + openCallIds);
+        closeSessionsInternal(closedReason);
+        boolean consumerHandledPendingSessions = cancelClosingTimeoutAndSendComplete(openCallIds);
+        if (consumerHandledPendingSessions) {
+            logw("closeSessions: call ID closure handled through consumer");
+            // sent the open call IDs through the pending complete mechanism to unblock any previous
+            // graceful close command and close them early.
+            return Collections.emptySet();
+        }
+        return openCallIds;
+    }
+
+    /**
+     * Verify a new outgoing SIP message before sending to the SipDelegate (ImsService).
+     * @param message The SIP message being verified
+     * @return The result of verifying the outgoing message.
+     */
+
+    public ValidationResult verifyOutgoingMessage(SipMessage message, long configVersion) {
+        if (mConfigVersion != configVersion) {
+            return new ValidationResult(
+                    SipDelegateManager.MESSAGE_FAILURE_REASON_STALE_IMS_CONFIGURATION,
+                    "stale IMS configuration: "  + configVersion + ", expected: "
+                            + mConfigVersion);
+        }
+        ValidationResult result = mOutgoingMessageValidator.validate(message);
+        logi("verifyOutgoingMessage: " + result + ", message=" + message);
+        if (result.isValidated) mSipSessionTracker.filterSipMessage(message);
+        return result;
+    }
+
+    /**
+     * Verify a new incoming SIP message before sending it to the
+     * DelegateConnectionMessageCallback (remote application).
+     * @param message The SipMessage to verify.
+     * @return The result of verifying the incoming message.
+     */
+    public ValidationResult verifyIncomingMessage(SipMessage message) {
+        ValidationResult result = mIncomingMessageValidator.validate(message);
+        logi("verifyIncomingMessage: " + result + ", message=" + message);
+        if (result.isValidated) mSipSessionTracker.filterSipMessage(message);
+        return result;
+    }
+
+    /**
+     * Acknowledge that a pending incoming or outgoing SIP message has been delivered successfully
+     * to the remote.
+     * @param transactionId The transaction ID associated with the message.
+     */
+    public void acknowledgePendingMessage(String transactionId) {
+        logi("acknowledgePendingMessage: id=" + transactionId);
+        mSipSessionTracker.acknowledgePendingMessage(transactionId);
+        onCallIdsChanged();
+    }
+
+    /**
+     * A pending incoming or outgoing SIP message has failed and should not be tracked.
+     * @param transactionId The transaction ID associated with the message.
+     */
+    public void notifyPendingMessageFailed(String transactionId) {
+        logi("notifyPendingMessageFailed: id=" + transactionId);
+        mSipSessionTracker.pendingMessageFailed(transactionId);
+    }
+
+    /** Dump state about this tracker that should be included in the dumpsys */
+    public void dump(PrintWriter printWriter) {
+        IndentingPrintWriter pw = new IndentingPrintWriter(printWriter, "  ");
+        pw.println("Supported Tags:" + mSupportedFeatureTags);
+        pw.println("Denied Tags:" + mDeniedFeatureTags);
+        pw.println(mOutgoingTransportStateValidator);
+        pw.println(mIncomingTransportStateValidator);
+        pw.println("Reg consumer pending: " + (mRegistrationAppliedConsumer != null));
+        pw.println("Close consumer pending: " + (mClosingCompleteConsumer != null));
+        pw.println();
+        mSipSessionTracker.dump(pw);
+        pw.println();
+        pw.println("Most recent logs:");
+        mLocalLog.dump(printWriter);
+    }
+
+    /**
+     * A event has occurred that can change the list of active call IDs.
+     */
+    private void onCallIdsChanged() {
+        if (getTrackedSipSessionCallIds().isEmpty() && mPendingClose != null
+                && !mPendingClose.isDone()) {
+            logi("onCallIdsChanged: no open sessions, completing any pending close events.");
+            // do not wait for timeout if pending sessions closed.
+            mPendingClose.cancel();
+            mPendingClose.run();
+        }
+        if (mPendingRegCleanup != null && !mPendingRegCleanup.isDone()) {
+            logi("onCallIdsChanged: updating pending reg cleanup task.");
+            // Recalculate the open call IDs based on the same feature tag set in the case that the
+            // call ID change has caused a change in pending reg cleanup task.
+            updatePendingRegCleanupTask(mPendingRegCleanup.featureTags);
+        }
+    }
+
+    /**
+     * If there are any pending registration clean up tasks, cancel them and clean up consumers.
+     */
+    private void cancelPendingRegCleanupTask() {
+        if (mPendingRegCleanup != null && !mPendingRegCleanup.isDone()) {
+            logi("cancelPendingRegCleanupTask: cancelling...");
+            mPendingRegCleanup.cancel();
+        }
+        cleanupAndNotifyRegistrationAppliedConsumer(Collections.emptySet());
+    }
+
+    /**
+     * Update the pending registration change clean up task based on the new set of restricted
+     * feature tags generated from the deregistering/deregistered feature tags.
+     *
+     * <p>
+     * This set of restricted tags will generate a set of call IDs associated to dialogs that
+     * are active and associated with the restricted tags. If there is no pending cleanup task, it
+     * will create a new one. If there was already a pending reg cleanup task, it will compare them
+     * and create a new one and cancel the old one if the new set of call ids is different from the
+     * old one.
+     */
+    private void updatePendingRegCleanupTask(Set<String> restrictedTags) {
+        Set<String> pendingCallIds = mSipSessionTracker.getCallIdsAssociatedWithFeatureTag(
+                restrictedTags);
+        if (pendingCallIds.isEmpty()) {
+            if (mPendingRegCleanup != null && !mPendingRegCleanup.isDone()) {
+                logi("updatePendingRegCleanupTask: no remaining call ids, finish cleanup task "
+                        + "now.");
+                mPendingRegCleanup.cancel();
+                mPendingRegCleanup.run();
+            } else {
+                if (mRegistrationAppliedConsumer != null) {
+                    logi("updatePendingRegCleanupTask: notify no pending call ids.");
+                    cleanupAndNotifyRegistrationAppliedConsumer(Collections.emptySet());
+                }
+            }
+            return;
+        }
+        if (mPendingRegCleanup != null && !mPendingRegCleanup.isDone()) {
+            if (mPendingRegCleanup.pendingCallIds.equals(pendingCallIds)) {
+                logi("updatePendingRegCleanupTask: pending reg change has same set of pending call"
+                        + " IDs, so keeping pending task");
+                return;
+            }
+            logi("updatePendingRegCleanupTask: cancelling, call ids have changed.");
+            mPendingRegCleanup.cancel();
+        }
+        mPendingRegCleanup = new PendingRegCleanupTask(restrictedTags, pendingCallIds) {
+            @Override
+            public void run() {
+                cleanupAndNotifyRegistrationAppliedConsumer(pendingCallIds);
+            }
+        };
+        logi("updatePendingRegCleanupTask: scheduling for call ids: " + pendingCallIds);
+        mPendingRegCleanup.scheduleDelayed(mExecutor, PENDING_REGISTRATION_CHANGE_TIMEOUT_MS);
+    }
+
+    /**
+     * Notify the pending registration applied consumer of the call ids that need to be cleaned up.
+     */
+    private void cleanupAndNotifyRegistrationAppliedConsumer(Set<String> pendingCallIds) {
+        if (mRegistrationAppliedConsumer != null) {
+            mRegistrationAppliedConsumer.accept(pendingCallIds);
+            mRegistrationAppliedConsumer = null;
+        }
+    }
+
+    /**
+     * Cancel any pending timeout to close pending sessions and send the provided call IDs to any
+     * pending closing complete consumer.
+     * @return {@code true} if a consumer was notified, {@code false} if there were no consumers.
+     */
+    private boolean cancelClosingTimeoutAndSendComplete(Set<String> openCallIds) {
+        if (mPendingClose != null && !mPendingClose.isDone()) {
+            logi("completing pending close consumer");
+            mPendingClose.cancel();
+        }
+        // Complete the pending consumer with no open sessions.
+        if (mClosingCompleteConsumer != null) {
+            mClosingCompleteConsumer.accept(openCallIds);
+            mClosingCompleteConsumer = null;
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Close and clear all stateful trackers and validators.
+     */
+    private void closeSessionsInternal(int closedReason) {
+        cancelPendingRegCleanupTask();
+        mOutgoingTransportStateValidator.close(closedReason);
+        mIncomingTransportStateValidator.close(closedReason);
+        mSipSessionTracker.clearAllSessions();
+    }
+
+    private Set<String> getTrackedSipSessionCallIds() {
+        return mSipSessionTracker.getTrackedDialogs().stream().map(SipDialog::getCallId)
+                .collect(Collectors.toSet());
+    }
+
+    private void logi(String log) {
+        Log.i(SipTransportController.LOG_TAG, LOG_TAG + "[" + mSubId + "] " + log);
+        mLocalLog.log("[I] " + log);
+    }
+
+    private void logw(String log) {
+        Log.w(SipTransportController.LOG_TAG, LOG_TAG + "[" + mSubId + "] " + log);
+        mLocalLog.log("[W] " + log);
+    }
+}
diff --git a/src/com/android/services/telephony/rcs/UceControllerManager.java b/src/com/android/services/telephony/rcs/UceControllerManager.java
index 3051253..009700f 100644
--- a/src/com/android/services/telephony/rcs/UceControllerManager.java
+++ b/src/com/android/services/telephony/rcs/UceControllerManager.java
@@ -16,8 +16,10 @@
 
 package com.android.services.telephony.rcs;
 
+import android.annotation.Nullable;
 import android.content.Context;
 import android.net.Uri;
+import android.telephony.SubscriptionManager;
 import android.telephony.ims.ImsException;
 import android.telephony.ims.RcsContactUceCapability;
 import android.telephony.ims.RcsUceAdapter;
@@ -52,37 +54,38 @@
     private final Context mContext;
     private final ExecutorService mExecutorService;
 
-    private volatile int mSubId;
-    private volatile UceController mUceController;
-    private volatile RcsFeatureManager mRcsFeatureManager;
+    private volatile @Nullable UceController mUceController;
+    private volatile @Nullable RcsFeatureManager mRcsFeatureManager;
 
     public UceControllerManager(Context context, int slotId, int subId) {
         Log.d(LOG_TAG, "create: slotId=" + slotId + ", subId=" + subId);
-
         mSlotId = slotId;
-        mSubId = subId;
         mContext = context;
         mExecutorService = Executors.newSingleThreadExecutor();
-        mUceController = new UceController(mContext, subId);
+        initUceController(subId);
     }
 
     /**
      * Constructor to inject dependencies for testing.
      */
     @VisibleForTesting
-    public UceControllerManager(Context context, int slotId, int subId, ExecutorService executor) {
+    public UceControllerManager(Context context, int slotId, ExecutorService executor,
+            UceController uceController) {
         mSlotId = slotId;
-        mSubId = subId;
         mContext = context;
         mExecutorService = executor;
-        mUceController = new UceController(mContext, subId);
+        mUceController = uceController;
     }
 
     @Override
     public void onRcsConnected(RcsFeatureManager manager) {
         mExecutorService.submit(() -> {
             mRcsFeatureManager = manager;
-            mUceController.onRcsConnected(manager);
+            if (mUceController != null) {
+                mUceController.onRcsConnected(manager);
+            } else {
+                Log.d(LOG_TAG, "onRcsConnected: UceController is null");
+            }
         });
     }
 
@@ -90,14 +93,22 @@
     public void onRcsDisconnected() {
         mExecutorService.submit(() -> {
             mRcsFeatureManager = null;
-            mUceController.onRcsDisconnected();
+            if (mUceController != null) {
+                mUceController.onRcsDisconnected();
+            } else {
+                Log.d(LOG_TAG, "onRcsDisconnected: UceController is null");
+            }
         });
     }
 
     @Override
     public void onDestroy() {
-        Log.d(LOG_TAG, "onDestroy");
-        mExecutorService.submit(() -> mUceController.onDestroy());
+        mExecutorService.submit(() -> {
+            Log.d(LOG_TAG, "onDestroy");
+            if (mUceController != null) {
+                mUceController.onDestroy();
+            }
+        });
         // When the shutdown is called, it will refuse any new tasks and let existing tasks finish.
         mExecutorService.shutdown();
     }
@@ -107,22 +118,17 @@
      * changed.
      */
     @Override
-    public void onAssociatedSubscriptionUpdated(int subId) {
+    public void onAssociatedSubscriptionUpdated(int newSubId) {
         mExecutorService.submit(() -> {
             Log.i(LOG_TAG, "onAssociatedSubscriptionUpdated: slotId=" + mSlotId
-                    + ", subId=" + mSubId + ", newSubId=" + subId);
-            if (mSubId == subId) {
-                Log.w(LOG_TAG, "onAssociatedSubscriptionUpdated called with the same subId");
-                return;
-            }
-            mSubId = subId;
-            // Destroy existing UceController and create a new one.
-            mUceController.onDestroy();
-            mUceController = new UceController(mContext, subId);
+                    + ", newSubId=" + newSubId);
+
+            // Check and create the UceController with the new updated subscription ID.
+            initUceController(newSubId);
 
             // The RCS should be connected when the mRcsFeatureManager is not null. Set it to the
             // new UceController instance.
-            if (mRcsFeatureManager != null) {
+            if (mUceController != null && mRcsFeatureManager != null) {
                 mUceController.onRcsConnected(mRcsFeatureManager);
             }
         });
@@ -135,16 +141,15 @@
     @Override
     public void onCarrierConfigChanged() {
         mExecutorService.submit(() -> {
-            Log.i(LOG_TAG, "onCarrierConfigChanged: subId=" + mSubId);
-            mUceController.onCarrierConfigChanged();
+            Log.i(LOG_TAG, "onCarrierConfigChanged");
+            if (mUceController != null) {
+                mUceController.onCarrierConfigChanged();
+            } else {
+                Log.d(LOG_TAG, "onCarrierConfigChanged: UceController is null");
+            }
         });
     }
 
-    @VisibleForTesting
-    public void setUceController(UceController uceController) {
-        mUceController = uceController;
-    }
-
     /**
      * Request the capabilities for contacts.
      *
@@ -328,6 +333,32 @@
     }
 
     /**
+     * Remove UCE requests cannot be sent to the network status.
+     * @return true if this command is successful.
+     */
+    public boolean removeUceRequestDisallowedStatus() throws ImsException {
+        Future<Boolean> future = mExecutorService.submit(() -> {
+            if (mUceController == null) {
+                throw new ImsException("UCE controller is null",
+                        ImsException.CODE_ERROR_SERVICE_UNAVAILABLE);
+            }
+            mUceController.removeRequestDisallowedStatus();
+            return true;
+        });
+
+        try {
+            return future.get();
+        } catch (ExecutionException | InterruptedException e) {
+            Log.w(LOG_TAG, "removeUceRequestDisallowedStatus exception: " + e);
+            Throwable cause = e.getCause();
+            if (cause instanceof ImsException) {
+                throw (ImsException) cause;
+            }
+            return false;
+        }
+    }
+
+    /**
      * Register the Publish state changed callback.
      *
      * @throws ImsException if the ImsService connected to this controller is currently down.
@@ -368,6 +399,31 @@
         }
     }
 
+    /**
+     * Initialize the UceController instance associated with the given subscription ID.
+     * The existing UceController will be destroyed if the original subscription ID is different
+     * from the new subscription ID.
+     * If the new subscription ID is invalid, the UceController instance will be null.
+     */
+    private void initUceController(int newSubId) {
+        Log.d(LOG_TAG, "initUceController: newSubId=" + newSubId + ", current UceController subId="
+                + ((mUceController == null) ? "null" : mUceController.getSubId()));
+        if (mUceController == null) {
+            // Create new UceController only when the subscription ID is valid.
+            if (SubscriptionManager.isValidSubscriptionId(newSubId)) {
+                mUceController = new UceController(mContext, newSubId);
+            }
+        } else if (mUceController.getSubId() != newSubId) {
+            // The subscription ID is updated. Remove the old UceController instance.
+            mUceController.onDestroy();
+            mUceController = null;
+            // Create new UceController only when the subscription ID is valid.
+            if (SubscriptionManager.isValidSubscriptionId(newSubId)) {
+                mUceController = new UceController(mContext, newSubId);
+            }
+        }
+    }
+
     private boolean checkUceControllerState() throws ImsException {
         if (mUceController == null || mUceController.isUnavailable()) {
             throw new ImsException("UCE controller is unavailable",
@@ -376,13 +432,26 @@
         return true;
     }
 
+    /**
+     * Get the UceController instance.
+     * <p>
+     * Used for testing ONLY.
+     */
+    @VisibleForTesting
+    public UceController getUceController() {
+        return mUceController;
+    }
 
     @Override
     public void dump(PrintWriter printWriter) {
         IndentingPrintWriter pw = new IndentingPrintWriter(printWriter, "  ");
         pw.println("UceControllerManager" + "[" + mSlotId + "]:");
         pw.increaseIndent();
-        mUceController.dump(pw);
+        if (mUceController != null) {
+            mUceController.dump(pw);
+        } else {
+            pw.println("UceController is null.");
+        }
         pw.decreaseIndent();
     }
 }
diff --git a/src/com/android/services/telephony/rcs/validator/IncomingTransportStateValidator.java b/src/com/android/services/telephony/rcs/validator/IncomingTransportStateValidator.java
new file mode 100644
index 0000000..24ab45e
--- /dev/null
+++ b/src/com/android/services/telephony/rcs/validator/IncomingTransportStateValidator.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs.validator;
+
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+import android.util.ArrayMap;
+
+/**
+ * Tracks the incoming SIP message transport state from the ImsService to the remote IMS
+ * application. Validates incoming SIP messages based on this state.
+ */
+public class IncomingTransportStateValidator implements SipMessageValidator {
+
+    /**
+     * The message transport is closed, meaning there can be no more incoming messages
+     */
+    private static final int STATE_CLOSED = 0;
+
+    /**
+     * The message transport is open and incoming traffic is not restricted.
+     */
+    private static final int STATE_OPEN = 1;
+
+    private static final ArrayMap<Integer, String> ENUM_TO_STRING_MAP  = new ArrayMap<>(2);
+    static {
+        ENUM_TO_STRING_MAP.append(STATE_CLOSED, "CLOSED");
+        ENUM_TO_STRING_MAP.append(STATE_OPEN, "OPEN");
+    }
+
+    private int mState = STATE_CLOSED;
+    private int mReason = SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED;
+
+    /**
+     * The SIP message transport is open and will successfully validate SIP messages.
+     */
+    public void open() {
+        mState = STATE_OPEN;
+        mReason = -1;
+    }
+
+    /**
+     * The SIP message transport is closed for incoming SIP messages.
+     * @param reason The error reason sent in response to any incoming SIP messages requests.
+     */
+    public void close(int reason) {
+        mState = STATE_CLOSED;
+        mReason = reason;
+    }
+
+    @Override
+    public ValidationResult validate(SipMessage message) {
+        if (mState != STATE_OPEN) {
+            return new ValidationResult(mReason,
+                    "incoming transport closed");
+        }
+        return ValidationResult.SUCCESS;
+    }
+
+    @Override
+    public String toString() {
+        return "Incoming Transport State: " + ENUM_TO_STRING_MAP.getOrDefault(mState,
+                String.valueOf(mState)) + ", reason: "
+                + SipDelegateManager.MESSAGE_FAILURE_REASON_STRING_MAP.getOrDefault(mReason,
+                String.valueOf(mReason));
+    }
+}
diff --git a/src/com/android/services/telephony/rcs/validator/MalformedSipMessageValidator.java b/src/com/android/services/telephony/rcs/validator/MalformedSipMessageValidator.java
new file mode 100644
index 0000000..a76ac3e
--- /dev/null
+++ b/src/com/android/services/telephony/rcs/validator/MalformedSipMessageValidator.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs.validator;
+
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+
+import com.android.internal.telephony.SipMessageParsingUtils;
+
+/**
+ * Validates that the SipMessage is not malformed before sending the message to the vendor
+ * ImsService by ensuring:
+ * <ul>The SipMessage is a valid SIP request or SIP response.</ul>
+ */
+public class MalformedSipMessageValidator implements SipMessageValidator {
+
+    @Override
+    public ValidationResult validate(SipMessage message) {
+        // Verify the request and response start lines are valid.
+        if (!SipMessageParsingUtils.isSipRequest(message.getStartLine())
+                && !SipMessageParsingUtils.isSipResponse(message.getStartLine())) {
+            return new ValidationResult(
+                    SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_START_LINE,
+                    "malformed start line: " + message.getStartLine());
+        }
+        return ValidationResult.SUCCESS;
+    }
+}
diff --git a/src/com/android/services/telephony/rcs/validator/OutgoingTransportStateValidator.java b/src/com/android/services/telephony/rcs/validator/OutgoingTransportStateValidator.java
new file mode 100644
index 0000000..72d22f8
--- /dev/null
+++ b/src/com/android/services/telephony/rcs/validator/OutgoingTransportStateValidator.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs.validator;
+
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+import android.text.TextUtils;
+import android.util.ArrayMap;
+import android.util.Log;
+
+import com.android.internal.telephony.SipMessageParsingUtils;
+import com.android.services.telephony.rcs.SipDialog;
+import com.android.services.telephony.rcs.SipSessionTracker;
+import com.android.services.telephony.rcs.SipTransportController;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Tracks the state of the outgoing SIP message transport from the remote IMS application to the
+ * ImsService. Used to validate outgoing SIP messages based off of this state.
+ */
+public class OutgoingTransportStateValidator implements SipMessageValidator {
+
+    /**
+     * The message transport is closed, meaning there can be no more outgoing messages
+     */
+    private static final int STATE_CLOSED = 0;
+
+    /**
+     * The message transport is restricted to only in-dialog outgoing traffic
+     */
+    private static final int STATE_RESTRICTED = 1;
+
+    /**
+     * The message transport is open and outgoing traffic is not restricted.
+     */
+    private static final int STATE_OPEN = 2;
+
+    private static final ArrayMap<Integer, String> ENUM_TO_STRING_MAP  = new ArrayMap<>(3);
+    static {
+        ENUM_TO_STRING_MAP.append(STATE_CLOSED, "CLOSED");
+        ENUM_TO_STRING_MAP.append(STATE_RESTRICTED, "RESTRICTED");
+        ENUM_TO_STRING_MAP.append(STATE_OPEN, "OPEN");
+    }
+
+    private final SipSessionTracker mSipSessionTracker;
+    private int mState = STATE_CLOSED;
+    private int mReason = SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED;
+    private Set<String> mAllowedTags = Collections.emptySet();
+    private Set<String> mDeniedTags = Collections.emptySet();
+    private Set<String> mRestrictedFeatureTags;
+
+    public OutgoingTransportStateValidator(SipSessionTracker sessionTracker) {
+        mSipSessionTracker = sessionTracker;
+    }
+
+    /**
+     * The SIP message transport is open and will successfully validate both in and out of dialog
+     * SIP messages.
+     */
+    public void open(Set<String> allowedFeatureTags, Set<String> deniedFeatureTags) {
+        mState = STATE_OPEN;
+        mReason = -1;
+        // This is for validation, so try to reduce matching errors due to upper/lower case.
+        mAllowedTags = allowedFeatureTags.stream().map(String::trim).map(String::toLowerCase)
+                .collect(Collectors.toSet());
+        mDeniedTags = deniedFeatureTags.stream().map(String::trim).map(String::toLowerCase)
+                .collect(Collectors.toSet());
+        mRestrictedFeatureTags = null;
+    }
+
+    /**
+     * Restrict the starting of dialogs for specific feature tags, excluding requests associated
+     * with ongoing sessions.
+     * @param restrictedFeatureTags The feature tags that are deregistering or deregistered and can
+     *                              not have new dialogs associated with them.
+     */
+    public void restrictFeatureTags(Set<String> restrictedFeatureTags) {
+        // This is for validation, so try to reduce matching errors due to upper/lower case.
+        mRestrictedFeatureTags = restrictedFeatureTags.stream().map(String::trim)
+                .map(String::toLowerCase).collect(Collectors.toSet());
+    }
+
+    /**
+     * The SIP message transport is restricted and only allows in-dialog outgoing messages.
+     * @param reason The reason that will be returned to outgoing out-of-dialog SIP messages that
+     *               are denied.
+     */
+    public void restrict(int reason) {
+        mState = STATE_RESTRICTED;
+        mReason = reason;
+    }
+
+    /**
+     * The SIP message transport is closed for outgoing SIP messages.
+     * @param reason The error reason sent in response to any outgoing SIP messages requests.
+     */
+    public void close(int reason) {
+        mState = STATE_CLOSED;
+        mReason = reason;
+        mAllowedTags = Collections.emptySet();
+    }
+
+    @Override
+    public ValidationResult validate(SipMessage message) {
+        switch (mState) {
+            case STATE_CLOSED:
+                return new ValidationResult(mReason, "outgoing transport closed.");
+            case STATE_RESTRICTED:
+                return verifyRestrictedMessage(message);
+            case STATE_OPEN:
+                return verifyOpenMessage(message);
+            default:
+                Log.w(SipTransportController.LOG_TAG, "OutgoingTSV - warning, unexpected state");
+                return ValidationResult.SUCCESS;
+        }
+    }
+
+    public Set<String> getAllowedCallIds() {
+        return Stream.concat(mSipSessionTracker.getEarlyDialogs().stream(),
+                mSipSessionTracker.getConfirmedDialogs().stream()).map(SipDialog::getCallId)
+                .collect(Collectors.toSet());
+    }
+
+    @Override
+    public String toString() {
+        return "Outgoing Transport State: " + ENUM_TO_STRING_MAP.getOrDefault(mState,
+                String.valueOf(mState)) + ", reason: "
+                + SipDelegateManager.MESSAGE_FAILURE_REASON_STRING_MAP.getOrDefault(mReason,
+                String.valueOf(mReason)) + ", allowed tags: " + mAllowedTags + ", restricted tags: "
+                + mRestrictedFeatureTags + ", denied tags: " + mDeniedTags;
+    }
+
+    private ValidationResult verifyOpenMessage(SipMessage m) {
+        // No need to validate responses to requests.
+        if (!SipMessageParsingUtils.isSipRequest(m.getStartLine())) {
+            return ValidationResult.SUCCESS;
+        }
+        if (mRestrictedFeatureTags == null) {
+            return new ValidationResult(SipDelegateManager.MESSAGE_FAILURE_REASON_NOT_REGISTERED,
+                    "no reg state from vendor");
+        }
+        String[] segments = SipMessageParsingUtils.splitStartLineAndVerify(m.getStartLine());
+        if (segments == null) {
+            return new ValidationResult(
+                    SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_START_LINE,
+                    "couldn't parse start line: " + m.getStartLine());
+        }
+        // Only need to validate requests that start dialogs.
+        boolean startsDialog = Arrays.stream(SipSessionTracker.SIP_REQUEST_DIALOG_START_METHODS)
+                .anyMatch(req -> req.equals(segments[0].trim().toLowerCase()));
+        // If part of an existing dialog, then no need to validate.
+        boolean needsFeatureValidation = startsDialog && !getAllowedCallIds()
+                .contains(m.getCallIdParameter());
+        if (needsFeatureValidation) {
+            return validateMessageFeatureTag(m);
+        }
+        return ValidationResult.SUCCESS;
+    }
+
+    /**
+     * Compares the "Accept-Contact" header against the supported/denied feature tags and ensures
+     * that there are no restricted or denied tags included.
+     */
+    private ValidationResult validateMessageFeatureTag(SipMessage m) {
+        Set<String> featureTags = SipMessageParsingUtils.getAcceptContactFeatureTags(
+                m.getHeaderSection());
+        // Get rid of potential formatting issues first.
+        featureTags = featureTags.stream().map(String::toLowerCase).map(String::trim)
+                .collect(Collectors.toSet());
+        long acceptedFeatureTagCount = featureTags.stream()
+                .filter(f -> mAllowedTags.contains(f)).count();
+        long deniedFeatureTagCount = featureTags.stream()
+                .filter(f -> mDeniedTags.contains(f)).count();
+        long restrictedFeatureTagCount = featureTags.stream()
+                .filter(f -> mRestrictedFeatureTags.contains(f)).count();
+        // we should not have any feature tags that are denied/restricted and there should be at
+        // least one accepted tag
+        if (deniedFeatureTagCount > 0) {
+            return new ValidationResult(
+                    SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_FEATURE_TAG,
+                    "contains denied tags in Accept-Contact: " + featureTags);
+        }
+        if (restrictedFeatureTagCount > 0) {
+            return new ValidationResult(
+                    SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_FEATURE_TAG,
+                    "contains restricted tags in Accept-Contact: " + featureTags);
+        }
+
+        if (acceptedFeatureTagCount == 0) {
+            return new ValidationResult(
+                    SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_FEATURE_TAG,
+                    "No Accept-Contact feature tags are in accepted feature tag list: "
+                            + featureTags);
+        }
+
+        return ValidationResult.SUCCESS;
+    }
+
+    private ValidationResult verifyRestrictedMessage(SipMessage m) {
+        // The validator is in the restricted state, so only in dialog requests and all responses
+        // are allowed.
+        if (!SipMessageParsingUtils.isSipRequest(m.getStartLine())) {
+            return ValidationResult.SUCCESS;
+        }
+        String callId = m.getCallIdParameter();
+        if (TextUtils.isEmpty(callId)) {
+            return new ValidationResult(mReason, "empty call id");
+        }
+        Set<String> mAllowedCallIds = getAllowedCallIds();
+        if (!mAllowedCallIds.contains(callId)) {
+            return new ValidationResult(mReason, "call id " + callId + " is not associated with"
+                    + " any active sessions");
+        }
+        return ValidationResult.SUCCESS;
+    }
+}
diff --git a/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSipRequestValidator.java b/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSipRequestValidator.java
new file mode 100644
index 0000000..2c2632f
--- /dev/null
+++ b/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSipRequestValidator.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs.validator;
+
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+
+import com.android.internal.telephony.SipMessageParsingUtils;
+
+import java.util.Arrays;
+
+/**
+ * Validate that any outgoing SIP request message does not contain methods that are only generated
+ * internally by the ImsService implementation.
+ */
+public class RestrictedOutgoingSipRequestValidator implements SipMessageValidator {
+
+    /**
+     * These SIP requests are always handled by the ImsService and are restricted to being
+     * generated internally. Messages with these request methods should fail validation.
+     */
+    private static final String[] IMS_SERVICE_HANDLED_REQUEST_METHODS = new String[]{
+            "register", "options", "publish"};
+
+    @Override
+    public ValidationResult validate(SipMessage message) {
+        String startLine = message.getStartLine();
+        if (SipMessageParsingUtils.isSipRequest(startLine)) {
+            String[] segments = SipMessageParsingUtils.splitStartLineAndVerify(startLine);
+            if (segments == null) {
+                return new ValidationResult(
+                        SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_START_LINE,
+                        "malformed start line: " + message.getStartLine());
+            }
+            if (Arrays.stream(IMS_SERVICE_HANDLED_REQUEST_METHODS).anyMatch(
+                    s -> segments[0].toLowerCase().contains(s))) {
+                return new ValidationResult(
+                        SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_START_LINE,
+                        "restricted method: " + segments[0]);
+            }
+        }
+        return ValidationResult.SUCCESS;
+    }
+}
diff --git a/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSubscribeValidator.java b/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSubscribeValidator.java
new file mode 100644
index 0000000..41074ed
--- /dev/null
+++ b/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSubscribeValidator.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs.validator;
+
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+import android.util.Pair;
+
+import com.android.internal.telephony.SipMessageParsingUtils;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Ensure that if there is an outgoing SUBSCRIBE request, that it does not contain the "Event"
+ * header "presence"
+ */
+public class RestrictedOutgoingSubscribeValidator implements SipMessageValidator {
+
+    private static final String SUBSCRIBE_REQUEST = "subscribe";
+    private static final String SUBSCRIBE_EVENT_HEADER = "event";
+    private static final String[] RESTRICTED_EVENTS = new String[]{ "presence" };
+
+
+    @Override
+    public ValidationResult validate(SipMessage message) {
+        if (!SipMessageParsingUtils.isSipRequest(message.getStartLine())) {
+            return ValidationResult.SUCCESS;
+        }
+        String[] requestSegments = SipMessageParsingUtils.splitStartLineAndVerify(
+                message.getStartLine());
+        if (requestSegments == null) {
+            return new ValidationResult(
+                    SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_START_LINE,
+                    "malformed start line: " + message.getStartLine());
+        }
+        // Request-Line  =  Method SP Request-URI SP SIP-Version CRLF, verify Method
+        if (!requestSegments[0].equalsIgnoreCase(SUBSCRIBE_REQUEST)) {
+            return ValidationResult.SUCCESS;
+        }
+
+        List<Pair<String, String>> eventHeaders = SipMessageParsingUtils.parseHeaders(
+                message.getHeaderSection(), true /*stopAtFirstMatch*/, SUBSCRIBE_EVENT_HEADER);
+        if (eventHeaders.size() == 0) {
+            return ValidationResult.SUCCESS;
+        }
+        boolean isRestricted = eventHeaders.stream().map(e -> e.second)
+                .anyMatch(e -> Arrays.asList(RESTRICTED_EVENTS).contains(e.trim().toLowerCase()));
+
+        return isRestricted ? new ValidationResult(
+                SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_HEADER_FIELDS,
+                "matched a restricted header field: " + eventHeaders.stream().map(e -> e.second)
+                        .collect(Collectors.toSet())) :
+                ValidationResult.SUCCESS;
+    }
+}
diff --git a/src/com/android/services/telephony/rcs/validator/SipMessageValidator.java b/src/com/android/services/telephony/rcs/validator/SipMessageValidator.java
new file mode 100644
index 0000000..6bbfbf4
--- /dev/null
+++ b/src/com/android/services/telephony/rcs/validator/SipMessageValidator.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs.validator;
+
+import android.telephony.ims.SipMessage;
+
+/**
+ * Validates a SipMessage and returns the result via an instance of {@link ValidationResult}.
+ */
+public interface SipMessageValidator {
+    /**
+     * Validate that the SipMessage is allowed to be sent to the remote.
+     * @param message The SipMessage being validated.
+     * @return A {@link ValidationResult} that represents whether or not the message was validated.
+     * If not validated, it also returns a reason why the SIP message was not validated.
+     */
+    ValidationResult validate(SipMessage message);
+
+    /**
+     * Compose a SipMessageValidator out of two validators, this validator running before the next
+     * validator.
+     * @param next The next validator that will be run if this validator validates the message
+     *             successfully.
+     * @return A new SipMessageValidator composed of this validator and the next one.
+     */
+    default SipMessageValidator andThen(SipMessageValidator next) {
+        return (SipMessage m) -> {
+            ValidationResult result = validate(m);
+            if (!result.isValidated) return result;
+            return next.validate(m);
+        };
+    }
+}
diff --git a/src/com/android/services/telephony/rcs/validator/ValidationResult.java b/src/com/android/services/telephony/rcs/validator/ValidationResult.java
new file mode 100644
index 0000000..e434163
--- /dev/null
+++ b/src/com/android/services/telephony/rcs/validator/ValidationResult.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs.validator;
+
+import android.telephony.ims.SipDelegateManager;
+
+/**
+ * Communicates the result of validating whether a SIP message should be sent to a remote based on
+ * the contents of the SIP message as well as if the transport is in an appropriate state for the
+ * intended recipient of the message.
+ */
+public class ValidationResult {
+    public static final ValidationResult SUCCESS = new ValidationResult();
+
+    /**
+     * If {@code true}, the requested SIP message has been validated and may be sent to the remote.
+     * If {@code false}, the SIP message has failed validation and should not be sent to the
+     * remote. The {@link #restrictedReason} field will contain the reason for the validation
+     * failure.
+     */
+    public final boolean isValidated;
+
+    /**
+     * The reason associated with why the SIP message was not verified and generated a
+     * {@code false} result for {@link #isValidated}.
+     */
+    public final int restrictedReason;
+
+    /**
+     * The human readable reason for why the validation failed for logging.
+     */
+    public final String logReason;
+
+    /**
+     * Communicates a validated result of success. Use {@link #SUCCESS} instead.
+     */
+    private ValidationResult() {
+        isValidated = true;
+        restrictedReason = SipDelegateManager.MESSAGE_FAILURE_REASON_UNKNOWN;
+        logReason = "";
+    }
+
+    /**
+     * The result of validating that the SIP Message should be sent.
+     *
+     * @param reason The reason associated with why the SIP message was not validated and
+     *               generated a {@code false} result for {@link #isValidated}.
+     */
+    public ValidationResult(@SipDelegateManager.MessageFailureReason int reason, String log) {
+        isValidated = false;
+        restrictedReason = reason;
+        logReason = log;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder b = new StringBuilder();
+        b.append("ValidationResult{");
+        b.append("validated=");
+        b.append(isValidated);
+        if (!isValidated) {
+            b.append(", restrictedReason=");
+            b.append(restrictedReason);
+            b.append(", logReason=");
+            b.append(logReason);
+        }
+        b.append('}');
+        return b.toString();
+    }
+}
diff --git a/tests/src/com/android/TestExecutorService.java b/tests/src/com/android/TestExecutorService.java
index fec502a..7685c6d 100644
--- a/tests/src/com/android/TestExecutorService.java
+++ b/tests/src/com/android/TestExecutorService.java
@@ -16,9 +16,11 @@
 
 package com.android;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.concurrent.Callable;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.Delayed;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
@@ -37,6 +39,8 @@
 
         private final Callable<T> mTask;
         private final long mDelayMs;
+        // Wrap callable in a CompletableFuture to support delays in execution.
+        private final CompletableFuture<T> mFuture = new CompletableFuture<>();
 
         CompletedFuture(Callable<T> task) {
             mTask = task;
@@ -50,36 +54,29 @@
 
         @Override
         public boolean cancel(boolean mayInterruptIfRunning) {
-            return false;
+            return mFuture.cancel(mayInterruptIfRunning);
         }
 
         @Override
         public boolean isCancelled() {
-            return false;
+            return mFuture.isCancelled();
         }
 
         @Override
         public boolean isDone() {
-            return true;
+            return mFuture.isDone();
         }
 
         @Override
         public T get() throws InterruptedException, ExecutionException {
-            try {
-                return mTask.call();
-            } catch (Exception e) {
-                throw new ExecutionException(e);
-            }
+            return mFuture.get();
         }
 
         @Override
         public T get(long timeout, TimeUnit unit)
                 throws InterruptedException, ExecutionException, TimeoutException {
-            try {
-                return mTask.call();
-            } catch (Exception e) {
-                throw new ExecutionException(e);
-            }
+            // delays not implemented, this should complete via completeTask for better control.
+            return mFuture.get(timeout, unit);
         }
 
         @Override
@@ -99,35 +96,71 @@
             if (o.getDelay(TimeUnit.MILLISECONDS) < mDelayMs) return 1;
             return 0;
         }
+
+        public void completeTask() {
+            try {
+                mFuture.complete(mTask.call());
+            } catch (Exception e) {
+                mFuture.completeExceptionally(e);
+            }
+        }
+    }
+
+    private final ArrayList<Runnable> mPendingRunnables = new ArrayList<>();
+    private final boolean mWaitToComplete;
+    private boolean mIsShutdown = false;
+
+    public TestExecutorService() {
+        mWaitToComplete = false;
+    }
+
+    /**
+     * Create a test executor service that also allows the constructor to provide a parameter to
+     * control when pending Runnables are executed.
+     * @param waitToComplete If true, this executor will wait to complete any pending Runnables
+     *                       until {@link #executePending()}} is called.
+     */
+    public TestExecutorService(boolean waitToComplete) {
+        mWaitToComplete = waitToComplete;
     }
 
     @Override
     public void shutdown() {
+        mIsShutdown = true;
+        for (Runnable r : mPendingRunnables) {
+            r.run();
+        }
     }
 
     @Override
     public List<Runnable> shutdownNow() {
-        return null;
+        mIsShutdown = true;
+        List<Runnable> runnables = new ArrayList<>(mPendingRunnables);
+        mPendingRunnables.clear();
+        return runnables;
     }
 
     @Override
     public boolean isShutdown() {
-        return false;
+        return mIsShutdown;
     }
 
     @Override
     public boolean isTerminated() {
-        return false;
+        return mIsShutdown;
     }
 
     @Override
     public boolean awaitTermination(long timeout, TimeUnit unit) {
-        return false;
+        shutdown();
+        return true;
     }
 
     @Override
     public <T> Future<T> submit(Callable<T> task) {
-        return new CompletedFuture<>(task);
+        CompletedFuture<T> f = new CompletedFuture<>(task);
+        onExecute(f::completeTask);
+        return f;
     }
 
     @Override
@@ -137,8 +170,12 @@
 
     @Override
     public Future<?> submit(Runnable task) {
-        task.run();
-        return new CompletedFuture<>(() -> null);
+        CompletedFuture<Void> f = new CompletedFuture<>(() -> {
+            task.run();
+            return null;
+        });
+        onExecute(f::completeTask);
+        return f;
     }
 
     @Override
@@ -164,14 +201,21 @@
 
     @Override
     public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) {
-        // No need to worry about delays yet
-        command.run();
-        return new CompletedFuture<>(() -> null, delay);
+        long millisDelay = TimeUnit.MILLISECONDS.convert(delay, unit);
+        CompletedFuture<Void> f = new CompletedFuture<>(() -> {
+            command.run();
+            return null;
+        }, millisDelay);
+        onExecute(f::completeTask);
+        return f;
     }
 
     @Override
     public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit) {
-        return new CompletedFuture<>(callable, delay);
+        long millisDelay = TimeUnit.MILLISECONDS.convert(delay, unit);
+        CompletedFuture<V> f = new CompletedFuture<>(callable, millisDelay);
+        onExecute(f::completeTask);
+        return f;
     }
 
     @Override
@@ -188,6 +232,21 @@
 
     @Override
     public void execute(Runnable command) {
-        command.run();
+        onExecute(command);
+    }
+
+    private void onExecute(Runnable command) {
+        if (mWaitToComplete) {
+            mPendingRunnables.add(command);
+        } else {
+            command.run();
+        }
+    }
+
+    public void executePending() {
+        for (Runnable r : mPendingRunnables) {
+            r.run();
+        }
+        mPendingRunnables.clear();
     }
 }
diff --git a/tests/src/com/android/phone/CarrierConfigLoaderTest.java b/tests/src/com/android/phone/CarrierConfigLoaderTest.java
index f58e6cc..9c605da 100644
--- a/tests/src/com/android/phone/CarrierConfigLoaderTest.java
+++ b/tests/src/com/android/phone/CarrierConfigLoaderTest.java
@@ -133,6 +133,7 @@
     public void tearDown() throws Exception {
         mContext.revokeAllPermissions();
         mTestableLooper.destroy();
+        mHandlerThread.quit();
         super.tearDown();
     }
 
diff --git a/tests/src/com/android/phone/LocationAccessPolicyBuilderTest.java b/tests/src/com/android/phone/LocationAccessPolicyBuilderTest.java
new file mode 100644
index 0000000..4fe2da9
--- /dev/null
+++ b/tests/src/com/android/phone/LocationAccessPolicyBuilderTest.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2018 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.phone;
+
+import static org.junit.Assert.fail;
+
+import android.os.Build;
+import android.telephony.LocationAccessPolicy;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class LocationAccessPolicyBuilderTest {
+    @Test
+    public void testBuilderMissingMinCoarse() {
+        try {
+            new LocationAccessPolicy.LocationPermissionQuery.Builder()
+                    .setMethod("test")
+                    .setCallingPackage("com.android.test")
+                    .setCallingFeatureId(null)
+                    .setCallingPid(0)
+                    .setCallingUid(0)
+                    .setMinSdkVersionForFine(Build.VERSION_CODES.N)
+                    .build();
+            fail("Should have failed without specifying min version for coarse");
+        } catch (IllegalArgumentException e) {
+            // expected
+        }
+    }
+
+    @Test
+    public void testBuilderMissingMinFine() {
+        try {
+            new LocationAccessPolicy.LocationPermissionQuery.Builder()
+                    .setMethod("test")
+                    .setCallingPackage("com.android.test")
+                    .setCallingFeatureId(null)
+                    .setCallingPid(0)
+                    .setCallingUid(0)
+                    .setMinSdkVersionForCoarse(Build.VERSION_CODES.N)
+                    .build();
+            fail("Should have failed without specifying min version for fine");
+        } catch (IllegalArgumentException e) {
+            // expected
+        }
+    }
+
+    @Test
+    public void testBuilderMissingMinEnforcement() {
+        try {
+            new LocationAccessPolicy.LocationPermissionQuery.Builder()
+                    .setMethod("test")
+                    .setCallingPackage("com.android.test")
+                    .setCallingFeatureId(null)
+                    .setCallingPid(0)
+                    .setCallingUid(0)
+                    .setMinSdkVersionForFine(Build.VERSION_CODES.N)
+                    .setMinSdkVersionForCoarse(Build.VERSION_CODES.N)
+                    .build();
+            fail("Should have failed without specifying min version for any enforcement");
+        } catch (IllegalArgumentException e) {
+            // expected
+        }
+    }
+}
diff --git a/tests/src/com/android/phone/LocationAccessPolicyTest.java b/tests/src/com/android/phone/LocationAccessPolicyTest.java
index b2489e7..84bf619 100644
--- a/tests/src/com/android/phone/LocationAccessPolicyTest.java
+++ b/tests/src/com/android/phone/LocationAccessPolicyTest.java
@@ -242,6 +242,7 @@
                 .setAppSdkLevel(Build.VERSION_CODES.P)
                 .setIsDynamicLocationEnabled(false)
                 .setQuery(getDefaultQueryBuilder()
+                        .setMinSdkVersionForEnforcement(Build.VERSION_CODES.N)
                         .setMinSdkVersionForFine(Build.VERSION_CODES.N)
                         .setMinSdkVersionForCoarse(Build.VERSION_CODES.N).build())
                 .setExpectedResult(LocationAccessPolicy.LocationPermissionResult.DENIED_SOFT)
@@ -254,6 +255,7 @@
                 .setAppSdkLevel(Build.VERSION_CODES.P)
                 .setIsDynamicLocationEnabled(true)
                 .setQuery(getDefaultQueryBuilder()
+                        .setMinSdkVersionForEnforcement(Build.VERSION_CODES.N)
                         .setMinSdkVersionForFine(Build.VERSION_CODES.N)
                         .setMinSdkVersionForCoarse(Build.VERSION_CODES.N).build())
                 .setExpectedResult(LocationAccessPolicy.LocationPermissionResult.ALLOWED)
@@ -266,6 +268,7 @@
                 .setAppSdkLevel(Build.VERSION_CODES.JELLY_BEAN)
                 .setIsDynamicLocationEnabled(true)
                 .setQuery(getDefaultQueryBuilder()
+                        .setMinSdkVersionForEnforcement(Build.VERSION_CODES.JELLY_BEAN)
                         .setMinSdkVersionForFine(Build.VERSION_CODES.M)
                         .setMinSdkVersionForCoarse(Build.VERSION_CODES.JELLY_BEAN).build())
                 .setExpectedResult(LocationAccessPolicy.LocationPermissionResult.ALLOWED)
@@ -278,6 +281,7 @@
                 .setAppSdkLevel(Build.VERSION_CODES.P)
                 .setIsDynamicLocationEnabled(true)
                 .setQuery(getDefaultQueryBuilder()
+                        .setMinSdkVersionForEnforcement(Build.VERSION_CODES.N)
                         .setMinSdkVersionForFine(Build.VERSION_CODES.N)
                         .setMinSdkVersionForCoarse(Build.VERSION_CODES.N).build())
                 .setExpectedResult(LocationAccessPolicy.LocationPermissionResult.DENIED_HARD)
@@ -292,6 +296,7 @@
                 .setQuery(getDefaultQueryBuilder()
                         .setMinSdkVersionForFine(
                                 LocationAccessPolicy.MAX_SDK_FOR_ANY_ENFORCEMENT + 1)
+                        .setMinSdkVersionForEnforcement(Build.VERSION_CODES.N)
                         .setMinSdkVersionForCoarse(Build.VERSION_CODES.N).build())
                 .setExpectedResult(LocationAccessPolicy.LocationPermissionResult.ALLOWED)
                 .build());
@@ -303,6 +308,7 @@
                 .setAppSdkLevel(Build.VERSION_CODES.P)
                 .setIsDynamicLocationEnabled(true)
                 .setQuery(getDefaultQueryBuilder()
+                        .setMinSdkVersionForEnforcement(Build.VERSION_CODES.N)
                         .setMinSdkVersionForFine(Build.VERSION_CODES.P)
                         .setMinSdkVersionForCoarse(Build.VERSION_CODES.N).build())
                 .setExpectedResult(LocationAccessPolicy.LocationPermissionResult.DENIED_HARD)
@@ -316,6 +322,7 @@
                 .setAppSdkLevel(Build.VERSION_CODES.P)
                 .setIsDynamicLocationEnabled(true)
                 .setQuery(getDefaultQueryBuilder()
+                        .setMinSdkVersionForEnforcement(Build.VERSION_CODES.O)
                         .setMinSdkVersionForFine(Build.VERSION_CODES.P)
                         .setMinSdkVersionForCoarse(Build.VERSION_CODES.O).build())
                 .setExpectedResult(LocationAccessPolicy.LocationPermissionResult.DENIED_HARD)
@@ -326,6 +333,7 @@
                 .setAppSdkLevel(Build.VERSION_CODES.N)
                 .setIsDynamicLocationEnabled(true)
                 .setQuery(getDefaultQueryBuilder()
+                        .setMinSdkVersionForEnforcement(Build.VERSION_CODES.O)
                         .setMinSdkVersionForFine(Build.VERSION_CODES.Q)
                         .setMinSdkVersionForCoarse(Build.VERSION_CODES.O).build())
                 .setExpectedResult(LocationAccessPolicy.LocationPermissionResult.ALLOWED)
@@ -338,6 +346,9 @@
                 .setAppSdkLevel(Build.VERSION_CODES.P)
                 .setIsDynamicLocationEnabled(true)
                 .setQuery(getDefaultQueryBuilder()
+                        .setMinSdkVersionForEnforcement(Build.VERSION_CODES.P)
+                        .setMinSdkVersionForFine(
+                                LocationAccessPolicy.MAX_SDK_FOR_ANY_ENFORCEMENT + 1)
                         .setMinSdkVersionForCoarse(Build.VERSION_CODES.P).build())
                 .setExpectedResult(LocationAccessPolicy.LocationPermissionResult.ALLOWED)
                 .build());
diff --git a/tests/src/com/android/phone/RcsProvisioningMonitorTest.java b/tests/src/com/android/phone/RcsProvisioningMonitorTest.java
index c7d0c8f..57e01f0 100644
--- a/tests/src/com/android/phone/RcsProvisioningMonitorTest.java
+++ b/tests/src/com/android/phone/RcsProvisioningMonitorTest.java
@@ -85,28 +85,56 @@
  */
 public class RcsProvisioningMonitorTest {
     private static final String TAG = "RcsProvisioningMonitorTest";
-    private static final String CONFIG_DEFAULT = "<RCSConfig>\n"
-            + "\t<rcsVolteSingleRegistration>1</rcsVolteSingleRegistration>\n"
-            + "\t<SERVICES>\n"
-            + "\t\t<SupportedRCSProfileVersions>UP_2.0</SupportedRCSProfileVersions>\n"
-            + "\t\t<ChatAuth>1</ChatAuth>\n"
-            + "\t\t<GroupChatAuth>1</GroupChatAuth>\n"
-            + "\t\t<ftAuth>1</ftAuth>\n"
-            + "\t\t<standaloneMsgAuth>1</standaloneMsgAuth>\n"
-            + "\t\t<geolocPushAuth>1</geolocPushAuth>\n"
-            + "\t\t<Ext>\n"
-            + "\t\t\t<DataOff>\n"
-            + "\t\t\t\t<rcsMessagingDataOff>1</rcsMessagingDataOff>\n"
-            + "\t\t\t\t<fileTransferDataOff>1</fileTransferDataOff>\n"
-            + "\t\t\t\t<mmsDataOff>1</mmsDataOff>\n"
-            + "\t\t\t\t<syncDataOff>1</syncDataOff>\n"
-            + "\t\t\t</DataOff>\n"
-            + "\t\t</Ext>\n"
-            + "\t</SERVICES>\n"
-            + "</RCSConfig>";
-    private static final String CONFIG_SINGLE_REGISTRATION_DISABLED = "<RCSConfig>\n"
-            + "\t<rcsVolteSingleRegistration>0</rcsVolteSingleRegistration>\n"
-            + "</RCSConfig>";
+    private static final String CONFIG_DEFAULT = "<?xml version=\"1.0\"?>\n"
+            + "<wap-provisioningdoc version=\"1.1\">\n"
+            + "\t<characteristic type=\"APPLICATION\">\n"
+            + "\t\t<parm name=\"AppID\" value=\"urn:oma:mo:ext-3gpp-ims:1.0\"/>\n"
+            + "\t\t<characteristic type=\"3GPP_IMS\">\n"
+            + "\t\t\t<parm name=\"AppID\" value=\"ap2001\"/>\n"
+            + "\t\t\t<parm name=\"Name\" value=\"RCS IMS Settings\"/>\n"
+            + "\t\t\t<characteristic type=\"Ext\">\n"
+            + "\t\t\t\t<characteristic type=\"GSMA\">\n"
+            + "\t\t\t\t\t<parm name=\"AppRef\" value=\"IMS-Setting\"/>\n"
+            + "\t\t\t\t\t<parm name=\"rcsVolteSingleRegistration\" value=\"1\"/>\n"
+            + "\t\t\t\t</characteristic>\n"
+            + "\t\t\t</characteristic>\n"
+            + "\t\t</characteristic>\n"
+            + "\t\t<characteristic type=\"SERVICES\">\n"
+            + "\t\t\t<parm name=\"SupportedRCSProfileVersions\" value=\"UP2.3\"/>\n"
+            + "\t\t\t<parm name=\"ChatAuth\" value=\"1\"/>\n"
+            + "\t\t\t<parm name=\"GroupChatAuth\" value=\"1\"/>\n"
+            + "\t\t\t<parm name=\"ftAuth\" value=\"1\"/>\n"
+            + "\t\t\t<parm name=\"standaloneMsgAuth\" value=\"1\"/>\n"
+            + "\t\t\t<parm name=\"geolocPushAuth\" value=\"1\"/>\n"
+            + "\t\t\t<characteristic type=\"Ext\">\n"
+            + "\t\t\t\t<characteristic type=\"DataOff\">\n"
+            + "\t\t\t\t\t<parm name=\"rcsMessagingDataOff\" value=\"1\"/>\n"
+            + "\t\t\t\t\t<parm name=\"fileTransferDataOff\" value=\"1\"/>\n"
+            + "\t\t\t\t\t<parm name=\"mmsDataOff\" value=\"1\"/>\n"
+            + "\t\t\t\t\t<parm name=\"syncDataOff\" value=\"1\"/>\n"
+            + "\t\t\t\t\t<characteristic type=\"Ext\"/>\n"
+            + "\t\t\t\t</characteristic>\n"
+            + "\t\t\t</characteristic>\n"
+            + "\t\t</characteristic>\n"
+            + "\t</characteristic>\n"
+            + "</wap-provisioningdoc>\n";
+
+    private static final String CONFIG_SINGLE_REGISTRATION_DISABLED = "<?xml version=\"1.0\"?>\n"
+            + "<wap-provisioningdoc version=\"1.1\">\n"
+            + "\t<characteristic type=\"APPLICATION\">\n"
+            + "\t\t<parm name=\"AppID\" value=\"urn:oma:mo:ext-3gpp-ims:1.0\"/>\n"
+            + "\t\t<characteristic type=\"3GPP_IMS\">\n"
+            + "\t\t\t<parm name=\"AppID\" value=\"ap2001\"/>\n"
+            + "\t\t\t<parm name=\"Name\" value=\"RCS IMS Settings\"/>\n"
+            + "\t\t\t<characteristic type=\"Ext\">\n"
+            + "\t\t\t\t<characteristic type=\"GSMA\">\n"
+            + "\t\t\t\t\t<parm name=\"AppRef\" value=\"IMS-Setting\"/>\n"
+            + "\t\t\t\t\t<parm name=\"rcsVolteSingleRegistration\" value=\"0\"/>\n"
+            + "\t\t\t\t</characteristic>\n"
+            + "\t\t\t</characteristic>\n"
+            + "\t\t</characteristic>\n"
+            + "\t</characteristic>\n"
+            + "</wap-provisioningdoc>\n";
     private static final int FAKE_SUB_ID_BASE = 0x0FFFFFF0;
     private static final String DEFAULT_MESSAGING_APP1 = "DMA1";
     private static final String DEFAULT_MESSAGING_APP2 = "DMA2";
diff --git a/tests/src/com/android/services/telephony/rcs/MessageTransportStateTrackerTest.java b/tests/src/com/android/services/telephony/rcs/MessageTransportStateTrackerTest.java
deleted file mode 100644
index f69b9a8..0000000
--- a/tests/src/com/android/services/telephony/rcs/MessageTransportStateTrackerTest.java
+++ /dev/null
@@ -1,243 +0,0 @@
-/*
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.services.telephony.rcs;
-
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.doThrow;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-
-import android.os.RemoteException;
-import android.telephony.ims.SipDelegateManager;
-import android.telephony.ims.SipMessage;
-import android.telephony.ims.aidl.ISipDelegate;
-import android.telephony.ims.aidl.ISipDelegateMessageCallback;
-
-import androidx.test.filters.SmallTest;
-import androidx.test.runner.AndroidJUnit4;
-
-import com.android.TelephonyTestBase;
-
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mock;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.concurrent.Executor;
-import java.util.function.Consumer;
-
-@RunWith(AndroidJUnit4.class)
-public class MessageTransportStateTrackerTest extends TelephonyTestBase {
-    private static final int TEST_SUB_ID = 1;
-
-    private static final SipMessage TEST_MESSAGE = new SipMessage(
-            "INVITE sip:callee@ex.domain.com SIP/2.0",
-            "Via: SIP/2.0/UDP ex.place.com;branch=z9hG4bK776asdhds",
-            new byte[0]);
-
-    // Use for finer-grained control of when the Executor executes.
-    private static class PendingExecutor implements Executor {
-        private final ArrayList<Runnable> mPendingRunnables = new ArrayList<>();
-
-        @Override
-        public void execute(Runnable command) {
-            mPendingRunnables.add(command);
-        }
-
-        public void executePending() {
-            for (Runnable r : mPendingRunnables) {
-                r.run();
-            }
-            mPendingRunnables.clear();
-        }
-    }
-
-    @Mock private ISipDelegateMessageCallback mDelegateMessageCallback;
-    @Mock private ISipDelegate mISipDelegate;
-    @Mock private Consumer<Boolean> mMockCloseConsumer;
-
-    @Before
-    public void setUp() throws Exception {
-        super.setUp();
-    }
-
-    @After
-    public void tearDown() throws Exception {
-        super.tearDown();
-    }
-
-    @SmallTest
-    @Test
-    public void testDelegateConnectionSendOutgoingMessage() throws Exception {
-        MessageTransportStateTracker tracker = new MessageTransportStateTracker(TEST_SUB_ID,
-                Runnable::run, mDelegateMessageCallback);
-
-        tracker.openTransport(mISipDelegate, Collections.emptySet());
-        tracker.getDelegateConnection().sendMessage(TEST_MESSAGE, 1 /*version*/);
-        verify(mISipDelegate).sendMessage(TEST_MESSAGE, 1 /*version*/);
-
-        doThrow(new RemoteException()).when(mISipDelegate).sendMessage(any(), anyLong());
-        tracker.getDelegateConnection().sendMessage(TEST_MESSAGE, 1 /*version*/);
-        verify(mDelegateMessageCallback).onMessageSendFailure(any(),
-                eq(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD));
-
-        tracker.close(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED);
-        tracker.getDelegateConnection().sendMessage(TEST_MESSAGE, 1 /*version*/);
-        verify(mDelegateMessageCallback).onMessageSendFailure(any(),
-                eq(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED));
-    }
-
-    @SmallTest
-    @Test
-    public void testDelegateConnectionCloseGracefully() throws Exception {
-        PendingExecutor executor = new PendingExecutor();
-        MessageTransportStateTracker tracker = new MessageTransportStateTracker(TEST_SUB_ID,
-                executor, mDelegateMessageCallback);
-
-        tracker.openTransport(mISipDelegate, Collections.emptySet());
-        tracker.getDelegateConnection().sendMessage(TEST_MESSAGE, 1 /*version*/);
-        executor.executePending();
-        verify(mISipDelegate).sendMessage(TEST_MESSAGE, 1 /*version*/);
-        verify(mDelegateMessageCallback, never()).onMessageSendFailure(any(), anyInt());
-
-        // Use PendingExecutor a little weird here, we need to queue sendMessage first, even though
-        // closeGracefully will complete partly synchronously to test that the pending message will
-        // return MESSAGE_FAILURE_REASON_INTERNAL_DELEGATE_STATE_TRANSITION before the scheduled
-        // graceful close operation completes.
-        tracker.getDelegateConnection().sendMessage(TEST_MESSAGE, 1 /*version*/);
-        tracker.closeGracefully(
-                SipDelegateManager.MESSAGE_FAILURE_REASON_INTERNAL_DELEGATE_STATE_TRANSITION,
-                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED,
-                mMockCloseConsumer);
-        verify(mMockCloseConsumer, never()).accept(any());
-        // resolve pending close operation
-        executor.executePending();
-        verify(mDelegateMessageCallback).onMessageSendFailure(any(),
-                eq(SipDelegateManager.MESSAGE_FAILURE_REASON_INTERNAL_DELEGATE_STATE_TRANSITION));
-        // Still should only report one call of sendMessage from before
-        verify(mISipDelegate).sendMessage(TEST_MESSAGE, 1 /*version*/);
-        verify(mMockCloseConsumer).accept(true);
-
-        // ensure that after close operation completes, we get the correct
-        // MESSAGE_FAILURE_REASON_DELEGATE_CLOSED message.
-        tracker.getDelegateConnection().sendMessage(TEST_MESSAGE, 1 /*version*/);
-        executor.executePending();
-        verify(mDelegateMessageCallback).onMessageSendFailure(any(),
-                eq(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED));
-        // Still should only report one call of sendMessage from before
-        verify(mISipDelegate).sendMessage(TEST_MESSAGE, 1 /*version*/);
-    }
-
-    @SmallTest
-    @Test
-    public void testDelegateConnectionNotifyMessageReceived() throws Exception {
-        MessageTransportStateTracker tracker = new MessageTransportStateTracker(TEST_SUB_ID,
-                Runnable::run, mDelegateMessageCallback);
-        tracker.openTransport(mISipDelegate, Collections.emptySet());
-        tracker.getDelegateConnection().notifyMessageReceived("z9hG4bK776asdhds");
-        verify(mISipDelegate).notifyMessageReceived("z9hG4bK776asdhds");
-    }
-
-    @SmallTest
-    @Test
-    public void testDelegateConnectionNotifyMessageReceiveError() throws Exception {
-        MessageTransportStateTracker tracker = new MessageTransportStateTracker(TEST_SUB_ID,
-                Runnable::run, mDelegateMessageCallback);
-        tracker.openTransport(mISipDelegate, Collections.emptySet());
-        tracker.getDelegateConnection().notifyMessageReceiveError("z9hG4bK776asdhds",
-                SipDelegateManager.MESSAGE_FAILURE_REASON_NETWORK_NOT_AVAILABLE);
-        verify(mISipDelegate).notifyMessageReceiveError("z9hG4bK776asdhds",
-                SipDelegateManager.MESSAGE_FAILURE_REASON_NETWORK_NOT_AVAILABLE);
-    }
-
-    @SmallTest
-    @Test
-    public void testDelegateConnectionCloseSession() throws Exception {
-        MessageTransportStateTracker tracker = new MessageTransportStateTracker(TEST_SUB_ID,
-                Runnable::run, mDelegateMessageCallback);
-        tracker.openTransport(mISipDelegate, Collections.emptySet());
-        tracker.getDelegateConnection().cleanupSession("testCallId");
-        verify(mISipDelegate).cleanupSession("testCallId");
-    }
-
-    @SmallTest
-    @Test
-    public void testDelegateOnMessageReceived() throws Exception {
-        MessageTransportStateTracker tracker = new MessageTransportStateTracker(TEST_SUB_ID,
-                Runnable::run, mDelegateMessageCallback);
-        tracker.openTransport(mISipDelegate, Collections.emptySet());
-
-        tracker.getMessageCallback().onMessageReceived(TEST_MESSAGE);
-        verify(mDelegateMessageCallback).onMessageReceived(TEST_MESSAGE);
-
-        doThrow(new RemoteException()).when(mDelegateMessageCallback).onMessageReceived(any());
-        tracker.getMessageCallback().onMessageReceived(TEST_MESSAGE);
-        verify(mISipDelegate).notifyMessageReceiveError(any(),
-                eq(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD));
-    }
-
-    @SmallTest
-    @Test
-    public void testDelegateOnMessageReceivedClosedGracefully() throws Exception {
-        PendingExecutor executor = new PendingExecutor();
-        MessageTransportStateTracker tracker = new MessageTransportStateTracker(TEST_SUB_ID,
-                executor, mDelegateMessageCallback);
-        tracker.openTransport(mISipDelegate, Collections.emptySet());
-
-        tracker.getMessageCallback().onMessageReceived(TEST_MESSAGE);
-        executor.executePending();
-        verify(mDelegateMessageCallback).onMessageReceived(TEST_MESSAGE);
-
-        tracker.getMessageCallback().onMessageReceived(TEST_MESSAGE);
-        tracker.closeGracefully(
-                SipDelegateManager.MESSAGE_FAILURE_REASON_INTERNAL_DELEGATE_STATE_TRANSITION,
-                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED,
-                mMockCloseConsumer);
-        executor.executePending();
-        // Incoming SIP message should not be blocked by closeGracefully
-        verify(mDelegateMessageCallback, times(2)).onMessageReceived(TEST_MESSAGE);
-    }
-
-    @SmallTest
-    @Test
-    public void testDelegateOnMessageSent() throws Exception {
-        MessageTransportStateTracker tracker = new MessageTransportStateTracker(TEST_SUB_ID,
-                Runnable::run, mDelegateMessageCallback);
-        tracker.openTransport(mISipDelegate, Collections.emptySet());
-        tracker.getMessageCallback().onMessageSent("z9hG4bK776asdhds");
-        verify(mDelegateMessageCallback).onMessageSent("z9hG4bK776asdhds");
-    }
-
-    @SmallTest
-    @Test
-    public void testDelegateonMessageSendFailure() throws Exception {
-        MessageTransportStateTracker tracker = new MessageTransportStateTracker(TEST_SUB_ID,
-                Runnable::run, mDelegateMessageCallback);
-        tracker.openTransport(mISipDelegate, Collections.emptySet());
-        tracker.getMessageCallback().onMessageSendFailure("z9hG4bK776asdhds",
-                SipDelegateManager.MESSAGE_FAILURE_REASON_NETWORK_NOT_AVAILABLE);
-        verify(mDelegateMessageCallback).onMessageSendFailure("z9hG4bK776asdhds",
-                SipDelegateManager.MESSAGE_FAILURE_REASON_NETWORK_NOT_AVAILABLE);
-    }
-}
diff --git a/tests/src/com/android/services/telephony/rcs/MessageTransportWrapperTest.java b/tests/src/com/android/services/telephony/rcs/MessageTransportWrapperTest.java
new file mode 100644
index 0000000..5ced75c
--- /dev/null
+++ b/tests/src/com/android/services/telephony/rcs/MessageTransportWrapperTest.java
@@ -0,0 +1,343 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.services.telephony.rcs;
+
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.net.InetAddresses;
+import android.os.RemoteException;
+import android.telephony.ims.DelegateRegistrationState;
+import android.telephony.ims.FeatureTagState;
+import android.telephony.ims.SipDelegateConfiguration;
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+import android.telephony.ims.aidl.ISipDelegate;
+import android.telephony.ims.aidl.ISipDelegateMessageCallback;
+import android.util.ArraySet;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.TelephonyTestBase;
+import com.android.TestExecutorService;
+import com.android.services.telephony.rcs.validator.ValidationResult;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.function.Consumer;
+
+@RunWith(AndroidJUnit4.class)
+public class MessageTransportWrapperTest extends TelephonyTestBase {
+    private static final int TEST_SUB_ID = 1;
+
+    private static final SipMessage TEST_MESSAGE = new SipMessage(
+            "INVITE sip:callee@ex.domain.com SIP/2.0",
+            "Via: SIP/2.0/UDP ex.place.com;branch=z9hG4bK776asdhds",
+            new byte[0]);
+
+    // Derived from TEST_MESSAGE above.
+    private static final String TEST_TRANSACTION_ID = "z9hG4bK776asdhds";
+
+    @Mock private ISipDelegateMessageCallback mDelegateMessageCallback;
+    @Mock private TransportSipMessageValidator mTransportSipSessionValidator;
+    @Mock private ISipDelegate mISipDelegate;
+
+    // Test executor that just calls run on the Runnable provided in execute.
+    private ScheduledExecutorService mExecutor = new TestExecutorService();
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        super.tearDown();
+    }
+
+    @SmallTest
+    @Test
+    public void testImsConfigurationChanged() {
+        MessageTransportWrapper tracker = createTestMessageTransportWrapper();
+        InetSocketAddress localAddr = new InetSocketAddress(
+                InetAddresses.parseNumericAddress("1.1.1.1"), 80);
+        InetSocketAddress serverAddr = new InetSocketAddress(
+                InetAddresses.parseNumericAddress("2.2.2.2"), 81);
+        SipDelegateConfiguration c = new SipDelegateConfiguration.Builder(1,
+                SipDelegateConfiguration.SIP_TRANSPORT_TCP, localAddr, serverAddr).build();
+        // Ensure IMS config changes are propagated to the message tracker.
+        tracker.onConfigurationChanged(c);
+        verify(mTransportSipSessionValidator).onConfigurationChanged(c);
+    }
+
+    @SmallTest
+    @Test
+    public void testOpenTransport() {
+        HashSet<String> allowedTags = new HashSet<>(1);
+        allowedTags.add("testTag");
+        HashSet<FeatureTagState> deniedTags = new HashSet<>(1);
+        deniedTags.add(new FeatureTagState("testBadTag",
+                SipDelegateManager.DENIED_REASON_INVALID));
+        MessageTransportWrapper tracker = createTestMessageTransportWrapper();
+        // Ensure openTransport passes denied tags to the session tracker
+        tracker.openTransport(mISipDelegate, allowedTags, deniedTags);
+        verify(mTransportSipSessionValidator).onTransportOpened(allowedTags, deniedTags);
+    }
+
+    @SmallTest
+    @Test
+    public void testRegistrationStateChanged() throws Exception {
+        ArraySet<String> callIds = new ArraySet<>(2);
+        callIds.add("callId1");
+        callIds.add("callId2");
+        // empty registration state for testing
+        DelegateRegistrationState state = new DelegateRegistrationState.Builder().build();
+        MessageTransportWrapper tracker = createTestMessageTransportWrapper();
+        tracker.openTransport(mISipDelegate, Collections.emptySet(), Collections.emptySet());
+
+        Consumer<Set<String>> callIdConsumer = trackerRegStateChanged(tracker, state);
+        callIdConsumer.accept(callIds);
+        // Verify that the pending call IDs are closed properly.
+        for (String callId : callIds) {
+            verify(mTransportSipSessionValidator).onSipSessionCleanup(callId);
+            verify(mISipDelegate).cleanupSession(callId);
+        }
+    }
+
+    @SmallTest
+    @Test
+    public void testCloseGracefully() throws Exception {
+        int closingReason = DelegateRegistrationState.DEREGISTERING_REASON_PROVISIONING_CHANGE;
+        int closedReason = DelegateRegistrationState.DEREGISTERED_REASON_NOT_PROVISIONED;
+        MessageTransportWrapper tracker = createTestMessageTransportWrapper();
+
+        Boolean[] result = new Boolean[1];
+        Consumer<Set<String>> callIdConsumer = closeTrackerGracefully(tracker, closingReason,
+                closedReason, (r) -> result[0] = r);
+        callIdConsumer.accept(Collections.emptySet());
+        // Verify that the pending call IDs are closed properly.
+        verify(mTransportSipSessionValidator, never()).onSipSessionCleanup(anyString());
+        verify(mISipDelegate, never()).cleanupSession(anyString());
+        // Result is true in the case that all call IDs were successfully closed.
+        assertTrue(result[0]);
+    }
+
+    @SmallTest
+    @Test
+    public void testCloseGracefullyForceCloseCallIds() throws Exception {
+        ArraySet<String> callIds = new ArraySet<>(2);
+        callIds.add("callId1");
+        callIds.add("callId2");
+        int closingReason = DelegateRegistrationState.DEREGISTERING_REASON_PROVISIONING_CHANGE;
+        int closedReason = DelegateRegistrationState.DEREGISTERED_REASON_NOT_PROVISIONED;
+        MessageTransportWrapper tracker = createTestMessageTransportWrapper();
+        tracker.openTransport(mISipDelegate, Collections.emptySet(), Collections.emptySet());
+
+        Boolean[] result = new Boolean[1];
+        Consumer<Set<String>> callIdConsumer = closeTrackerGracefully(tracker, closingReason,
+                closedReason, (r) -> result[0] = r);
+        callIdConsumer.accept(callIds);
+        // Verify that the pending call IDs are closed properly.
+        for (String callId : callIds) {
+            verify(mTransportSipSessionValidator).onSipSessionCleanup(callId);
+            verify(mISipDelegate).cleanupSession(callId);
+        }
+        // Result is false in this case because there were still callIds left that were not
+        // successfully closed.
+        assertFalse(result[0]);
+    }
+
+    @SmallTest
+    @Test
+    public void testClose() throws Exception {
+        ArraySet<String> callIds = new ArraySet<>(2);
+        callIds.add("callId1");
+        callIds.add("callId2");
+        int closedReason = SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED;
+        doReturn(callIds).when(mTransportSipSessionValidator).closeSessions(closedReason);
+        MessageTransportWrapper tracker = createTestMessageTransportWrapper();
+        tracker.openTransport(mISipDelegate, Collections.emptySet(), Collections.emptySet());
+
+        tracker.close(closedReason);
+        // Verify that the pending call IDs are closed properly.
+        for (String callId : callIds) {
+            verify(mTransportSipSessionValidator).onSipSessionCleanup(callId);
+            verify(mISipDelegate).cleanupSession(callId);
+        }
+    }
+
+    @SmallTest
+    @Test
+    public void testDelegateConnectionSendOutgoingMessage() throws Exception {
+        MessageTransportWrapper tracker = createTestMessageTransportWrapper();
+
+        tracker.openTransport(mISipDelegate, Collections.emptySet(), Collections.emptySet());
+        doReturn(ValidationResult.SUCCESS)
+                .when(mTransportSipSessionValidator)
+                .verifyOutgoingMessage(TEST_MESSAGE, 1 /*version*/);
+        tracker.getDelegateConnection().sendMessage(TEST_MESSAGE, 1 /*version*/);
+        verify(mISipDelegate).sendMessage(TEST_MESSAGE, 1 /*version*/);
+
+        doThrow(new RemoteException()).when(mISipDelegate).sendMessage(any(), anyLong());
+        tracker.getDelegateConnection().sendMessage(TEST_MESSAGE, 1 /*version*/);
+        verify(mDelegateMessageCallback).onMessageSendFailure(any(),
+                eq(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD));
+
+        doReturn(new ValidationResult(
+                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED, ""))
+                .when(mTransportSipSessionValidator)
+                .verifyOutgoingMessage(TEST_MESSAGE, 1 /*version*/);
+        tracker.getDelegateConnection().sendMessage(TEST_MESSAGE, 1 /*version*/);
+        verify(mDelegateMessageCallback).onMessageSendFailure(TEST_TRANSACTION_ID,
+                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED);
+    }
+
+    @SmallTest
+    @Test
+    public void testDelegateConnectionNotifyMessageReceived() throws Exception {
+        MessageTransportWrapper tracker = createTestMessageTransportWrapper();
+        tracker.openTransport(mISipDelegate, Collections.emptySet(), Collections.emptySet());
+        tracker.getDelegateConnection().notifyMessageReceived(TEST_TRANSACTION_ID);
+        verify(mISipDelegate).notifyMessageReceived(TEST_TRANSACTION_ID);
+        verify(mTransportSipSessionValidator).acknowledgePendingMessage(TEST_TRANSACTION_ID);
+    }
+
+    @SmallTest
+    @Test
+    public void testDelegateConnectionNotifyMessageReceiveError() throws Exception {
+        MessageTransportWrapper tracker = createTestMessageTransportWrapper();
+        tracker.openTransport(mISipDelegate, Collections.emptySet(), Collections.emptySet());
+        tracker.getDelegateConnection().notifyMessageReceiveError(TEST_TRANSACTION_ID,
+                SipDelegateManager.MESSAGE_FAILURE_REASON_NETWORK_NOT_AVAILABLE);
+        verify(mISipDelegate).notifyMessageReceiveError(TEST_TRANSACTION_ID,
+                SipDelegateManager.MESSAGE_FAILURE_REASON_NETWORK_NOT_AVAILABLE);
+        verify(mTransportSipSessionValidator).notifyPendingMessageFailed(TEST_TRANSACTION_ID);
+    }
+
+    @SmallTest
+    @Test
+    public void testDelegateConnectionCloseSession() throws Exception {
+        MessageTransportWrapper tracker = createTestMessageTransportWrapper();
+        tracker.openTransport(mISipDelegate, Collections.emptySet(), Collections.emptySet());
+        tracker.getDelegateConnection().cleanupSession("testCallId");
+        verify(mISipDelegate).cleanupSession("testCallId");
+        verify(mTransportSipSessionValidator).onSipSessionCleanup("testCallId");
+    }
+
+    @SmallTest
+    @Test
+    public void testDelegateOnMessageReceived() throws Exception {
+        MessageTransportWrapper tracker = createTestMessageTransportWrapper();
+        tracker.openTransport(mISipDelegate, Collections.emptySet(), Collections.emptySet());
+
+        doReturn(ValidationResult.SUCCESS)
+                .when(mTransportSipSessionValidator).verifyIncomingMessage(TEST_MESSAGE);
+        tracker.getMessageCallback().onMessageReceived(TEST_MESSAGE);
+        verify(mDelegateMessageCallback).onMessageReceived(TEST_MESSAGE);
+
+        doThrow(new RemoteException()).when(mDelegateMessageCallback).onMessageReceived(any());
+        tracker.getMessageCallback().onMessageReceived(TEST_MESSAGE);
+        verify(mISipDelegate).notifyMessageReceiveError(TEST_TRANSACTION_ID,
+                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD);
+
+        doReturn(new ValidationResult(
+                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD, ""))
+                .when(mTransportSipSessionValidator).verifyIncomingMessage(TEST_MESSAGE);
+        tracker.getMessageCallback().onMessageReceived(TEST_MESSAGE);
+        verify(mISipDelegate, times(2)).notifyMessageReceiveError(TEST_TRANSACTION_ID,
+                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD);
+    }
+
+    @SmallTest
+    @Test
+    public void testDelegateOnMessageSent() throws Exception {
+        MessageTransportWrapper tracker = createTestMessageTransportWrapper();
+        tracker.openTransport(mISipDelegate, Collections.emptySet(), Collections.emptySet());
+        tracker.getMessageCallback().onMessageSent(TEST_TRANSACTION_ID);
+        verify(mTransportSipSessionValidator).acknowledgePendingMessage(TEST_TRANSACTION_ID);
+        verify(mDelegateMessageCallback).onMessageSent(TEST_TRANSACTION_ID);
+    }
+
+    @SmallTest
+    @Test
+    public void testDelegateOnMessageSendFailure() throws Exception {
+        MessageTransportWrapper tracker = createTestMessageTransportWrapper();
+        tracker.openTransport(mISipDelegate, Collections.emptySet(), Collections.emptySet());
+        tracker.getMessageCallback().onMessageSendFailure(TEST_TRANSACTION_ID,
+                SipDelegateManager.MESSAGE_FAILURE_REASON_NETWORK_NOT_AVAILABLE);
+        verify(mTransportSipSessionValidator).notifyPendingMessageFailed(TEST_TRANSACTION_ID);
+        verify(mDelegateMessageCallback).onMessageSendFailure(TEST_TRANSACTION_ID,
+                SipDelegateManager.MESSAGE_FAILURE_REASON_NETWORK_NOT_AVAILABLE);
+    }
+
+    private MessageTransportWrapper createTestMessageTransportWrapper() {
+        return new MessageTransportWrapper(TEST_SUB_ID,
+                mExecutor, mDelegateMessageCallback, mTransportSipSessionValidator);
+    }
+
+    private Consumer<Set<String>> trackerRegStateChanged(MessageTransportWrapper tracker,
+            DelegateRegistrationState state) {
+        ArrayList<Consumer<Set<String>>> consumerCaptor = new ArrayList<>(1);
+        Mockito.doAnswer(it -> {
+            // Capture the consumer here.
+            consumerCaptor.add(it.getArgument(0));
+            return null;
+        }).when(mTransportSipSessionValidator).onRegistrationStateChanged(any(), eq(state));
+        tracker.onRegistrationStateChanged(state);
+        verify(mTransportSipSessionValidator).onRegistrationStateChanged(any(), eq(state));
+        assertFalse(consumerCaptor.isEmpty());
+        return consumerCaptor.get(0);
+    }
+
+    private Consumer<Set<String>> closeTrackerGracefully(MessageTransportWrapper tracker,
+            int closingReason, int closedReason, Consumer<Boolean> resultConsumer) {
+        ArrayList<Consumer<Set<String>>> consumerCaptor = new ArrayList<>(1);
+        Mockito.doAnswer(it -> {
+            // Capture the consumer here.
+            consumerCaptor.add(it.getArgument(0));
+            return null;
+        }).when(mTransportSipSessionValidator).closeSessionsGracefully(any(), eq(closingReason),
+                eq(closedReason));
+        tracker.closeGracefully(closingReason, closedReason, resultConsumer);
+        verify(mTransportSipSessionValidator).closeSessionsGracefully(any(), eq(closingReason),
+                eq(closedReason));
+        assertFalse(consumerCaptor.isEmpty());
+        return consumerCaptor.get(0);
+    }
+}
diff --git a/tests/src/com/android/services/telephony/rcs/SipDelegateControllerTest.java b/tests/src/com/android/services/telephony/rcs/SipDelegateControllerTest.java
index 27f896b..5b0e7c5 100644
--- a/tests/src/com/android/services/telephony/rcs/SipDelegateControllerTest.java
+++ b/tests/src/com/android/services/telephony/rcs/SipDelegateControllerTest.java
@@ -58,13 +58,14 @@
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.function.BiConsumer;
 import java.util.function.Consumer;
+import java.util.stream.Collectors;
 
 @RunWith(AndroidJUnit4.class)
 public class SipDelegateControllerTest extends TelephonyTestBase {
     private static final int TEST_SUB_ID = 1;
 
     @Mock private ISipDelegate mMockSipDelegate;
-    @Mock private MessageTransportStateTracker mMockMessageTracker;
+    @Mock private MessageTransportWrapper mMockMessageTracker;
     @Mock private ISipDelegateMessageCallback mMockMessageCallback;
     @Mock private DelegateStateTracker mMockDelegateStateTracker;
     @Mock private DelegateBinderStateManager mMockBinderConnection;
@@ -104,12 +105,44 @@
         assertFalse(future.isDone());
         consumer.accept(mMockSipDelegate, Collections.emptySet());
         assertTrue(future.get());
-        verify(mMockMessageTracker).openTransport(mMockSipDelegate, Collections.emptySet());
+        verify(mMockMessageTracker).openTransport(mMockSipDelegate, request.getFeatureTags(),
+                Collections.emptySet());
         verify(mMockDelegateStateTracker).sipDelegateConnected(Collections.emptySet());
     }
 
     @SmallTest
     @Test
+    public void testCreateDeniedFeatures() throws Exception {
+        DelegateRequest request = getLargeDelegateRequest();
+        ArraySet<FeatureTagState> deniedTags = new ArraySet<>(1);
+        deniedTags.add(new FeatureTagState(ImsSignallingUtils.GROUP_CHAT_TAG,
+                SipDelegateManager.DENIED_REASON_NOT_ALLOWED));
+        SipDelegateController controller = getTestDelegateController(request,
+                deniedTags);
+
+        doReturn(true).when(mMockBinderConnection).create(eq(mMockMessageCallback), any());
+        CompletableFuture<Boolean> future = controller.create(request.getFeatureTags(),
+                deniedTags);
+        BiConsumer<ISipDelegate, Set<FeatureTagState>> consumer =
+                verifyConnectionCreated(1);
+        assertNotNull(consumer);
+
+        assertFalse(future.isDone());
+        // Send in additional tags denied by the service
+        deniedTags.add(new FeatureTagState(ImsSignallingUtils.FILE_TRANSFER_HTTP_TAG,
+                SipDelegateManager.DENIED_REASON_NOT_ALLOWED));
+        consumer.accept(mMockSipDelegate, deniedTags);
+        assertTrue(future.get());
+        // Allowed tags should be initial request set - denied tags
+        ArraySet<String> allowedTags = new ArraySet<>(request.getFeatureTags());
+        allowedTags.removeAll(deniedTags.stream().map(FeatureTagState::getFeatureTag)
+                .collect(Collectors.toSet()));
+        verify(mMockMessageTracker).openTransport(mMockSipDelegate, allowedTags, deniedTags);
+        verify(mMockDelegateStateTracker).sipDelegateConnected(deniedTags);
+    }
+
+    @SmallTest
+    @Test
     public void testCreateDelegateTransportDied() throws Exception {
         DelegateRequest request = getBaseDelegateRequest();
         SipDelegateController controller = getTestDelegateController(request,
@@ -212,7 +245,9 @@
         consumer.accept(mMockSipDelegate, Collections.emptySet());
         assertTrue(pendingChange.get());
 
-        verify(mMockMessageTracker, times(2)).openTransport(mMockSipDelegate,
+        verify(mMockMessageTracker).openTransport(mMockSipDelegate, request.getFeatureTags(),
+                Collections.emptySet());
+        verify(mMockMessageTracker).openTransport(mMockSipDelegate, newFts,
                 Collections.emptySet());
         verify(mMockDelegateStateTracker, times(2)).sipDelegateConnected(Collections.emptySet());
     }
@@ -235,10 +270,22 @@
         return request;
     }
 
+    private ArraySet<String> getLargeFTSet() {
+        ArraySet<String> request = new ArraySet<>();
+        request.add(ImsSignallingUtils.ONE_TO_ONE_CHAT_TAG);
+        request.add(ImsSignallingUtils.GROUP_CHAT_TAG);
+        request.add(ImsSignallingUtils.FILE_TRANSFER_HTTP_TAG);
+        return request;
+    }
+
     private DelegateRequest getBaseDelegateRequest() {
         return new DelegateRequest(getBaseFTSet());
     }
 
+    private DelegateRequest getLargeDelegateRequest() {
+        return new DelegateRequest(getLargeFTSet());
+    }
+
     private SipDelegateController getTestDelegateController(DelegateRequest request,
             Set<FeatureTagState> deniedSet) {
         return new SipDelegateController(TEST_SUB_ID, request, "", mExecutorService,
diff --git a/tests/src/com/android/services/telephony/rcs/SipDialogTest.java b/tests/src/com/android/services/telephony/rcs/SipDialogTest.java
new file mode 100644
index 0000000..17830c5
--- /dev/null
+++ b/tests/src/com/android/services/telephony/rcs/SipDialogTest.java
@@ -0,0 +1,209 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertNotNull;
+import static junit.framework.Assert.assertNull;
+import static junit.framework.Assert.assertTrue;
+
+import android.telephony.ims.SipMessage;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SipDialogTest {
+
+    private static final String SIP_URI_ALICE = "sip:alice@client.example.com";
+    private static final String BASE_CONTACT_URI_ALICE = "Alice <" + SIP_URI_ALICE + ">";
+    private static final String SIP_URI_BOB = "sip:bob@client.example.com";
+    private static final String BASE_CONTACT_URI_BOB = "Bob <" + SIP_URI_BOB + ">";
+
+    @Test
+    public void testCreateEarlyDialog() {
+        String branchId = "testBranchId";
+        String fromTag = "abcd";
+        String callId = "testCallId";
+        SipMessage inviteRequest = SipMessageUtils.generateSipRequest(
+                SipMessageUtils.INVITE_SIP_METHOD,
+                BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB, SIP_URI_BOB, branchId, callId,
+                fromTag, null /*toTag*/);
+        SipDialog dialog = SipDialog.fromSipMessage(inviteRequest);
+        assertNotNull(dialog);
+        assertEquals(SipDialog.STATE_EARLY, dialog.getState());
+        assertEquals(callId, dialog.getCallId());
+        assertNull(dialog.getToTag());
+        // receive an earlyResponse
+        String toTag = "testToTag";
+        dialog.earlyResponse(toTag);
+        assertEquals(toTag, dialog.getToTag());
+    }
+
+    @Test
+    public void testIsResponseAssociated() {
+        String branchId = "testBranchId";
+        String fromTag = "abcd";
+        String callId = "testCallId";
+        SipMessage inviteRequest = SipMessageUtils.generateSipRequest(
+                SipMessageUtils.INVITE_SIP_METHOD,
+                BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB, SIP_URI_BOB, branchId, callId,
+                fromTag, null /*toTag*/);
+        SipDialog dialog = SipDialog.fromSipMessage(inviteRequest);
+        assertNotNull(dialog);
+
+        // A response with no to tag should match
+        SipMessage inviteTrying = SipMessageUtils.generateSipResponse("100", "Trying",
+                BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB, branchId, callId, fromTag,
+                null /*toTag*/);
+        assertTrue(dialog.isResponseAssociatedWithDialog(inviteTrying));
+        // A response with a different to tag should match
+        inviteTrying = SipMessageUtils.generateSipResponse("100", "Trying",
+                BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB, branchId, callId, fromTag,
+                "testToTag");
+        assertTrue(dialog.isResponseAssociatedWithDialog(inviteTrying));
+        // A response with a different from tag shouldn't match.
+        String fromTag2 = "testFromTag2";
+        inviteTrying = SipMessageUtils.generateSipResponse("100", "Trying",
+                BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB, branchId, callId, fromTag2,
+                null /*toTag*/);
+        assertFalse(dialog.isResponseAssociatedWithDialog(inviteTrying));
+        // A response with a different branch ID shouldn't match.
+        String branchId2 = "testBranchId2";
+        inviteTrying = SipMessageUtils.generateSipResponse("100", "Trying",
+                BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB, branchId2, callId, fromTag,
+                null /*toTag*/);
+        assertFalse(dialog.isResponseAssociatedWithDialog(inviteTrying));
+        // A response with a different call id shouldn't match.
+        String callId2 = "testCallId2";
+        inviteTrying = SipMessageUtils.generateSipResponse("100", "Trying",
+                BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB, branchId, callId2, fromTag,
+                null /*toTag*/);
+        assertFalse(dialog.isResponseAssociatedWithDialog(inviteTrying));
+    }
+
+    @Test
+    public void testFork() {
+        String branchId = "testBranchId";
+        String fromTag = "abcd";
+        String callId = "testCallId";
+        SipMessage inviteRequest = SipMessageUtils.generateSipRequest(
+                SipMessageUtils.INVITE_SIP_METHOD,
+                BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB, SIP_URI_BOB, branchId, callId,
+                fromTag, null /*toTag*/);
+        SipDialog dialog = SipDialog.fromSipMessage(inviteRequest);
+        assertNotNull(dialog);
+        assertEquals(SipDialog.STATE_EARLY, dialog.getState());
+        assertEquals(callId, dialog.getCallId());
+        // receive an earlyResponse
+        dialog.earlyResponse("testToTag");
+        assertEquals(SipDialog.STATE_EARLY, dialog.getState());
+        // fork dialog
+        SipDialog dialogFork = dialog.forkDialog();
+        assertEquals(SipDialog.STATE_EARLY, dialogFork.getState());
+        assertEquals(callId, dialogFork.getCallId());
+        assertNull(dialogFork.getToTag());
+    }
+
+    @Test
+    public void testConfirmDialog() {
+        String branchId = "testBranchId";
+        String fromTag = "abcd";
+        String callId = "testCallId";
+        SipMessage inviteRequest = SipMessageUtils.generateSipRequest(
+                SipMessageUtils.INVITE_SIP_METHOD,
+                BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB, SIP_URI_BOB, branchId, callId,
+                fromTag, null /*toTag*/);
+        SipDialog dialog = SipDialog.fromSipMessage(inviteRequest);
+        assertNotNull(dialog);
+        assertEquals(SipDialog.STATE_EARLY, dialog.getState());
+        assertEquals(callId, dialog.getCallId());
+        // receive a confirm response
+        dialog.confirm("testToTag");
+        assertEquals(SipDialog.STATE_CONFIRMED, dialog.getState());
+        assertEquals(callId, dialog.getCallId());
+    }
+
+    @Test
+    public void testIsRequestAssociated() {
+        String branchId = "testBranchId";
+        String fromTag = "testFromTag";
+        String callId = "testCallId";
+        String toTag = "testToTag";
+        SipMessage inviteRequest = SipMessageUtils.generateSipRequest(
+                SipMessageUtils.INVITE_SIP_METHOD,
+                BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB, SIP_URI_BOB, branchId, callId,
+                fromTag, null /*toTag*/);
+        SipDialog dialog = SipDialog.fromSipMessage(inviteRequest);
+        assertNotNull(dialog);
+        dialog.earlyResponse(toTag);
+
+        SipMessage cancelRequest = SipMessageUtils.generateSipRequest(
+                SipMessageUtils.CANCEL_SIP_METHOD, BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB,
+                SIP_URI_BOB, branchId, callId, fromTag, toTag);
+        assertTrue(dialog.isRequestAssociatedWithDialog(cancelRequest));
+        // cancel request with no toTag should fail
+        cancelRequest = SipMessageUtils.generateSipRequest(
+                SipMessageUtils.INVITE_SIP_METHOD, BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB,
+                SIP_URI_BOB, branchId, callId, fromTag, null /*toTag*/);
+        assertFalse(dialog.isRequestAssociatedWithDialog(cancelRequest));
+        // cancel request to a different dialog in the same session should fail
+        String toTag2 = "testToTag2";
+        cancelRequest = SipMessageUtils.generateSipRequest(
+                SipMessageUtils.INVITE_SIP_METHOD, BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB,
+                SIP_URI_BOB, branchId, callId, fromTag, toTag2);
+        assertFalse(dialog.isRequestAssociatedWithDialog(cancelRequest));
+        // cancel request to a different session should fail (even with the same from/to)
+        String callId2 = "testCallId2";
+        cancelRequest = SipMessageUtils.generateSipRequest(
+                SipMessageUtils.INVITE_SIP_METHOD, BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB,
+                SIP_URI_BOB, branchId, callId2, fromTag, toTag);
+        assertFalse(dialog.isRequestAssociatedWithDialog(cancelRequest));
+        // Same call id but different from and to (although not really possible) should fail.
+        String fromTag3 = "testFromTag3";
+        String toTag3 = "testToTag3";
+        cancelRequest = SipMessageUtils.generateSipRequest(
+                SipMessageUtils.INVITE_SIP_METHOD, BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB,
+                SIP_URI_BOB, branchId, callId, fromTag3, toTag3);
+        assertFalse(dialog.isRequestAssociatedWithDialog(cancelRequest));
+    }
+
+    @Test
+    public void testCloseDialog() {
+        String branchId = "testBranchId";
+        String fromTag = "abcd";
+        String callId = "testCallId";
+        SipMessage inviteRequest = SipMessageUtils.generateSipRequest(
+                SipMessageUtils.INVITE_SIP_METHOD,
+                BASE_CONTACT_URI_ALICE, BASE_CONTACT_URI_BOB, SIP_URI_BOB, branchId, callId,
+                fromTag, null /*toTag*/);
+        SipDialog dialog = SipDialog.fromSipMessage(inviteRequest);
+        assertNotNull(dialog);
+        assertEquals(SipDialog.STATE_EARLY, dialog.getState());
+        assertEquals(callId, dialog.getCallId());
+
+        // receive a confirm response
+        dialog.confirm("testToTag");
+        assertEquals(SipDialog.STATE_CONFIRMED, dialog.getState());
+
+        dialog.close();
+        assertEquals(SipDialog.STATE_CLOSED, dialog.getState());
+    }
+}
diff --git a/tests/src/com/android/services/telephony/rcs/SipMessageParsingUtilsTest.java b/tests/src/com/android/services/telephony/rcs/SipMessageParsingUtilsTest.java
new file mode 100644
index 0000000..ff96eb6
--- /dev/null
+++ b/tests/src/com/android/services/telephony/rcs/SipMessageParsingUtilsTest.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertNotNull;
+import static junit.framework.Assert.assertTrue;
+
+import android.util.ArraySet;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.internal.telephony.SipMessageParsingUtils;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+import java.util.Set;
+
+@RunWith(AndroidJUnit4.class)
+public class SipMessageParsingUtilsTest {
+
+    @Test
+    public void testNoAcceptContactHeader() {
+        String header = "Via: SIP/2.0/UDP ex.place.com;branch=z9hG4bK776asdhds";
+        assertTrue(SipMessageParsingUtils.getAcceptContactFeatureTags(header).isEmpty());
+    }
+
+    @Test
+    public void testAcceptContactHeaderMultipleValues() {
+        Set<String> testFeatures = new ArraySet<>(2);
+        // "+a="b,c" should be split into +a="b" and +a="c"
+        testFeatures.add("+a=\"b,c\"");
+        testFeatures.add("+d");
+        testFeatures.add("+e=\"f\"");
+        // These non-feature tags should be filtered out.
+        testFeatures.add("video");
+        testFeatures.add("blah=BLAH");
+        String header = "Via: SIP/2.0/UDP ex.place.com;branch=z9hG4bK776asdhds";
+        header = addFeatures(header, testFeatures);
+        Set<String> features = SipMessageParsingUtils.getAcceptContactFeatureTags(header);
+        assertNotNull(features);
+        assertEquals(4, features.size());
+        assertTrue(features.containsAll(Arrays.asList("+a=\"b\"", "+a=\"c\"", "+d", "+e=\"f\"")));
+    }
+
+    private String addFeatures(String headers, Set<String> features) {
+        StringBuilder newHeader = new StringBuilder(headers + "\n");
+        newHeader.append("Accept-Contact:*");
+        for (String feature : features) {
+            newHeader.append(";").append(feature);
+        }
+        return newHeader.toString();
+    }
+}
diff --git a/tests/src/com/android/services/telephony/rcs/SipMessageUtils.java b/tests/src/com/android/services/telephony/rcs/SipMessageUtils.java
new file mode 100644
index 0000000..8ad0f2a
--- /dev/null
+++ b/tests/src/com/android/services/telephony/rcs/SipMessageUtils.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs;
+
+import android.telephony.ims.SipMessage;
+
+public class SipMessageUtils {
+
+    public static final String INVITE_SIP_METHOD = "INVITE";
+    public static final String CANCEL_SIP_METHOD = "CANCEL";
+    public static final String BYE_SIP_METHOD = "BYE";
+    public static final String ACK_SIP_METHOD = "ACK";
+    public static final String BASE_ADDRESS = "client.example.com";
+
+    private static final String PARAM_SEPARATOR = ";";
+    private static final String PARAM_KEY_VALUE_SEPARATOR = "=";
+    private static final String BASE_VIA_HEADER_VALUE = "SIP/2.0/TCP " + BASE_ADDRESS + ":5060";
+
+    /**
+     * @return A new SIP request from the given parameters.
+     */
+    public static SipMessage generateSipRequest(String requestMethod, String fromContact,
+            String toContact, String toUri, String branchId, String callId, String fromTag,
+            String toTag) {
+        String header = "Via: " + addParamToHeader(BASE_VIA_HEADER_VALUE, "branch", branchId);
+        header += "\n";
+        header += "From: " + addParamToHeader(fromContact, "tag", fromTag);
+        header += "\n";
+        // To tag is optional
+        header += "To: " + ((toTag != null)
+                ? addParamToHeader(toContact, "tag", toTag) : toContact);
+        header += "\n";
+        header += "Call-ID: " + callId;
+        return new SipMessage(requestMethod + " " + toUri + " SIP/2.0", header, new byte[0]);
+    }
+
+    /**
+     * @return Generates a SIP response.
+     */
+    public static SipMessage generateSipResponse(String statusCode, String statusString,
+            String fromContact, String toContact, String branchId, String callId, String fromTag,
+            String toTag) {
+        String header = "Via: " + addParamToHeader(BASE_VIA_HEADER_VALUE, "branch", branchId);
+        header += "\n";
+        header += "From: " + addParamToHeader(fromContact, "tag", fromTag);
+        header += "\n";
+        // To tag is optional
+        header += "To: " + ((toTag != null)
+                ? addParamToHeader(toContact, "tag", toTag) : toContact);
+        header += "\n";
+        header += "Call-ID: " + callId;
+        return new SipMessage("SIP/2.0 " + statusCode + " " + statusString, header,
+                new byte[0]);
+    }
+
+    private static String addParamToHeader(String headerValue, String paramKey, String paramValue) {
+        headerValue += PARAM_SEPARATOR + paramKey.trim() + PARAM_KEY_VALUE_SEPARATOR
+                + paramValue.trim();
+        return headerValue;
+    }
+}
diff --git a/tests/src/com/android/services/telephony/rcs/SipSessionTrackerTest.java b/tests/src/com/android/services/telephony/rcs/SipSessionTrackerTest.java
new file mode 100644
index 0000000..823a8be
--- /dev/null
+++ b/tests/src/com/android/services/telephony/rcs/SipSessionTrackerTest.java
@@ -0,0 +1,377 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertNotNull;
+import static junit.framework.Assert.assertTrue;
+
+import android.net.Uri;
+import android.telephony.ims.SipMessage;
+import android.util.Base64;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@RunWith(AndroidJUnit4.class)
+public class SipSessionTrackerTest {
+
+    private class DialogAttributes {
+        public final String branchId;
+        public final String callId;
+        public final String fromHeader;
+        public final String fromTag;
+        public final String toUri;
+        public final String toHeader;
+        private final String mFromUri;
+        // This may be populated later.
+        public String toTag;
+
+        DialogAttributes() {
+            branchId = getNextString();
+            callId = getNextString();
+            mFromUri = generateRandomSipUri();
+            fromHeader = generateContactUri(mFromUri);
+            fromTag = getNextString();
+            toUri = generateRandomSipUri();
+            toHeader = generateContactUri(toUri);
+        }
+
+        private DialogAttributes(String branchId, String callId, String fromUri,
+                String fromTag, String toUri, String toTag) {
+            this.branchId = branchId;
+            this.callId = callId;
+            this.mFromUri = fromUri;
+            this.fromHeader = generateContactUri(fromUri);
+            this.fromTag = fromTag;
+            this.toUri = toUri;
+            this.toHeader = generateContactUri(toUri);
+            this.toTag = toTag;
+        }
+
+        public void setToTag() {
+            if (toTag == null) {
+                toTag = getNextString();
+            }
+        }
+
+        public DialogAttributes fromExisting() {
+            return new DialogAttributes(branchId, callId, mFromUri, fromTag, toUri, null);
+        }
+
+        public DialogAttributes invertFromTo() {
+            return new DialogAttributes(branchId, callId, toUri, fromTag, mFromUri, toTag);
+        }
+    }
+
+    // Keep track of the string entry so we can generate unique strings.
+    private int mStringEntryCounter = 0;
+    private SipSessionTracker mTrackerUT;
+
+    @Before
+    public void setUp() {
+        mStringEntryCounter = 0;
+        mTrackerUT = new SipSessionTracker();
+    }
+
+    @Test
+    public void testEarlyDialogToConfirmed() {
+        DialogAttributes attr = new DialogAttributes();
+        // INVITE A -> B
+        SipMessage inviteRequest = generateSipRequest(SipMessageUtils.INVITE_SIP_METHOD, attr);
+        filterMessage(inviteRequest, attr);
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getEarlyDialogs(), attr);
+        // 100 TRYING A <- proxy
+        SipMessage inviteTrying = generateSipResponse("100", "Trying", attr);
+        filterMessage(inviteTrying, attr);
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getEarlyDialogs(), attr);
+        // INVITE proxy -> B
+        // (BOB generates To tag)
+        attr.setToTag();
+        // 180 RINGING proxy <- B
+        // 180 RINGING A <- proxy
+        SipMessage inviteRinging = generateSipResponse("180", "Ringing", attr);
+        filterMessage(inviteRinging, attr);
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getEarlyDialogs(), attr);
+        // User answers phone
+        // 200 OK proxy <- B
+        // 200 OK A <- proxy
+        SipMessage inviteConfirm = generateSipResponse("200", "OK", attr);
+        filterMessage(inviteConfirm, attr);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getConfirmedDialogs(), attr);
+    }
+
+    @Test
+    public void testForkDialog() {
+        DialogAttributes attrB1 = new DialogAttributes();
+        // INVITE A -> B
+        SipMessage inviteRequest = generateSipRequest(SipMessageUtils.INVITE_SIP_METHOD, attrB1);
+        filterMessage(inviteRequest, attrB1);
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getEarlyDialogs(), attrB1);
+        // INVITE proxy -> B
+        // (BOB generates To tag)
+        attrB1.setToTag();
+        // 180 RINGING proxy <- B1
+        // 180 RINGING A <- proxy
+        SipMessage inviteRingingB1 = generateSipResponse("180", "Ringing", attrB1);
+        filterMessage(inviteRingingB1, attrB1);
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getEarlyDialogs(), attrB1);
+        // Now get another RINGING indication from another device associated with the same user.
+        // 180 RINGING proxy <- B2
+        // 180 RINGING A <- proxy
+        DialogAttributes attrB2 = attrB1.fromExisting();
+        // set different To tag
+        attrB2.setToTag();
+        SipMessage inviteRingingB2 = generateSipResponse("180", "Ringing", attrB2);
+        filterMessage(inviteRingingB2, attrB2);
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getEarlyDialogs(), attrB1, attrB2);
+        // User answers B1
+        // 200 OK proxy <- B1
+        // 200 OK A <- proxy
+        SipMessage inviteConfirm = generateSipResponse("200", "OK", attrB1);
+        filterMessage(inviteConfirm, attrB1);
+        verifyContainsCallIds(mTrackerUT.getEarlyDialogs(), attrB2);
+        verifyContainsCallIds(mTrackerUT.getConfirmedDialogs(), attrB1);
+        // Receive indication that B2 is terminated because user answered on B1
+        // 487 A <- proxy
+        SipMessage terminatedResponse = generateSipResponse("487",
+                "Request Terminated", attrB2);
+        filterMessage(terminatedResponse, attrB2);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getConfirmedDialogs(), attrB1);
+        verifyContainsCallIds(mTrackerUT.getClosedDialogs(), attrB2);
+        SipMessage byeRequest = generateSipRequest(SipMessageUtils.BYE_SIP_METHOD, attrB1);
+        // Send BYE request for the open dialog.
+        filterMessage(byeRequest, attrB1);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getClosedDialogs(), attrB1, attrB2);
+        // Clean up the session and ensure the close dialog is completely removed from the tracker.
+        mTrackerUT.cleanupSession(attrB1.callId);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        assertTrue(mTrackerUT.getClosedDialogs().isEmpty());
+    }
+
+    @Test
+    public void testCloseLocalDialog() {
+        DialogAttributes attr = new DialogAttributes();
+        attr.setToTag();
+        createConfirmedDialog(attr);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getConfirmedDialogs(), attr);
+
+        // Send BYE request for a dialog that was started locally and ensure that we see the call id
+        // move to the closed list.
+        SipMessage byeRequest = generateSipRequest(SipMessageUtils.BYE_SIP_METHOD, attr);
+        filterMessage(byeRequest, attr);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getClosedDialogs(), attr);
+        // Clean up the session and ensure the close dialog is completely removed from the tracker.
+        mTrackerUT.cleanupSession(attr.callId);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        assertTrue(mTrackerUT.getClosedDialogs().isEmpty());
+    }
+
+    @Test
+    public void testAcceptContactFts() {
+        DialogAttributes attr = new DialogAttributes();
+        attr.setToTag();
+        SipMessage inviteRequest = generateSipRequest(
+                SipMessageUtils.INVITE_SIP_METHOD,
+                attr);
+        // add accept contact header
+        inviteRequest = new SipMessage(inviteRequest.getStartLine(),
+                inviteRequest.getHeaderSection() + "\nAccept-Contact:*;+test",
+                new byte[0]);
+        filterMessage(inviteRequest, attr);
+        assertTrue(mTrackerUT.getCallIdsAssociatedWithFeatureTag(Collections.singleton("+test"))
+                .contains(attr.callId));
+    }
+
+    @Test
+    public void testCloseRemoteDialog() {
+        DialogAttributes remoteAttr = new DialogAttributes();
+        remoteAttr.setToTag();
+        createConfirmedDialog(remoteAttr);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getConfirmedDialogs(), remoteAttr);
+
+        // Send BYE request on a dialog that was started from the remote party.
+        DialogAttributes localAttr = remoteAttr.invertFromTo();
+        SipMessage byeRequest = generateSipRequest(SipMessageUtils.BYE_SIP_METHOD, localAttr);
+        filterMessage(byeRequest, localAttr);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getClosedDialogs(), remoteAttr);
+        // Clean up the session and ensure the dialog is completely removed from the tracker.
+        mTrackerUT.cleanupSession(remoteAttr.callId);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        assertTrue(mTrackerUT.getClosedDialogs().isEmpty());
+    }
+
+    @Test
+    public void testCleanupConfirmedDialog() {
+        DialogAttributes attr = new DialogAttributes();
+        attr.setToTag();
+        createConfirmedDialog(attr);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getConfirmedDialogs(), attr);
+        // Clean up the session and ensure the dialog is completely removed from the tracker.
+        mTrackerUT.cleanupSession(attr.callId);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        assertTrue(mTrackerUT.getClosedDialogs().isEmpty());
+    }
+
+    @Test
+    public void testMultipleDialogs() {
+        DialogAttributes attr1 = new DialogAttributes();
+        createConfirmedDialog(attr1);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getConfirmedDialogs(), attr1);
+        // add a second dialog
+        DialogAttributes attr2 = new DialogAttributes();
+        createConfirmedDialog(attr2);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getConfirmedDialogs(), attr1, attr2);
+        // Send BYE request on dialogs
+        SipMessage byeRequest = generateSipRequest(SipMessageUtils.BYE_SIP_METHOD, attr1);
+        filterMessage(byeRequest, attr1);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getConfirmedDialogs(), attr2);
+        verifyContainsCallIds(mTrackerUT.getClosedDialogs(), attr1);
+        mTrackerUT.cleanupSession(attr1.callId);
+        // Send BYE request on dialogs
+        byeRequest = generateSipRequest(SipMessageUtils.BYE_SIP_METHOD, attr2);
+        filterMessage(byeRequest, attr2);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getClosedDialogs(), attr2);
+        mTrackerUT.cleanupSession(attr2.callId);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        assertTrue(mTrackerUT.getClosedDialogs().isEmpty());
+    }
+
+    @Test
+    public void testAcknowledgeMessageFailed() {
+        DialogAttributes attr = new DialogAttributes();
+        SipMessage inviteRequest = generateSipRequest(SipMessageUtils.INVITE_SIP_METHOD, attr);
+        mTrackerUT.filterSipMessage(inviteRequest);
+        // Do not acknowledge the request and ensure that the operation has not been applied yet.
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        // send message ack failed event, the operation shouldn't have been applied
+        mTrackerUT.pendingMessageFailed(attr.branchId);
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+    }
+
+    @Test
+    public void testAcknowledgeBatchEvents() {
+        DialogAttributes attr = new DialogAttributes();
+        SipMessage inviteRequest = generateSipRequest(SipMessageUtils.INVITE_SIP_METHOD, attr);
+        attr.setToTag();
+        SipMessage inviteConfirm = generateSipResponse("200", "OK", attr);
+        // We unexpectedly received two filter requests for the same branchId without
+        // acknowledgePendingMessage being called in between. Ensure that when it is called, it
+        // applies both operations.
+        mTrackerUT.filterSipMessage(inviteRequest);
+        mTrackerUT.filterSipMessage(inviteConfirm);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        assertTrue(mTrackerUT.getConfirmedDialogs().isEmpty());
+        // we should skip right to confirmed as both operations run back-to-back
+        mTrackerUT.acknowledgePendingMessage(attr.branchId);
+        assertTrue(mTrackerUT.getEarlyDialogs().isEmpty());
+        verifyContainsCallIds(mTrackerUT.getConfirmedDialogs(), attr);
+    }
+
+    private void filterMessage(SipMessage m, DialogAttributes attr) {
+        mTrackerUT.filterSipMessage(m);
+        mTrackerUT.acknowledgePendingMessage(attr.branchId);
+    }
+    private void verifyContainsCallIds(Set<SipDialog> callIdSet, DialogAttributes... attrs) {
+        Set<String> callIds = Arrays.stream(attrs).map(a -> a.callId).collect(
+                Collectors.toSet());
+        assertTrue(callIdSet.stream().map(SipDialog::getCallId).collect(Collectors.toSet())
+                .containsAll(callIds));
+    }
+
+    private SipMessage generateSipRequest(String requestMethod,
+            DialogAttributes attr) {
+        return SipMessageUtils.generateSipRequest(requestMethod, attr.fromHeader, attr.toHeader,
+                attr.toUri, attr.branchId, attr.callId, attr.fromTag, attr.toTag);
+    }
+    private SipMessage generateSipResponse(String statusCode, String statusString,
+            DialogAttributes attr) {
+        return SipMessageUtils.generateSipResponse(statusCode, statusString, attr.fromHeader,
+                attr.toHeader, attr.branchId, attr.callId, attr.fromTag, attr.toTag);
+    }
+
+    private String generateContactUri(String sipUri) {
+        Uri uri = Uri.parse(sipUri);
+        assertNotNull(uri);
+        String[] user = uri.getSchemeSpecificPart().split("@", 2);
+        assertNotNull(user);
+        assertEquals(2, user.length);
+        return user[0] + " <" + sipUri + ">";
+    }
+
+    private String generateRandomSipUri() {
+        return "sip:" + getNextString() + "@" + SipMessageUtils.BASE_ADDRESS;
+    }
+
+    private void createConfirmedDialog(DialogAttributes attr) {
+        // INVITE ALICE -> BOB
+        SipMessage inviteRequest = generateSipRequest(
+                SipMessageUtils.INVITE_SIP_METHOD,
+                attr);
+        filterMessage(inviteRequest, attr);
+        attr.setToTag();
+        // skip to confirmed state for test.
+        SipMessage inviteConfirm = generateSipResponse("200", "OK",
+                attr);
+        filterMessage(inviteConfirm, attr);
+    }
+
+    private String getNextString() {
+        // Get a string representation of the entry counter
+        byte[] idByteArray = ByteBuffer.allocate(4).putInt(mStringEntryCounter++).array();
+        return Base64.encodeToString(idByteArray,
+                Base64.NO_WRAP | Base64.NO_PADDING | Base64.URL_SAFE);
+    }
+}
diff --git a/tests/src/com/android/services/telephony/rcs/TransportSipMessageValidatorTest.java b/tests/src/com/android/services/telephony/rcs/TransportSipMessageValidatorTest.java
new file mode 100644
index 0000000..4d222e3
--- /dev/null
+++ b/tests/src/com/android/services/telephony/rcs/TransportSipMessageValidatorTest.java
@@ -0,0 +1,487 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.net.InetAddresses;
+import android.telephony.ims.DelegateRegistrationState;
+import android.telephony.ims.SipDelegateConfiguration;
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+import android.util.ArraySet;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.TelephonyTestBase;
+import com.android.TestExecutorService;
+import com.android.services.telephony.rcs.validator.IncomingTransportStateValidator;
+import com.android.services.telephony.rcs.validator.OutgoingTransportStateValidator;
+import com.android.services.telephony.rcs.validator.ValidationResult;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+
+import java.net.InetSocketAddress;
+import java.util.Collections;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ScheduledExecutorService;
+
+@RunWith(AndroidJUnit4.class)
+public class TransportSipMessageValidatorTest extends TelephonyTestBase {
+    private static final int TEST_SUB_ID = 1;
+    private static final int TEST_CONFIG_VERSION = 1;
+    private static final SipMessage TEST_MESSAGE = new SipMessage(
+            "INVITE sip:bob@biloxi.com SIP/2.0",
+            // Typical Via
+            "Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds\n"
+                    + "Max-Forwards: 70\n"
+                    + "To: Bob <sip:bob@biloxi.com>\n"
+                    + "From: Alice <sip:alice@atlanta.com>;tag=1928301774\n"
+                    + "Call-ID: testid\n"
+                    + "CSeq: 314159 INVITE\n"
+                    + "Contact: <sip:alice@pc33.atlanta.com>\n"
+                    + "Content-Type: application/sdp\n"
+                    + "Content-Length: 142",
+            new byte[0]);
+
+    @Mock
+    private SipSessionTracker mSipSessionTracker;
+    @Mock
+    private IncomingTransportStateValidator mIncomingStateValidator;
+    @Mock
+    private OutgoingTransportStateValidator mOutgoingStateValidator;
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        super.tearDown();
+    }
+
+    @Test
+    public void testTransportOpening() {
+        TestExecutorService executor = new TestExecutorService();
+        TransportSipMessageValidator tracker = getTestTracker(executor);
+        tracker.onTransportOpened(Collections.emptySet(), Collections.emptySet());
+        verify(mOutgoingStateValidator).open(Collections.emptySet(), Collections.emptySet());
+        verify(mIncomingStateValidator).open();
+        // Incoming messages are already verified
+        assertTrue(isIncomingTransportOpen(tracker));
+        // IMS config needs to be sent before outgoing messages can be verified.
+        assertFalse(isOutgoingTransportOpen(tracker));
+        tracker.onConfigurationChanged(getConfigBuilder(TEST_CONFIG_VERSION).build());
+        // Incoming messages are already verified
+        // Config set, transport is now open.
+        assertTrue(isIncomingTransportOpen(tracker));
+        assertTrue(isOutgoingTransportOpen(tracker));
+    }
+
+    @Test
+    public void testTransportOpenConfigChange() {
+        TestExecutorService executor = new TestExecutorService();
+        TransportSipMessageValidator tracker = openTransport(executor);
+
+        // Update IMS config version and send a message with an outdated version.
+        tracker.onConfigurationChanged(getConfigBuilder(TEST_CONFIG_VERSION + 1).build());
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_STALE_IMS_CONFIGURATION,
+                verifyOutgoingTransportClosed(tracker));
+    }
+
+    @Test
+    public void testSessionTrackerFiltering() {
+        TestExecutorService executor = new TestExecutorService();
+        TransportSipMessageValidator tracker = openTransport(executor);
+        // Since the incoming/outgoing messages were verified, there should have been two calls
+        // to filter the message.
+        verify(mSipSessionTracker, times(2)).filterSipMessage(TEST_MESSAGE);
+        // ensure pass through methods are working
+        tracker.acknowledgePendingMessage("abc");
+        verify(mSipSessionTracker).acknowledgePendingMessage("abc");
+        tracker.notifyPendingMessageFailed("abc");
+        verify(mSipSessionTracker).pendingMessageFailed("abc");
+        tracker.onSipSessionCleanup("abc");
+        verify(mSipSessionTracker).cleanupSession("abc");
+        // Now have validators return a non-successful result for validation and the tracker should
+        // not get the indication to filter the message.
+        doReturn(new ValidationResult(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED,
+                "")).when(mOutgoingStateValidator).validate(any());
+        doReturn(new ValidationResult(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED,
+                "")).when(mIncomingStateValidator).validate(any());
+        assertFalse(tracker.verifyIncomingMessage(TEST_MESSAGE).isValidated);
+        assertFalse(tracker.verifyOutgoingMessage(TEST_MESSAGE, TEST_CONFIG_VERSION).isValidated);
+        // The number of times the filter method was called should still only be two after these
+        // messages were not validated.
+        verify(mSipSessionTracker, times(2)).filterSipMessage(TEST_MESSAGE);
+    }
+
+
+    @Test
+    public void testTransportClosingGracefullyNoPendingSessions() {
+        TestExecutorService executor = new TestExecutorService(true /*wait*/);
+        TransportSipMessageValidator tracker = openTransport(executor);
+
+        doReturn(Collections.emptySet()).when(mSipSessionTracker).getTrackedDialogs();
+        ArraySet<String> pendingCallIds = new ArraySet<>();
+        CountDownLatch l = new CountDownLatch(1);
+        tracker.closeSessionsGracefully((ids) -> {
+            pendingCallIds.addAll(ids);
+            l.countDown();
+        }, SipDelegateManager.MESSAGE_FAILURE_REASON_INTERNAL_DELEGATE_STATE_TRANSITION,
+                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED);
+        // Since there are no pending call ids, this should be completed with no call ids pending.
+        assertEquals(0, l.getCount());
+        assertTrue(pendingCallIds.isEmpty());
+        verify(mOutgoingStateValidator).close(anyInt());
+        verify(mIncomingStateValidator).close(anyInt());
+    }
+
+    @Test
+    public void testTransportClosingGracefullyCloseCallIds() {
+        TestExecutorService executor = new TestExecutorService(true /*wait*/);
+        TransportSipMessageValidator tracker = openTransport(executor);
+
+        ArraySet<SipDialog> dialogs = new ArraySet<>();
+        dialogs.add(SipDialog.fromSipMessage(TEST_MESSAGE));
+        doReturn(dialogs).when(mSipSessionTracker).getTrackedDialogs();
+        ArraySet<String> pendingCallIds = new ArraySet<>();
+        CountDownLatch l = new CountDownLatch(1);
+        tracker.closeSessionsGracefully((ids) -> {
+            pendingCallIds.addAll(ids);
+            l.countDown();
+        }, SipDelegateManager.MESSAGE_FAILURE_REASON_INTERNAL_DELEGATE_STATE_TRANSITION,
+                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED);
+        // Before executor executes, outgoing messages will be restricted due to pending call ids.
+        assertTrue(l.getCount() >= 1);
+        assertTrue(pendingCallIds.isEmpty());
+        assertTrue(isIncomingTransportOpen(tracker));
+        verify(mOutgoingStateValidator).restrict(anyInt());
+        // pretend a sip message has been acknowledged, which closed pending call id. Since there
+        // are no more pending call ids, the transport should move to closed.
+        dialogs.clear();
+        tracker.acknowledgePendingMessage("blah");
+        assertEquals(0, l.getCount());
+        assertTrue(pendingCallIds.isEmpty());
+        verify(mOutgoingStateValidator).close(anyInt());
+        verify(mIncomingStateValidator).close(anyInt());
+    }
+
+    @Test
+    public void testTransportClosingGracefullyThenForceClose() {
+        TestExecutorService executor = new TestExecutorService(true /*wait*/);
+        TransportSipMessageValidator tracker = openTransport(executor);
+
+        ArraySet<SipDialog> dialogs = new ArraySet<>();
+        dialogs.add(SipDialog.fromSipMessage(TEST_MESSAGE));
+        doReturn(dialogs).when(mSipSessionTracker).getTrackedDialogs();
+        ArraySet<String> pendingCallIds = new ArraySet<>();
+        CountDownLatch l = new CountDownLatch(1);
+        tracker.closeSessionsGracefully((ids) -> {
+            pendingCallIds.addAll(ids);
+            l.countDown();
+        }, SipDelegateManager.MESSAGE_FAILURE_REASON_INTERNAL_DELEGATE_STATE_TRANSITION,
+                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED);
+        // Before executor executes, outgoing messages will be restricted due to pending call ids.
+        assertTrue(l.getCount() >= 1);
+        assertTrue(pendingCallIds.isEmpty());
+        assertTrue(isIncomingTransportOpen(tracker));
+        verify(mOutgoingStateValidator).restrict(anyInt());
+        // force close amd ensure pending close is
+        assertTrue(tracker.closeSessions(
+                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED).isEmpty());
+        assertEquals(0, l.getCount());
+        assertEquals(pendingCallIds, pendingCallIds);
+        verify(mOutgoingStateValidator).close(anyInt());
+        verify(mIncomingStateValidator).close(anyInt());
+    }
+
+    @Test
+    public void testTransportClosingGracefullyTimeout() {
+        TestExecutorService executor = new TestExecutorService(true /*wait*/);
+        TransportSipMessageValidator tracker = openTransport(executor);
+
+        ArraySet<SipDialog> dialogs = new ArraySet<>();
+        dialogs.add(SipDialog.fromSipMessage(TEST_MESSAGE));
+        doReturn(dialogs).when(mSipSessionTracker).getTrackedDialogs();
+        ArraySet<String> pendingCallIds = new ArraySet<>();
+        CountDownLatch l = new CountDownLatch(1);
+        tracker.closeSessionsGracefully((ids) -> {
+            pendingCallIds.addAll(ids);
+            l.countDown();
+        }, SipDelegateManager.MESSAGE_FAILURE_REASON_INTERNAL_DELEGATE_STATE_TRANSITION,
+                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED);
+        // Before executor executes, outgoing messages will be restricted due to pending call ids.
+        assertTrue(l.getCount() >= 1);
+        assertTrue(pendingCallIds.isEmpty());
+        assertTrue(isIncomingTransportOpen(tracker));
+        verify(mOutgoingStateValidator).restrict(anyInt());
+        // Process timeout event - pending call id should be passed to transport.
+        executor.executePending();
+        assertEquals(0, l.getCount());
+        assertTrue(pendingCallIds.contains("testid"));
+        verify(mOutgoingStateValidator).close(anyInt());
+        verify(mIncomingStateValidator).close(anyInt());
+    }
+
+    @Test
+    public void testTransportClosingGracefullyCleanup() {
+        TestExecutorService executor = new TestExecutorService(true /*wait*/);
+        TransportSipMessageValidator tracker = openTransport(executor);
+
+        ArraySet<SipDialog> dialogs = new ArraySet<>();
+        dialogs.add(SipDialog.fromSipMessage(TEST_MESSAGE));
+        doReturn(dialogs).when(mSipSessionTracker).getTrackedDialogs();
+        ArraySet<String> pendingCallIds = new ArraySet<>();
+        CountDownLatch l = new CountDownLatch(1);
+        tracker.closeSessionsGracefully((ids) -> {
+            pendingCallIds.addAll(ids);
+            l.countDown();
+        }, SipDelegateManager.MESSAGE_FAILURE_REASON_INTERNAL_DELEGATE_STATE_TRANSITION,
+                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED);
+        // Before executor executes, outgoing messages will be restricted due to pending call ids.
+        assertTrue(l.getCount() >= 1);
+        assertTrue(pendingCallIds.isEmpty());
+        assertTrue(isIncomingTransportOpen(tracker));
+        verify(mOutgoingStateValidator).restrict(anyInt());
+        // Mock cleanupSession event was called for pending callId
+        dialogs.clear();
+        tracker.onSipSessionCleanup("abc");
+        assertEquals(0, l.getCount());
+        assertTrue(pendingCallIds.isEmpty());
+        verify(mOutgoingStateValidator).close(anyInt());
+        verify(mIncomingStateValidator).close(anyInt());
+    }
+
+    @Test
+    public void testTransportClosingForcefully() {
+        TestExecutorService executor = new TestExecutorService();
+        TransportSipMessageValidator tracker = openTransport(executor);
+
+        tracker.closeSessions(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED);
+
+        // All messages will be rejected.
+        verify(mOutgoingStateValidator).close(anyInt());
+        verify(mIncomingStateValidator).close(anyInt());
+    }
+
+    @Test
+    public void setRegStateChangedNoPendingCallIds() {
+        TestExecutorService executor = new TestExecutorService(true /*wait*/);
+        TransportSipMessageValidator tracker = openTransport(executor);
+
+        doReturn(Collections.emptySet()).when(mSipSessionTracker)
+                .getCallIdsAssociatedWithFeatureTag(any());
+        ArraySet<String> pendingCallIds = new ArraySet<>();
+        CountDownLatch l = new CountDownLatch(1);
+        // no feature tags are deregistering/deregistered, should return immediately
+        tracker.onRegistrationStateChanged((ids) -> {
+            pendingCallIds.addAll(ids);
+            l.countDown();
+        }, getTestRegistrationState());
+        verify(mOutgoingStateValidator, times(2)).restrictFeatureTags(Collections.emptySet());
+        assertTrue(pendingCallIds.isEmpty());
+        assertEquals(0, l.getCount());
+    }
+
+    @Test
+    public void setRegStateChangedPendingCallIdsMultipleCalls() {
+        TestExecutorService executor = new TestExecutorService(true /*wait*/);
+        TransportSipMessageValidator tracker = openTransport(executor);
+
+        ArraySet<String> callIds = new ArraySet<>(1);
+        callIds.add("abc");
+        doReturn(callIds).when(mSipSessionTracker)
+                .getCallIdsAssociatedWithFeatureTag(any());
+        ArraySet<String> pendingCallIds = new ArraySet<>();
+        CountDownLatch l = new CountDownLatch(2);
+        tracker.onRegistrationStateChanged((ids) -> {
+            pendingCallIds.addAll(ids);
+            l.countDown();
+        }, getTestRegistrationState());
+        assertEquals(2, l.getCount());
+        // If called again, the previous request will complete with no call ids
+        tracker.onRegistrationStateChanged((ids) -> {
+            pendingCallIds.addAll(ids);
+            l.countDown();
+        }, getTestRegistrationState());
+        assertEquals(1, l.getCount());
+        assertTrue(pendingCallIds.isEmpty());
+        // Simulate timeout - we should get callback with the pending call ids.
+        executor.executePending();
+        assertEquals(0, l.getCount());
+        assertEquals(callIds, pendingCallIds);
+    }
+
+    @Test
+    public void setRegStateChangedPendingCallIdsTimeout() {
+        TestExecutorService executor = new TestExecutorService(true /*wait*/);
+        TransportSipMessageValidator tracker = openTransport(executor);
+
+        ArraySet<String> callIds = new ArraySet<>(1);
+        callIds.add("abc");
+        doReturn(callIds).when(mSipSessionTracker)
+                .getCallIdsAssociatedWithFeatureTag(any());
+        ArraySet<String> pendingCallIds = new ArraySet<>();
+        CountDownLatch l = new CountDownLatch(1);
+        tracker.onRegistrationStateChanged((ids) -> {
+            pendingCallIds.addAll(ids);
+            l.countDown();
+        }, getTestRegistrationState());
+        assertEquals(1, l.getCount());
+        assertTrue(pendingCallIds.isEmpty());
+        // Simulate timeout - we should get callback with the pending call ids.
+        executor.executePending();
+        assertEquals(0, l.getCount());
+        assertEquals(callIds, pendingCallIds);
+    }
+
+    @Test
+    public void setRegStateChangedPendingCallIdsResolved() {
+        TestExecutorService executor = new TestExecutorService(true /*wait*/);
+        TransportSipMessageValidator tracker = openTransport(executor);
+
+        ArraySet<String> callIds = new ArraySet<>(1);
+        callIds.add("abc");
+        doReturn(callIds).when(mSipSessionTracker)
+                .getCallIdsAssociatedWithFeatureTag(any());
+        ArraySet<String> pendingCallIds = new ArraySet<>();
+        CountDownLatch l = new CountDownLatch(1);
+        tracker.onRegistrationStateChanged((ids) -> {
+            pendingCallIds.addAll(ids);
+            l.countDown();
+        }, getTestRegistrationState());
+        assertEquals(1, l.getCount());
+        assertTrue(pendingCallIds.isEmpty());
+        // Simulate ack pending SIP session, which has cleared pending call id
+        doReturn(Collections.emptySet()).when(mSipSessionTracker)
+                .getCallIdsAssociatedWithFeatureTag(any());
+        tracker.acknowledgePendingMessage("blah");
+        assertEquals(0, l.getCount());
+        assertEquals(callIds, pendingCallIds);
+    }
+
+    @Test
+    public void setRegStateChangedPendingCallIdsChanged() {
+        TestExecutorService executor = new TestExecutorService(true /*wait*/);
+        TransportSipMessageValidator tracker = openTransport(executor);
+
+        ArraySet<String> callIds = new ArraySet<>(1);
+        callIds.add("abc");
+        doReturn(callIds).when(mSipSessionTracker)
+                .getCallIdsAssociatedWithFeatureTag(any());
+        ArraySet<String> pendingCallIds = new ArraySet<>();
+        CountDownLatch l = new CountDownLatch(1);
+        tracker.onRegistrationStateChanged((ids) -> {
+            pendingCallIds.addAll(ids);
+            l.countDown();
+        }, getTestRegistrationState());
+        assertEquals(1, l.getCount());
+        assertTrue(pendingCallIds.isEmpty());
+        // Simulate pending callIds changed to add another session.
+        callIds.add("def");
+        executor.executePending();
+        assertEquals(0, l.getCount());
+        assertEquals(callIds, pendingCallIds);
+    }
+
+    @Test
+    public void setRegStateRegThenClose() {
+        TestExecutorService executor = new TestExecutorService(true /*wait*/);
+        TransportSipMessageValidator tracker = openTransport(executor);
+
+        ArraySet<String> callIds = new ArraySet<>(1);
+        callIds.add("abc");
+        doReturn(callIds).when(mSipSessionTracker)
+                .getCallIdsAssociatedWithFeatureTag(any());
+        ArraySet<String> pendingCallIds = new ArraySet<>();
+        CountDownLatch l = new CountDownLatch(1);
+        tracker.onRegistrationStateChanged((ids) -> {
+            pendingCallIds.addAll(ids);
+            l.countDown();
+        }, getTestRegistrationState());
+        assertEquals(1, l.getCount());
+        assertTrue(pendingCallIds.isEmpty());
+        // If close is called during pending reg state change, it should be completed with no
+        // pending call IDs (close will take care of closing everything).
+        ArraySet<SipDialog> dialogs = new ArraySet<>();
+        dialogs.add(SipDialog.fromSipMessage(TEST_MESSAGE));
+        doReturn(dialogs).when(mSipSessionTracker).getTrackedDialogs();
+        tracker.closeSessions(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD);
+        assertEquals(0, l.getCount());
+        assertEquals(Collections.emptySet(), pendingCallIds);
+    }
+
+    private SipDelegateConfiguration.Builder getConfigBuilder(int version) {
+        InetSocketAddress localAddr = new InetSocketAddress(
+                InetAddresses.parseNumericAddress("1.1.1.1"), 80);
+        InetSocketAddress serverAddr = new InetSocketAddress(
+                InetAddresses.parseNumericAddress("2.2.2.2"), 81);
+        return new SipDelegateConfiguration.Builder(version,
+                SipDelegateConfiguration.SIP_TRANSPORT_TCP, localAddr, serverAddr);
+    }
+
+    private boolean isIncomingTransportOpen(TransportSipMessageValidator tracker) {
+        return tracker.verifyIncomingMessage(TEST_MESSAGE).isValidated;
+    }
+
+    private boolean isOutgoingTransportOpen(TransportSipMessageValidator tracker) {
+        return tracker.verifyOutgoingMessage(TEST_MESSAGE, TEST_CONFIG_VERSION).isValidated;
+    }
+
+    private int verifyOutgoingTransportClosed(TransportSipMessageValidator tracker) {
+        ValidationResult result = tracker.verifyOutgoingMessage(TEST_MESSAGE, TEST_CONFIG_VERSION);
+        assertFalse(result.isValidated);
+        return result.restrictedReason;
+    }
+
+    private DelegateRegistrationState getTestRegistrationState() {
+        return new DelegateRegistrationState.Builder().build();
+    }
+
+    private TransportSipMessageValidator openTransport(ScheduledExecutorService executor) {
+        TransportSipMessageValidator tracker = getTestTracker(executor);
+        tracker.onTransportOpened(Collections.emptySet(), Collections.emptySet());
+        tracker.onConfigurationChanged(getConfigBuilder(TEST_CONFIG_VERSION).build());
+        tracker.onRegistrationStateChanged((ignore) -> {}, getTestRegistrationState());
+        // Config set + IMS reg state sent, transport is now open.
+        assertTrue(isIncomingTransportOpen(tracker));
+        assertTrue(isOutgoingTransportOpen(tracker));
+        return tracker;
+    }
+
+    private TransportSipMessageValidator getTestTracker(ScheduledExecutorService executor) {
+        doReturn(ValidationResult.SUCCESS).when(mOutgoingStateValidator).validate(any());
+        doReturn(ValidationResult.SUCCESS).when(mIncomingStateValidator).validate(any());
+        doReturn(mIncomingStateValidator).when(mIncomingStateValidator).andThen(any());
+        return new TransportSipMessageValidator(TEST_SUB_ID, executor, mSipSessionTracker,
+                mOutgoingStateValidator, mIncomingStateValidator);
+    }
+}
diff --git a/tests/src/com/android/services/telephony/rcs/UceControllerManagerTest.java b/tests/src/com/android/services/telephony/rcs/UceControllerManagerTest.java
index 82687f8..8d719fd 100644
--- a/tests/src/com/android/services/telephony/rcs/UceControllerManagerTest.java
+++ b/tests/src/com/android/services/telephony/rcs/UceControllerManagerTest.java
@@ -17,6 +17,7 @@
 package com.android.services.telephony.rcs;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.fail;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.never;
@@ -108,6 +109,21 @@
     }
 
     @Test
+    public void testSubIdAndCarrierConfigUpdateWithInvalidSubId() throws Exception {
+        UceControllerManager uceCtrlManager = getUceControllerManager();
+
+        // Updates with the same subId should not destroy the UceController
+        uceCtrlManager.onCarrierConfigChanged();
+        verify(mUceController, never()).onDestroy();
+
+        // Updates with invalid subscription ID
+        uceCtrlManager.onAssociatedSubscriptionUpdated(-1);
+
+        verify(mUceController).onDestroy();
+        assertNull(uceCtrlManager.getUceController());
+    }
+
+    @Test
     public void testRequestCapabilitiesWithRcsUnavailable() throws Exception {
         UceControllerManager uceCtrlManager = getUceControllerManager();
         doReturn(true).when(mUceController).isUnavailable();
@@ -242,9 +258,8 @@
     }
 
     private UceControllerManager getUceControllerManager() {
-        UceControllerManager manager = new UceControllerManager(mContext, mSlotId, mSubId,
-                mExecutorService);
-        manager.setUceController(mUceController);
+        UceControllerManager manager = new UceControllerManager(mContext, mSlotId,
+                mExecutorService, mUceController);
         return manager;
     }
 }
diff --git a/tests/src/com/android/services/telephony/rcs/validator/IncomingTransportStateValidatorTest.java b/tests/src/com/android/services/telephony/rcs/validator/IncomingTransportStateValidatorTest.java
new file mode 100644
index 0000000..0e7e1be
--- /dev/null
+++ b/tests/src/com/android/services/telephony/rcs/validator/IncomingTransportStateValidatorTest.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs.validator;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class IncomingTransportStateValidatorTest {
+    private static final SipMessage TEST_MESSAGE = new SipMessage(
+            "INVITE sip:bob@biloxi.com SIP/2.0",
+            "Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds\n"
+                    + "Max-Forwards: 70\n"
+                    + "To: Bob <sip:bob@biloxi.com>\n"
+                    + "From: Alice <sip:alice@atlanta.com>;tag=1928301774\n"
+                    + "Call-ID: a84b4c76e66710@pc33.atlanta.com\n"
+                    + "CSeq: 314159 INVITE\n"
+                    + "Contact: <sip:alice@pc33.atlanta.com>\n"
+                    + "Content-Type: application/sdp\n"
+                    + "Content-Length: 142",
+            new byte[0]);
+
+    @Test
+    public void testVerifyMessageAndUpdateState() {
+        IncomingTransportStateValidator validator = new IncomingTransportStateValidator();
+        ValidationResult result = validator.validate(TEST_MESSAGE);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED,
+                result.restrictedReason);
+
+        validator.open();
+        result = validator.validate(TEST_MESSAGE);
+        assertTrue(result.isValidated);
+
+        validator.close(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED);
+        result = validator.validate(TEST_MESSAGE);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED,
+                result.restrictedReason);
+    }
+}
diff --git a/tests/src/com/android/services/telephony/rcs/validator/MalformedSipMessageValidatorTest.java b/tests/src/com/android/services/telephony/rcs/validator/MalformedSipMessageValidatorTest.java
new file mode 100644
index 0000000..18b37fc
--- /dev/null
+++ b/tests/src/com/android/services/telephony/rcs/validator/MalformedSipMessageValidatorTest.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs.validator;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class MalformedSipMessageValidatorTest {
+
+    @Test
+    public void testValidRequest() {
+        SipMessage msg = new SipMessage(
+                "INVITE sip:bob@biloxi.com SIP/2.0",
+                // Typical Via
+                "Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK.TeSt\n"
+                        + "Max-Forwards: 70\n"
+                        + "To: Bob <sip:bob@biloxi.com>\n"
+                        + "From: Alice <sip:alice@atlanta.com>;tag=1928301774\n"
+                        + "Call-ID: a84b4c76e66710@pc33.atlanta.com\n"
+                        + "CSeq: 314159 INVITE\n"
+                        + "Contact: <sip:alice@pc33.atlanta.com>\n"
+                        + "Content-Type: application/sdp\n"
+                        + "Content-Length: 142",
+                new byte[0]);
+        ValidationResult result = new MalformedSipMessageValidator().validate(msg);
+        assertTrue(result.isValidated);
+    }
+
+    @Test
+    public void testInvalidRequest() {
+        SipMessage msg = new SipMessage(
+                "INVITE sip:bob@biloxi.comSIP/2.0",
+                "Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds\n"
+                        + "Max-Forwards: 70\n"
+                        + "To: Bob <sip:bob@biloxi.com>\n"
+                        + "From: Alice <sip:alice@atlanta.com>;tag=1928301774\n"
+                        + "Call-ID: a84b4c76e66710@pc33.atlanta.com\n"
+                        + "CSeq: 314159 INVITE\n"
+                        + "Contact: <sip:alice@pc33.atlanta.com>\n"
+                        + "Content-Type: application/sdp\n"
+                        + "Content-Length: 142",
+                new byte[0]);
+        ValidationResult result = new MalformedSipMessageValidator().validate(msg);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_START_LINE,
+                result.restrictedReason);
+    }
+
+    @Test
+    public void testInvalidResponse() {
+        SipMessage msg = new SipMessage(
+                "SIP/2.0 200OK",
+                "Via: SIP/2.0/TCP terminal.vancouver.example.com;"
+                        + "branch=z9hG4bKwYb6QREiCL\n"
+                        + "To: <sip:adam-buddies@pres.vancouver.example.com>;tag=zpNctbZq\n"
+                        + "From: <sip:adam@vancouver.example.com>;tag=ie4hbb8t\n"
+                        + "Call-ID: cdB34qLToC@terminal.vancouver.example.com\n"
+                        + "CSeq: 322723822 SUBSCRIBE\n"
+                        + "Contact: <sip:pres.vancouver.example.com>\n"
+                        + "Expires: 7200\n"
+                        + "Require: eventlist\n"
+                        + "Content-Length: 0",
+                new byte[0]);
+        ValidationResult result = new MalformedSipMessageValidator().validate(msg);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_START_LINE,
+                result.restrictedReason);
+    }
+}
diff --git a/tests/src/com/android/services/telephony/rcs/validator/OutgoingTransportStateValidatorTest.java b/tests/src/com/android/services/telephony/rcs/validator/OutgoingTransportStateValidatorTest.java
new file mode 100644
index 0000000..8dbeb9b
--- /dev/null
+++ b/tests/src/com/android/services/telephony/rcs/validator/OutgoingTransportStateValidatorTest.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs.validator;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import static org.mockito.Mockito.doReturn;
+
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+import android.util.ArraySet;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.TelephonyTestBase;
+import com.android.services.telephony.rcs.SipDialog;
+import com.android.services.telephony.rcs.SipSessionTracker;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+
+import java.util.Collections;
+import java.util.Random;
+
+@RunWith(AndroidJUnit4.class)
+public class OutgoingTransportStateValidatorTest  extends TelephonyTestBase {
+
+    @Mock
+    private SipSessionTracker mMockSessionTracker;
+    private final Random mRandom = new Random();
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        super.tearDown();
+    }
+
+    @Test
+    public void testVerifyMessageInOpenCloseState() {
+        SipMessage testMessage = generateSipRequestForCallId("callId1");
+        OutgoingTransportStateValidator validator =
+                new OutgoingTransportStateValidator(mMockSessionTracker);
+        ValidationResult result = validator.validate(testMessage);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED,
+                result.restrictedReason);
+
+        validator.open(Collections.singleton("+tag"), Collections.emptySet());
+        validator.restrictFeatureTags(Collections.emptySet());
+        result = validator.validate(testMessage);
+        assertTrue(result.isValidated);
+
+        validator.close(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED);
+        result = validator.validate(testMessage);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED,
+                result.restrictedReason);
+    }
+
+    @Test
+    public void testVerifyMessageRestricted() {
+        String callId1 = "callId1";
+        String callId2 = "callId2";
+        String callId3 = "callId3";
+        SipMessage testInDialogEarlyMessage = generateSipRequestForCallId(callId1);
+        SipMessage testInDialogConfirmedMessage = generateSipRequestForCallId(callId2);
+        SipMessage testOutOfDialogInvite = generateSipRequestForCallId(callId3);
+        SipMessage testStatelessRequest = generateMessageRequest();
+        ArraySet<SipDialog> inDialogEarlyCallIds = new ArraySet<>(1);
+        inDialogEarlyCallIds.add(SipDialog.fromSipMessage(testInDialogEarlyMessage));
+        ArraySet<String> inDialogConfirmedCallIds = new ArraySet<>();
+        inDialogEarlyCallIds.add(SipDialog.fromSipMessage(testInDialogConfirmedMessage));
+        // For the sake of testing, add the same call id to early and confirmed dialogs, since we
+        // will accept requests for both right now.
+        doReturn(inDialogEarlyCallIds).when(mMockSessionTracker).getEarlyDialogs();
+        doReturn(inDialogConfirmedCallIds).when(mMockSessionTracker).getConfirmedDialogs();
+        OutgoingTransportStateValidator validator =
+                new OutgoingTransportStateValidator(mMockSessionTracker);
+        validator.restrict(
+                SipDelegateManager.MESSAGE_FAILURE_REASON_INTERNAL_DELEGATE_STATE_TRANSITION);
+
+        // ensure a response to a pending request is not restricted, even if it is not for a tracked
+        // call ID.
+        ValidationResult result = validator.validate(generate200OkResponse("callId4"));
+        assertTrue(result.isValidated);
+
+        // confirm in dialog messages are not restricted
+        result = validator.validate(testInDialogEarlyMessage);
+        assertTrue(result.isValidated);
+        result = validator.validate(testInDialogConfirmedMessage);
+        assertTrue(result.isValidated);
+
+        // confirm out-of-dialog requests are restricted.
+        result = validator.validate(testOutOfDialogInvite);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_INTERNAL_DELEGATE_STATE_TRANSITION,
+                result.restrictedReason);
+        result = validator.validate(testStatelessRequest);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_INTERNAL_DELEGATE_STATE_TRANSITION,
+                result.restrictedReason);
+    }
+
+    @Test
+    public void testDeniedFeatureTag() {
+        SipMessage testMessage = generateSipRequestForCallId("callId1");
+        OutgoingTransportStateValidator validator =
+                new OutgoingTransportStateValidator(mMockSessionTracker);
+        ValidationResult result = validator.validate(testMessage);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED,
+                result.restrictedReason);
+
+        // Assert that invites associated with denied tags are denied.
+        validator.open(Collections.emptySet(), Collections.singleton("+tag"));
+        validator.restrictFeatureTags(Collections.emptySet());
+        result = validator.validate(testMessage);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_FEATURE_TAG,
+                result.restrictedReason);
+    }
+
+    @Test
+    public void testRestrictedFeatureTag() {
+        SipMessage testMessage = generateSipRequestForCallId("callId1");
+        OutgoingTransportStateValidator validator =
+                new OutgoingTransportStateValidator(mMockSessionTracker);
+        ValidationResult result = validator.validate(testMessage);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED,
+                result.restrictedReason);
+
+        validator.open(Collections.singleton("+tag"), Collections.emptySet());
+        // Ensure that when supported tags are restricted, the message is not validated.
+        validator.restrictFeatureTags(Collections.singleton("+tag"));
+        result = validator.validate(testMessage);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_FEATURE_TAG,
+                result.restrictedReason);
+    }
+
+    @Test
+    public void testNoSupportedFeatureTag() {
+        SipMessage testMessage = generateSipRequestForCallId("callId1");
+        OutgoingTransportStateValidator validator =
+                new OutgoingTransportStateValidator(mMockSessionTracker);
+        ValidationResult result = validator.validate(testMessage);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED,
+                result.restrictedReason);
+
+        // Assert if a message doesn't have any related supported tags, it should be denied
+        validator.open(Collections.emptySet(), Collections.emptySet());
+        validator.restrictFeatureTags(Collections.emptySet());
+        result = validator.validate(testMessage);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_FEATURE_TAG,
+                result.restrictedReason);
+
+        // responses and non-dialog starting messages do not matter
+        result = validator.validate(generate200OkResponse("callId2"));
+        assertTrue(result.isValidated);
+        result = validator.validate(generateMessageRequest());
+        assertTrue(result.isValidated);
+    }
+
+    /**
+     * @return A INVITE with the call ID specified. Note: this request is not technically valid, but
+     * only contains the relevant headers for testing.
+     */
+    private SipMessage generateSipRequestForCallId(String callId) {
+        return new SipMessage(
+                "INVITE sip:b@client.example.com SIP/2.0",
+                "Via: SIP/2.0/UDP client.example.com;branch=z9hG4bK776asdhds\n"
+                        + "To: B <sip:b@example.com>\n"
+                        + "From: A <sip:a@example.com>;tag=1928301774\n"
+                        + "Accept-Contact: *;+tag\n"
+                        + "Call-ID: " + callId,
+                new byte[0]);
+    }
+
+    /**
+     * @return A MESSAGE request. Note: this request is not technically valid, but only contains the
+     * relevant headers for testing.
+     */
+    private SipMessage generateMessageRequest() {
+        return new SipMessage(
+                "MESSAGE sip:b@client.example.com SIP/2.0",
+                "Via: SIP/2.0/UDP client.example.com;branch=z9hG4bK776asdhds\n"
+                        + "To: B <sip:b@example.com>\n"
+                        + "From: A <sip:a@example.com>;tag=1928301774\n",
+                new byte[0]);
+    }
+
+    /**
+     * @return A 200 OK associated with the supplied call ID.
+     */
+    private SipMessage generate200OkResponse(String callId) {
+        return new SipMessage(
+                "SIP/2.0 200 OK",
+                "Via: SIP/2.0/UDP client.example.com;branch=z9hG4bK776asdhds\n"
+                        + "To: B <sip:b@example.com>\n"
+                        + "From: A <sip:a@example.com>;tag=1928301774\n"
+                        + "Call-ID: " + callId,
+                new byte[0]);
+    }
+}
diff --git a/tests/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSipRequestValidatorTest.java b/tests/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSipRequestValidatorTest.java
new file mode 100644
index 0000000..a90aaeb
--- /dev/null
+++ b/tests/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSipRequestValidatorTest.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs.validator;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class RestrictedOutgoingSipRequestValidatorTest {
+
+    @Test
+    public void testRegisterNotAllowed() {
+        SipMessage msg = new SipMessage(
+                "REGISTER sip:bob@biloxi.com SIP/2.0",
+                // Not representative of real REGISTER message, but close enough for validation.
+                "Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds\n"
+                        + "Max-Forwards: 70\n"
+                        + "To: Bob <sip:bob@biloxi.com>\n"
+                        + "From: Alice <sip:alice@atlanta.com>;tag=1928301774\n"
+                        + "CSeq: 314159 REGISTER\n"
+                        + "Contact: <sip:alice@pc33.atlanta.com>\n",
+                new byte[0]);
+        ValidationResult result = new RestrictedOutgoingSipRequestValidator().validate(msg);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_START_LINE,
+                result.restrictedReason);
+    }
+
+    @Test
+    public void testPublishNotAllowed() {
+        SipMessage msg = new SipMessage(
+                "PUBLISH sip:bob@biloxi.com SIP/2.0",
+                // Not representative of real REGISTER message, but close enough for validation.
+                "Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds\n"
+                        + "Max-Forwards: 70\n"
+                        + "To: Bob <sip:bob@biloxi.com>\n"
+                        + "From: Alice <sip:alice@atlanta.com>;tag=1928301774\n"
+                        + "CSeq: 314159 PUBLISH\n"
+                        + "Contact: <sip:alice@pc33.atlanta.com>\n",
+                new byte[0]);
+        ValidationResult result = new RestrictedOutgoingSipRequestValidator().validate(msg);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_START_LINE,
+                result.restrictedReason);
+
+    }
+
+    @Test
+    public void testOptionsNotAllowed() {
+        SipMessage msg = new SipMessage(
+                "OPTIONS sip:bob@biloxi.com SIP/2.0",
+                // Not representative of real REGISTER message, but close enough for validation.
+                "Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds\n"
+                        + "Max-Forwards: 70\n"
+                        + "To: Bob <sip:bob@biloxi.com>\n"
+                        + "From: Alice <sip:alice@atlanta.com>;tag=1928301774\n"
+                        + "CSeq: 314159 OPTIONS\n"
+                        + "Contact: <sip:alice@pc33.atlanta.com>\n",
+                new byte[0]);
+        ValidationResult result = new RestrictedOutgoingSipRequestValidator().validate(msg);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_START_LINE,
+                result.restrictedReason);
+
+    }
+
+    @Test
+    public void testInviteAllowed() {
+        SipMessage msg = new SipMessage(
+                "INVITE sip:bob@biloxi.com SIP/2.0",
+                "Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds\n"
+                        + "Max-Forwards: 70\n"
+                        + "To: Bob <sip:bob@biloxi.com>\n"
+                        + "From: Alice <sip:alice@atlanta.com>;tag=1928301774\n"
+                        + "Call-ID: a84b4c76e66710@pc33.atlanta.com\n"
+                        + "CSeq: 314159 INVITE\n"
+                        + "Contact: <sip:alice@pc33.atlanta.com>\n"
+                        + "Content-Type: application/sdp\n"
+                        + "Content-Length: 142",
+                new byte[0]);
+        ValidationResult result = new RestrictedOutgoingSipRequestValidator().validate(msg);
+        assertTrue(result.isValidated);
+    }
+
+    @Test
+    public void testResponseAllowed() {
+        SipMessage msg = new SipMessage(
+                "SIP/2.0 200 OK",
+                "Via: SIP/2.0/TCP terminal.vancouver.example.com;"
+                        + "branch=z9hG4bKwYb6QREiCL\n"
+                        + "To: <sip:adam-buddies@pres.vancouver.example.com>;tag=zpNctbZq\n"
+                        + "From: <sip:adam@vancouver.example.com>;tag=ie4hbb8t\n"
+                        + "Call-ID: cdB34qLToC@terminal.vancouver.example.com\n"
+                        + "CSeq: 322723822 SUBSCRIBE\n"
+                        + "Contact: <sip:pres.vancouver.example.com>\n"
+                        + "Expires: 7200\n"
+                        + "Require: eventlist\n"
+                        + "Content-Length: 0",
+                new byte[0]);
+        ValidationResult result = new RestrictedOutgoingSipRequestValidator().validate(msg);
+        assertTrue(result.isValidated);
+    }
+}
diff --git a/tests/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSubscribeValidatorTest.java b/tests/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSubscribeValidatorTest.java
new file mode 100644
index 0000000..9864872
--- /dev/null
+++ b/tests/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSubscribeValidatorTest.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2021 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.services.telephony.rcs.validator;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class RestrictedOutgoingSubscribeValidatorTest {
+
+    @Test
+    public void testValidUnrelatedRequest() {
+        SipMessage msg = new SipMessage(
+                "INVITE sip:bob@biloxi.com SIP/2.0",
+                // Typical Via
+                "Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK.TeSt\n"
+                        + "Max-Forwards: 70\n"
+                        + "To: Bob <sip:bob@biloxi.com>\n"
+                        + "From: Alice <sip:alice@atlanta.com>;tag=1928301774\n"
+                        + "Call-ID: a84b4c76e66710@pc33.atlanta.com\n"
+                        + "CSeq: 314159 INVITE\n"
+                        + "Contact: <sip:alice@pc33.atlanta.com>\n"
+                        + "Content-Type: application/sdp\n"
+                        + "Content-Length: 142",
+                new byte[0]);
+        ValidationResult result = new MalformedSipMessageValidator().validate(msg);
+        assertTrue(result.isValidated);
+    }
+
+    @Test
+    public void testValidUnrelatedResponse() {
+        SipMessage msg = new SipMessage(
+                "SIP/2.0 200 OK",
+                "Via: SIP/2.0/TCP terminal.vancouver.example.com;"
+                        + "branch=z9hG4bKwYb6QREiCL\n"
+                        + "To: <sip:adam-buddies@pres.vancouver.example.com>;tag=zpNctbZq\n"
+                        + "From: <sip:adam@vancouver.example.com>;tag=ie4hbb8t\n"
+                        + "Call-ID: cdB34qLToC@terminal.vancouver.example.com\n"
+                        + "CSeq: 322723822 SUBSCRIBE\n"
+                        + "Contact: <sip:pres.vancouver.example.com>\n"
+                        + "Expires: 7200\n"
+                        + "Require: eventlist\n"
+                        + "Content-Length: 0",
+                new byte[0]);
+        ValidationResult result = new MalformedSipMessageValidator().validate(msg);
+        assertTrue(result.isValidated);
+    }
+
+    @Test
+    public void testValidSubscribeRequest() {
+        SipMessage msg = new SipMessage(
+                "SUBSCRIBE sip:joe@example.com SIP/2.0",
+                "Via: SIP/2.0/UDP app.example.com;branch=z9hG4bKnashds7\n"
+                        + "From: sip:app.example.com;tag=123aa9\n"
+                        + "To: sip:joe@example.com\n"
+                        + "Call-ID: 9987@app.example.com\n"
+                        + "CSeq: 9887 SUBSCRIBE\n"
+                        + "Contact: sip:app.example.com\n"
+                        + "Event:    conference \n"
+                        + "Max-Forwards: 70\n",
+                new byte[0]);
+        ValidationResult result = new RestrictedOutgoingSubscribeValidator().validate(msg);
+        assertTrue(result.isValidated);
+    }
+
+    @Test
+    public void testInvalidSubscribeRequest() {
+        SipMessage msg = new SipMessage(
+                "SUBSCRIBE sip:joe@example.com SIP/2.0",
+                "Via: SIP/2.0/UDP app.example.com;branch=z9hG4bKnashds7\n"
+                        + "From: sip:app.example.com;tag=123aa9\n"
+                        + "To: sip:joe@example.com\n"
+                        + "Call-ID: 9987@app.example.com\n"
+                        + "CSeq: 9887 SUBSCRIBE\n"
+                        + "Contact: sip:app.example.com\n"
+                        + "Event:  presence  \n"
+                        + "Max-Forwards: 70\n",
+                new byte[0]);
+        ValidationResult result = new RestrictedOutgoingSubscribeValidator().validate(msg);
+        assertFalse(result.isValidated);
+        assertEquals(SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_HEADER_FIELDS,
+                result.restrictedReason);
+
+    }
+}