Merge "Move CarrierConfigLoader registrar call to init"
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/ImsRcsController.java b/src/com/android/phone/ImsRcsController.java
index bd6ba6b..31059e7 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(
@@ -668,19 +661,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/services/telephony/rcs/MessageTransportWrapper.java b/src/com/android/services/telephony/rcs/MessageTransportWrapper.java
index c4edf49..0d4265a 100644
--- a/src/com/android/services/telephony/rcs/MessageTransportWrapper.java
+++ b/src/com/android/services/telephony/rcs/MessageTransportWrapper.java
@@ -38,7 +38,6 @@
 import com.android.services.telephony.rcs.validator.ValidationResult;
 
 import java.io.PrintWriter;
-import java.util.List;
 import java.util.Set;
 import java.util.concurrent.Executor;
 import java.util.concurrent.ScheduledExecutorService;
@@ -130,8 +129,8 @@
                             mSipSessionTracker.verifyOutgoingMessage(sipMessage, configVersion);
                     result = maybeOverrideValidationForTesting(result);
                     if (!result.isValidated) {
-                        notifyDelegateSendError("Outgoing messages restricted", sipMessage,
-                                result.restrictedReason);
+                        notifyDelegateSendError("Outgoing - " + result.logReason,
+                                sipMessage, result.restrictedReason);
                         return;
                     }
                     try {
@@ -186,7 +185,7 @@
                 mExecutor.execute(() -> {
                     ValidationResult result = mSipSessionTracker.verifyIncomingMessage(message);
                     if (!result.isValidated) {
-                        notifyAppReceiveError("Incoming messages restricted", message,
+                        notifyAppReceiveError("Incoming - " + result.logReason, message,
                                 result.restrictedReason);
                         return;
                     }
@@ -304,7 +303,7 @@
 
     @Override
     public void onRegistrationStateChanged(DelegateRegistrationState registrationState) {
-        mSipSessionTracker.onRegistrationStateChanged((List<String> callIds) -> {
+        mSipSessionTracker.onRegistrationStateChanged((callIds) -> {
             for (String id : callIds)  {
                 cleanupSessionInternal(id);
             }
@@ -371,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>
@@ -408,19 +408,19 @@
     }
 
     /**
-     * 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) {
-        List<String> openDialogs = mSipSessionTracker.closeSessionsForcefully(closedReason);
-        logi("close: closedReason=" + closedReason + "open call IDs:{" + openDialogs + "}");
-        closeTransport(openDialogs);
+        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(List<String> openCallIds) {
+    private void closeTransport(Set<String> openCallIds) {
         for (String id : openCallIds) {
             cleanupSessionInternal(id);
         }
@@ -428,20 +428,19 @@
     }
 
     private void cleanupSessionInternal(String callId) {
-        logi("cleanupSessionInternal: closing session with callId: " + callId);
-        mSipSessionTracker.onSipSessionCleanup(callId);
-
-        if (mSipDelegate == null) {
-            logw("cleanupSession called when SipDelegate is not associated, callId: "
-                    + callId);
-            return;
-        }
+        logi("cleanupSessionInternal: clean up session with callId: " + callId);
         try {
-            mSipDelegate.cleanupSession(callId);
+            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("SipDelegate not available when cleanupSession was called "
+            logw("cleanupSessionInternal: remote not available when cleanupSession was called "
                     + "for call id: " + callId);
         }
+        mSipSessionTracker.onSipSessionCleanup(callId);
     }
 
     private ValidationResult maybeOverrideValidationForTesting(ValidationResult result) {
@@ -454,7 +453,8 @@
         } 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);
+            return new ValidationResult(SipDelegateManager.MESSAGE_FAILURE_REASON_UNKNOWN,
+                    "validation failed due to a testing override being set");
         }
         return result;
     }
diff --git a/src/com/android/services/telephony/rcs/SipDelegateController.java b/src/com/android/services/telephony/rcs/SipDelegateController.java
index c2e5e67..30edca1 100644
--- a/src/com/android/services/telephony/rcs/SipDelegateController.java
+++ b/src/com/android/services/telephony/rcs/SipDelegateController.java
@@ -269,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);
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
index 66dea85..777026c 100644
--- a/src/com/android/services/telephony/rcs/TransportSipMessageValidator.java
+++ b/src/com/android/services/telephony/rcs/TransportSipMessageValidator.java
@@ -26,6 +26,7 @@
 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;
@@ -36,17 +37,21 @@
 
 import java.io.PrintWriter;
 import java.util.Collections;
-import java.util.List;
 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.</li>
+ *    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
@@ -59,9 +64,74 @@
 
     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;
@@ -71,29 +141,35 @@
     private Set<String> mSupportedFeatureTags;
     private Set<FeatureTagState> mDeniedFeatureTags;
     private long mConfigVersion = -1;
-    private DelegateRegistrationState mLatestRegistrationState;
-    private Consumer<List<String>> mClosingCompleteConsumer;
-    private Consumer<List<String>> mRegistrationAppliedConsumer;
+    private Consumer<Set<String>> mClosingCompleteConsumer;
+    private PendingTask mPendingClose;
+    private PendingRegCleanupTask mPendingRegCleanup;
+    private Consumer<Set<String>> mRegistrationAppliedConsumer;
 
     public TransportSipMessageValidator(int subId, ScheduledExecutorService executor) {
-        this(subId, executor, new OutgoingTransportStateValidator(),
-                new IncomingTransportStateValidator(), new MalformedSipMessageValidator().andThen(
+        mSubId = subId;
+        mExecutor = executor;
+        mSipSessionTracker = new SipSessionTracker();
+        mOutgoingTransportStateValidator = new OutgoingTransportStateValidator(mSipSessionTracker);
+        mIncomingTransportStateValidator = new IncomingTransportStateValidator();
+        mOutgoingMessageValidator = new MalformedSipMessageValidator().andThen(
                 new RestrictedOutgoingSipRequestValidator()).andThen(
-                new RestrictedOutgoingSubscribeValidator()));
+                new RestrictedOutgoingSubscribeValidator()).andThen(
+                        mOutgoingTransportStateValidator);
+        mIncomingMessageValidator = mIncomingTransportStateValidator;
     }
 
-
     @VisibleForTesting
     public TransportSipMessageValidator(int subId, ScheduledExecutorService executor,
+            SipSessionTracker sipSessionTracker,
             OutgoingTransportStateValidator outgoingStateValidator,
-            IncomingTransportStateValidator incomingStateValidator,
-            SipMessageValidator statelessMessageValidator) {
+            IncomingTransportStateValidator incomingStateValidator) {
         mSubId = subId;
         mExecutor = executor;
+        mSipSessionTracker = sipSessionTracker;
         mOutgoingTransportStateValidator = outgoingStateValidator;
         mIncomingTransportStateValidator = incomingStateValidator;
-        mOutgoingMessageValidator = mOutgoingTransportStateValidator.andThen(
-                statelessMessageValidator);
+        mOutgoingMessageValidator = mOutgoingTransportStateValidator;
         mIncomingMessageValidator = mIncomingTransportStateValidator;
     }
 
@@ -115,30 +191,27 @@
      *         changes.
      * @param regState The new registration state.
      */
-    public void onRegistrationStateChanged(Consumer<List<String>> stateChangeComplete,
+    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.
-            mRegistrationAppliedConsumer.accept(Collections.emptyList());
+            cleanupAndNotifyRegistrationAppliedConsumer(Collections.emptySet());
         }
-        mLatestRegistrationState = regState;
-        // evaluate if this needs to be set based on reg state.
+        Set<String> restrictedTags = Stream.concat(
+                regState.getDeregisteringFeatureTags().stream(),
+                regState.getDeregisteredFeatureTags().stream()).map(FeatureTagState::getFeatureTag)
+                .collect(Collectors.toSet());
+        mOutgoingTransportStateValidator.restrictFeatureTags(restrictedTags);
         mRegistrationAppliedConsumer = stateChangeComplete;
-        // notify stateChangeComplete when reg state applied
-        mExecutor.execute(() -> {
-            // TODO: Track open regState & signal dialogs to close if required.
-            // Collect open dialogs associated with features that regState is signalling as
-            // DEREGISTERING. When PENDING_DIALOG_CLOSING_TIMEOUT_MS occurs, these dialogs need to
-            // close so that the features can move to DEREGISTERED.
-
-            // For now, just pass back an empty list and complete the Consumer.
-            if (mRegistrationAppliedConsumer != null) {
-                mRegistrationAppliedConsumer.accept(Collections.emptyList());
-                mRegistrationAppliedConsumer = null;
-            }
-        });
+        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.");
+        }
     }
 
     /**
@@ -184,7 +257,8 @@
         logi("onTransportOpened: moving to open state");
         mSupportedFeatureTags = supportedFeatureTags;
         mDeniedFeatureTags = deniedFeatureTags;
-        mOutgoingTransportStateValidator.open();
+        mOutgoingTransportStateValidator.open(supportedFeatureTags, deniedFeatureTags.stream().map(
+                FeatureTagState::getFeatureTag).collect(Collectors.toSet()));
         mIncomingTransportStateValidator.open();
     }
 
@@ -193,7 +267,8 @@
      * @param callId The call ID associated with the SIP session.
      */
     public void onSipSessionCleanup(String callId) {
-        //TODO track SIP sessions.
+        mSipSessionTracker.cleanupSession(callId);
+        onCallIdsChanged();
     }
 
     /**
@@ -213,54 +288,66 @@
      * @param closedReason The reason that will be provided if any outgoing SIP message is sent
      *         once the transport is closed.
      */
-    public void closeSessionsGracefully(Consumer<List<String>> closingCompleteConsumer,
+    public void closeSessionsGracefully(Consumer<Set<String>> closingCompleteConsumer,
             int closingReason, int closedReason) {
-        if (mClosingCompleteConsumer != null) {
-            logw("closeSessionsGracefully: already pending close, completing consumer to unblock");
-            closingCompleteConsumer.accept(Collections.emptyList());
+        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);
-        mClosingCompleteConsumer = closingCompleteConsumer;
         mOutgoingTransportStateValidator.restrict(closingReason);
-        mExecutor.execute(() -> {
-            logi("closeSessionsGracefully: moving to closed state, reason=" + closedReason);
-            mOutgoingTransportStateValidator.close(closedReason);
-            mIncomingTransportStateValidator.close(closedReason);
-            if (mClosingCompleteConsumer != null) {
-                // TODO: Track SIP sessions and complete when there are no SIP dialogs open anymore
-                //  or the timeout occurs.
-                mClosingCompleteConsumer.accept(Collections.emptyList());
-                mClosingCompleteConsumer = null;
+        mPendingClose = new PendingTask() {
+            @Override
+            public void run() {
+                closeSessions(closingReason);
             }
-        });
+        };
+        mPendingClose.scheduleDelayed(mExecutor, PENDING_CLOSE_TIMEOUT_MS);
     }
 
     /**
-     * The message transport must close now due to a configuration change (SIM subscription change,
-     * user disabled RCS, the service is dead, etc...).
+     * 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 List<String> closeSessionsForcefully(int closedReason) {
-        logi("closeSessionsForcefully: moving to closed state, reason=" + closedReason);
-        mOutgoingTransportStateValidator.close(closedReason);
-        mIncomingTransportStateValidator.close(closedReason);
-        // TODO: add logic to collect open SIP dialogs to be forcefully closed once they are being
-        //  tracked.
-        List<String> openCallIds = Collections.emptyList();
-        if (mClosingCompleteConsumer != null) {
-            logi("closeSessionsForcefully: sending pending call ids through close consumer");
-            // send the call ID through the pending complete mechanism to unblock any previous
-            // graceful close command.
-            mClosingCompleteConsumer.accept(openCallIds);
-            mClosingCompleteConsumer = null;
-            return Collections.emptyList();
-        } else {
-            return openCallIds;
+    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;
     }
 
     /**
@@ -270,20 +357,15 @@
      */
 
     public ValidationResult verifyOutgoingMessage(SipMessage message, long configVersion) {
-        ValidationResult result = mOutgoingMessageValidator.validate(message);
-        if (!result.isValidated) return result;
-
         if (mConfigVersion != configVersion) {
-            logi("VerifyOutgoingMessage failed: for message: " + message + ", due to stale IMS "
-                    + "configuration: " + configVersion + ", expected: " + mConfigVersion);
             return new ValidationResult(
-                    SipDelegateManager.MESSAGE_FAILURE_REASON_STALE_IMS_CONFIGURATION);
+                    SipDelegateManager.MESSAGE_FAILURE_REASON_STALE_IMS_CONFIGURATION,
+                    "stale IMS configuration: "  + configVersion + ", expected: "
+                            + mConfigVersion);
         }
-        if (mLatestRegistrationState == null) {
-            result = new ValidationResult(
-                    SipDelegateManager.MESSAGE_FAILURE_REASON_NOT_REGISTERED);
-        }
-        logi("VerifyOutgoingMessage: " + result + ", message=" + message);
+        ValidationResult result = mOutgoingMessageValidator.validate(message);
+        logi("verifyOutgoingMessage: " + result + ", message=" + message);
+        if (result.isValidated) mSipSessionTracker.filterSipMessage(message);
         return result;
     }
 
@@ -294,7 +376,10 @@
      * @return The result of verifying the incoming message.
      */
     public ValidationResult verifyIncomingMessage(SipMessage message) {
-        return mIncomingMessageValidator.validate(message);
+        ValidationResult result = mIncomingMessageValidator.validate(message);
+        logi("verifyIncomingMessage: " + result + ", message=" + message);
+        if (result.isValidated) mSipSessionTracker.filterSipMessage(message);
+        return result;
     }
 
     /**
@@ -304,33 +389,158 @@
      */
     public void acknowledgePendingMessage(String transactionId) {
         logi("acknowledgePendingMessage: id=" + transactionId);
-        //TODO: keep track of pending messages to add to SIP session candidates.
+        mSipSessionTracker.acknowledgePendingMessage(transactionId);
+        onCallIdsChanged();
     }
 
     /**
      * A pending incoming or outgoing SIP message has failed and should not be tracked.
-     * @param transactionId
+     * @param transactionId The transaction ID associated with the message.
      */
     public void notifyPendingMessageFailed(String transactionId) {
         logi("notifyPendingMessageFailed: id=" + transactionId);
-        //TODO: keep track of pending messages to remove from SIP session candidates.
+        mSipSessionTracker.pendingMessageFailed(transactionId);
     }
 
     /** Dump state about this tracker that should be included in the dumpsys */
     public void dump(PrintWriter printWriter) {
-        printWriter.println("Supported Tags:" + mSupportedFeatureTags);
-        printWriter.println("Denied Tags:" + mDeniedFeatureTags);
-        printWriter.println(mOutgoingTransportStateValidator);
-        printWriter.println(mIncomingTransportStateValidator);
-        printWriter.println("Reg consumer pending: " + (mRegistrationAppliedConsumer != null));
-        printWriter.println("Close consumer pending: " + (mClosingCompleteConsumer != null));
-        printWriter.println();
-        printWriter.println("Most recent logs:");
+        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.w(SipTransportController.LOG_TAG, LOG_TAG + "[" + mSubId + "] " + log);
+        Log.i(SipTransportController.LOG_TAG, LOG_TAG + "[" + mSubId + "] " + log);
         mLocalLog.log("[I] " + log);
     }
 
diff --git a/src/com/android/services/telephony/rcs/validator/IncomingTransportStateValidator.java b/src/com/android/services/telephony/rcs/validator/IncomingTransportStateValidator.java
index 2ab4bbe..24ab45e 100644
--- a/src/com/android/services/telephony/rcs/validator/IncomingTransportStateValidator.java
+++ b/src/com/android/services/telephony/rcs/validator/IncomingTransportStateValidator.java
@@ -65,7 +65,8 @@
     @Override
     public ValidationResult validate(SipMessage message) {
         if (mState != STATE_OPEN) {
-            return new ValidationResult(mReason);
+            return new ValidationResult(mReason,
+                    "incoming transport closed");
         }
         return ValidationResult.SUCCESS;
     }
diff --git a/src/com/android/services/telephony/rcs/validator/MalformedSipMessageValidator.java b/src/com/android/services/telephony/rcs/validator/MalformedSipMessageValidator.java
index 3ac461d..a76ac3e 100644
--- a/src/com/android/services/telephony/rcs/validator/MalformedSipMessageValidator.java
+++ b/src/com/android/services/telephony/rcs/validator/MalformedSipMessageValidator.java
@@ -34,7 +34,8 @@
         if (!SipMessageParsingUtils.isSipRequest(message.getStartLine())
                 && !SipMessageParsingUtils.isSipResponse(message.getStartLine())) {
             return new ValidationResult(
-                    SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_START_LINE);
+                    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
index 348c213..72d22f8 100644
--- a/src/com/android/services/telephony/rcs/validator/OutgoingTransportStateValidator.java
+++ b/src/com/android/services/telephony/rcs/validator/OutgoingTransportStateValidator.java
@@ -18,7 +18,20 @@
 
 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
@@ -48,16 +61,42 @@
         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() {
+    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());
     }
 
     /**
@@ -77,15 +116,28 @@
     public void close(int reason) {
         mState = STATE_CLOSED;
         mReason = reason;
+        mAllowedTags = Collections.emptySet();
     }
 
     @Override
     public ValidationResult validate(SipMessage message) {
-        // TODO: integrate in and out-of-dialog message detection as well as supported & denied tags
-        if (mState != STATE_OPEN) {
-            return new ValidationResult(mReason);
+        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;
         }
-        return ValidationResult.SUCCESS;
+    }
+
+    public Set<String> getAllowedCallIds() {
+        return Stream.concat(mSipSessionTracker.getEarlyDialogs().stream(),
+                mSipSessionTracker.getConfirmedDialogs().stream()).map(SipDialog::getCallId)
+                .collect(Collectors.toSet());
     }
 
     @Override
@@ -93,6 +145,91 @@
         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));
+                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
index e3aba25..2c2632f 100644
--- a/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSipRequestValidator.java
+++ b/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSipRequestValidator.java
@@ -43,12 +43,14 @@
             String[] segments = SipMessageParsingUtils.splitStartLineAndVerify(startLine);
             if (segments == null) {
                 return new ValidationResult(
-                        SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_START_LINE);
+                        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);
+                        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
index 0db3381..41074ed 100644
--- a/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSubscribeValidator.java
+++ b/src/com/android/services/telephony/rcs/validator/RestrictedOutgoingSubscribeValidator.java
@@ -24,6 +24,7 @@
 
 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"
@@ -45,7 +46,8 @@
                 message.getStartLine());
         if (requestSegments == null) {
             return new ValidationResult(
-                    SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_START_LINE);
+                    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)) {
@@ -61,7 +63,9 @@
                 .anyMatch(e -> Arrays.asList(RESTRICTED_EVENTS).contains(e.trim().toLowerCase()));
 
         return isRestricted ? new ValidationResult(
-                SipDelegateManager.MESSAGE_FAILURE_REASON_INVALID_HEADER_FIELDS) :
+                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/ValidationResult.java b/src/com/android/services/telephony/rcs/validator/ValidationResult.java
index f3f6470..e434163 100644
--- a/src/com/android/services/telephony/rcs/validator/ValidationResult.java
+++ b/src/com/android/services/telephony/rcs/validator/ValidationResult.java
@@ -41,11 +41,17 @@
     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 = "";
     }
 
     /**
@@ -54,9 +60,10 @@
      * @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) {
+    public ValidationResult(@SipDelegateManager.MessageFailureReason int reason, String log) {
         isValidated = false;
         restrictedReason = reason;
+        logReason = log;
     }
 
     @Override
@@ -68,6 +75,8 @@
         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/services/telephony/rcs/MessageTransportWrapperTest.java b/tests/src/com/android/services/telephony/rcs/MessageTransportWrapperTest.java
index 3542cc1..5ced75c 100644
--- a/tests/src/com/android/services/telephony/rcs/MessageTransportWrapperTest.java
+++ b/tests/src/com/android/services/telephony/rcs/MessageTransportWrapperTest.java
@@ -38,6 +38,7 @@
 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;
@@ -57,7 +58,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashSet;
-import java.util.List;
+import java.util.Set;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.function.Consumer;
 
@@ -76,7 +77,6 @@
     @Mock private ISipDelegateMessageCallback mDelegateMessageCallback;
     @Mock private TransportSipMessageValidator mTransportSipSessionValidator;
     @Mock private ISipDelegate mISipDelegate;
-    @Mock private Consumer<Boolean> mMockCloseConsumer;
 
     // Test executor that just calls run on the Runnable provided in execute.
     private ScheduledExecutorService mExecutor = new TestExecutorService();
@@ -123,7 +123,7 @@
     @SmallTest
     @Test
     public void testRegistrationStateChanged() throws Exception {
-        ArrayList<String> callIds = new ArrayList<>(2);
+        ArraySet<String> callIds = new ArraySet<>(2);
         callIds.add("callId1");
         callIds.add("callId2");
         // empty registration state for testing
@@ -131,7 +131,7 @@
         MessageTransportWrapper tracker = createTestMessageTransportWrapper();
         tracker.openTransport(mISipDelegate, Collections.emptySet(), Collections.emptySet());
 
-        Consumer<List<String>> callIdConsumer = trackerRegStateChanged(tracker, state);
+        Consumer<Set<String>> callIdConsumer = trackerRegStateChanged(tracker, state);
         callIdConsumer.accept(callIds);
         // Verify that the pending call IDs are closed properly.
         for (String callId : callIds) {
@@ -148,9 +148,9 @@
         MessageTransportWrapper tracker = createTestMessageTransportWrapper();
 
         Boolean[] result = new Boolean[1];
-        Consumer<List<String>> callIdConsumer = closeTrackerGracefully(tracker, closingReason,
+        Consumer<Set<String>> callIdConsumer = closeTrackerGracefully(tracker, closingReason,
                 closedReason, (r) -> result[0] = r);
-        callIdConsumer.accept(Collections.emptyList());
+        callIdConsumer.accept(Collections.emptySet());
         // Verify that the pending call IDs are closed properly.
         verify(mTransportSipSessionValidator, never()).onSipSessionCleanup(anyString());
         verify(mISipDelegate, never()).cleanupSession(anyString());
@@ -161,7 +161,7 @@
     @SmallTest
     @Test
     public void testCloseGracefullyForceCloseCallIds() throws Exception {
-        ArrayList<String> callIds = new ArrayList<>(2);
+        ArraySet<String> callIds = new ArraySet<>(2);
         callIds.add("callId1");
         callIds.add("callId2");
         int closingReason = DelegateRegistrationState.DEREGISTERING_REASON_PROVISIONING_CHANGE;
@@ -170,7 +170,7 @@
         tracker.openTransport(mISipDelegate, Collections.emptySet(), Collections.emptySet());
 
         Boolean[] result = new Boolean[1];
-        Consumer<List<String>> callIdConsumer = closeTrackerGracefully(tracker, closingReason,
+        Consumer<Set<String>> callIdConsumer = closeTrackerGracefully(tracker, closingReason,
                 closedReason, (r) -> result[0] = r);
         callIdConsumer.accept(callIds);
         // Verify that the pending call IDs are closed properly.
@@ -186,11 +186,11 @@
     @SmallTest
     @Test
     public void testClose() throws Exception {
-        ArrayList<String> callIds = new ArrayList<>(2);
+        ArraySet<String> callIds = new ArraySet<>(2);
         callIds.add("callId1");
         callIds.add("callId2");
         int closedReason = SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED;
-        doReturn(callIds).when(mTransportSipSessionValidator).closeSessionsForcefully(closedReason);
+        doReturn(callIds).when(mTransportSipSessionValidator).closeSessions(closedReason);
         MessageTransportWrapper tracker = createTestMessageTransportWrapper();
         tracker.openTransport(mISipDelegate, Collections.emptySet(), Collections.emptySet());
 
@@ -220,7 +220,7 @@
                 eq(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD));
 
         doReturn(new ValidationResult(
-                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED))
+                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED, ""))
                 .when(mTransportSipSessionValidator)
                 .verifyOutgoingMessage(TEST_MESSAGE, 1 /*version*/);
         tracker.getDelegateConnection().sendMessage(TEST_MESSAGE, 1 /*version*/);
@@ -277,7 +277,7 @@
                 SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD);
 
         doReturn(new ValidationResult(
-                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD))
+                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_DEAD, ""))
                 .when(mTransportSipSessionValidator).verifyIncomingMessage(TEST_MESSAGE);
         tracker.getMessageCallback().onMessageReceived(TEST_MESSAGE);
         verify(mISipDelegate, times(2)).notifyMessageReceiveError(TEST_TRANSACTION_ID,
@@ -311,9 +311,9 @@
                 mExecutor, mDelegateMessageCallback, mTransportSipSessionValidator);
     }
 
-    private Consumer<List<String>> trackerRegStateChanged(MessageTransportWrapper tracker,
+    private Consumer<Set<String>> trackerRegStateChanged(MessageTransportWrapper tracker,
             DelegateRegistrationState state) {
-        ArrayList<Consumer<List<String>>> consumerCaptor = new ArrayList<>(1);
+        ArrayList<Consumer<Set<String>>> consumerCaptor = new ArrayList<>(1);
         Mockito.doAnswer(it -> {
             // Capture the consumer here.
             consumerCaptor.add(it.getArgument(0));
@@ -325,9 +325,9 @@
         return consumerCaptor.get(0);
     }
 
-    private Consumer<List<String>> closeTrackerGracefully(MessageTransportWrapper tracker,
+    private Consumer<Set<String>> closeTrackerGracefully(MessageTransportWrapper tracker,
             int closingReason, int closedReason, Consumer<Boolean> resultConsumer) {
-        ArrayList<Consumer<List<String>>> consumerCaptor = new ArrayList<>(1);
+        ArrayList<Consumer<Set<String>>> consumerCaptor = new ArrayList<>(1);
         Mockito.doAnswer(it -> {
             // Capture the consumer here.
             consumerCaptor.add(it.getArgument(0));
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
index c991a8c..4d222e3 100644
--- a/tests/src/com/android/services/telephony/rcs/TransportSipMessageValidatorTest.java
+++ b/tests/src/com/android/services/telephony/rcs/TransportSipMessageValidatorTest.java
@@ -23,6 +23,7 @@
 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;
@@ -30,6 +31,7 @@
 import android.telephony.ims.SipDelegateConfiguration;
 import android.telephony.ims.SipDelegateManager;
 import android.telephony.ims.SipMessage;
+import android.util.ArraySet;
 
 import androidx.test.runner.AndroidJUnit4;
 
@@ -37,7 +39,6 @@
 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.SipMessageValidator;
 import com.android.services.telephony.rcs.validator.ValidationResult;
 
 import org.junit.After;
@@ -48,6 +49,7 @@
 
 import java.net.InetSocketAddress;
 import java.util.Collections;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ScheduledExecutorService;
 
 @RunWith(AndroidJUnit4.class)
@@ -61,7 +63,7 @@
                     + "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"
+                    + "Call-ID: testid\n"
                     + "CSeq: 314159 INVITE\n"
                     + "Contact: <sip:alice@pc33.atlanta.com>\n"
                     + "Content-Type: application/sdp\n"
@@ -69,11 +71,11 @@
             new byte[0]);
 
     @Mock
+    private SipSessionTracker mSipSessionTracker;
+    @Mock
     private IncomingTransportStateValidator mIncomingStateValidator;
     @Mock
     private OutgoingTransportStateValidator mOutgoingStateValidator;
-    @Mock
-    private SipMessageValidator mStatelessValidator;
 
     @Before
     public void setUp() throws Exception {
@@ -90,19 +92,15 @@
         TestExecutorService executor = new TestExecutorService();
         TransportSipMessageValidator tracker = getTestTracker(executor);
         tracker.onTransportOpened(Collections.emptySet(), Collections.emptySet());
-        verify(mOutgoingStateValidator).open();
+        verify(mOutgoingStateValidator).open(Collections.emptySet(), Collections.emptySet());
         verify(mIncomingStateValidator).open();
         // Incoming messages are already verified
         assertTrue(isIncomingTransportOpen(tracker));
-        // IMS config and registration state needs to be sent before outgoing messages can be
-        // verified.
+        // 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
-        assertTrue(isIncomingTransportOpen(tracker));
-        assertFalse(isOutgoingTransportOpen(tracker));
-        tracker.onRegistrationStateChanged((ignore) -> {}, getTestRegistrationState());
-        // Config set + IMS reg state sent, transport is now open.
+        // Config set, transport is now open.
         assertTrue(isIncomingTransportOpen(tracker));
         assertTrue(isOutgoingTransportOpen(tracker));
     }
@@ -110,13 +108,7 @@
     @Test
     public void testTransportOpenConfigChange() {
         TestExecutorService executor = new TestExecutorService();
-        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));
+        TransportSipMessageValidator tracker = openTransport(executor);
 
         // Update IMS config version and send a message with an outdated version.
         tracker.onConfigurationChanged(getConfigBuilder(TEST_CONFIG_VERSION + 1).build());
@@ -125,25 +117,165 @@
     }
 
     @Test
-    public void testTransportClosingGracefully() {
+    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 = 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));
+        TransportSipMessageValidator tracker = openTransport(executor);
 
-        tracker.closeSessionsGracefully((ignore) -> {},
-                SipDelegateManager.MESSAGE_FAILURE_REASON_INTERNAL_DELEGATE_STATE_TRANSITION,
+        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());
+    }
 
-        // Before executor executes, outgoing messages will be restricted.
+    @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();
-        // After Executor executes, all messages will be rejected.
+        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());
     }
@@ -151,22 +283,162 @@
     @Test
     public void testTransportClosingForcefully() {
         TestExecutorService executor = new TestExecutorService();
-        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));
+        TransportSipMessageValidator tracker = openTransport(executor);
 
-        tracker.closeSessionsForcefully(
-                SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED);
+        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);
@@ -176,7 +448,6 @@
                 SipDelegateConfiguration.SIP_TRANSPORT_TCP, localAddr, serverAddr);
     }
 
-
     private boolean isIncomingTransportOpen(TransportSipMessageValidator tracker) {
         return tracker.verifyIncomingMessage(TEST_MESSAGE).isValidated;
     }
@@ -195,15 +466,22 @@
         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(mStatelessValidator).validate(any());
-        doReturn(mStatelessValidator).when(mStatelessValidator).andThen(any());
         doReturn(ValidationResult.SUCCESS).when(mOutgoingStateValidator).validate(any());
-        // recreate chain for mocked instances.
-        doReturn(mStatelessValidator).when(mOutgoingStateValidator).andThen(any());
         doReturn(ValidationResult.SUCCESS).when(mIncomingStateValidator).validate(any());
         doReturn(mIncomingStateValidator).when(mIncomingStateValidator).andThen(any());
-        return new TransportSipMessageValidator(TEST_SUB_ID, executor, mOutgoingStateValidator,
-                mIncomingStateValidator, mStatelessValidator);
+        return new TransportSipMessageValidator(TEST_SUB_ID, executor, mSipSessionTracker,
+                mOutgoingStateValidator, mIncomingStateValidator);
     }
 }
diff --git a/tests/src/com/android/services/telephony/rcs/validator/OutgoingTransportStateValidatorTest.java b/tests/src/com/android/services/telephony/rcs/validator/OutgoingTransportStateValidatorTest.java
index e54e5ff..8dbeb9b 100644
--- a/tests/src/com/android/services/telephony/rcs/validator/OutgoingTransportStateValidatorTest.java
+++ b/tests/src/com/android/services/telephony/rcs/validator/OutgoingTransportStateValidatorTest.java
@@ -20,53 +20,211 @@
 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 {
+public class OutgoingTransportStateValidatorTest  extends TelephonyTestBase {
 
-    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]);
+    @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 testVerifyMessageAndUpdateState() {
-        OutgoingTransportStateValidator validator = new OutgoingTransportStateValidator();
-        ValidationResult result = validator.validate(TEST_MESSAGE);
+    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();
-        result = validator.validate(TEST_MESSAGE);
+        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);
-        result = validator.validate(TEST_MESSAGE);
+
+        // 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);
+    }
 
-        validator.close(SipDelegateManager.MESSAGE_FAILURE_REASON_DELEGATE_CLOSED);
-        result = validator.validate(TEST_MESSAGE);
+    @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]);
     }
 }