Merge "Improve Logic for Detecting Carrier Load Complete"
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 18e8d04..488326c 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -243,6 +243,13 @@
                 android:readPermission="android.permission.READ_CONTACTS"
                 android:writePermission="android.permission.WRITE_CONTACTS" />
 
+        <provider android:name=".SimPhonebookProvider"
+            android:authorities="com.android.simphonebook"
+            android:multiprocess="true"
+            android:exported="true"
+            android:readPermission="android.permission.READ_CONTACTS"
+            android:writePermission="android.permission.WRITE_CONTACTS" />
+
         <provider android:name="com.android.ims.rcs.uce.eab.EabProvider"
                 android:authorities="eab"
                 android:exported="false"/>
diff --git a/res/values/config.xml b/res/values/config.xml
index 7dd26bb..7ce141e 100644
--- a/res/values/config.xml
+++ b/res/values/config.xml
@@ -304,8 +304,8 @@
         positive n - release in n milliseconds -->
     <integer name="config_gba_release_time">0</integer>
 
-    <!-- Whether or not to support RCS VoLTE single registration  -->
-    <bool name="config_rcsVolteSingleRegistrationEnabled">true</bool>
+    <!-- Whether or not to support RCS User Capability Exchange -->
+    <bool name="config_rcs_user_capability_exchange_enabled">true</bool>
 
     <!-- Whether or not to support device to device communication using RTP and DTMF communication
          transports. -->
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 83a5673..1613ca8 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -2172,4 +2172,12 @@
     <string name="carrier_provisioning">Carrier Provisioning Info</string>
     <!-- Trigger Carrier Provisioning [CHAR LIMIT=NONE] -->
     <string name="trigger_carrier_provisioning">Trigger Carrier Provisioning</string>
+
+    <!-- details of the message popped up when there is
+    bad call quality caused by bluetooth connection-->
+    <string name="call_quality_notification_bluetooth_details">
+        Suggestion: Improve Bluetooth connectivity</string>
+    <!-- name of the notification that pops up during
+    a phone call when there is bad call quality -->
+    <string name="call_quality_notification_name">Call Quality Notification</string>
 </resources>
diff --git a/src/com/android/phone/ImsRcsController.java b/src/com/android/phone/ImsRcsController.java
index 2c87f7c..9334078 100644
--- a/src/com/android/phone/ImsRcsController.java
+++ b/src/com/android/phone/ImsRcsController.java
@@ -67,6 +67,8 @@
     private PhoneGlobals mApp;
     private TelephonyRcsService mRcsService;
     private ImsResolver mImsResolver;
+    // set by shell cmd phone src set-device-enabled true/false
+    private Boolean mSingleRegistrationOverride;
 
     /**
      * Initialize the singleton ImsRcsController instance.
@@ -98,7 +100,8 @@
      */
     @Override
     public void registerImsRegistrationCallback(int subId, IImsRegistrationCallback callback) {
-        enforceReadPrivilegedPermission("registerImsRegistrationCallback");
+        TelephonyPermissions.enforeceCallingOrSelfReadPrecisePhoneStatePermissionOrCarrierPrivilege(
+                mApp, subId, "registerImsRegistrationCallback");
         final long token = Binder.clearCallingIdentity();
         try {
             getRcsFeatureController(subId).registerImsRegistrationCallback(subId, callback);
@@ -115,7 +118,8 @@
      */
     @Override
     public void unregisterImsRegistrationCallback(int subId, IImsRegistrationCallback callback) {
-        enforceReadPrivilegedPermission("unregisterImsRegistrationCallback");
+        TelephonyPermissions.enforeceCallingOrSelfReadPrecisePhoneStatePermissionOrCarrierPrivilege(
+                mApp, subId, "unregisterImsRegistrationCallback");
         final long token = Binder.clearCallingIdentity();
         try {
             getRcsFeatureController(subId).unregisterImsRegistrationCallback(subId, callback);
@@ -131,7 +135,8 @@
      */
     @Override
     public void getImsRcsRegistrationState(int subId, IIntegerConsumer consumer) {
-        enforceReadPrivilegedPermission("getImsRcsRegistrationState");
+        TelephonyPermissions.enforeceCallingOrSelfReadPrecisePhoneStatePermissionOrCarrierPrivilege(
+                mApp, subId, "getImsRcsRegistrationState");
         final long token = Binder.clearCallingIdentity();
         try {
             getRcsFeatureController(subId).getRegistrationState(regState -> {
@@ -152,7 +157,8 @@
      */
     @Override
     public void getImsRcsRegistrationTransportType(int subId, IIntegerConsumer consumer) {
-        enforceReadPrivilegedPermission("getImsRcsRegistrationTransportType");
+        TelephonyPermissions.enforeceCallingOrSelfReadPrecisePhoneStatePermissionOrCarrierPrivilege(
+                mApp, subId, "getImsRcsRegistrationTransportType");
         final long token = Binder.clearCallingIdentity();
         try {
             getRcsFeatureController(subId).getRegistrationTech(regTech -> {
@@ -215,7 +221,7 @@
      *
      * @param subId the subscription ID
      * @param capability the RCS capability to query.
-     * @param radioTech the radio tech that this capability failed for
+     * @param radioTech the radio technology type that we are querying.
      * @return true if the RCS capability is capable for this subscription, false otherwise.
      */
     @Override
@@ -241,15 +247,17 @@
      * @param subId the subscription ID
      * @param capability the RCS capability to query.
      * @return true if the RCS capability is currently available for the associated subscription,
+     * @param radioTech the radio technology type that we are querying.
      * false otherwise.
      */
     @Override
     public boolean isAvailable(int subId,
-            @RcsFeature.RcsImsCapabilities.RcsImsCapabilityFlag int capability) {
+            @RcsFeature.RcsImsCapabilities.RcsImsCapabilityFlag int capability,
+            @ImsRegistrationImplBase.ImsRegistrationTech int radioTech) {
         enforceReadPrivilegedPermission("isAvailable");
         final long token = Binder.clearCallingIdentity();
         try {
-            return getRcsFeatureController(subId).isAvailable(capability);
+            return getRcsFeatureController(subId).isAvailable(capability, radioTech);
         } catch (ImsException e) {
             Log.e(TAG, "isAvailable: sudId=" + subId
                     + ", capability=" + capability + ", " + e.getMessage());
@@ -386,6 +394,9 @@
     @Override
     public boolean isSipDelegateSupported(int subId) {
         enforceReadPrivilegedPermission("isSipDelegateSupported");
+        if (!isImsSingleRegistrationSupportedOnDevice()) {
+            return false;
+        }
         final long token = Binder.clearCallingIdentity();
         try {
             SipTransportController transport = getRcsFeatureController(subId).getFeature(
@@ -411,6 +422,11 @@
             ISipDelegateConnectionStateCallback delegateState,
             ISipDelegateMessageCallback delegateMessage) {
         enforceModifyPermission();
+        if (!isImsSingleRegistrationSupportedOnDevice()) {
+            throw new ServiceSpecificException(ImsException.CODE_ERROR_UNSUPPORTED_OPERATION,
+                    "SipDelegate creation is only supported for devices supporting IMS single "
+                            + "registration");
+        }
         if (!UserHandle.getUserHandleForUid(Binder.getCallingUid()).isSystem()) {
             throw new ServiceSpecificException(ImsException.CODE_ERROR_UNSUPPORTED_OPERATION,
                     "SipDelegate creation is only available to primary user.");
@@ -586,7 +602,21 @@
         return c;
     }
 
+    private boolean isImsSingleRegistrationSupportedOnDevice() {
+        return mSingleRegistrationOverride != null ? mSingleRegistrationOverride
+                : mApp.getPackageManager().hasSystemFeature(
+                        PackageManager.FEATURE_TELEPHONY_IMS_SINGLE_REGISTRATION);
+    }
+
     void setRcsService(TelephonyRcsService rcsService) {
         mRcsService = rcsService;
     }
+
+    /**
+     * Override device RCS single registration support check for CTS testing or remove override
+     * if the Boolean is set to null.
+     */
+    void setDeviceSingleRegistrationSupportOverride(Boolean deviceOverrideValue) {
+        mSingleRegistrationOverride = deviceOverrideValue;
+    }
 }
diff --git a/src/com/android/phone/PhoneGlobals.java b/src/com/android/phone/PhoneGlobals.java
index 5c97597..58382a1 100644
--- a/src/com/android/phone/PhoneGlobals.java
+++ b/src/com/android/phone/PhoneGlobals.java
@@ -943,6 +943,23 @@
     }
 
     /**
+     * @return whether the device supports RCS User Capability Exchange or not.
+     */
+    public boolean getDeviceUceEnabled() {
+        return (mTelephonyRcsService == null) ? false : mTelephonyRcsService.isDeviceUceEnabled();
+    }
+
+    /**
+     * Set the device supports RCS User Capability Exchange.
+     * @param isEnabled true if the device supports UCE.
+     */
+    public void setDeviceUceEnabled(boolean isEnabled) {
+        if (mTelephonyRcsService != null) {
+            mTelephonyRcsService.setDeviceUceEnabled(isEnabled);
+        }
+    }
+
+    /**
      * Dump the state of the object, add calls to other objects as desired.
      *
      * @param fd File descriptor
diff --git a/src/com/android/phone/PhoneInterfaceManager.java b/src/com/android/phone/PhoneInterfaceManager.java
index e85b483..f1be951 100755
--- a/src/com/android/phone/PhoneInterfaceManager.java
+++ b/src/com/android/phone/PhoneInterfaceManager.java
@@ -9757,6 +9757,7 @@
         Boolean enabled = "NULL".equalsIgnoreCase(enabledStr) ? null
                 : Boolean.parseBoolean(enabledStr);
         RcsProvisioningMonitor.getInstance().overrideDeviceSingleRegistrationEnabled(enabled);
+        mApp.imsRcsController.setDeviceSingleRegistrationSupportOverride(enabled);
     }
 
     /**
@@ -9864,6 +9865,28 @@
     }
 
     @Override
+    public boolean getDeviceUceEnabled() {
+        TelephonyPermissions.enforceShellOnly(Binder.getCallingUid(), "getDeviceUceEnabled");
+        final long identity = Binder.clearCallingIdentity();
+        try {
+            return mApp.getDeviceUceEnabled();
+        } finally {
+            Binder.restoreCallingIdentity(identity);
+        }
+    }
+
+    @Override
+    public void setDeviceUceEnabled(boolean isEnabled) {
+        TelephonyPermissions.enforceShellOnly(Binder.getCallingUid(), "setDeviceUceEnabled");
+        final long identity = Binder.clearCallingIdentity();
+        try {
+            mApp.setDeviceUceEnabled(isEnabled);
+        } finally {
+            Binder.restoreCallingIdentity(identity);
+        }
+    }
+
+    @Override
     public void setSignalStrengthUpdateRequest(int subId, SignalStrengthUpdateRequest request,
             String callingPackage) {
         TelephonyPermissions.enforceCallingOrSelfModifyPermissionOrCarrierPrivilege(
diff --git a/src/com/android/phone/RcsProvisioningMonitor.java b/src/com/android/phone/RcsProvisioningMonitor.java
index 7b51eeb..79310ef 100644
--- a/src/com/android/phone/RcsProvisioningMonitor.java
+++ b/src/com/android/phone/RcsProvisioningMonitor.java
@@ -22,6 +22,7 @@
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
+import android.content.pm.PackageManager;
 import android.os.Build;
 import android.os.Handler;
 import android.os.HandlerThread;
@@ -543,7 +544,8 @@
         boolean isSingleRegistrationEnabledOnDevice =
                 mDeviceSingleRegistrationEnabledOverride != null
                 ? mDeviceSingleRegistrationEnabledOverride
-                : mPhone.getResources().getBoolean(R.bool.config_rcsVolteSingleRegistrationEnabled);
+                : mPhone.getPackageManager().hasSystemFeature(
+                        PackageManager.FEATURE_TELEPHONY_IMS_SINGLE_REGISTRATION);
 
         int value = (isSingleRegistrationEnabledOnDevice ? 0
                 : ProvisioningManager.STATUS_DEVICE_NOT_CAPABLE) | (
diff --git a/src/com/android/phone/SimPhonebookProvider.java b/src/com/android/phone/SimPhonebookProvider.java
new file mode 100644
index 0000000..8307672
--- /dev/null
+++ b/src/com/android/phone/SimPhonebookProvider.java
@@ -0,0 +1,932 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.phone;
+
+import android.Manifest;
+import android.annotation.TestApi;
+import android.content.ContentProvider;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.content.pm.PackageManager;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.RemoteException;
+import android.provider.SimPhonebookContract;
+import android.provider.SimPhonebookContract.ElementaryFiles;
+import android.provider.SimPhonebookContract.SimRecords;
+import android.telephony.PhoneNumberUtils;
+import android.telephony.Rlog;
+import android.telephony.SubscriptionInfo;
+import android.telephony.SubscriptionManager;
+import android.telephony.TelephonyFrameworkInitializer;
+import android.telephony.TelephonyManager;
+import android.util.ArraySet;
+import android.util.Pair;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.telephony.IIccPhoneBook;
+import com.android.internal.telephony.uicc.AdnRecord;
+import com.android.internal.telephony.uicc.IccConstants;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Supplier;
+
+/**
+ * Provider for contact records stored on the SIM card.
+ *
+ * @see SimPhonebookContract
+ */
+public class SimPhonebookProvider extends ContentProvider {
+
+    @VisibleForTesting
+    static final String[] ELEMENTARY_FILES_ALL_COLUMNS = {
+            ElementaryFiles.SLOT_INDEX,
+            ElementaryFiles.SUBSCRIPTION_ID,
+            ElementaryFiles.EF_TYPE,
+            ElementaryFiles.MAX_RECORDS,
+            ElementaryFiles.RECORD_COUNT,
+            ElementaryFiles.NAME_MAX_LENGTH,
+            ElementaryFiles.PHONE_NUMBER_MAX_LENGTH
+    };
+    @VisibleForTesting
+    static final String[] SIM_RECORDS_ALL_COLUMNS = {
+            SimRecords.SUBSCRIPTION_ID,
+            SimRecords.ELEMENTARY_FILE_TYPE,
+            SimRecords.RECORD_NUMBER,
+            SimRecords.NAME,
+            SimRecords.PHONE_NUMBER
+    };
+    private static final String TAG = "SimPhonebookProvider";
+    private static final Set<String> ELEMENTARY_FILES_COLUMNS_SET =
+            ImmutableSet.copyOf(ELEMENTARY_FILES_ALL_COLUMNS);
+    private static final Set<String> SIM_RECORDS_WRITABLE_COLUMNS = ImmutableSet.of(
+            SimRecords.NAME, SimRecords.PHONE_NUMBER
+    );
+
+    private static final int WRITE_TIMEOUT_SECONDS = 30;
+
+    private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
+
+    private static final int ELEMENTARY_FILES = 100;
+    private static final int ELEMENTARY_FILES_ITEM = 101;
+    private static final int SIM_RECORDS = 200;
+    private static final int SIM_RECORDS_ITEM = 201;
+    private static final int VALIDATE_NAME = 300;
+
+    static {
+        URI_MATCHER.addURI(SimPhonebookContract.AUTHORITY,
+                ElementaryFiles.ELEMENTARY_FILES_PATH_SEGMENT, ELEMENTARY_FILES);
+        URI_MATCHER.addURI(
+                SimPhonebookContract.AUTHORITY,
+                ElementaryFiles.ELEMENTARY_FILES_PATH_SEGMENT + "/"
+                        + SimPhonebookContract.SUBSCRIPTION_ID_PATH_SEGMENT + "/#/*",
+                ELEMENTARY_FILES_ITEM);
+        URI_MATCHER.addURI(SimPhonebookContract.AUTHORITY,
+                SimPhonebookContract.SUBSCRIPTION_ID_PATH_SEGMENT + "/#/*", SIM_RECORDS);
+        URI_MATCHER.addURI(SimPhonebookContract.AUTHORITY,
+                SimPhonebookContract.SUBSCRIPTION_ID_PATH_SEGMENT + "/#/*/#", SIM_RECORDS_ITEM);
+        URI_MATCHER.addURI(SimPhonebookContract.AUTHORITY,
+                SimPhonebookContract.SUBSCRIPTION_ID_PATH_SEGMENT + "/#/*/"
+                        + SimRecords.VALIDATE_NAME_PATH_SEGMENT,
+                VALIDATE_NAME);
+    }
+
+    // Only allow 1 write at a time to prevent races; the mutations are based on reads of the
+    // existing list of records which means concurrent writes would be problematic.
+    private final Lock mWriteLock = new ReentrantLock(true);
+    private SubscriptionManager mSubscriptionManager;
+    private Supplier<IIccPhoneBook> mIccPhoneBookSupplier;
+    private ContentNotifier mContentNotifier;
+
+    static int efIdForEfType(@ElementaryFiles.EfType int efType) {
+        switch (efType) {
+            case ElementaryFiles.EF_ADN:
+                return IccConstants.EF_ADN;
+            case ElementaryFiles.EF_FDN:
+                return IccConstants.EF_FDN;
+            case ElementaryFiles.EF_SDN:
+                return IccConstants.EF_SDN;
+            default:
+                return 0;
+        }
+    }
+
+    private static void validateProjection(Set<String> allowed, String[] projection) {
+        if (projection == null || allowed.containsAll(Arrays.asList(projection))) {
+            return;
+        }
+        Set<String> invalidColumns = new LinkedHashSet<>(Arrays.asList(projection));
+        invalidColumns.removeAll(allowed);
+        throw new IllegalArgumentException(
+                "Unsupported columns: " + Joiner.on(",").join(invalidColumns));
+    }
+
+    private static int getRecordSize(int[] recordsSize) {
+        return recordsSize[0];
+    }
+
+    private static int getRecordCount(int[] recordsSize) {
+        return recordsSize[2];
+    }
+
+    /** Returns the IccPhoneBook used to load the AdnRecords. */
+    private static IIccPhoneBook getIccPhoneBook() {
+        return IIccPhoneBook.Stub.asInterface(TelephonyFrameworkInitializer
+                .getTelephonyServiceManager().getIccPhoneBookServiceRegisterer().get());
+    }
+
+    @Override
+    public boolean onCreate() {
+        ContentResolver resolver = getContext().getContentResolver();
+        return onCreate(getContext().getSystemService(SubscriptionManager.class),
+                SimPhonebookProvider::getIccPhoneBook,
+                uri -> resolver.notifyChange(uri, null));
+    }
+
+    @TestApi
+    boolean onCreate(SubscriptionManager subscriptionManager,
+            Supplier<IIccPhoneBook> iccPhoneBookSupplier, ContentNotifier notifier) {
+        if (subscriptionManager == null) {
+            return false;
+        }
+        mSubscriptionManager = subscriptionManager;
+        mIccPhoneBookSupplier = iccPhoneBookSupplier;
+        mContentNotifier = notifier;
+
+        mSubscriptionManager.addOnSubscriptionsChangedListener(MoreExecutors.directExecutor(),
+                new SubscriptionManager.OnSubscriptionsChangedListener() {
+                    boolean mFirstCallback = true;
+                    private int[] mNotifiedSubIds = {};
+
+                    @Override
+                    public void onSubscriptionsChanged() {
+                        if (mFirstCallback) {
+                            mFirstCallback = false;
+                            return;
+                        }
+                        int[] activeSubIds = mSubscriptionManager.getActiveSubscriptionIdList();
+                        if (!Arrays.equals(mNotifiedSubIds, activeSubIds)) {
+                            notifier.notifyChange(SimPhonebookContract.AUTHORITY_URI);
+                            mNotifiedSubIds = Arrays.copyOf(activeSubIds, activeSubIds.length);
+                        }
+                    }
+                });
+        return true;
+    }
+
+
+    @Nullable
+    @Override
+    public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable Bundle queryArgs,
+            @Nullable CancellationSignal cancellationSignal) {
+        if (queryArgs != null && (queryArgs.containsKey(ContentResolver.QUERY_ARG_SQL_SELECTION)
+                || queryArgs.containsKey(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS)
+                || queryArgs.containsKey(ContentResolver.QUERY_ARG_SQL_LIMIT))) {
+            throw new IllegalArgumentException(
+                    "A SQL selection was provided but it is not supported by this provider.");
+        }
+        switch (URI_MATCHER.match(uri)) {
+            case ELEMENTARY_FILES:
+                return queryElementaryFiles(projection);
+            case ELEMENTARY_FILES_ITEM:
+                return queryElementaryFilesItem(PhonebookArgs.forElementaryFilesItem(uri),
+                        projection);
+            case SIM_RECORDS:
+                return querySimRecords(PhonebookArgs.forSimRecords(uri, queryArgs), projection);
+            case SIM_RECORDS_ITEM:
+                return querySimRecordsItem(PhonebookArgs.forSimRecordsItem(uri, queryArgs),
+                        projection);
+            case VALIDATE_NAME:
+                return queryValidateName(PhonebookArgs.forValidateName(uri, queryArgs), queryArgs);
+            default:
+                throw new IllegalArgumentException("Unsupported Uri " + uri);
+        }
+    }
+
+    @Nullable
+    @Override
+    public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
+            @Nullable String[] selectionArgs, @Nullable String sortOrder,
+            @Nullable CancellationSignal cancellationSignal) {
+        throw new UnsupportedOperationException("Only query with Bundle is supported");
+    }
+
+    @Nullable
+    @Override
+    public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
+            @Nullable String[] selectionArgs, @Nullable String sortOrder) {
+        throw new UnsupportedOperationException("Only query with Bundle is supported");
+    }
+
+    private Cursor queryElementaryFiles(String[] projection) {
+        validateProjection(ELEMENTARY_FILES_COLUMNS_SET, projection);
+        if (projection == null) {
+            projection = ELEMENTARY_FILES_ALL_COLUMNS;
+        }
+
+        MatrixCursor result = new MatrixCursor(projection);
+
+        List<SubscriptionInfo> activeSubscriptions = getActiveSubscriptionInfoList();
+        for (SubscriptionInfo subInfo : activeSubscriptions) {
+            try {
+                addEfToCursor(result, subInfo, ElementaryFiles.EF_ADN);
+                addEfToCursor(result, subInfo, ElementaryFiles.EF_FDN);
+                addEfToCursor(result, subInfo, ElementaryFiles.EF_SDN);
+            } catch (RemoteException e) {
+                // Return an empty cursor. If service to access it is throwing remote
+                // exceptions then it's basically the same as not having a SIM.
+                return new MatrixCursor(projection, 0);
+            }
+        }
+        return result;
+    }
+
+    private Cursor queryElementaryFilesItem(PhonebookArgs args, String[] projection) {
+        validateProjection(ELEMENTARY_FILES_COLUMNS_SET, projection);
+
+        MatrixCursor result = new MatrixCursor(projection);
+        try {
+            addEfToCursor(
+                    result, getActiveSubscriptionInfo(args.subscriptionId), args.efType);
+        } catch (RemoteException e) {
+            // Return an empty cursor. If service to access it is throwing remote
+            // exceptions then it's basically the same as not having a SIM.
+            return new MatrixCursor(projection, 0);
+        }
+        return result;
+    }
+
+    private void addEfToCursor(MatrixCursor result, SubscriptionInfo subscriptionInfo,
+            int efType) throws RemoteException {
+        int[] recordsSize = mIccPhoneBookSupplier.get().getAdnRecordsSizeForSubscriber(
+                subscriptionInfo.getSubscriptionId(), efIdForEfType(efType));
+        addEfToCursor(result, subscriptionInfo, efType, recordsSize);
+    }
+
+    private void addEfToCursor(MatrixCursor result, SubscriptionInfo subscriptionInfo,
+            int efType, int[] recordsSize) throws RemoteException {
+        // If the record count is zero then the SIM doesn't support the elementary file so just
+        // omit it.
+        if (recordsSize == null || getRecordCount(recordsSize) == 0) {
+            return;
+        }
+        MatrixCursor.RowBuilder row = result.newRow()
+                .add(ElementaryFiles.SLOT_INDEX, subscriptionInfo.getSimSlotIndex())
+                .add(ElementaryFiles.SUBSCRIPTION_ID, subscriptionInfo.getSubscriptionId())
+                .add(ElementaryFiles.EF_TYPE, efType)
+                .add(ElementaryFiles.MAX_RECORDS, getRecordCount(recordsSize))
+                .add(ElementaryFiles.NAME_MAX_LENGTH,
+                        AdnRecord.getMaxAlphaTagBytes(getRecordSize(recordsSize)))
+                .add(ElementaryFiles.PHONE_NUMBER_MAX_LENGTH,
+                        AdnRecord.getMaxPhoneNumberDigits());
+        if (result.getColumnIndex(ElementaryFiles.RECORD_COUNT) != -1) {
+            int efid = efIdForEfType(efType);
+            List<AdnRecord> existingRecords = mIccPhoneBookSupplier.get()
+                    .getAdnRecordsInEfForSubscriber(subscriptionInfo.getSubscriptionId(), efid);
+            int nonEmptyCount = 0;
+            for (AdnRecord record : existingRecords) {
+                if (!record.isEmpty()) {
+                    nonEmptyCount++;
+                }
+            }
+            row.add(ElementaryFiles.RECORD_COUNT, nonEmptyCount);
+        }
+    }
+
+    private Cursor querySimRecords(PhonebookArgs args, String[] projection) {
+        validateSubscriptionAndEf(args);
+        if (projection == null) {
+            projection = SIM_RECORDS_ALL_COLUMNS;
+        }
+
+        List<AdnRecord> records = loadRecordsForEf(args);
+        if (records == null) {
+            return new MatrixCursor(projection, 0);
+        }
+        MatrixCursor result = new MatrixCursor(projection, records.size());
+        List<Pair<AdnRecord, MatrixCursor.RowBuilder>> rowBuilders = new ArrayList<>(
+                records.size());
+        for (AdnRecord record : records) {
+            if (!record.isEmpty()) {
+                rowBuilders.add(Pair.create(record, result.newRow()));
+            }
+        }
+        // This is kind of ugly but avoids looking up columns in an inner loop.
+        for (String column : projection) {
+            switch (column) {
+                case SimRecords.SUBSCRIPTION_ID:
+                    for (Pair<AdnRecord, MatrixCursor.RowBuilder> row : rowBuilders) {
+                        row.second.add(args.subscriptionId);
+                    }
+                    break;
+                case SimRecords.ELEMENTARY_FILE_TYPE:
+                    for (Pair<AdnRecord, MatrixCursor.RowBuilder> row : rowBuilders) {
+                        row.second.add(args.efType);
+                    }
+                    break;
+                case SimRecords.RECORD_NUMBER:
+                    for (Pair<AdnRecord, MatrixCursor.RowBuilder> row : rowBuilders) {
+                        row.second.add(row.first.getRecId());
+                    }
+                    break;
+                case SimRecords.NAME:
+                    for (Pair<AdnRecord, MatrixCursor.RowBuilder> row : rowBuilders) {
+                        row.second.add(row.first.getAlphaTag());
+                    }
+                    break;
+                case SimRecords.PHONE_NUMBER:
+                    for (Pair<AdnRecord, MatrixCursor.RowBuilder> row : rowBuilders) {
+                        row.second.add(row.first.getNumber());
+                    }
+                    break;
+                default:
+                    Rlog.w(TAG, "Column " + column + " is unsupported for " + args.uri);
+                    break;
+            }
+        }
+        return result;
+    }
+
+    private Cursor querySimRecordsItem(PhonebookArgs args, String[] projection) {
+        if (projection == null) {
+            projection = SIM_RECORDS_ALL_COLUMNS;
+        }
+        validateSubscriptionAndEf(args);
+        AdnRecord record = loadRecord(args);
+
+        MatrixCursor result = new MatrixCursor(projection, 1);
+        if (record == null || record.isEmpty()) {
+            return result;
+        }
+        result.newRow()
+                .add(SimRecords.SUBSCRIPTION_ID, args.subscriptionId)
+                .add(SimRecords.ELEMENTARY_FILE_TYPE, args.efType)
+                .add(SimRecords.RECORD_NUMBER, record.getRecId())
+                .add(SimRecords.NAME, record.getAlphaTag())
+                .add(SimRecords.PHONE_NUMBER, record.getNumber());
+        return result;
+    }
+
+    private Cursor queryValidateName(PhonebookArgs args, @Nullable Bundle queryArgs) {
+        if (queryArgs == null) {
+            throw new IllegalArgumentException(SimRecords.NAME + " is required.");
+        }
+        validateSubscriptionAndEf(args);
+        String name = queryArgs.getString(SimRecords.NAME);
+
+        // Cursor extras are used to return the result.
+        Cursor result = new MatrixCursor(new String[0], 0);
+        Bundle extras = new Bundle();
+        extras.putParcelable(SimRecords.EXTRA_NAME_VALIDATION_RESULT, validateName(args, name));
+        result.setExtras(extras);
+        return result;
+    }
+
+    @Nullable
+    @Override
+    public String getType(@NonNull Uri uri) {
+        switch (URI_MATCHER.match(uri)) {
+            case ELEMENTARY_FILES:
+                return ElementaryFiles.CONTENT_TYPE;
+            case ELEMENTARY_FILES_ITEM:
+                return ElementaryFiles.CONTENT_ITEM_TYPE;
+            case SIM_RECORDS:
+                return SimRecords.CONTENT_TYPE;
+            case SIM_RECORDS_ITEM:
+                return SimRecords.CONTENT_ITEM_TYPE;
+            default:
+                throw new IllegalArgumentException("Unsupported Uri " + uri);
+        }
+    }
+
+    @Nullable
+    @Override
+    public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
+        return insert(uri, values, null);
+    }
+
+    @Nullable
+    @Override
+    public Uri insert(@NonNull Uri uri, @Nullable ContentValues values, @Nullable Bundle extras) {
+        switch (URI_MATCHER.match(uri)) {
+            case SIM_RECORDS:
+                return insertSimRecord(PhonebookArgs.forSimRecords(uri, extras), values);
+            case ELEMENTARY_FILES:
+            case ELEMENTARY_FILES_ITEM:
+            case SIM_RECORDS_ITEM:
+                throw new UnsupportedOperationException(uri + " does not support insert");
+            default:
+                throw new IllegalArgumentException("Unsupported Uri " + uri);
+        }
+    }
+
+    private Uri insertSimRecord(PhonebookArgs args, ContentValues values) {
+        validateWritableEf(args, "insert");
+        validateSubscriptionAndEf(args);
+
+        if (values == null || values.isEmpty()) {
+            return null;
+        }
+        validateValues(args, values);
+        String newName = Strings.nullToEmpty(values.getAsString(SimRecords.NAME));
+        String newPhoneNumber = Strings.nullToEmpty(values.getAsString(SimRecords.PHONE_NUMBER));
+
+        acquireWriteLockOrThrow();
+        try {
+            List<AdnRecord> records = loadRecordsForEf(args);
+            if (records == null) {
+                Rlog.e(TAG, "Failed to load existing records for " + args.uri);
+                return null;
+            }
+            AdnRecord emptyRecord = null;
+            for (AdnRecord record : records) {
+                if (record.isEmpty()) {
+                    emptyRecord = record;
+                    break;
+                }
+            }
+            if (emptyRecord == null) {
+                // When there are no empty records that means the EF is full.
+                throw new IllegalStateException(
+                        args.uri + " is full. Please delete records to add new ones.");
+            }
+            boolean success = updateRecord(args, emptyRecord, args.pin2, newName, newPhoneNumber);
+            if (!success) {
+                Rlog.e(TAG, "Insert failed for " + args.uri);
+                // Something didn't work but since we don't have any more specific
+                // information to provide to the caller it's better to just return null
+                // rather than throwing and possibly crashing their process.
+                return null;
+            }
+            notifyChange();
+            return SimRecords.getItemUri(args.subscriptionId, args.efType, emptyRecord.getRecId());
+        } finally {
+            releaseWriteLock();
+        }
+    }
+
+    @Override
+    public int delete(@NonNull Uri uri, @Nullable String selection,
+            @Nullable String[] selectionArgs) {
+        throw new UnsupportedOperationException("Only delete with Bundle is supported");
+    }
+
+    @Override
+    public int delete(@NonNull Uri uri, @Nullable Bundle extras) {
+        switch (URI_MATCHER.match(uri)) {
+            case SIM_RECORDS_ITEM:
+                return deleteSimRecordsItem(PhonebookArgs.forSimRecordsItem(uri, extras));
+            case ELEMENTARY_FILES:
+            case ELEMENTARY_FILES_ITEM:
+            case SIM_RECORDS:
+                throw new UnsupportedOperationException(uri + " does not support delete");
+            default:
+                throw new IllegalArgumentException("Unsupported Uri " + uri);
+        }
+    }
+
+    private int deleteSimRecordsItem(PhonebookArgs args) {
+        validateWritableEf(args, "delete");
+        validateSubscriptionAndEf(args);
+
+        acquireWriteLockOrThrow();
+        try {
+            AdnRecord record = loadRecord(args);
+            if (record == null || record.isEmpty()) {
+                return 0;
+            }
+            if (!updateRecord(args, record, args.pin2, "", "")) {
+                Rlog.e(TAG, "Failed to delete " + args.uri);
+            }
+            notifyChange();
+        } finally {
+            releaseWriteLock();
+        }
+        return 1;
+    }
+
+
+    @Override
+    public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable Bundle extras) {
+        switch (URI_MATCHER.match(uri)) {
+            case SIM_RECORDS_ITEM:
+                return updateSimRecordsItem(PhonebookArgs.forSimRecordsItem(uri, extras), values);
+            case ELEMENTARY_FILES:
+            case ELEMENTARY_FILES_ITEM:
+            case SIM_RECORDS:
+                throw new UnsupportedOperationException(uri + " does not support update");
+            default:
+                throw new IllegalArgumentException("Unsupported Uri " + uri);
+        }
+    }
+
+    @Override
+    public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection,
+            @Nullable String[] selectionArgs) {
+        throw new UnsupportedOperationException("Only Update with bundle is supported");
+    }
+
+    private int updateSimRecordsItem(PhonebookArgs args, ContentValues values) {
+        validateWritableEf(args, "update");
+        validateSubscriptionAndEf(args);
+
+        if (values == null || values.isEmpty()) {
+            return 0;
+        }
+        validateValues(args, values);
+        String newName = Strings.nullToEmpty(values.getAsString(SimRecords.NAME));
+        String newPhoneNumber = Strings.nullToEmpty(values.getAsString(SimRecords.PHONE_NUMBER));
+
+        acquireWriteLockOrThrow();
+
+        try {
+            AdnRecord record = loadRecord(args);
+
+            // Note we allow empty records to be updated. This is a bit weird because they are
+            // not returned by query methods but this allows a client application assign a name
+            // to a specific record number. This may be desirable in some phone app use cases since
+            // the record number is often used as a quick dial index.
+            if (record == null) {
+                return 0;
+            }
+            if (!updateRecord(args, record, args.pin2, newName, newPhoneNumber)) {
+                Rlog.e(TAG, "Failed to update " + args.uri);
+                return 0;
+            }
+            notifyChange();
+        } finally {
+            releaseWriteLock();
+        }
+        return 1;
+    }
+
+    void validateSubscriptionAndEf(PhonebookArgs args) {
+        SubscriptionInfo info =
+                args.subscriptionId != SubscriptionManager.INVALID_SUBSCRIPTION_ID
+                        ? getActiveSubscriptionInfo(args.subscriptionId)
+                        : null;
+        if (info == null) {
+            throw new IllegalArgumentException("No active SIM with subscription ID "
+                    + args.subscriptionId);
+        }
+
+        int[] recordsSize = getRecordsSizeForEf(args);
+        if (recordsSize == null || recordsSize[1] == 0) {
+            throw new IllegalArgumentException(args.efName
+                    + " is not supported for SIM with subscription ID " + args.subscriptionId);
+        }
+    }
+
+    private void acquireWriteLockOrThrow() {
+        try {
+            if (!mWriteLock.tryLock(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
+                throw new IllegalStateException("Timeout waiting to write");
+            }
+        } catch (InterruptedException e) {
+            throw new IllegalStateException("Write failed");
+        }
+    }
+
+    private void releaseWriteLock() {
+        mWriteLock.unlock();
+    }
+
+    private void validateWritableEf(PhonebookArgs args, String operationName) {
+        if (args.efType == ElementaryFiles.EF_FDN) {
+            if (hasPermissionsForFdnWrite(args)) {
+                return;
+            }
+        }
+        if (args.efType != ElementaryFiles.EF_ADN) {
+            throw new UnsupportedOperationException(
+                    args.uri + " does not support " + operationName);
+        }
+    }
+
+    private boolean hasPermissionsForFdnWrite(PhonebookArgs args) {
+        TelephonyManager telephonyManager = getContext().getSystemService(
+                TelephonyManager.class);
+        String callingPackage = getCallingPackage();
+        int granted = PackageManager.PERMISSION_DENIED;
+        if (callingPackage != null) {
+            granted = getContext().getPackageManager().checkPermission(
+                    Manifest.permission.MODIFY_PHONE_STATE, callingPackage);
+        }
+        return granted == PackageManager.PERMISSION_GRANTED
+                || telephonyManager.hasCarrierPrivileges(args.subscriptionId);
+
+    }
+
+
+    private boolean updateRecord(PhonebookArgs args, AdnRecord existingRecord, String pin2,
+            String newName, String newPhone) {
+        try {
+            return mIccPhoneBookSupplier.get().updateAdnRecordsInEfByIndexForSubscriber(
+                    args.subscriptionId, existingRecord.getEfid(), newName, newPhone,
+                    existingRecord.getRecId(),
+                    pin2);
+        } catch (RemoteException e) {
+            return false;
+        }
+    }
+
+    private SimRecords.NameValidationResult validateName(
+            PhonebookArgs args, @Nullable String name) {
+        name = Strings.nullToEmpty(name);
+        int recordSize = getRecordSize(getRecordsSizeForEf(args));
+        // Validating the name consists of encoding the record in the binary format that it is
+        // stored on the SIM then decoding it and checking whether the decoded name is the same.
+        // The AOSP implementation of AdnRecord replaces unsupported characters with spaces during
+        // encoding.
+        // TODO: It would be good to update AdnRecord to support UCS-2 on the encode path (it
+        //  supports it on the decode path). Right now it's not supported and so any non-latin
+        //  characters will not be valid (at least in the AOSP implementation).
+        byte[] encodedName = AdnRecord.encodeAlphaTag(name);
+        String sanitizedName = AdnRecord.decodeAlphaTag(encodedName, 0, encodedName.length);
+        return new SimRecords.NameValidationResult(name, sanitizedName,
+                encodedName.length, AdnRecord.getMaxAlphaTagBytes(recordSize));
+    }
+
+    private void validatePhoneNumber(@Nullable String phoneNumber) {
+        if (phoneNumber == null || phoneNumber.isEmpty()) {
+            throw new IllegalArgumentException(SimRecords.PHONE_NUMBER + " is required.");
+        }
+        int actualLength = phoneNumber.length();
+        // When encoded the "+" prefix sets a bit and so doesn't count against the maximum length
+        if (phoneNumber.startsWith("+")) {
+            actualLength--;
+        }
+        if (actualLength > AdnRecord.getMaxPhoneNumberDigits()) {
+            throw new IllegalArgumentException(SimRecords.PHONE_NUMBER + " is too long.");
+        }
+        for (int i = 0; i < phoneNumber.length(); i++) {
+            char c = phoneNumber.charAt(i);
+            if (!PhoneNumberUtils.isNonSeparator(c)) {
+                throw new IllegalArgumentException(
+                        SimRecords.PHONE_NUMBER + " contains unsupported characters.");
+            }
+        }
+    }
+
+    private void validateValues(PhonebookArgs args, ContentValues values) {
+        if (!SIM_RECORDS_WRITABLE_COLUMNS.containsAll(values.keySet())) {
+            Set<String> unsupportedColumns = new ArraySet<>(values.keySet());
+            unsupportedColumns.removeAll(SIM_RECORDS_WRITABLE_COLUMNS);
+            throw new IllegalArgumentException("Unsupported columns: " + Joiner.on(',')
+                    .join(unsupportedColumns));
+        }
+
+        String phoneNumber = values.getAsString(SimRecords.PHONE_NUMBER);
+        validatePhoneNumber(phoneNumber);
+
+        String name = values.getAsString(SimRecords.NAME);
+        SimRecords.NameValidationResult result = validateName(args, name);
+
+        if (result.getEncodedLength() > result.getMaxEncodedLength()) {
+            throw new IllegalArgumentException(SimRecords.NAME + " is too long.");
+        } else if (!Objects.equals(result.getName(), result.getSanitizedName())) {
+            throw new IllegalArgumentException(
+                    SimRecords.NAME + " contains unsupported characters.");
+        }
+    }
+
+    private List<SubscriptionInfo> getActiveSubscriptionInfoList() {
+        // Getting the SubscriptionInfo requires READ_PHONE_STATE but we're only returning
+        // the subscription ID and slot index which are not sensitive information.
+        CallingIdentity identity = clearCallingIdentity();
+        try {
+            return mSubscriptionManager.getActiveSubscriptionInfoList();
+        } finally {
+            restoreCallingIdentity(identity);
+        }
+    }
+
+    private SubscriptionInfo getActiveSubscriptionInfo(int subId) {
+        // Getting the SubscriptionInfo requires READ_PHONE_STATE.
+        CallingIdentity identity = clearCallingIdentity();
+        try {
+            return mSubscriptionManager.getActiveSubscriptionInfo(subId);
+        } finally {
+            restoreCallingIdentity(identity);
+        }
+    }
+
+    private List<AdnRecord> loadRecordsForEf(PhonebookArgs args) {
+        try {
+            return mIccPhoneBookSupplier.get().getAdnRecordsInEfForSubscriber(
+                    args.subscriptionId, args.efid);
+        } catch (RemoteException e) {
+            return null;
+        }
+    }
+
+    private AdnRecord loadRecord(PhonebookArgs args) {
+        List<AdnRecord> records = loadRecordsForEf(args);
+        if (args.recordNumber > records.size()) {
+            return null;
+        }
+        AdnRecord result = records.get(args.recordNumber - 1);
+        // This should be true but the service could have a different implementation.
+        if (result.getRecId() == args.recordNumber) {
+            return result;
+        }
+        for (AdnRecord record : records) {
+            if (record.getRecId() == args.recordNumber) {
+                return result;
+            }
+        }
+        return null;
+    }
+
+
+    private int[] getRecordsSizeForEf(PhonebookArgs args) {
+        try {
+            return mIccPhoneBookSupplier.get().getAdnRecordsSizeForSubscriber(
+                    args.subscriptionId, args.efid);
+        } catch (RemoteException e) {
+            return null;
+        }
+    }
+
+    void notifyChange() {
+        mContentNotifier.notifyChange(SimPhonebookContract.AUTHORITY_URI);
+    }
+
+    /** Testable wrapper around {@link ContentResolver#notifyChange(Uri, ContentObserver)} */
+    @TestApi
+    interface ContentNotifier {
+        void notifyChange(Uri uri);
+    }
+
+    /**
+     * Holds the arguments extracted from the Uri and query args for accessing the referenced
+     * phonebook data on a SIM.
+     */
+    private static class PhonebookArgs {
+        public final Uri uri;
+        public final int subscriptionId;
+        public final String efName;
+        public final int efType;
+        public final int efid;
+        public final int recordNumber;
+        public final String pin2;
+
+        PhonebookArgs(Uri uri, int subscriptionId, String efName,
+                @ElementaryFiles.EfType int efType, int efid, int recordNumber,
+                @Nullable Bundle queryArgs) {
+            this.uri = uri;
+            this.subscriptionId = subscriptionId;
+            this.efName = efName;
+            this.efType = efType;
+            this.efid = efid;
+            this.recordNumber = recordNumber;
+            pin2 = efType == ElementaryFiles.EF_FDN && queryArgs != null
+                    ? queryArgs.getString(SimRecords.QUERY_ARG_PIN2)
+                    : null;
+        }
+
+        static PhonebookArgs createFromEfName(Uri uri, int subscriptionId,
+                String efName, int recordNumber, @Nullable Bundle queryArgs) {
+            int efType;
+            int efid;
+            if (efName != null) {
+                switch (efName) {
+                    case ElementaryFiles.EF_ADN_PATH_SEGMENT:
+                        efType = ElementaryFiles.EF_ADN;
+                        efid = IccConstants.EF_ADN;
+                        break;
+                    case ElementaryFiles.EF_FDN_PATH_SEGMENT:
+                        efType = ElementaryFiles.EF_FDN;
+                        efid = IccConstants.EF_FDN;
+                        break;
+                    case ElementaryFiles.EF_SDN_PATH_SEGMENT:
+                        efType = ElementaryFiles.EF_SDN;
+                        efid = IccConstants.EF_SDN;
+                        break;
+                    default:
+                        throw new IllegalArgumentException(
+                                "Unrecognized elementary file " + efName);
+                }
+            } else {
+                efType = ElementaryFiles.EF_UNKNOWN;
+                efid = 0;
+            }
+            return new PhonebookArgs(uri, subscriptionId, efName, efType, efid, recordNumber,
+                    queryArgs);
+        }
+
+        /**
+         * Pattern: elementary_files/subid/${subscriptionId}/${efName}
+         *
+         * e.g. elementary_files/subid/1/adn
+         *
+         * @see ElementaryFiles#getItemUri(int, int)
+         * @see #ELEMENTARY_FILES_ITEM
+         */
+        static PhonebookArgs forElementaryFilesItem(Uri uri) {
+            int subscriptionId = parseSubscriptionIdFromUri(uri, 2);
+            String efName = uri.getPathSegments().get(3);
+            return PhonebookArgs.createFromEfName(
+                    uri, subscriptionId, efName, -1, null);
+        }
+
+        /**
+         * Pattern: subid/${subscriptionId}/${efName}
+         *
+         * <p>e.g. subid/1/adn
+         *
+         * @see SimRecords#getContentUri(int, int)
+         * @see #SIM_RECORDS
+         */
+        static PhonebookArgs forSimRecords(Uri uri, Bundle queryArgs) {
+            int subscriptionId = parseSubscriptionIdFromUri(uri, 1);
+            String efName = uri.getPathSegments().get(2);
+            return PhonebookArgs.createFromEfName(uri, subscriptionId, efName, -1, queryArgs);
+        }
+
+        /**
+         * Pattern: subid/${subscriptionId}/${efName}/${recordNumber}
+         *
+         * <p>e.g. subid/1/adn/10
+         *
+         * @see SimRecords#getItemUri(int, int, int)
+         * @see #SIM_RECORDS_ITEM
+         */
+        static PhonebookArgs forSimRecordsItem(Uri uri, Bundle queryArgs) {
+            int subscriptionId = parseSubscriptionIdFromUri(uri, 1);
+            String efName = uri.getPathSegments().get(2);
+            int recordNumber = parseRecordNumberFromUri(uri, 3);
+            return PhonebookArgs.createFromEfName(uri, subscriptionId, efName, recordNumber,
+                    queryArgs);
+        }
+
+        /**
+         * Pattern: subid/${subscriptionId}/${efName}/validate_name
+         *
+         * @see SimRecords#validateName(ContentResolver, int, int, String)
+         * @see #VALIDATE_NAME
+         */
+        static PhonebookArgs forValidateName(Uri uri, Bundle queryArgs) {
+            int subscriptionId = parseSubscriptionIdFromUri(uri, 1);
+            String efName = uri.getPathSegments().get(2);
+            return PhonebookArgs.createFromEfName(
+                    uri, subscriptionId, efName, -1, queryArgs);
+        }
+
+        private static int parseSubscriptionIdFromUri(Uri uri, int pathIndex) {
+            if (pathIndex == -1) {
+                return SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+            }
+            String segment = uri.getPathSegments().get(pathIndex);
+            try {
+                return Integer.parseInt(segment);
+            } catch (NumberFormatException e) {
+                throw new IllegalArgumentException("Invalid subscription ID: " + segment);
+            }
+        }
+
+        private static int parseRecordNumberFromUri(Uri uri, int pathIndex) {
+            try {
+                return Integer.parseInt(uri.getPathSegments().get(pathIndex));
+            } catch (NumberFormatException e) {
+                throw new IllegalArgumentException(
+                        "Invalid record index: " + uri.getLastPathSegment());
+            }
+        }
+    }
+}
diff --git a/src/com/android/phone/TelephonyShellCommand.java b/src/com/android/phone/TelephonyShellCommand.java
index 1e87fbe..af293ce 100644
--- a/src/com/android/phone/TelephonyShellCommand.java
+++ b/src/com/android/phone/TelephonyShellCommand.java
@@ -108,6 +108,8 @@
     private static final String RCS_UCE_COMMAND = "uce";
     private static final String UCE_GET_EAB_CONTACT = "get-eab-contact";
     private static final String UCE_REMOVE_EAB_CONTACT = "remove-eab-contact";
+    private static final String UCE_GET_DEVICE_ENABLED = "get-device-enabled";
+    private static final String UCE_SET_DEVICE_ENABLED = "set-device-enabled";
 
     // Take advantage of existing methods that already contain permissions checks when possible.
     private final ITelephony mInterface;
@@ -307,6 +309,11 @@
         pw.println("      -s: The SIM slot ID to read carrier config value for. If no option");
         pw.println("          is specified, it will choose the default voice SIM slot.");
         pw.println("      PHONE_NUMBER: The phone numbers to be removed from the EAB databases");
+        pw.println("  uce get-device-enabled");
+        pw.println("    Get the config to check whether the device supports RCS UCE or not.");
+        pw.println("  uce set-device-enabled true|false");
+        pw.println("    Set the device config for RCS User Capability Exchange to the value.");
+        pw.println("    The value could be true, false.");
     }
 
     private void onHelpNumberVerification() {
@@ -1621,6 +1628,10 @@
                 return handleRemovingEabContactCommand();
             case UCE_GET_EAB_CONTACT:
                 return handleGettingEabContactCommand();
+            case UCE_GET_DEVICE_ENABLED:
+                return handleUceGetDeviceEnabledCommand();
+            case UCE_SET_DEVICE_ENABLED:
+                return handleUceSetDeviceEnabledCommand();
         }
         return -1;
     }
@@ -1647,7 +1658,7 @@
         if (VDBG) {
             Log.v(LOG_TAG, "uce remove-eab-contact -s " + subId + ", result: " + result);
         }
-        return result;
+        return 0;
     }
 
     private int handleGettingEabContactCommand() {
@@ -1672,6 +1683,41 @@
         return 0;
     }
 
+    private int handleUceGetDeviceEnabledCommand() {
+        boolean result = false;
+        try {
+            result = mInterface.getDeviceUceEnabled();
+        } catch (RemoteException e) {
+            Log.w(LOG_TAG, "uce get-device-enabled, error " + e.getMessage());
+            return -1;
+        }
+        if (VDBG) {
+            Log.v(LOG_TAG, "uce get-device-enabled, returned: " + result);
+        }
+        getOutPrintWriter().println(result);
+        return 0;
+    }
+
+    private int handleUceSetDeviceEnabledCommand() {
+        String enabledStr = getNextArg();
+        if (TextUtils.isEmpty(enabledStr)) {
+            return -1;
+        }
+
+        try {
+            boolean isEnabled = Boolean.parseBoolean(enabledStr);
+            mInterface.setDeviceUceEnabled(isEnabled);
+            if (VDBG) {
+                Log.v(LOG_TAG, "uce set-device-enabled " + enabledStr + ", done");
+            }
+        } catch (NumberFormatException | RemoteException e) {
+            Log.w(LOG_TAG, "uce set-device-enabled " + enabledStr + ", error " + e.getMessage());
+            getErrPrintWriter().println("Exception: " + e.getMessage());
+            return -1;
+        }
+        return 0;
+    }
+
     private int handleSrcSetDeviceEnabledCommand() {
         String enabledStr = getNextArg();
         if (enabledStr == null) {
diff --git a/src/com/android/services/telephony/CallQualityManager.java b/src/com/android/services/telephony/CallQualityManager.java
new file mode 100644
index 0000000..01b5bae
--- /dev/null
+++ b/src/com/android/services/telephony/CallQualityManager.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.services.telephony;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.content.Context;
+import android.os.Bundle;
+import android.telecom.BluetoothCallQualityReport;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.phone.R;
+
+/**
+ * class to handle call quality events that are received by telecom and telephony
+ */
+public class CallQualityManager {
+    private static final String TAG = CallQualityManager.class.getCanonicalName();
+    private static final String CALL_QUALITY_REPORT_CHANNEL = "call_quality_report_channel";
+
+    /** notification ids */
+    public static final int BLUETOOTH_CHOPPY_VOICE_NOTIFICATION_ID = 700;
+
+    public static final String CALL_QUALITY_CHANNEL_ID = "CallQualityNotification";
+
+    private final Context mContext;
+    private final NotificationChannel mNotificationChannel;
+    private final NotificationManager mNotificationManager;
+
+    public CallQualityManager(Context context) {
+        mContext = context;
+        mNotificationChannel = new NotificationChannel(CALL_QUALITY_CHANNEL_ID,
+                mContext.getString(R.string.call_quality_notification_name),
+                NotificationManager.IMPORTANCE_HIGH);
+        mNotificationManager = (NotificationManager)
+                mContext.getSystemService(Context.NOTIFICATION_SERVICE);
+        mNotificationManager.createNotificationChannel(mNotificationChannel);
+    }
+
+    /**
+     * method that is called whenever a
+     * {@code BluetoothCallQualityReport.EVENT_SEND_BLUETOOTH_CALL_QUALITY_REPORT} is received
+     * @param extras Bundle that includes serialized {@code BluetoothCallQualityReport} parcelable
+     */
+    @VisibleForTesting
+    public void onBluetoothCallQualityReported(Bundle extras) {
+        if (extras == null) {
+            Log.d(TAG, "onBluetoothCallQualityReported: no extras provided");
+        }
+
+        BluetoothCallQualityReport callQualityReport = extras.getParcelable(
+                BluetoothCallQualityReport.EXTRA_BLUETOOTH_CALL_QUALITY_REPORT);
+
+        if (callQualityReport.isChoppyVoice()) {
+            onChoppyVoice();
+        }
+        // TODO: once other signals are also sent, we will add more actions here
+    }
+
+    /**
+     * method to post a notification to user suggesting ways to improve call quality in case of
+     * bluetooth choppy voice
+     */
+    @VisibleForTesting
+    public void onChoppyVoice() {
+        String title = "Call Quality Improvement";
+        //TODO: update call_quality_bluetooth_enhancement_suggestion with below before submitting:
+//        "Voice is not being transmitted properly via your bluetooth device."
+//                + "To improve, try:\n"
+//                + "1. moving your phone closer to your bluetooth device\n"
+//                + "2. using a different bluetooth device, or your phone's speaker\n";
+        popUpNotification(title,
+                mContext.getText(R.string.call_quality_notification_bluetooth_details));
+    }
+
+    private void popUpNotification(String title, CharSequence details) {
+        int iconId = android.R.drawable.stat_notify_error;
+
+        Notification notification = new Notification.Builder(mContext)
+                .setSmallIcon(iconId)
+                .setWhen(System.currentTimeMillis())
+                .setAutoCancel(true)
+                .setContentTitle(title)
+                .setContentText(details)
+                .setStyle(new Notification.BigTextStyle().bigText(details))
+                .setOngoing(true)
+                .setChannelId(CALL_QUALITY_CHANNEL_ID)
+                .setOnlyAlertOnce(true)
+                .build();
+
+        mNotificationManager.notify(TAG, BLUETOOTH_CHOPPY_VOICE_NOTIFICATION_ID, notification);
+    }
+}
diff --git a/src/com/android/services/telephony/TelephonyConnection.java b/src/com/android/services/telephony/TelephonyConnection.java
index 45c00e4..97c2773 100755
--- a/src/com/android/services/telephony/TelephonyConnection.java
+++ b/src/com/android/services/telephony/TelephonyConnection.java
@@ -18,6 +18,7 @@
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.content.ContentResolver;
 import android.content.Context;
 import android.content.res.Resources;
 import android.graphics.drawable.Icon;
@@ -28,6 +29,7 @@
 import android.os.Looper;
 import android.os.Message;
 import android.os.PersistableBundle;
+import android.telecom.BluetoothCallQualityReport;
 import android.telecom.CallAudioState;
 import android.telecom.Conference;
 import android.telecom.Connection;
@@ -64,8 +66,11 @@
 import com.android.internal.telephony.Phone;
 import com.android.internal.telephony.PhoneConstants;
 import com.android.internal.telephony.d2d.Communicator;
+import com.android.internal.telephony.d2d.DtmfAdapter;
+import com.android.internal.telephony.d2d.DtmfTransport;
 import com.android.internal.telephony.d2d.RtpAdapter;
 import com.android.internal.telephony.d2d.RtpTransport;
+import com.android.internal.telephony.d2d.Timeouts;
 import com.android.internal.telephony.gsm.SuppServiceNotification;
 import com.android.internal.telephony.imsphone.ImsPhone;
 import com.android.internal.telephony.imsphone.ImsPhoneCall;
@@ -86,6 +91,7 @@
 import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
 
 /**
  * Base class for CDMA and GSM connections.
@@ -843,6 +849,8 @@
 
     private RtpTransport mRtpTransport;
 
+    private DtmfTransport mDtmfTransport;
+
     /**
      * Facilitates device to device communication.
      */
@@ -854,6 +862,8 @@
     private final Set<TelephonyConnectionListener> mTelephonyListeners = Collections.newSetFromMap(
             new ConcurrentHashMap<TelephonyConnectionListener, Boolean>(8, 0.9f, 1));
 
+    private CallQualityManager mCallQualityManager;
+
     protected TelephonyConnection(com.android.internal.telephony.Connection originalConnection,
             String callId, @android.telecom.Call.Details.CallDirection int callDirection) {
         setCallDirection(callDirection);
@@ -863,6 +873,20 @@
         }
     }
 
+    @Override
+    public void onCallEvent(String event, Bundle extras) {
+        switch (event) {
+            case BluetoothCallQualityReport.EVENT_BLUETOOTH_CALL_QUALITY_REPORT:
+                if (mCallQualityManager == null) {
+                    mCallQualityManager = new CallQualityManager(getPhone().getContext());
+                }
+                mCallQualityManager.onBluetoothCallQualityReported(extras);
+                break;
+            default:
+                break;
+        }
+
+    }
     /**
      * Creates a clone of the current {@link TelephonyConnection}.
      *
@@ -3202,7 +3226,20 @@
             }
         };
         mRtpTransport = new RtpTransport(rtpAdapter, null /* TODO: not needed yet */, mHandler);
-        mCommunicator = new Communicator(List.of(mRtpTransport), this);
+
+        DtmfAdapter dtmfAdapter = digit -> {
+            if (!isImsConnection()) {
+                Log.w(TelephonyConnection.this, "sendDtmf: not an ims conn.");
+            }
+            Log.d(TelephonyConnection.this, "sendDtmf: send digit %c", digit);
+            ImsPhoneConnection originalConnection =
+                    (ImsPhoneConnection) mOriginalConnection;
+            originalConnection.getImsCall().sendDtmf(digit, null /* result msg not needed */);
+        };
+        ContentResolver cr = getPhone().getContext().getContentResolver();
+        mDtmfTransport = new DtmfTransport(dtmfAdapter, new Timeouts.Adapter(cr),
+                Executors.newSingleThreadScheduledExecutor());
+        mCommunicator = new Communicator(List.of(mRtpTransport, mDtmfTransport), this);
         mD2DCallStateAdapter = new D2DCallStateAdapter(mCommunicator);
         addTelephonyConnectionListener(mD2DCallStateAdapter);
     }
diff --git a/src/com/android/services/telephony/rcs/RcsFeatureController.java b/src/com/android/services/telephony/rcs/RcsFeatureController.java
index 304a74d..5a1acb5 100644
--- a/src/com/android/services/telephony/rcs/RcsFeatureController.java
+++ b/src/com/android/services/telephony/rcs/RcsFeatureController.java
@@ -20,9 +20,7 @@
 import android.content.Context;
 import android.net.Uri;
 import android.telephony.ims.ImsException;
-import android.telephony.ims.ImsRcsManager;
 import android.telephony.ims.ImsReasonInfo;
-import android.telephony.ims.RegistrationManager;
 import android.telephony.ims.aidl.IImsCapabilityCallback;
 import android.telephony.ims.aidl.IImsRegistrationCallback;
 import android.telephony.ims.stub.ImsRegistrationImplBase;
@@ -355,13 +353,14 @@
     /**
      * Query the availability of an IMS RCS capability.
      */
-    public boolean isAvailable(int capability) throws android.telephony.ims.ImsException {
+    public boolean isAvailable(int capability, int radioTech)
+            throws android.telephony.ims.ImsException {
         RcsFeatureManager manager = getFeatureManager();
         if (manager == null) {
             throw new ImsException("Service is not available",
                     ImsException.CODE_ERROR_SERVICE_UNAVAILABLE);
         }
-        return manager.isAvailable(capability);
+        return manager.isAvailable(capability, radioTech);
     }
 
     /**
diff --git a/src/com/android/services/telephony/rcs/TelephonyRcsService.java b/src/com/android/services/telephony/rcs/TelephonyRcsService.java
index a6789f5..66492ae 100644
--- a/src/com/android/services/telephony/rcs/TelephonyRcsService.java
+++ b/src/com/android/services/telephony/rcs/TelephonyRcsService.java
@@ -33,6 +33,7 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.telephony.PhoneConfigurationManager;
 import com.android.internal.util.IndentingPrintWriter;
+import com.android.phone.R;
 
 import java.io.FileDescriptor;
 import java.io.PrintWriter;
@@ -87,6 +88,22 @@
         }
     };
 
+    /**
+     * Used to inject device resource for testing.
+     */
+    @VisibleForTesting
+    public interface ResourceProxy {
+        /**
+         * @return an whether the device supports User Capability Exchange.
+         */
+        boolean getDeviceUceEnabled(Context context);
+    }
+
+    private static ResourceProxy sResourceProxy = context -> {
+        return context.getResources().getBoolean(
+                R.bool.config_rcs_user_capability_exchange_enabled);
+    };
+
     // Notifies this service that there has been a change in available slots.
     private static final int HANDLER_MSIM_CONFIGURATION_CHANGE = 1;
 
@@ -97,6 +114,9 @@
     // Maps slot ID -> RcsFeatureController.
     private SparseArray<RcsFeatureController> mFeatureControllers;
 
+    // Whether the device supports User Capability Exchange
+    private boolean mRcsUceEnabled;
+
     private BroadcastReceiver mCarrierConfigChangedReceiver = new BroadcastReceiver() {
         @Override
         public void onReceive(Context context, Intent intent) {
@@ -139,6 +159,16 @@
         mContext = context;
         mNumSlots = numSlots;
         mFeatureControllers = new SparseArray<>(numSlots);
+        mRcsUceEnabled = sResourceProxy.getDeviceUceEnabled(mContext);
+    }
+
+    @VisibleForTesting
+    public TelephonyRcsService(Context context, int numSlots, ResourceProxy resourceProxy) {
+        mContext = context;
+        mNumSlots = numSlots;
+        mFeatureControllers = new SparseArray<>(numSlots);
+        sResourceProxy = resourceProxy;
+        mRcsUceEnabled = sResourceProxy.getDeviceUceEnabled(mContext);
     }
 
     /**
@@ -234,7 +264,7 @@
     }
 
     private void updateSupportedFeatures(RcsFeatureController c, int slotId, int subId) {
-        if (doesSubscriptionSupportPresence(subId)) {
+        if (isDeviceUceEnabled() && doesSubscriptionSupportPresence(subId)) {
             if (c.getFeature(UceControllerManager.class) == null) {
                 c.addFeature(mFeatureFactory.createUceControllerManager(mContext, slotId, subId),
                         UceControllerManager.class);
@@ -259,6 +289,20 @@
         if (c.hasActiveFeatures()) c.connect();
     }
 
+    /**
+     * Get whether the device supports RCS User Capability Exchange or not.
+     */
+    public boolean isDeviceUceEnabled() {
+        return mRcsUceEnabled;
+    }
+
+    /**
+     * Set the device supports RCS User Capability Exchange.
+     */
+    public void setDeviceUceEnabled(boolean isEnabled) {
+        mRcsUceEnabled = isEnabled;
+    }
+
     private boolean doesSubscriptionSupportPresence(int subId) {
         if (!SubscriptionManager.isValidSubscriptionId(subId)) return false;
         CarrierConfigManager carrierConfigManager =
diff --git a/testapps/TestRcsApp/OWNERS b/testapps/TestRcsApp/OWNERS
new file mode 100644
index 0000000..1d0d52b
--- /dev/null
+++ b/testapps/TestRcsApp/OWNERS
@@ -0,0 +1,3 @@
+allenwtsu@google.com
+calvinpan@google.com
+jamescflin@google.com
diff --git a/testapps/TestRcsApp/TestApp/Android.bp b/testapps/TestRcsApp/TestApp/Android.bp
new file mode 100644
index 0000000..dfa1f2e
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/Android.bp
@@ -0,0 +1,19 @@
+android_app {
+    name: "TestRcsApp",
+
+    srcs: [
+        "src/**/*.java",
+    ],
+
+    static_libs: [
+        "androidx-constraintlayout_constraintlayout",
+        "aosp_test_rcs_client_base",
+        "androidx.appcompat_appcompat",
+    ],
+    certificate: "platform",
+
+    sdk_version: "system_current",
+    min_sdk_version: "30",
+}
+
+
diff --git a/testapps/TestRcsApp/TestApp/AndroidManifest.xml b/testapps/TestRcsApp/TestApp/AndroidManifest.xml
new file mode 100644
index 0000000..2ff1df0
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/AndroidManifest.xml
@@ -0,0 +1,116 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/* //packages/services/Telephony/testapps/TestRcsApp/TestApp/AndroidManifest.xml
+**
+** Copyright 2020, The Android Open Source Project
+**
+** Licensed under the Apache License, Version 2.0 (the "License");
+** you may not use this file except in compliance with the License.
+** You may obtain a copy of the License at
+**
+**     http://www.apache.org/licenses/LICENSE-2.0
+**
+** Unless required by applicable law or agreed to in writing, software
+** distributed under the License is distributed on an "AS IS" BASIS,
+** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+** See the License for the specific language governing permissions and
+** limitations under the License.
+*/
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.google.android.sample.rcsclient"
+    android:versionCode="2"
+    android:versionName="1.0.1">
+
+    <uses-sdk
+        android:minSdkVersion="30"
+        android:targetSdkVersion="30" />
+
+    <uses-permission android:name="android.permission.MODIFY_PHONE_STATE" />
+    <uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE" />
+
+    <application
+        android:allowBackup="true"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:roundIcon="@mipmap/ic_launcher_round"
+        android:supportsRtl="true"
+        android:theme="@style/AppTheme">
+        <activity android:name=".MainActivity">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+
+        <activity android:name=".DelegateActivity" />
+        <activity android:name=".UceActivity" />
+        <activity android:name=".GbaActivity" />
+        <activity android:name=".PhoneNumberActivity" />
+        <activity android:name=".ChatActivity" />
+        <activity android:name=".ContactListActivity" />
+        <activity android:name=".ProvisioningActivity" />
+
+        <provider
+            android:name=".util.ChatProvider"
+            android:authorities="rcsprovider" />
+
+
+        <!-- In order to make this App eligible to be selected as the default Message App, the
+             following components are required to be declared even if they are not implemented.
+        -->
+
+        <!-- BroadcastReceiver that listens for incoming SMS messages -->
+        <receiver
+            android:name=".SmsReceiver"
+            android:permission="android.permission.BROADCAST_SMS">
+            <intent-filter>
+                <action android:name="android.provider.Telephony.SMS_DELIVER" />
+            </intent-filter>
+        </receiver>
+
+        <!-- BroadcastReceiver that listens for incoming MMS messages -->
+        <receiver
+            android:name=".MmsReceiver"
+            android:permission="android.permission.BROADCAST_WAP_PUSH">
+            <intent-filter>
+                <action android:name="android.provider.Telephony.WAP_PUSH_DELIVER" />
+                <data android:mimeType="application/vnd.wap.mms-message" />
+            </intent-filter>
+        </receiver>
+
+        <!-- Activity that allows the user to send new SMS/MMS messages -->
+        <activity android:name=".ComposeSmsActivity">
+            <intent-filter>
+                <action android:name="android.intent.action.SEND" />
+                <action android:name="android.intent.action.SENDTO" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data android:scheme="sms" />
+                <data android:scheme="smsto" />
+                <data android:scheme="mms" />
+                <data android:scheme="mmsto" />
+            </intent-filter>
+        </activity>
+
+        <!-- Service that delivers messages from the phone "quick response" -->
+        <service
+            android:name=".HeadlessSmsSendService"
+            android:exported="true"
+            android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE">
+            <intent-filter>
+                <action android:name="android.intent.action.RESPOND_VIA_MESSAGE" />
+                <category android:name="android.intent.category.DEFAULT" />
+
+                <data android:scheme="sms" />
+                <data android:scheme="smsto" />
+                <data android:scheme="mms" />
+                <data android:scheme="mmsto" />
+            </intent-filter>
+        </service>
+
+    </application>
+
+</manifest>
diff --git a/testapps/TestRcsApp/TestApp/res/drawable-v24/ic_launcher_foreground.xml b/testapps/TestRcsApp/TestApp/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 0000000..fc0c6ab
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,42 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:aapt="http://schemas.android.com/aapt"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <path
+        android:fillType="evenOdd"
+        android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,
+        49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
+        android:strokeWidth="1"
+        android:strokeColor="#00000000">
+        <aapt:attr name="android:fillColor">
+            <gradient
+                android:endX="78.5885"
+                android:endY="90.9159"
+                android:startX="48.7653"
+                android:startY="61.0927"
+                android:type="linear">
+                <item
+                    android:color="#44000000"
+                    android:offset="0.0" />
+                <item
+                    android:color="#00000000"
+                    android:offset="1.0" />
+            </gradient>
+        </aapt:attr>
+    </path>
+    <path
+        android:fillColor="#FFFFFF"
+        android:fillType="nonZero"
+        android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,
+        50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,
+        37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,
+        42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,
+        40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,
+        52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,
+        56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,
+        52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
+        android:strokeWidth="1"
+        android:strokeColor="#00000000" />
+</vector>
diff --git a/testapps/TestRcsApp/TestApp/res/drawable/ic_launcher_background.xml b/testapps/TestRcsApp/TestApp/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <path
+        android:fillColor="#3DDC84"
+        android:pathData="M0,0h108v108h-108z" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M9,0L9,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,0L19,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,0L29,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,0L39,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,0L49,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,0L59,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,0L69,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,0L79,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M89,0L89,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M99,0L99,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,9L108,9"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,19L108,19"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,29L108,29"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,39L108,39"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,49L108,49"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,59L108,59"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,69L108,69"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,79L108,79"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,89L108,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,99L108,99"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,29L89,29"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,39L89,39"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,49L89,49"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,59L89,59"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,69L89,69"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,79L89,79"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,19L29,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,19L39,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,19L49,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,19L59,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,19L69,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,19L79,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+</vector>
diff --git a/testapps/TestRcsApp/TestApp/res/layout/activity_main.xml b/testapps/TestRcsApp/TestApp/res/layout/activity_main.xml
new file mode 100644
index 0000000..db7ea33
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/layout/activity_main.xml
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".MainActivity">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical">
+
+        <Button
+            android:id="@+id/provision"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/provisioning_test"
+            android:textAlignment="center"
+            android:textAllCaps="false" />
+
+        <Button
+            android:id="@+id/delegate"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/delegate_test"
+            android:textAlignment="center"
+            android:textAllCaps="false" />
+
+        <Button
+            android:id="@+id/uce"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/uce_test"
+            android:textAlignment="center"
+            android:textAllCaps="false" />
+
+        <Button
+            android:id="@+id/gba"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/gba_test"
+            android:textAlignment="center"
+            android:textAllCaps="false" />
+
+        <Button
+            android:id="@+id/msgClient"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/test_msg_client"
+            android:textAlignment="center"
+            android:textAllCaps="false" />
+
+        <TextView
+            android:id="@+id/version_info"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/version_info"
+            android:textAlignment="center"
+            android:paddingTop="7dp"/>
+    </LinearLayout>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/testapps/TestRcsApp/TestApp/res/layout/chat_layout.xml b/testapps/TestRcsApp/TestApp/res/layout/chat_layout.xml
new file mode 100644
index 0000000..374db9b
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/layout/chat_layout.xml
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical">
+
+    <LinearLayout
+        android:id="@id/title"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="@string/to"
+            android:textSize="15dp"
+            android:textStyle="bold" />
+
+        <EditText
+            android:id="@+id/destNum"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:inputType="number"
+            android:text="16504483120" />
+    </LinearLayout>
+
+
+    <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_below="@+id/title">
+
+        <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+            android:id="@+id/relative_layout"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"></RelativeLayout>
+    </ScrollView>
+
+    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_alignParentBottom="true">
+
+        <EditText
+            android:id="@+id/new_msg"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_toLeftOf="@+id/chat_btn"
+            android:text="@string/chat_message" />
+
+        <Button
+            android:id="@+id/chat_btn"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_alignParentRight="true"
+            android:text="@string/send" />
+    </RelativeLayout>
+
+</RelativeLayout>
\ No newline at end of file
diff --git a/testapps/TestRcsApp/TestApp/res/layout/contact_list.xml b/testapps/TestRcsApp/TestApp/res/layout/contact_list.xml
new file mode 100644
index 0000000..44f6d3c
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/layout/contact_list.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical">
+
+    <Button
+        android:id="@+id/start_chat_btn"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="10dp"
+        android:layout_marginBottom="10dp"
+        android:text="@string/start_chat"
+        android:textAllCaps="false" />
+
+    <ListView
+        android:id="@+id/listview"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_alignParentBottom="true" />
+
+</LinearLayout>
\ No newline at end of file
diff --git a/testapps/TestRcsApp/TestApp/res/layout/delegate_layout.xml b/testapps/TestRcsApp/TestApp/res/layout/delegate_layout.xml
new file mode 100644
index 0000000..106a024
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/layout/delegate_layout.xml
@@ -0,0 +1,118 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".DelegateActivity">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical">
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal">
+
+            <LinearLayout
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:orientation="vertical">
+
+                <CheckBox
+                    android:id="@+id/standalone-pager"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="@string/standalone_pager" />
+
+                <CheckBox
+                    android:id="@+id/standalone-large"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="@string/standalone_large" />
+
+                <CheckBox
+                    android:id="@+id/standalone-deferred"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="@string/standalone_deferred" />
+
+                <CheckBox
+                    android:id="@+id/standalone-pager-large"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="@string/standalone_pager_large" />
+
+                <CheckBox
+                    android:id="@+id/chat"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="@string/chat" />
+            </LinearLayout>
+
+            <LinearLayout
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:orientation="vertical">
+
+                <CheckBox
+                    android:id="@+id/file_transfer"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="@string/file_transfer" />
+
+                <CheckBox
+                    android:id="@+id/geolocation_sms"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="@string/geolocation_sms" />
+
+                <CheckBox
+                    android:id="@+id/chatbot_session"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="@string/chatbot_session" />
+
+                <CheckBox
+                    android:id="@+id/chatbot_standalone"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="@string/chatbot_standalone" />
+
+                <CheckBox
+                    android:id="@+id/chatbot_version"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="@string/chatbot_version" />
+            </LinearLayout>
+        </LinearLayout>
+
+        <Button
+            android:id="@+id/init_btn"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:text="@string/initialize_delegate"
+            android:textAllCaps="false" />
+
+        <Button
+            android:id="@+id/destroy_btn"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:text="@string/destroy_delegate"
+            android:textAllCaps="false" />
+
+        <TextView
+            android:id="@+id/delegate_callback_result"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginBottom="30dp"
+            android:scrollbars="vertical"
+            android:text="@string/callback_result"
+            android:textSize="15dp"
+            android:textStyle="bold" />
+
+    </LinearLayout>
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/testapps/TestRcsApp/TestApp/res/layout/gba_layout.xml b/testapps/TestRcsApp/TestApp/res/layout/gba_layout.xml
new file mode 100644
index 0000000..5ccbc8d
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/layout/gba_layout.xml
@@ -0,0 +1,129 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".GbaActivity">
+
+
+    <LinearLayout
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="vertical">
+
+        <LinearLayout
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal">
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="@string/organization"
+                android:textSize="15dp"
+                android:textStyle="bold" />
+
+            <Spinner
+                android:id="@+id/organization_list"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content" />
+        </LinearLayout>
+
+        <LinearLayout
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:layout_marginBottom="10dp"
+            android:orientation="horizontal">
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="@string/uicc_type"
+                android:textSize="15dp"
+                android:textStyle="bold" />
+
+            <Spinner
+                android:id="@+id/uicc_list"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content" />
+        </LinearLayout>
+
+        <LinearLayout
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:layout_marginBottom="10dp"
+            android:orientation="horizontal">
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="@string/protocol"
+                android:textSize="15dp"
+                android:textStyle="bold" />
+
+            <Spinner
+                android:id="@+id/protocol_list"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content" />
+        </LinearLayout>
+
+        <LinearLayout
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:layout_marginBottom="10dp"
+            android:orientation="horizontal">
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="@string/tls_cs"
+                android:textSize="15dp"
+                android:textStyle="bold" />
+
+            <EditText
+                android:id="@+id/tls_id"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:inputType="number"
+                android:text="47"
+                android:textSize="15dp" />
+        </LinearLayout>
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="@string/naf"
+            android:textSize="15dp"
+            android:textStyle="bold" />
+
+        <EditText
+            android:id="@+id/naf_url"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:inputType="number"
+            android:text="https://3GPP-bootstrapping@ue.fcs.mstore.msg.t-mobile.com"
+            android:textSize="15dp" />
+
+        <Button
+            android:id="@+id/gba_btn"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:layout_marginBottom="10dp"
+            android:text="@string/gba_bootstrap"
+            android:textAllCaps="false" />
+
+        <TextView
+            android:id="@+id/gba_result"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:scrollbars="vertical"
+            android:text="@string/result"
+            android:textSize="15dp"
+            android:textStyle="bold" />
+    </LinearLayout>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/testapps/TestRcsApp/TestApp/res/layout/number_to_chat.xml b/testapps/TestRcsApp/TestApp/res/layout/number_to_chat.xml
new file mode 100644
index 0000000..5d71cd1
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/layout/number_to_chat.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="@string/to"
+            android:textSize="15dp"
+            android:textStyle="bold" />
+
+        <EditText
+            android:id="@+id/destNum"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:inputType="number"
+            android:text="16504396583" />
+    </LinearLayout>
+
+    <Button
+        android:id="@+id/launch_chat_btn"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/ok" />
+
+</LinearLayout>
\ No newline at end of file
diff --git a/testapps/TestRcsApp/TestApp/res/layout/provision_layout.xml b/testapps/TestRcsApp/TestApp/res/layout/provision_layout.xml
new file mode 100644
index 0000000..d98dde2
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/layout/provision_layout.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".ProvisionActivity">
+
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical">
+
+        <Button
+            android:id="@+id/provisioning_register_btn"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:text="register"
+            android:textAllCaps="false" />
+
+        <Button
+            android:id="@+id/provisioning_unregister_btn"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:text="unregister"
+            android:textAllCaps="false" />
+
+        <Button
+            android:id="@+id/provisioning_singlereg_btn"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:layout_marginBottom="10dp"
+            android:text="isRcsVolteSingleRegCapable"
+            android:textAllCaps="false" />
+
+        <TextView
+            android:id="@+id/provisioning_singlereg_result"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/result"
+            android:textSize="15dp"
+            android:textStyle="bold" />
+
+        <TextView
+            android:id="@+id/provisioning_callback_result"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:scrollbars="vertical"
+            android:text="@string/callback_result"
+            android:textSize="15dp"
+            android:textStyle="bold" />
+
+    </LinearLayout>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/testapps/TestRcsApp/TestApp/res/layout/uce_layout.xml b/testapps/TestRcsApp/TestApp/res/layout/uce_layout.xml
new file mode 100644
index 0000000..0174d71
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/layout/uce_layout.xml
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".UceActivity">
+
+
+    <LinearLayout
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="vertical">
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/uce_description"
+            android:textSize="15dp"
+            android:textStyle="bold" />
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal">
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="@string/number"
+                android:textSize="15dp"
+                android:textStyle="bold" />
+
+            <EditText
+                android:id="@+id/number_list"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:inputType="number"
+                android:text="16504483123, 16504489023" />
+        </LinearLayout>
+
+        <Button
+            android:id="@+id/capability_btn"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:layout_marginBottom="10dp"
+            android:text="@string/request_capability"
+            android:textAllCaps="false" />
+
+        <TextView
+            android:id="@+id/capability_result"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/result"
+            android:textSize="15dp"
+            android:textStyle="bold" />
+
+        <Button
+            android:id="@+id/availability_btn"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:layout_marginBottom="10dp"
+            android:text="@string/request_availability"
+            android:textAllCaps="false" />
+
+        <TextView
+            android:id="@+id/availability_result"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/result"
+            android:textSize="15dp"
+            android:textStyle="bold" />
+    </LinearLayout>
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/testapps/TestRcsApp/TestApp/res/mipmap-anydpi-v26/ic_launcher.xml b/testapps/TestRcsApp/TestApp/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..eca70cf
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@drawable/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>
\ No newline at end of file
diff --git a/testapps/TestRcsApp/TestApp/res/mipmap-anydpi-v26/ic_launcher_round.xml b/testapps/TestRcsApp/TestApp/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..eca70cf
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@drawable/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>
\ No newline at end of file
diff --git a/testapps/TestRcsApp/TestApp/res/values/colors.xml b/testapps/TestRcsApp/TestApp/res/values/colors.xml
new file mode 100644
index 0000000..3d5cded
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/values/colors.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="colorPrimary">#008577</color>
+    <color name="colorPrimaryDark">#00574B</color>
+    <color name="colorAccent">#D81B60</color>
+</resources>
+
diff --git a/testapps/TestRcsApp/TestApp/res/values/donottranslate_strings.xml b/testapps/TestRcsApp/TestApp/res/values/donottranslate_strings.xml
new file mode 100644
index 0000000..3528add
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/values/donottranslate_strings.xml
@@ -0,0 +1,75 @@
+<resources>
+    <string name="app_name">RcsClient</string>
+    <string name="provisioning_test">Provisioning Test</string>
+    <string name="delegate_test">Delegate Test</string>
+    <string name="uce_test">UCE Test</string>
+    <string name="gba_test">GBA Test</string>
+    <string name="test_msg_client">TestMessageClient</string>
+    <string name="db_client">DBClient</string>
+    <string name="result">Result:</string>
+    <string name="callback_result">Callback Result:</string>
+    <string name="initialize_delegate">initializeSipDelegate</string>
+    <string name="destroy_delegate">destroySipDelegate</string>
+    <string name="uce_description">Enter the number to query capability and separate by \',\' if
+        multiple ones.</string>
+    <string name="number">Number: </string>
+    <string name="request_capability">requestCapability</string>
+    <string name="request_availability">requestNetworkAvailability</string>
+    <string name="gba_bootstrap">bootstrapAuthenticationRequest</string>
+    <string name="start_chat">Start Chat</string>
+    <string name="to">To:</string>
+    <string name="chat_message">Chat Message</string>
+    <string name="send">Send</string>
+    <string name="ok">OK</string>
+    <string name="session_succeeded">Session init succeeded</string>
+    <string name="session_failed">Session init failed</string>
+    <string name="session_not_ready">Session not ready</string>
+    <string name="organization">Organization:</string>
+    <string name="uicc_type">UICC Type:</string>
+    <string name="protocol">Protocol:</string>
+    <string name="tls_cs">TLS Cipher Suite:</string>
+    <string name="naf">NAF URI:</string>
+    <string name="standalone_pager">Standalone Pager</string>
+    <string name="standalone_large">Standalone Large</string>
+    <string name="standalone_deferred">Standalone Deferred</string>
+    <string name="standalone_pager_large">Standalone Large Pager</string>
+    <string name="chat">Chat</string>
+    <string name="file_transfer">File Transfer</string>
+    <string name="geolocation_sms">Geolocation SMS</string>
+    <string name="chatbot_session">Chatbot Session</string>
+    <string name="chatbot_standalone">Chatbot Standalone</string>
+    <string name="chatbot_version">Chatbot Version</string>
+    <string name="provisioning_done">Provisioning Done</string>
+    <string name="registration_done">Registration Done</string>
+    <string name="version_info">Version: %s</string>
+
+    <string-array name="organization">
+        <item>NONE</item>
+        <item>3GPP</item>
+        <item>3GPP2</item>
+        <item>OMA</item>
+        <item>GSMA</item>
+        <item>LOCAL</item>
+    </string-array>
+    <string-array name="protocol">
+        <item>SUBSCRIBER_CERTIFICATE</item>
+        <item>MBMS</item>
+        <item>HTTP_DIGEST_AUTH</item>
+        <item>3GPP_HTTP_BASED_MBMS</item>
+        <item>GENERIC_PUSH_LAYER</item>
+        <item>IMS_MEDIA_PLANE</item>
+        <item>GENERATION_TMPI</item>
+        <item>3GPP_HTTP_BASED_MBMS</item>
+        <item>TLS_DEFAULT</item>
+        <item>TLS_BROWSER</item>
+    </string-array>
+    <string-array name="uicc_type">
+        <item>UNKNOWN</item>
+        <item>SIM</item>
+        <item>USIM</item>
+        <item>RSIM</item>
+        <item>CSIM</item>
+        <item>ISIM</item>
+    </string-array>
+
+</resources>
diff --git a/testapps/TestRcsApp/TestApp/res/values/styles.xml b/testapps/TestRcsApp/TestApp/res/values/styles.xml
new file mode 100644
index 0000000..5885930
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/res/values/styles.xml
@@ -0,0 +1,11 @@
+<resources>
+
+    <!-- Base application theme. -->
+    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
+        <!-- Customize your theme here. -->
+        <item name="colorPrimary">@color/colorPrimary</item>
+        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
+        <item name="colorAccent">@color/colorAccent</item>
+    </style>
+
+</resources>
diff --git a/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/ChatActivity.java b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/ChatActivity.java
new file mode 100644
index 0000000..1619f14
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/ChatActivity.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.sample.rcsclient;
+
+import android.content.Intent;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.telephony.SubscriptionManager;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.MenuItem;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.google.android.sample.rcsclient.util.ChatManager;
+import com.google.android.sample.rcsclient.util.ChatProvider;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/** An activity to show chat message with specific number. */
+public class ChatActivity extends AppCompatActivity {
+
+    public static final String EXTRA_REMOTE_PHONE_NUMBER = "REMOTE_PHONE_NUMBER";
+    public static final String TELURI_PREFIX = "tel:";
+    private static final String TAG = "TestRcsApp.ChatActivity";
+    private static final int INIT_LIST = 1;
+    private static final int SHOW_TOAST = 2;
+    private static final float TEXT_SIZE = 20.0f;
+    private static final int MARGIN_SIZE = 20;
+    private final ExecutorService mFixedThreadPool = Executors.newFixedThreadPool(3);
+    private boolean mSessionInitResult = false;
+    private Button mSend;
+    private String mDestNumber;
+    private EditText mNewMessage;
+    private ChatObserver mChatObserver;
+    private Handler mHandler;
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        Log.i(TAG, "onCreate");
+        setContentView(R.layout.chat_layout);
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowHomeEnabled(true);
+
+        mHandler = new Handler() {
+            public void handleMessage(Message msg) {
+                super.handleMessage(msg);
+                Log.d(TAG, "handleMessage:" + msg.what);
+                switch (msg.what) {
+                    case INIT_LIST:
+                        initChatMessageLayout((Cursor) msg.obj);
+                        break;
+                    case SHOW_TOAST:
+                        Toast.makeText(ChatActivity.this, msg.obj.toString(),
+                                Toast.LENGTH_SHORT).show();
+                        break;
+                    default:
+                        Log.d(TAG, "unknown msg:" + msg.what);
+                        break;
+                }
+
+            }
+        };
+        initDestNumber();
+        mChatObserver = new ChatObserver(mHandler);
+    }
+
+    private void initDestNumber() {
+        Intent intent = getIntent();
+        mDestNumber = intent.getStringExtra(EXTRA_REMOTE_PHONE_NUMBER);
+        TextView destNumber = findViewById(R.id.destNum);
+        destNumber.setText(mDestNumber);
+    }
+
+    @Override
+    protected void onStart() {
+        super.onStart();
+        initChatButton();
+        queryChatData();
+        getContentResolver().registerContentObserver(ChatProvider.CHAT_URI, false,
+                mChatObserver);
+    }
+
+    private void initChatButton() {
+        mNewMessage = findViewById(R.id.new_msg);
+        mSend = findViewById(R.id.chat_btn);
+
+        int subId = SubscriptionManager.getDefaultSmsSubscriptionId();
+        if (!SubscriptionManager.isValidSubscriptionId(subId)) {
+            Log.e(TAG, "invalid subId:" + subId);
+            return;
+        }
+        try {
+
+            ChatManager.getInstance(getApplicationContext(), subId).initChatSession(
+                    TELURI_PREFIX + mDestNumber, new SessionStateCallback() {
+                        @Override
+                        public void onSuccess() {
+                            Log.i(TAG, "session init succeeded");
+                            mHandler.sendMessage(mHandler.obtainMessage(SHOW_TOAST,
+                                    ChatActivity.this.getResources().getString(
+                                            R.string.session_succeeded)));
+                            mSessionInitResult = true;
+                        }
+
+                        @Override
+                        public void onFailure() {
+                            Log.i(TAG, "session init failed");
+                            mHandler.sendMessage(mHandler.obtainMessage(SHOW_TOAST,
+                                    ChatActivity.this.getResources().getString(
+                                            R.string.session_failed)));
+                            mSessionInitResult = false;
+                        }
+                    });
+
+            mSend.setOnClickListener(view -> {
+                if (!mSessionInitResult) {
+                    Toast.makeText(ChatActivity.this,
+                            getResources().getString(R.string.session_not_ready),
+                            Toast.LENGTH_SHORT).show();
+                    Log.i(TAG, "session not ready");
+                    return;
+                }
+                mFixedThreadPool.execute(() -> {
+                    if (TextUtils.isEmpty(mDestNumber)) {
+                        Log.i(TAG, "Destination number is empty");
+                    } else {
+                        ChatManager.getInstance(getApplicationContext(), subId).addNewMessage(
+                                mNewMessage.getText().toString(), ChatManager.SELF, mDestNumber);
+                        ChatManager.getInstance(getApplicationContext(), subId).sendMessage(
+                                TELURI_PREFIX + mDestNumber, mNewMessage.getText().toString());
+                    }
+                });
+            });
+        } catch (Exception e) {
+            Log.e(TAG, e.getMessage());
+            e.printStackTrace();
+        }
+    }
+
+    private void initChatMessageLayout(Cursor cursor) {
+        Log.i(TAG, "initChatMessageLayout");
+        RelativeLayout rl = findViewById(R.id.relative_layout);
+        int id = 1;
+        if (cursor != null && cursor.moveToNext()) {
+            TextView chatMessage = initChatMessageItem(cursor, id++, true);
+            rl.addView(chatMessage);
+        }
+        while (cursor != null && cursor.moveToNext()) {
+            TextView chatMessage = initChatMessageItem(cursor, id++, false);
+            rl.addView(chatMessage);
+        }
+        if (cursor != null) {
+            cursor.close();
+        }
+    }
+
+    private TextView initChatMessageItem(Cursor cursor, int id, boolean isFirst) {
+        TextView chatMsg = new TextView(this);
+        chatMsg.setId(id);
+        chatMsg.setText(
+                cursor.getString(cursor.getColumnIndex(ChatProvider.RcsColumns.CHAT_MESSAGE)));
+        chatMsg.setTextSize(TEXT_SIZE);
+        chatMsg.setTypeface(null, Typeface.BOLD);
+        RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(
+                ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+        lp.setMargins(0, MARGIN_SIZE, 0, 0);
+        if (messageFromSelf(cursor)) {
+            lp.addRule(RelativeLayout.ALIGN_PARENT_RIGHT);
+            chatMsg.setBackgroundColor(Color.YELLOW);
+        } else {
+            lp.addRule(RelativeLayout.ALIGN_PARENT_LEFT);
+            chatMsg.setBackgroundColor(Color.LTGRAY);
+        }
+        if (!isFirst) {
+            lp.addRule(RelativeLayout.BELOW, id - 1);
+        }
+        chatMsg.setLayoutParams(lp);
+        return chatMsg;
+    }
+
+    private boolean messageFromSelf(Cursor cursor) {
+        return ChatManager.SELF.equals(
+                cursor.getString(cursor.getColumnIndex(ChatProvider.RcsColumns.SRC_PHONE_NUMBER)));
+    }
+
+    private void queryChatData() {
+        mFixedThreadPool.execute(() -> {
+            Cursor cursor = getContentResolver().query(ChatProvider.CHAT_URI,
+                    new String[]{ChatProvider.RcsColumns.SRC_PHONE_NUMBER,
+                            ChatProvider.RcsColumns.DEST_PHONE_NUMBER,
+                            ChatProvider.RcsColumns.CHAT_MESSAGE},
+                    ChatProvider.RcsColumns.SRC_PHONE_NUMBER + "=? OR "
+                            + ChatProvider.RcsColumns.DEST_PHONE_NUMBER + "=?",
+                    new String[]{mDestNumber, mDestNumber},
+                    ChatProvider.RcsColumns.MSG_TIMESTAMP + " ASC");
+            mHandler.sendMessage(mHandler.obtainMessage(INIT_LIST, cursor));
+        });
+    }
+
+    @Override
+    protected void onStop() {
+        super.onStop();
+        Log.i(TAG, "onStop");
+        getContentResolver().unregisterContentObserver(mChatObserver);
+    }
+
+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        Log.i(TAG, "onDestroy");
+    }
+
+    private void dispose() {
+        int subId = SubscriptionManager.getDefaultSmsSubscriptionId();
+        if (!SubscriptionManager.isValidSubscriptionId(subId)) {
+            Log.e(TAG, "invalid subId:" + subId);
+            return;
+        }
+        ChatManager chatManager = ChatManager.getInstance(this, subId);
+        chatManager.deregister();
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    private class ChatObserver extends ContentObserver {
+        ChatObserver(Handler handler) {
+            super(handler);
+        }
+
+        @Override
+        public void onChange(boolean selfChange) {
+            Log.i(TAG, "onChange");
+            queryChatData();
+        }
+    }
+
+}
diff --git a/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/ContactListActivity.java b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/ContactListActivity.java
new file mode 100644
index 0000000..70715f0
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/ContactListActivity.java
@@ -0,0 +1,255 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.sample.rcsclient;
+
+import android.content.Intent;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.graphics.Typeface;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.telephony.SubscriptionManager;
+import android.util.Log;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.android.libraries.rcs.simpleclient.SimpleRcsClient.State;
+
+import com.google.android.sample.rcsclient.util.ChatManager;
+import com.google.android.sample.rcsclient.util.ChatProvider;
+
+import java.util.ArrayList;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/** An activity to show the contacts which UE ever chatted before. */
+public class ContactListActivity extends AppCompatActivity {
+
+    private static final String TAG = "TestRcsApp.ContactListActivity";
+    private static final int RENDER_LISTVIEW = 1;
+    private static final int SHOW_TOAST = 2;
+    private final ExecutorService mSingleThread = Executors.newSingleThreadExecutor();
+    private Button mStartChatButton;
+    private Handler mHandler;
+    private SummaryObserver mSummaryObserver;
+    private ArrayAdapter mAdapter;
+    private ListView mListview;
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        Log.i(TAG, "onCreate");
+        setContentView(R.layout.contact_list);
+
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowHomeEnabled(true);
+
+        mStartChatButton = findViewById(R.id.start_chat_btn);
+        mStartChatButton.setOnClickListener(view -> {
+            Intent intent = new Intent(ContactListActivity.this, PhoneNumberActivity.class);
+            ContactListActivity.this.startActivity(intent);
+        });
+
+        mHandler = new Handler() {
+            public void handleMessage(Message message) {
+                Log.i(TAG, "handleMessage:" + message.what);
+                switch (message.what) {
+                    case RENDER_LISTVIEW:
+                        renderListView((ArrayList<ContactAttributes>) message.obj);
+                        break;
+                    case SHOW_TOAST:
+                        Toast.makeText(ContactListActivity.this, message.obj.toString(),
+                                Toast.LENGTH_SHORT).show();
+                        break;
+                    default:
+                        Log.i(TAG, "unknown msg:" + message.what);
+                }
+            }
+        };
+        initListView();
+        mSummaryObserver = new SummaryObserver(mHandler);
+    }
+
+    @Override
+    protected void onStart() {
+        super.onStart();
+        Log.i(TAG, "onStart");
+        initSipDelegate();
+        querySummaryData();
+        getContentResolver().registerContentObserver(ChatProvider.SUMMARY_URI, false,
+                mSummaryObserver);
+    }
+
+    @Override
+    protected void onStop() {
+        super.onStop();
+        Log.i(TAG, "onStop");
+        getContentResolver().unregisterContentObserver(mSummaryObserver);
+    }
+
+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        Log.i(TAG, "onDestroy");
+        dispose();
+    }
+
+    private void dispose() {
+        int subId = SubscriptionManager.getDefaultSmsSubscriptionId();
+        if (!SubscriptionManager.isValidSubscriptionId(subId)) {
+            Log.e(TAG, "invalid subId:" + subId);
+            return;
+        }
+        ChatManager chatManager = ChatManager.getInstance(this, subId);
+        chatManager.deregister();
+    }
+
+    private void initListView() {
+        Log.i(TAG, "initListView");
+        mListview = findViewById(R.id.listview);
+
+        mAdapter = new ArrayAdapter<ContactAttributes>(this,
+                android.R.layout.simple_list_item_2,
+                android.R.id.text1) {
+            @Override
+            public View getView(int pos, View convert, ViewGroup group) {
+                View v = super.getView(pos, convert, group);
+                TextView t1 = (TextView) v.findViewById(android.R.id.text1);
+                TextView t2 = (TextView) v.findViewById(android.R.id.text2);
+                t1.setText(getItem(pos).phoneNumber);
+                t2.setText(getItem(pos).message);
+                if (!getItem(pos).isRead) {
+                    t1.setTypeface(null, Typeface.BOLD);
+                    t2.setTypeface(null, Typeface.BOLD);
+                }
+                return v;
+            }
+        };
+        mListview.setAdapter(mAdapter);
+    }
+
+    private void querySummaryData() {
+        mSingleThread.execute(() -> {
+            Cursor cursor = getContentResolver().query(ChatProvider.SUMMARY_URI,
+                    new String[]{ChatProvider.SummaryColumns.REMOTE_PHONE_NUMBER,
+                            ChatProvider.SummaryColumns.LATEST_MESSAGE,
+                            ChatProvider.SummaryColumns.IS_READ}, null, null, null);
+
+            ArrayList<ContactAttributes> contactList = new ArrayList<>();
+            while (cursor.moveToNext()) {
+                String phoneNumber = getPhoneNumber(cursor);
+                String latestMessage = getLatestMessage(cursor);
+                boolean isRead = getIsRead(cursor);
+                contactList.add(new ContactAttributes(phoneNumber, latestMessage, isRead));
+            }
+            mHandler.sendMessage(mHandler.obtainMessage(RENDER_LISTVIEW, contactList));
+            cursor.close();
+        });
+    }
+
+    private void renderListView(ArrayList<ContactAttributes> contactList) {
+        mAdapter.clear();
+        mAdapter.addAll(contactList);
+        mListview.setOnItemClickListener((parent, view, position, id) -> {
+            Intent intent = new Intent(ContactListActivity.this, ChatActivity.class);
+            intent.putExtra(ChatActivity.EXTRA_REMOTE_PHONE_NUMBER,
+                    contactList.get(position).phoneNumber);
+            ContactListActivity.this.startActivity(intent);
+        });
+
+    }
+
+    private void initSipDelegate() {
+        int subId = SubscriptionManager.getDefaultSmsSubscriptionId();
+        if (!SubscriptionManager.isValidSubscriptionId(subId)) {
+            Log.e(TAG, "invalid subId:" + subId);
+            return;
+        }
+        Log.i(TAG, "initSipDelegate");
+        ChatManager chatManager = ChatManager.getInstance(this, subId);
+        chatManager.setRcsStateChangedCallback((oldState, newState) -> {
+            //Show toast when provisioning or registration is done.
+            if (newState == State.REGISTERING) {
+                mHandler.sendMessage(mHandler.obtainMessage(SHOW_TOAST,
+                        ContactListActivity.this.getResources().getString(
+                                R.string.provisioning_done)));
+            } else if (newState == State.REGISTERED) {
+                mHandler.sendMessage(mHandler.obtainMessage(SHOW_TOAST,
+                        ContactListActivity.this.getResources().getString(
+                                R.string.registration_done)));
+            }
+
+        });
+        chatManager.register();
+    }
+
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+
+    private String getPhoneNumber(Cursor cursor) {
+        return cursor.getString(
+                cursor.getColumnIndex(ChatProvider.SummaryColumns.REMOTE_PHONE_NUMBER));
+    }
+
+    private String getLatestMessage(Cursor cursor) {
+        return cursor.getString(cursor.getColumnIndex(ChatProvider.SummaryColumns.LATEST_MESSAGE));
+    }
+
+    private boolean getIsRead(Cursor cursor) {
+        return 1 == cursor.getInt(cursor.getColumnIndex(ChatProvider.SummaryColumns.IS_READ));
+    }
+
+    class ContactAttributes {
+        public String phoneNumber;
+        public String message;
+        public boolean isRead;
+
+        ContactAttributes(String phoneNumber, String message, boolean isRead) {
+            this.phoneNumber = phoneNumber;
+            this.message = message;
+            this.isRead = isRead;
+        }
+    }
+
+    private class SummaryObserver extends ContentObserver {
+        SummaryObserver(Handler handler) {
+            super(handler);
+        }
+
+        @Override
+        public void onChange(boolean selfChange) {
+            querySummaryData();
+        }
+    }
+}
diff --git a/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/DelegateActivity.java b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/DelegateActivity.java
new file mode 100644
index 0000000..7f751d6
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/DelegateActivity.java
@@ -0,0 +1,406 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.sample.rcsclient;
+
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.telephony.SmsManager;
+import android.telephony.SubscriptionManager;
+import android.telephony.ims.DelegateRegistrationState;
+import android.telephony.ims.DelegateRequest;
+import android.telephony.ims.FeatureTagState;
+import android.telephony.ims.ImsException;
+import android.telephony.ims.ImsManager;
+import android.telephony.ims.SipDelegateConnection;
+import android.telephony.ims.SipDelegateImsConfiguration;
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+import android.telephony.ims.stub.DelegateConnectionMessageCallback;
+import android.telephony.ims.stub.DelegateConnectionStateCallback;
+import android.text.method.ScrollingMovementMethod;
+import android.util.Log;
+import android.view.MenuItem;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/** An activity to verify SipDelegate creation and destruction. */
+public class DelegateActivity extends AppCompatActivity {
+
+    private static final String TAG = "TestRcsApp.DelegateActivity";
+    private static final String ICSI = "+g.3gpp.icsi-ref=";
+    private static final String IARI = "+g.3gpp.iari-ref=";
+
+    //https://www.gsma.com/futurenetworks/wp-content/uploads/2019/10/RCC.07-v11.0.pdf
+    private static final String SESSION_TAG =
+            "+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.oma.cpm.session\"";
+    private static final String STANDALONE_PAGER =
+            "+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.oma.cpm.msg\"";
+    private static final String STANDALONE_LARGE =
+            "+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.oma.cpm.largemsg\"";
+    private static final String STANDALONE_DEFERRED =
+            "+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.oma.cpm.deferred\"";
+    private static final String STANDALONE_LARGE_PAGER = "+g.gsma.rcs.cpm.pager-large";
+
+
+    private static final String FILE_TRANSFER =
+            "+g.3gpp.iari-ref=\"urn%3Aurn-7%3A3gpp-application.ims.iari.rcs.fthttp\"";
+    private static final String GEOLOCATION_SMS =
+            "+g.3gpp.iari-ref=\"urn%3Aurn-7%3A3gpp-application.ims.iari.rcs.geosms\"";
+
+    private static final String CHATBOT_SESSION =
+            "+g.3gpp.iari-ref=\"urn%3Aurn-7%3A3gpp-application.ims.iari.rcs.chatbot\"";
+    private static final String CHATBOT_STANDALONE =
+            "+g.3gpp.iari-ref=\"urn%3Aurn-7%3A3gpp-application.ims.iari.rcs.chatbot.sa\"";
+    private static final String CHATBOT_VERSION = "+g.gsma.rcs.botversion=\"#=1,#=2\"";
+
+
+    private static final int MSG_RESULT = 1;
+    private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor();
+    // Callback for incoming messages on the modem connection
+    private final DelegateConnectionMessageCallback mMessageCallback =
+            new DelegateConnectionMessageCallback() {
+                @Override
+                public void onMessageReceived(@NonNull SipMessage message) {
+                    Log.i(TAG, "onMessageReceived:" + message);
+                }
+
+                @Override
+                public void onMessageSendFailure(@NonNull String viaTransactionId, int reason) {
+                    Log.i(TAG, "onMessageSendFailure, viaTransactionId:" + viaTransactionId
+                            + " reason:" + reason);
+                }
+
+                @Override
+                public void onMessageSent(@NonNull String viaTransactionId) {
+                    Log.i(TAG, "onMessageSent, viaTransactionId:" + viaTransactionId);
+                }
+
+            };
+    private String mCallbackResultStr = "";
+    private int mDefaultSmsSubId;
+    private SipDelegateManager mSipDelegateManager;
+    private SipDelegateConnection mSipDelegateConnection;
+    private Button mInitButton;
+    private Button mDestroyButton;
+    private TextView mCallbackResult;
+    private CheckBox mChatCb, mStandalonePagerCb, mStandaloneLargeCb, mStandaloneDeferredCb,
+            mStandaloneLargePagerCb, mFileTransferCb, mGeolocationSmsCb, mChatbotSessionCb,
+            mChatbotStandaloneCb, mChatbotVersionCb;
+    private Handler mHandler;
+    private final DelegateConnectionStateCallback mConnectionCallback =
+            new DelegateConnectionStateCallback() {
+
+                @Override
+                public void onCreated(SipDelegateConnection c) {
+                    mSipDelegateConnection = c;
+                    mCallbackResultStr += "onCreated\r\n\r\n";
+                    Log.i(TAG, mCallbackResultStr);
+                    mHandler.sendMessage(mHandler.obtainMessage(MSG_RESULT));
+                }
+
+                @Override
+                public void onImsConfigurationChanged(
+                        SipDelegateImsConfiguration registeredSipConfig) {
+                    mCallbackResultStr += "onImsConfigurationChanged SipDelegateImsConfiguration:"
+                            + registeredSipConfig + "\r\n\r\n";
+                    Log.i(TAG, mCallbackResultStr);
+                    mHandler.sendMessage(mHandler.obtainMessage(MSG_RESULT));
+                    dumpConfig(registeredSipConfig);
+                }
+
+                @Override
+                public void onFeatureTagStatusChanged(
+                        @NonNull DelegateRegistrationState registrationState,
+                        @NonNull Set<FeatureTagState> deniedFeatureTags) {
+                    StringBuilder stringBuilder = new StringBuilder(
+                            "onFeatureTagStatusChanged ").append(
+                            " deniedFeatureTags:[");
+                    Iterator<FeatureTagState> iterator = deniedFeatureTags.iterator();
+                    while (iterator.hasNext()) {
+                        FeatureTagState featureTagState = iterator.next();
+                        stringBuilder.append(featureTagState.getFeatureTag()).append(" ").append(
+                                featureTagState.getState());
+                    }
+                    Set<String> registeredFt = registrationState.getRegisteredFeatureTags();
+                    Iterator<String> iteratorStr = registeredFt.iterator();
+                    stringBuilder.append("] registeredFT:[");
+                    while (iteratorStr.hasNext()) {
+                        String ft = iteratorStr.next();
+                        stringBuilder.append(ft).append(" ");
+                    }
+                    stringBuilder.append("]\r\n\r\n");
+                    mCallbackResultStr += stringBuilder.toString();
+                    Log.i(TAG, mCallbackResultStr);
+                    mHandler.sendMessage(mHandler.obtainMessage(MSG_RESULT));
+                }
+
+                @Override
+                public void onDestroyed(int reason) {
+                    mCallbackResultStr = "onDestroyed reason:" + reason;
+                    Log.i(TAG, mCallbackResultStr);
+                    mHandler.sendMessage(mHandler.obtainMessage(MSG_RESULT));
+                }
+            };
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.delegate_layout);
+
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowHomeEnabled(true);
+
+        mHandler = new Handler() {
+            public void handleMessage(Message message) {
+                switch (message.what) {
+                    case MSG_RESULT:
+                        mCallbackResult.setText(mCallbackResultStr);
+                        break;
+                }
+            }
+        };
+    }
+
+    @Override
+    protected void onStart() {
+        super.onStart();
+        init();
+    }
+
+    private void init() {
+        mInitButton = findViewById(R.id.init_btn);
+        mDestroyButton = findViewById(R.id.destroy_btn);
+        mCallbackResult = findViewById(R.id.delegate_callback_result);
+        mChatCb = findViewById(R.id.chat);
+        mStandalonePagerCb = findViewById(R.id.standalone_pager);
+        mStandaloneLargeCb = findViewById(R.id.standalone_large);
+        mStandaloneDeferredCb = findViewById(R.id.standalone_deferred);
+        mStandaloneLargePagerCb = findViewById(R.id.standalone_pager_large);
+
+        mFileTransferCb = findViewById(R.id.file_transfer);
+        mGeolocationSmsCb = findViewById(R.id.geolocation_sms);
+        mChatbotSessionCb = findViewById(R.id.chatbot_session);
+        mChatbotStandaloneCb = findViewById(R.id.chatbot_standalone);
+        mChatbotVersionCb = findViewById(R.id.chatbot_version);
+
+        mChatCb.setChecked(true);
+
+        mDefaultSmsSubId = SmsManager.getDefaultSmsSubscriptionId();
+        mCallbackResult.setMovementMethod(new ScrollingMovementMethod());
+
+        ImsManager imsManager = this.getSystemService(ImsManager.class);
+        if (SubscriptionManager.isValidSubscriptionId(mDefaultSmsSubId)) {
+            mSipDelegateManager = imsManager.getSipDelegateManager(mDefaultSmsSubId);
+        }
+        setClickable(mDestroyButton, false);
+
+        mInitButton.setOnClickListener(view -> {
+            mCallbackResultStr = "";
+            if (mSipDelegateManager != null) {
+                Set<String> featureTags = getFeatureTags();
+                try {
+                    Log.i(TAG, "createSipDelegate");
+                    dumpFt(featureTags);
+                    mSipDelegateManager.createSipDelegate(new DelegateRequest(featureTags),
+                            mExecutorService, mConnectionCallback, mMessageCallback);
+                } catch (ImsException e) {
+                    //e.printStackTrace();
+                    mCallbackResult.setText(e.toString());
+                    Log.e(TAG, e.toString());
+                }
+                setClickable(mInitButton, false);
+                setClickable(mDestroyButton, true);
+            }
+        });
+
+        mDestroyButton.setOnClickListener(view -> {
+            mCallbackResultStr = "";
+            if (mSipDelegateManager != null && mSipDelegateConnection != null) {
+                Log.i(TAG, "destroySipDelegate");
+                mSipDelegateManager.destroySipDelegate(mSipDelegateConnection,
+                        SipDelegateManager.SIP_DELEGATE_DESTROY_REASON_REQUESTED_BY_APP);
+                setClickable(mInitButton, true);
+                setClickable(mDestroyButton, false);
+            }
+        });
+    }
+
+    private Set<String> getFeatureTags() {
+        HashSet<String> fts = new HashSet<>();
+        if (mChatCb.isChecked()) {
+            fts.add(SESSION_TAG);
+        }
+        if (mStandalonePagerCb.isChecked()) {
+            fts.add(STANDALONE_PAGER);
+        }
+        if (mStandaloneLargeCb.isChecked()) {
+            fts.add(STANDALONE_LARGE);
+        }
+        if (mStandaloneDeferredCb.isChecked()) {
+            fts.add(STANDALONE_DEFERRED);
+        }
+        if (mStandaloneLargePagerCb.isChecked()) {
+            fts.add(STANDALONE_LARGE_PAGER);
+        }
+        if (mFileTransferCb.isChecked()) {
+            fts.add(FILE_TRANSFER);
+        }
+        if (mGeolocationSmsCb.isChecked()) {
+            fts.add(GEOLOCATION_SMS);
+        }
+        if (mChatbotSessionCb.isChecked()) {
+            fts.add(CHATBOT_SESSION);
+        }
+        if (mChatbotStandaloneCb.isChecked()) {
+            fts.add(CHATBOT_STANDALONE);
+        }
+        if (mChatbotVersionCb.isChecked()) {
+            fts.add(CHATBOT_VERSION);
+        }
+        return fts;
+    }
+
+    private void dumpFt(Set<String> fts) {
+        Iterator<String> iterator = fts.iterator();
+        StringBuilder res = new StringBuilder();
+        while (iterator.hasNext()) {
+            res.append(iterator.next()).append("\r\n");
+        }
+        Log.i(TAG, "FeatureTag: " + res.toString());
+    }
+
+    private void setClickable(Button button, boolean clickable) {
+        if (clickable) {
+            button.setAlpha(1);
+            button.setClickable(true);
+        } else {
+            button.setAlpha(.5f);
+            button.setClickable(false);
+        }
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    @Override
+    protected void onStop() {
+        super.onStop();
+        if (mSipDelegateManager != null && mSipDelegateConnection != null) {
+            Log.i(TAG, "onStop() destroySipDelegate");
+            mSipDelegateManager.destroySipDelegate(mSipDelegateConnection,
+                    SipDelegateManager.SIP_DELEGATE_DESTROY_REASON_REQUESTED_BY_APP);
+            setClickable(mInitButton, true);
+            setClickable(mDestroyButton, false);
+        }
+
+    }
+
+    private void dumpConfig(SipDelegateImsConfiguration config) {
+        Log.i(TAG, "KEY_SIP_CONFIG_TRANSPORT_TYPE_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_TRANSPORT_TYPE_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_UE_PUBLIC_USER_ID_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_PUBLIC_USER_ID_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_UE_PRIVATE_USER_ID_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_PRIVATE_USER_ID_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_HOME_DOMAIN_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_HOME_DOMAIN_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_IMEI_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_IMEI_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_IPTYPE_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_IPTYPE_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_UE_DEFAULT_IPADDRESS_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_DEFAULT_IPADDRESS_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_SERVER_DEFAULT_IPADDRESS_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_SERVER_DEFAULT_IPADDRESS_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_UE_PUBLIC_IPADDRESS_WITH_NAT_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_PUBLIC_IPADDRESS_WITH_NAT_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_UE_PUBLIC_GRUU_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_PUBLIC_GRUU_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_AUTHENTICATION_HEADER_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_AUTHENTICATION_HEADER_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_AUTHENTICATION_NONCE_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_AUTHENTICATION_NONCE_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_SERVICE_ROUTE_HEADER_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_SERVICE_ROUTE_HEADER_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_SECURITY_VERIFY_HEADER_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_SECURITY_VERIFY_HEADER_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_PATH_HEADER_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_PATH_HEADER_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_URI_USER_PART_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_URI_USER_PART_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_P_ACCESS_NETWORK_INFO_HEADER_STRING:"
+                + config.getString(SipDelegateImsConfiguration
+                .KEY_SIP_CONFIG_P_ACCESS_NETWORK_INFO_HEADER_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_P_LAST_ACCESS_NETWORK_INFO_HEADER_STRING:"
+                + config.getString(SipDelegateImsConfiguration
+                .KEY_SIP_CONFIG_P_LAST_ACCESS_NETWORK_INFO_HEADER_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_P_ASSOCIATED_URI_HEADER_STRING:"
+                + config.getString(SipDelegateImsConfiguration
+                .KEY_SIP_CONFIG_P_ASSOCIATED_URI_HEADER_STRING));
+        Log.i(TAG, "KEY_SIP_CONFIG_USER_AGENT_HEADER_STRING:" + config.getString(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_USER_AGENT_HEADER_STRING));
+
+        Log.i(TAG, "KEY_SIP_CONFIG_MAX_PAYLOAD_SIZE_ON_UDP_INT:" + config.getInt(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_MAX_PAYLOAD_SIZE_ON_UDP_INT, -99));
+        Log.i(TAG, "KEY_SIP_CONFIG_UE_DEFAULT_PORT_INT:" + config.getInt(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_DEFAULT_PORT_INT, -99));
+        Log.i(TAG, "KEY_SIP_CONFIG_SERVER_DEFAULT_PORT_INT:" + config.getInt(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_SERVER_DEFAULT_PORT_INT, -99));
+        Log.i(TAG, "KEY_SIP_CONFIG_UE_PUBLIC_PORT_WITH_NAT_INT:" + config.getInt(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_PUBLIC_PORT_WITH_NAT_INT, -99));
+        Log.i(TAG, "KEY_SIP_CONFIG_UE_IPSEC_CLIENT_PORT_INT:" + config.getInt(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_IPSEC_CLIENT_PORT_INT, -99));
+        Log.i(TAG, "KEY_SIP_CONFIG_UE_IPSEC_SERVER_PORT_INT:" + config.getInt(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_IPSEC_SERVER_PORT_INT, -99));
+        Log.i(TAG, "KEY_SIP_CONFIG_UE_IPSEC_OLD_CLIENT_PORT_INT:" + config.getInt(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_IPSEC_OLD_CLIENT_PORT_INT, -99));
+        Log.i(TAG, "KEY_SIP_CONFIG_SERVER_IPSEC_CLIENT_PORT_INT:" + config.getInt(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_SERVER_IPSEC_CLIENT_PORT_INT, -99));
+        Log.i(TAG, "KEY_SIP_CONFIG_SERVER_IPSEC_SERVER_PORT_INT:" + config.getInt(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_SERVER_IPSEC_SERVER_PORT_INT, -99));
+        Log.i(TAG, "KEY_SIP_CONFIG_SERVER_IPSEC_OLD_CLIENT_PORT_INT:" + config.getInt(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_SERVER_IPSEC_OLD_CLIENT_PORT_INT, -99));
+
+        Log.i(TAG, "KEY_SIP_CONFIG_IS_COMPACT_FORM_ENABLED_BOOL:" + config.getBoolean(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_IS_COMPACT_FORM_ENABLED_BOOL, false));
+        Log.i(TAG, "KEY_SIP_CONFIG_IS_KEEPALIVE_ENABLED_BOOL:" + config.getBoolean(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_IS_KEEPALIVE_ENABLED_BOOL, false));
+        Log.i(TAG, "KEY_SIP_CONFIG_IS_NAT_ENABLED_BOOL:" + config.getBoolean(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_IS_NAT_ENABLED_BOOL, false));
+        Log.i(TAG, "KEY_SIP_CONFIG_IS_GRUU_ENABLED_BOOL:" + config.getBoolean(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_IS_GRUU_ENABLED_BOOL, false));
+        Log.i(TAG, "KEY_SIP_CONFIG_IS_IPSEC_ENABLED_BOOL:" + config.getBoolean(
+                SipDelegateImsConfiguration.KEY_SIP_CONFIG_IS_IPSEC_ENABLED_BOOL, false));
+    }
+}
diff --git a/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/GbaActivity.java b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/GbaActivity.java
new file mode 100644
index 0000000..5b889fb
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/GbaActivity.java
@@ -0,0 +1,289 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.sample.rcsclient;
+
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.telephony.TelephonyManager;
+import android.telephony.TelephonyManager.BootstrapAuthenticationCallback;
+import android.telephony.gba.UaSecurityProtocolIdentifier;
+import android.text.method.ScrollingMovementMethod;
+import android.util.Log;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemSelectedListener;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import java.util.Locale;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/** An activity to verify GBA authentication. */
+public class GbaActivity extends AppCompatActivity {
+
+    private static final String TAG = "TestRcsApp.GbaActivity";
+    private static final int MSG_RESULT = 1;
+    private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor();
+    private Button mGbaButton;
+    private TextView mCallbackResult;
+    private Spinner mOrganizationSpinner, mProtocolSpinner, mUiccSpinner;
+    private EditText mTlsCs;
+    private EditText mNaf;
+    private Handler mHandler;
+    private int mOrganization;
+    private int mProtocol;
+    private int mUiccType;
+
+    private static String bytesToHex(byte[] bytes) {
+        StringBuilder result = new StringBuilder();
+        for (byte aByte : bytes) {
+            result.append(String.format(Locale.US, "%02X", aByte));
+        }
+        return result.toString();
+    }
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.gba_layout);
+
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowHomeEnabled(true);
+        initLayout();
+        mHandler = new Handler() {
+            public void handleMessage(Message message) {
+                switch (message.what) {
+                    case MSG_RESULT:
+                        mCallbackResult.setText(message.obj.toString());
+                        break;
+                }
+            }
+        };
+    }
+
+    private void initLayout() {
+        mGbaButton = findViewById(R.id.gba_btn);
+        mCallbackResult = findViewById(R.id.gba_result);
+        mCallbackResult.setMovementMethod(new ScrollingMovementMethod());
+        mTlsCs = findViewById(R.id.tls_id);
+        mNaf = findViewById(R.id.naf_url);
+
+        initOrganization();
+        initProtocol();
+        initUicctype();
+
+        mGbaButton.setOnClickListener(view -> {
+            Log.i(TAG, "trigger bootstrapAuthenticationRequest");
+            UaSecurityProtocolIdentifier.Builder builder =
+                    new UaSecurityProtocolIdentifier.Builder();
+            try {
+                builder.setOrg(mOrganization)
+                        .setProtocol(mProtocol)
+                        .setTlsCipherSuite(Integer.parseInt(mTlsCs.getText().toString()));
+            } catch (IllegalArgumentException e) {
+                Log.e(TAG, e.getMessage());
+                return;
+            }
+            UaSecurityProtocolIdentifier spId = builder.build();
+            TelephonyManager telephonyManager = this.getSystemService(TelephonyManager.class);
+            telephonyManager.bootstrapAuthenticationRequest(mUiccType,
+                    Uri.parse(mNaf.getText().toString()),
+                    spId,
+                    true,
+                    mExecutorService,
+                    new BootstrapAuthenticationCallback() {
+                        @Override
+                        public void onKeysAvailable(byte[] gbaKey, String btId) {
+                            String result = "OnKeysAvailable key:" + bytesToHex(gbaKey)
+                                    + "\r\n btId:" + btId;
+                            Log.i(TAG, result);
+                            mHandler.sendMessage(mHandler.obtainMessage(MSG_RESULT, result));
+                        }
+
+                        @Override
+                        public void onAuthenticationFailure(int reason) {
+                            String result = "OnAuthenticationFailure reason: " + reason;
+                            Log.i(TAG, result);
+                            mHandler.sendMessage(mHandler.obtainMessage(MSG_RESULT, result));
+                        }
+                    });
+        });
+    }
+
+    private void initOrganization() {
+        mOrganizationSpinner = findViewById(R.id.organization_list);
+        ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(this,
+                R.array.organization, android.R.layout.simple_spinner_item);
+        adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+        mOrganizationSpinner.setAdapter(adapter);
+        mOrganizationSpinner.setOnItemSelectedListener(new OnItemSelectedListener() {
+            @Override
+            public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+                Log.i(TAG, "Organization position:" + position);
+                switch (position) {
+                    case 0:
+                        mOrganization = UaSecurityProtocolIdentifier.ORG_NONE;
+                        break;
+                    case 1:
+                        mOrganization = UaSecurityProtocolIdentifier.ORG_3GPP;
+                        break;
+                    case 2:
+                        mOrganization = UaSecurityProtocolIdentifier.ORG_3GPP2;
+                        break;
+                    case 3:
+                        mOrganization = UaSecurityProtocolIdentifier.ORG_GSMA;
+                        break;
+                    case 4:
+                        mOrganization = UaSecurityProtocolIdentifier.ORG_LOCAL;
+                        break;
+                    default:
+                        Log.e(TAG, "invalid position:" + position);
+                }
+            }
+
+            @Override
+            public void onNothingSelected(AdapterView<?> parent) {
+                // TODO Auto-generated method stub
+            }
+        });
+        mOrganizationSpinner.setSelection(1);
+    }
+
+    private void initProtocol() {
+        mProtocolSpinner = findViewById(R.id.protocol_list);
+        ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(this,
+                R.array.protocol, android.R.layout.simple_spinner_item);
+        adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+        mProtocolSpinner.setAdapter(adapter);
+        mProtocolSpinner.setOnItemSelectedListener(new OnItemSelectedListener() {
+            @Override
+            public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+                Log.i(TAG, "Protocol position:" + position);
+                switch (position) {
+                    case 0:
+                        mProtocol = UaSecurityProtocolIdentifier
+                                .UA_SECURITY_PROTOCOL_3GPP_SUBSCRIBER_CERTIFICATE;
+                        break;
+                    case 1:
+                        mProtocol = UaSecurityProtocolIdentifier
+                                .UA_SECURITY_PROTOCOL_3GPP_MBMS;
+                        break;
+                    case 2:
+                        mProtocol = UaSecurityProtocolIdentifier
+                                .UA_SECURITY_PROTOCOL_3GPP_HTTP_DIGEST_AUTHENTICATION;
+                        break;
+                    case 3:
+                        mProtocol = UaSecurityProtocolIdentifier
+                                .UA_SECURITY_PROTOCOL_3GPP_HTTP_BASED_MBMS;
+                        break;
+                    case 4:
+                        mProtocol = UaSecurityProtocolIdentifier
+                                .UA_SECURITY_PROTOCOL_3GPP_SIP_BASED_MBMS;
+                        break;
+                    case 5:
+                        mProtocol = UaSecurityProtocolIdentifier
+                                .UA_SECURITY_PROTOCOL_3GPP_GENERIC_PUSH_LAYER;
+                        break;
+                    case 6:
+                        mProtocol = UaSecurityProtocolIdentifier
+                                .UA_SECURITY_PROTOCOL_3GPP_IMS_MEDIA_PLANE;
+                        break;
+                    case 7:
+                        mProtocol = UaSecurityProtocolIdentifier
+                                .UA_SECURITY_PROTOCOL_3GPP_GENERATION_TMPI;
+                        break;
+                    case 8:
+                        mProtocol = UaSecurityProtocolIdentifier
+                                .UA_SECURITY_PROTOCOL_3GPP_TLS_DEFAULT;
+                        break;
+                    case 9:
+                        mProtocol = UaSecurityProtocolIdentifier
+                                .UA_SECURITY_PROTOCOL_3GPP_TLS_BROWSER;
+                        break;
+                    default:
+                        Log.e(TAG, "invalid position:" + position);
+                }
+            }
+
+            @Override
+            public void onNothingSelected(AdapterView<?> parent) {
+                // TODO Auto-generated method stub
+            }
+        });
+        mProtocolSpinner.setSelection(8);
+    }
+
+    private void initUicctype() {
+        mUiccSpinner = findViewById(R.id.uicc_list);
+        ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(this,
+                R.array.uicc_type, android.R.layout.simple_spinner_item);
+        adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+        mUiccSpinner.setAdapter(adapter);
+        mUiccSpinner.setOnItemSelectedListener(new OnItemSelectedListener() {
+            @Override
+            public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+                Log.i(TAG, "uicc position:" + position);
+                switch (position) {
+                    case 0:
+                        mUiccType = TelephonyManager.APPTYPE_UNKNOWN;
+                        break;
+                    case 1:
+                        mUiccType = TelephonyManager.APPTYPE_SIM;
+                        break;
+                    case 2:
+                        mUiccType = TelephonyManager.APPTYPE_USIM;
+                        break;
+                    case 3:
+                        mUiccType = TelephonyManager.APPTYPE_RUIM;
+                        break;
+                    case 4:
+                        mUiccType = TelephonyManager.APPTYPE_CSIM;
+                        break;
+                    case 5:
+                        mUiccType = TelephonyManager.APPTYPE_ISIM;
+                        break;
+                    default:
+                        Log.e(TAG, "invalid position:" + position);
+                }
+            }
+
+            @Override
+            public void onNothingSelected(AdapterView<?> parent) {
+                // TODO Auto-generated method stub
+            }
+        });
+        mUiccSpinner.setSelection(5);
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+        }
+        return super.onOptionsItemSelected(item);
+    }
+}
diff --git a/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/MainActivity.java b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/MainActivity.java
new file mode 100644
index 0000000..62302fe
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/MainActivity.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+package com.google.android.sample.rcsclient;
+
+import android.content.Intent;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.MenuItem;
+import android.widget.Button;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+/** An activity to show function list. */
+public class MainActivity extends AppCompatActivity {
+    private static final String TAG = "TestRcsApp.MainActivity";
+    private Button mProvisionButton;
+    private Button mDelegateButton;
+    private Button mUceButton;
+    private Button mGbaButton;
+    private Button mMessageClientButton;
+    private TextView mVersionInfo;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_main);
+
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowHomeEnabled(true);
+
+        mProvisionButton = (Button) this.findViewById(R.id.provision);
+        mDelegateButton = (Button) this.findViewById(R.id.delegate);
+        mMessageClientButton = (Button) this.findViewById(R.id.msgClient);
+        mUceButton = (Button) this.findViewById(R.id.uce);
+        mGbaButton = (Button) this.findViewById(R.id.gba);
+        mVersionInfo = this.findViewById(R.id.version_info);
+        mProvisionButton.setOnClickListener(view -> {
+            Intent intent = new Intent(this, ProvisioningActivity.class);
+            MainActivity.this.startActivity(intent);
+        });
+
+        mDelegateButton.setOnClickListener(view -> {
+            Intent intent = new Intent(this, DelegateActivity.class);
+            MainActivity.this.startActivity(intent);
+        });
+
+        mUceButton.setOnClickListener(view -> {
+            Intent intent = new Intent(this, UceActivity.class);
+            MainActivity.this.startActivity(intent);
+        });
+
+        mGbaButton.setOnClickListener(view -> {
+            Intent intent = new Intent(this, GbaActivity.class);
+            MainActivity.this.startActivity(intent);
+        });
+        mMessageClientButton.setOnClickListener(view -> {
+            Intent intent = new Intent(this, ContactListActivity.class);
+            MainActivity.this.startActivity(intent);
+        });
+
+        String appVersionName = getVersionCode(getPackageName());
+        if (!TextUtils.isEmpty(appVersionName)) {
+            String version = String.format(getResources().getString(R.string.version_info),
+                    appVersionName);
+            mVersionInfo.setText(version);
+        }
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    private String getVersionCode(String packageName) {
+        try {
+            // get android:versionName from the android manifest
+            PackageInfo info = getPackageManager().getPackageInfo(getPackageName(), 0 /*flags*/);
+            return info.versionName;
+        } catch (PackageManager.NameNotFoundException e) {
+            Log.w(TAG, "couldn't get version info for package name:" + packageName);
+        }
+        return null;
+    }
+}
diff --git a/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/PhoneNumberActivity.java b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/PhoneNumberActivity.java
new file mode 100644
index 0000000..a277994
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/PhoneNumberActivity.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.sample.rcsclient;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.MenuItem;
+import android.widget.Button;
+import android.widget.EditText;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+/** An activity to let user input phone number to chat. */
+public class PhoneNumberActivity extends AppCompatActivity {
+
+    private static final String TAG = "TestRcsApp.PhoneNumberActivity";
+    private Button mChatButton;
+    private EditText mPhoneNumber;
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.number_to_chat);
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowHomeEnabled(true);
+
+        mChatButton = this.findViewById(R.id.launch_chat_btn);
+        mPhoneNumber = findViewById(R.id.destNum);
+        mChatButton.setOnClickListener(view -> {
+            Intent intent = new Intent(PhoneNumberActivity.this, ChatActivity.class);
+            intent.putExtra(ChatActivity.EXTRA_REMOTE_PHONE_NUMBER,
+                    mPhoneNumber.getText().toString());
+            PhoneNumberActivity.this.startActivity(intent);
+
+        });
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    @Override
+    protected void onStop() {
+        super.onStop();
+        Log.i(TAG, "onStop");
+    }
+
+    @Override
+    protected void onDestroy() {
+        super.onStop();
+        Log.i(TAG, "onDestroy");
+    }
+}
diff --git a/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/ProvisioningActivity.java b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/ProvisioningActivity.java
new file mode 100644
index 0000000..da0cf39
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/ProvisioningActivity.java
@@ -0,0 +1,239 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.sample.rcsclient;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.telephony.SmsManager;
+import android.telephony.SubscriptionManager;
+import android.telephony.ims.ImsException;
+import android.telephony.ims.ProvisioningManager;
+import android.telephony.ims.ProvisioningManager.RcsProvisioningCallback;
+import android.telephony.ims.RcsClientConfiguration;
+import android.text.method.ScrollingMovementMethod;
+import android.util.Log;
+import android.view.MenuItem;
+import android.widget.Button;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+
+/** An activity to verify provisioning. */
+public class ProvisioningActivity extends AppCompatActivity {
+
+    private static final String TAG = "TestRcsApp.ProvisioningActivity";
+    private static final int MSG_RESULT = 1;
+    private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor();
+    private int mDefaultSmsSubId;
+    private ProvisioningManager mProvisioningManager;
+    private Button mRegisterButton;
+    private Button mUnRegisterButton;
+    private Button mIsCapableButton;
+    private TextView mSingleRegResult;
+    private TextView mCallbackResult;
+    private SingleRegCapabilityReceiver mSingleRegCapabilityReceiver;
+    private boolean mIsRegistered = false;
+    private Handler mHandler;
+    private RcsProvisioningCallback mCallback =
+            new RcsProvisioningCallback() {
+                @Override
+                public void onConfigurationChanged(@NonNull byte[] configXml) {
+                    String configResult = new String(configXml);
+                    Log.i(TAG, "RcsProvisioningCallback.onConfigurationChanged called with xml:");
+                    Log.i(TAG, configResult);
+                    mHandler.sendMessage(mHandler.obtainMessage(MSG_RESULT,
+                            "onConfigurationChanged \r\n" + configResult));
+                }
+
+                @Override
+                public void onConfigurationReset() {
+                    Log.i(TAG, "RcsProvisioningCallback.onConfigurationReset called.");
+                    mHandler.sendMessage(
+                            mHandler.obtainMessage(MSG_RESULT, "onConfigurationReset"));
+                }
+
+                @Override
+                public void onRemoved() {
+                    Log.i(TAG, "RcsProvisioningCallback.onRemoved called.");
+                    mHandler.sendMessage(mHandler.obtainMessage(MSG_RESULT, "onRemoved"));
+                }
+            };
+
+    // Static configuration.
+    private static RcsClientConfiguration getDefaultClientConfiguration() {
+        return new RcsClientConfiguration(
+                /*rcsVersion=*/ "6.0",
+                /*rcsProfile=*/ "UP_2.3",
+                /*clientVendor=*/ "Goog",
+                /*clientVersion=*/ "RCSAndrd-1.0");
+    }
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.provision_layout);
+
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowHomeEnabled(true);
+        mSingleRegCapabilityReceiver = new SingleRegCapabilityReceiver();
+        this.registerReceiver(mSingleRegCapabilityReceiver, new IntentFilter(
+                ProvisioningManager.ACTION_RCS_SINGLE_REGISTRATION_CAPABILITY_UPDATE));
+        mHandler = new Handler() {
+            public void handleMessage(Message message) {
+                switch (message.what) {
+                    case MSG_RESULT:
+                        mCallbackResult.setText(message.obj.toString());
+                        break;
+                }
+            }
+        };
+    }
+
+    @Override
+    protected void onStart() {
+        super.onStart();
+        mDefaultSmsSubId = SmsManager.getDefaultSmsSubscriptionId();
+        Log.i(TAG, "defaultSmsSubId:" + mDefaultSmsSubId);
+        if (isValidSubscriptionId(mDefaultSmsSubId)) {
+            mProvisioningManager = ProvisioningManager.createForSubscriptionId(mDefaultSmsSubId);
+            init();
+        }
+    }
+
+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        this.unregisterReceiver(mSingleRegCapabilityReceiver);
+        if (mIsRegistered) {
+            mProvisioningManager.unregisterRcsProvisioningChangedCallback(mCallback);
+        }
+    }
+
+    private void init() {
+        mRegisterButton = findViewById(R.id.provisioning_register_btn);
+        mUnRegisterButton = findViewById(R.id.provisioning_unregister_btn);
+        mIsCapableButton = findViewById(R.id.provisioning_singlereg_btn);
+        mSingleRegResult = findViewById(R.id.provisioning_singlereg_result);
+        mCallbackResult = findViewById(R.id.provisioning_callback_result);
+        mCallbackResult.setMovementMethod(new ScrollingMovementMethod());
+
+        boolean isSingleRegCapable = false;
+        try {
+            mProvisioningManager.isRcsVolteSingleRegistrationCapable();
+        } catch (ImsException e) {
+            Log.i(TAG, e.getMessage());
+        }
+        if (isSingleRegCapable && !mIsRegistered) {
+            setClickable(mRegisterButton, true);
+        }
+
+        mRegisterButton.setOnClickListener(view -> {
+            if (mProvisioningManager != null) {
+                Log.i(TAG, "Using configuration: " + getDefaultClientConfiguration());
+                try {
+                    Log.i(TAG, "setRcsClientConfiguration()");
+                    Log.i(TAG, "registerRcsProvisioningChangedCallback()");
+                    mProvisioningManager.setRcsClientConfiguration(getDefaultClientConfiguration());
+                    mProvisioningManager.registerRcsProvisioningChangedCallback(mExecutorService,
+                            mCallback);
+                    mIsRegistered = true;
+                } catch (ImsException e) {
+                    Log.e(TAG, e.getMessage());
+                }
+                setClickable(mRegisterButton, false);
+                setClickable(mUnRegisterButton, true);
+            } else {
+                Log.i(TAG, "provisioningManager null");
+            }
+        });
+        mUnRegisterButton.setOnClickListener(view -> {
+            if (mProvisioningManager != null) {
+                mProvisioningManager.unregisterRcsProvisioningChangedCallback(mCallback);
+                setClickable(mRegisterButton, false);
+                setClickable(mRegisterButton, true);
+                mIsRegistered = false;
+            }
+        });
+        mIsCapableButton.setOnClickListener(view -> {
+            if (mProvisioningManager != null) {
+                try {
+                    boolean capable = mProvisioningManager.isRcsVolteSingleRegistrationCapable();
+                    mSingleRegResult.setText(String.valueOf(capable));
+                    Log.i(TAG, "isRcsVolteSingleRegistrationCapable:" + capable);
+                } catch (ImsException e) {
+                    Log.e(TAG, e.getMessage());
+                }
+            }
+        });
+    }
+
+    private void setClickable(Button button, boolean clickable) {
+        if (clickable) {
+            button.setAlpha(1);
+            button.setClickable(true);
+        } else {
+            button.setAlpha(.5f);
+            button.setClickable(false);
+        }
+    }
+
+    private boolean isValidSubscriptionId(int subId) {
+        return SubscriptionManager.isValidSubscriptionId(mDefaultSmsSubId);
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    class SingleRegCapabilityReceiver extends BroadcastReceiver {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            String action = intent.getAction();
+            Log.i(TAG, "onReceive action:" + action);
+            if (ProvisioningManager.ACTION_RCS_SINGLE_REGISTRATION_CAPABILITY_UPDATE.equals(
+                    action)) {
+                int status = intent.getIntExtra(ProvisioningManager.EXTRA_STATUS,
+                        ProvisioningManager.STATUS_DEVICE_NOT_CAPABLE);
+                Log.i(TAG, "singleRegCap status:" + status);
+                if (mRegisterButton != null && !mIsRegistered) {
+                    if (status == ProvisioningManager.STATUS_DEVICE_NOT_CAPABLE) {
+                        setClickable(mRegisterButton, true);
+                    } else {
+                        setClickable(mRegisterButton, false);
+                    }
+                }
+
+            }
+        }
+    }
+
+}
diff --git a/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/RcsStateChangedCallback.java b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/RcsStateChangedCallback.java
new file mode 100644
index 0000000..fd36f01
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/RcsStateChangedCallback.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.sample.rcsclient;
+
+import com.android.libraries.rcs.simpleclient.SimpleRcsClient.State;
+
+/** A callback used to notify RCS state change. */
+public interface RcsStateChangedCallback {
+    /** callback for RCS state change. */
+    void notifyStateChange(State oldState, State newState);
+}
diff --git a/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/SessionStateCallback.java b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/SessionStateCallback.java
new file mode 100644
index 0000000..3881775
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/SessionStateCallback.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.sample.rcsclient;
+
+/** A callback used to inform chat session creation result. */
+public interface SessionStateCallback {
+    /** callback for successful session creation */
+    void onSuccess();
+
+    /**callback for failed session creation. */
+    void onFailure();
+}
diff --git a/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/UceActivity.java b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/UceActivity.java
new file mode 100644
index 0000000..9edb817
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/UceActivity.java
@@ -0,0 +1,221 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.sample.rcsclient;
+
+import android.net.Uri;
+import android.os.Bundle;
+import android.telephony.SmsManager;
+import android.telephony.ims.ImsException;
+import android.telephony.ims.ImsManager;
+import android.telephony.ims.ImsRcsManager;
+import android.telephony.ims.RcsContactPresenceTuple;
+import android.telephony.ims.RcsContactUceCapability;
+import android.telephony.ims.RcsUceAdapter;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.MenuItem;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/** An activity to verify UCE. */
+public class UceActivity extends AppCompatActivity {
+
+    private static final String TAG = "TestRcsApp.UceActivity";
+    private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor();
+    private Button mCapabilityButton;
+    private Button mAvailabilityButton;
+    private TextView mCapabilityResult;
+    private TextView mAvailabilityResult;
+    private EditText mNumbers;
+    private int mDefaultSmsSubId;
+    private ImsRcsManager mImsRcsManager;
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.uce_layout);
+
+        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+        getSupportActionBar().setDisplayShowHomeEnabled(true);
+
+        initLayout();
+    }
+
+    private void initLayout() {
+        mDefaultSmsSubId = SmsManager.getDefaultSmsSubscriptionId();
+
+        mCapabilityButton = findViewById(R.id.capability_btn);
+        mAvailabilityButton = findViewById(R.id.availability_btn);
+        mCapabilityResult = findViewById(R.id.capability_result);
+        mAvailabilityResult = findViewById(R.id.capability_result);
+
+        List<Uri> contactList = getContectList();
+        mImsRcsManager = getImsRcsManager(mDefaultSmsSubId);
+        mCapabilityButton.setOnClickListener(view -> {
+            if (contactList.size() == 0) {
+                Log.i(TAG, "empty contact list");
+                return;
+            }
+            mCapabilityResult.setText("pending...\n");
+            try {
+                mImsRcsManager.getUceAdapter().requestCapabilities(contactList, mExecutorService,
+                        new RcsUceAdapter.CapabilitiesCallback() {
+                            public void onCapabilitiesReceived(
+                                    List<RcsContactUceCapability> contactCapabilities) {
+                                Log.i(TAG, "onCapabilitiesReceived()");
+                                StringBuilder b = new StringBuilder("onCapabilitiesReceived:\n");
+                                for (RcsContactUceCapability c : contactCapabilities) {
+                                    b.append(getReadableCapability(c));
+                                    b.append("\n");
+                                }
+                                mCapabilityResult.append(b.toString() + "\n");
+                            }
+
+                            public void onComplete() {
+                                Log.i(TAG, "onComplete()");
+                                mCapabilityResult.append("complete");
+
+                            }
+
+                            public void onError(int errorCode, long retryAfterMilliseconds) {
+                                Log.i(TAG, "onError() errorCode:" + errorCode + " retryAfterMs:"
+                                        + retryAfterMilliseconds);
+                                mCapabilityResult.append("error - errorCode:" + errorCode
+                                        + " retryAfterMs:" + retryAfterMilliseconds);
+                            }
+                        });
+            } catch (ImsException e) {
+                mCapabilityResult.setText("ImsException:" + e);
+            }
+        });
+
+        mAvailabilityButton.setOnClickListener(view -> {
+            if (contactList.size() == 0) {
+                Log.i(TAG, "empty contact list");
+                return;
+            }
+            mAvailabilityResult.setText("pending...\n");
+            try {
+                mImsRcsManager.getUceAdapter().requestAvailability(contactList.get(0),
+                        mExecutorService, new RcsUceAdapter.CapabilitiesCallback() {
+                            public void onCapabilitiesReceived(
+                                    List<RcsContactUceCapability> contactCapabilities) {
+                                Log.i(TAG, "onCapabilitiesReceived()");
+                                StringBuilder b = new StringBuilder("onCapabilitiesReceived:\n");
+                                for (RcsContactUceCapability c : contactCapabilities) {
+                                    b.append(getReadableCapability(c));
+                                    b.append("\n");
+                                }
+                                mAvailabilityResult.append(b.toString() + "\n");
+                            }
+
+                            public void onComplete() {
+                                Log.i(TAG, "onComplete()");
+                                mAvailabilityResult.append("complete");
+
+                            }
+
+                            public void onError(int errorCode, long retryAfterMilliseconds) {
+                                Log.i(TAG, "onError() errorCode:" + errorCode + " retryAfterMs:"
+                                        + retryAfterMilliseconds);
+                                mAvailabilityResult.append("error - errorCode:" + errorCode
+                                        + " retryAfterMs:" + retryAfterMilliseconds);
+                            }
+                        });
+            } catch (ImsException e) {
+                mAvailabilityResult.setText("ImsException:" + e);
+            }
+        });
+    }
+
+    private List<Uri> getContectList() {
+        mNumbers = findViewById(R.id.number_list);
+        String []numbers;
+        ArrayList<Uri> contactList = new ArrayList<>();
+        if (!TextUtils.isEmpty(mNumbers.getText().toString())) {
+            String numberList = mNumbers.getText().toString().trim();
+            numbers = numberList.split(",");
+            for (String number : numbers) {
+                contactList.add(Uri.parse(ChatActivity.TELURI_PREFIX + number));
+            }
+        }
+
+        return contactList;
+    }
+
+    private ImsRcsManager getImsRcsManager(int subId) {
+        ImsManager imsManager = getSystemService(ImsManager.class);
+        if (imsManager != null) {
+            try {
+                return imsManager.getImsRcsManager(subId);
+            } catch (Exception e) {
+                Log.e(TAG, "fail to getImsRcsManager " + e.getMessage());
+                return null;
+            }
+        }
+        return null;
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == android.R.id.home) {
+            finish();
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    private String getReadableCapability(RcsContactUceCapability c) {
+        StringBuilder b = new StringBuilder("RcsContactUceCapability: uri=");
+        b.append(c.getContactUri());
+        b.append(", requestResult=");
+        b.append(c.getRequestResult());
+        b.append(", sourceType=");
+        b.append(c.getSourceType());
+        if (c.getCapabilityMechanism() == RcsContactUceCapability.CAPABILITY_MECHANISM_PRESENCE) {
+            b.append(", tuples={");
+            for (RcsContactPresenceTuple t : c.getCapabilityTuples()) {
+                b.append("[uri=");
+                b.append(t.getContactUri());
+                b.append(", serviceId=");
+                b.append(t.getServiceId());
+                b.append(", serviceVersion=");
+                b.append(t.getServiceVersion());
+                if (t.getServiceCapabilities() != null) {
+                    RcsContactPresenceTuple.ServiceCapabilities servCaps =
+                            t.getServiceCapabilities();
+                    b.append(", servCaps=(supported=");
+                    b.append(servCaps.getSupportedDuplexModes());
+                    b.append("), servCaps=(unsupported=");
+                    b.append(servCaps.getUnsupportedDuplexModes());
+                    b.append("))");
+                }
+                b.append("]");
+            }
+            b.append("}");
+        }
+        return b.toString();
+    }
+}
diff --git a/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/util/ChatManager.java b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/util/ChatManager.java
new file mode 100644
index 0000000..9d27fbc
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/util/ChatManager.java
@@ -0,0 +1,270 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.sample.rcsclient.util;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.telephony.ims.ImsManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.libraries.rcs.simpleclient.SimpleRcsClient;
+import com.android.libraries.rcs.simpleclient.SimpleRcsClient.State;
+import com.android.libraries.rcs.simpleclient.provisioning.ProvisioningController;
+import com.android.libraries.rcs.simpleclient.provisioning.StaticConfigProvisioningController;
+import com.android.libraries.rcs.simpleclient.registration.RegistrationController;
+import com.android.libraries.rcs.simpleclient.registration.RegistrationControllerImpl;
+import com.android.libraries.rcs.simpleclient.service.chat.MinimalCpmChatService;
+import com.android.libraries.rcs.simpleclient.service.chat.SimpleChatSession;
+
+import com.google.android.sample.rcsclient.RcsStateChangedCallback;
+import com.google.android.sample.rcsclient.SessionStateCallback;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import gov.nist.javax.sip.address.AddressFactoryImpl;
+
+import java.text.ParseException;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import javax.sip.address.AddressFactory;
+import javax.sip.address.URI;
+
+/**
+ * This class takes advantage of rcs library to manage chat session and send/receive chat message.
+ */
+public class ChatManager {
+    public static final String SELF = "self";
+    private static final String TAG = "TestRcsApp.ChatManager";
+    private static AddressFactory sAddressFactory = new AddressFactoryImpl();
+    private static HashMap<Integer, ChatManager> sChatManagerInstances = new HashMap<>();
+    private final ExecutorService mFixedThreadPool = Executors.newFixedThreadPool(5);
+    private Context mContext;
+    private ProvisioningController mProvisioningController;
+    private RegistrationController mRegistrationController;
+    private MinimalCpmChatService mImsService;
+    private SimpleRcsClient mSimpleRcsClient;
+    private State mState;
+    private int mSubId;
+    private HashMap<URI, SimpleChatSession> mContactSessionMap = new HashMap<>();
+    private RcsStateChangedCallback mRcsStateChangedCallback;
+
+    private ChatManager(Context context, int subId) {
+        mContext = context;
+        mSubId = subId;
+        mProvisioningController = StaticConfigProvisioningController.createForSubscriptionId(subId);
+        ImsManager imsManager = mContext.getSystemService(ImsManager.class);
+        mRegistrationController = new RegistrationControllerImpl(subId, mFixedThreadPool,
+                imsManager);
+        mImsService = new MinimalCpmChatService(context);
+        mSimpleRcsClient = SimpleRcsClient.newBuilder()
+                .registrationController(mRegistrationController)
+                .provisioningController(mProvisioningController)
+                .imsService(mImsService)
+                .executor(mFixedThreadPool).build();
+        mState = State.NEW;
+        // register callback for state change
+        mSimpleRcsClient.onStateChanged((oldState, newState) -> {
+            Log.i(TAG, "notifyStateChange() oldState:" + oldState + " newState:" + newState);
+            mState = newState;
+            mRcsStateChangedCallback.notifyStateChange(oldState, newState);
+        });
+        mImsService.setListener((session) -> {
+            Log.i(TAG, "onIncomingSession()");
+            mContactSessionMap.put(session.getRemoteUri(), session);
+        });
+    }
+
+    /**
+     * Create ChatManager with a specific subId.
+     */
+    public static ChatManager getInstance(Context context, int subId) {
+        synchronized (sChatManagerInstances) {
+            if (sChatManagerInstances.containsKey(subId)) {
+                return sChatManagerInstances.get(subId);
+            }
+            ChatManager chatManager = new ChatManager(context, subId);
+            sChatManagerInstances.put(subId, chatManager);
+            return chatManager;
+        }
+    }
+
+    /**
+     * Try to parse the given uri.
+     *
+     * @throws IllegalArgumentException in case of parsing error.
+     */
+    public static URI createUri(String uri) {
+        try {
+            return sAddressFactory.createURI(uri);
+        } catch (ParseException exception) {
+            throw new IllegalArgumentException("URI cannot be created", exception);
+        }
+    }
+
+    private static String getNumberFromUri(String number) {
+        String[] numberParts = number.split("[@;:]");
+        if (numberParts.length < 2) {
+            return null;
+        }
+        return numberParts[1];
+    }
+
+    /**
+     * set callback for RCS state change.
+     */
+    public void setRcsStateChangedCallback(RcsStateChangedCallback callback) {
+        mRcsStateChangedCallback = callback;
+    }
+
+    /**
+     * Start to register by doing provisioning and creating SipDelegate
+     */
+    public void register() {
+        Log.i(TAG, "do start(), State State = " + mState);
+        if (mState == State.NEW) {
+            mSimpleRcsClient.start();
+        }
+    }
+
+    /**
+     * Deregister chat feature.
+     */
+    public void deregister() {
+        Log.i(TAG, "deregister");
+        sChatManagerInstances.remove(mSubId);
+        mSimpleRcsClient.stop();
+    }
+
+    /**
+     * Initiate 1 to 1 chat session.
+     * @param telUriContact destination tel Uri.
+     * @param callback callback for session state.
+     */
+    public void initChatSession(String telUriContact, SessionStateCallback callback) {
+        if (mState != State.REGISTERED) {
+            Log.i(TAG, "Could not init session due to State = " + mState);
+            return;
+        }
+        URI uri = createUri(telUriContact);
+        if (mContactSessionMap.containsKey(uri)) {
+            callback.onSuccess();
+        }
+        Futures.addCallback(
+                mImsService.startOriginatingChatSession(telUriContact),
+                new FutureCallback<SimpleChatSession>() {
+                    @Override
+                    public void onSuccess(SimpleChatSession chatSession) {
+                        mContactSessionMap.put(chatSession.getRemoteUri(), chatSession);
+                        chatSession.setListener(
+                                // implement onMessageReceived()
+                                (message) -> {
+                                    mFixedThreadPool.execute(() -> {
+                                        String msg = message.content();
+                                        String phoneNumber = getNumberFromUri(
+                                                chatSession.getRemoteUri().toString());
+                                        if (TextUtils.isEmpty(phoneNumber)) {
+                                            Log.i(TAG, "dest number is empty, uri:"
+                                                    + chatSession.getRemoteUri());
+                                        } else {
+                                            addNewMessage(msg, phoneNumber, SELF);
+                                        }
+                                    });
+
+                                });
+                        callback.onSuccess();
+                    }
+
+                    @Override
+                    public void onFailure(Throwable t) {
+                        callback.onFailure();
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    /**
+     * Send a chat message.
+     * @param telUriContact destination tel Uri.
+     * @param message chat message.
+     */
+    public void sendMessage(String telUriContact, String message) {
+        if (mState != State.REGISTERED) {
+            Log.i(TAG, "Could not send msg due to State = " + mState);
+            return;
+        }
+        SimpleChatSession chatSession = mContactSessionMap.get(createUri(telUriContact));
+        if (chatSession == null) {
+            Log.i(TAG, "session is unavailable for telUriContact = " + telUriContact);
+            return;
+        }
+        chatSession.sendMessage(message);
+    }
+
+    /**
+     * Insert chat information into database.
+     * @param message chat message.
+     * @param src source phone number.
+     * @param dest destination phone number.
+     */
+    public void addNewMessage(String message, String src, String dest) {
+        long currentTime = Instant.now().getEpochSecond();
+        ContentValues contentValues = new ContentValues();
+        contentValues.put(ChatProvider.RcsColumns.SRC_PHONE_NUMBER, src);
+        contentValues.put(ChatProvider.RcsColumns.DEST_PHONE_NUMBER, dest);
+        contentValues.put(ChatProvider.RcsColumns.CHAT_MESSAGE, message);
+        contentValues.put(ChatProvider.RcsColumns.MSG_TIMESTAMP, currentTime);
+        contentValues.put(ChatProvider.RcsColumns.IS_READ, Boolean.TRUE);
+        // insert chat table
+        mContext.getContentResolver().insert(ChatProvider.CHAT_URI, contentValues);
+
+        ContentValues summary = new ContentValues();
+        summary.put(ChatProvider.SummaryColumns.LATEST_MESSAGE, message);
+        summary.put(ChatProvider.SummaryColumns.MSG_TIMESTAMP, currentTime);
+        summary.put(ChatProvider.SummaryColumns.IS_READ, Boolean.TRUE);
+
+        String remoteNumber = src.equals(SELF) ? dest : src;
+        if (remoteNumberExists(remoteNumber)) {
+            mContext.getContentResolver().update(ChatProvider.SUMMARY_URI, summary,
+                    ChatProvider.SummaryColumns.REMOTE_PHONE_NUMBER + "=?",
+                    new String[]{remoteNumber});
+        } else {
+            summary.put(ChatProvider.SummaryColumns.REMOTE_PHONE_NUMBER, remoteNumber);
+            mContext.getContentResolver().insert(ChatProvider.SUMMARY_URI, summary);
+        }
+    }
+
+    /**
+     * Check if the number exists in the database.
+     */
+    public boolean remoteNumberExists(String number) {
+        Cursor cursor = mContext.getContentResolver().query(ChatProvider.SUMMARY_URI, null,
+                ChatProvider.SummaryColumns.REMOTE_PHONE_NUMBER + "=?", new String[]{number},
+                null);
+        if (cursor != null) {
+            int count = cursor.getCount();
+            return count > 0;
+        }
+        return false;
+    }
+
+}
diff --git a/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/util/ChatProvider.java b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/util/ChatProvider.java
new file mode 100644
index 0000000..050da1f
--- /dev/null
+++ b/testapps/TestRcsApp/TestApp/src/com/google/android/sample/rcsclient/util/ChatProvider.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.sample.rcsclient.util;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.net.Uri;
+import android.provider.BaseColumns;
+import android.text.TextUtils;
+import android.util.Log;
+
+/** A database to store chat message. */
+public class ChatProvider extends ContentProvider {
+    public static final Uri CHAT_URI = Uri.parse("content://rcsprovider/chat");
+    public static final Uri SUMMARY_URI = Uri.parse("content://rcsprovider/summary");
+    public static final String AUTHORITY = "rcsprovider";
+    private static final String TAG = "TestRcsApp.ChatProvider";
+    private static final int DATABASE_VERSION = 1;
+    private static final String CHAT_TABLE_NAME = "chat";
+    private static final String SUMMARY_TABLE_NAME = "summary";
+
+    private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
+    private static final int URI_CHAT = 1;
+    private static final int URI_SUMMARY = 2;
+    private static final String QUERY_CHAT_TABLE = " SELECT * FROM " + CHAT_TABLE_NAME;
+
+    static {
+        URI_MATCHER.addURI(AUTHORITY, "chat", URI_CHAT);
+        URI_MATCHER.addURI(AUTHORITY, "summary", URI_SUMMARY);
+    }
+
+    private RcsDatabaseHelper mRcsHelper;
+
+    @Override
+    public boolean onCreate() {
+        mRcsHelper = new RcsDatabaseHelper(getContext());
+        return true;
+    }
+
+    @Override
+    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+            String sortOrder) {
+        SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+        SQLiteDatabase db = mRcsHelper.getReadableDatabase();
+        int match = URI_MATCHER.match(uri);
+
+        Log.d(TAG, "Query URI: " + match);
+        switch (match) {
+            case URI_CHAT:
+                qb.setTables(CHAT_TABLE_NAME);
+                return qb.query(db, projection, selection, selectionArgs, null, null, sortOrder);
+            case URI_SUMMARY:
+                qb.setTables(SUMMARY_TABLE_NAME);
+                return qb.query(db, projection, selection, selectionArgs, null, null, sortOrder);
+            default:
+                Log.e(TAG, "no such uri");
+                return null;
+        }
+    }
+
+    @Override
+    public Uri insert(Uri uri, ContentValues contentValues) {
+        SQLiteDatabase db = mRcsHelper.getWritableDatabase();
+        int match = URI_MATCHER.match(uri);
+        long id;
+        switch (match) {
+            case URI_CHAT:
+                id = db.insert(CHAT_TABLE_NAME, "", contentValues);
+                break;
+            case URI_SUMMARY:
+                id = db.insert(SUMMARY_TABLE_NAME, "", contentValues);
+                break;
+            default:
+                Log.e(TAG, "no such uri");
+                throw new SQLException("no such uri");
+        }
+        if (id > 0) {
+            Uri msgUri = Uri.withAppendedPath(uri, String.valueOf(id));
+            getContext().getContentResolver().notifyChange(uri, null);
+            Log.i(TAG, "msgUri:" + msgUri);
+            return msgUri;
+        } else {
+            throw new SQLException("fail to add chat message");
+        }
+    }
+
+    @Override
+    public int delete(Uri uri, String s, String[] strings) {
+        return 0;
+    }
+
+    @Override
+    public int update(Uri uri, ContentValues contentValues, String selection,
+            String[] selectionArgs) {
+        SQLiteDatabase db = mRcsHelper.getWritableDatabase();
+        int match = URI_MATCHER.match(uri);
+        int result = 0;
+        String tableName = "";
+        switch (match) {
+            case URI_CHAT:
+                tableName = CHAT_TABLE_NAME;
+                break;
+            case URI_SUMMARY:
+                tableName = SUMMARY_TABLE_NAME;
+                break;
+        }
+        if (!TextUtils.isEmpty(tableName)) {
+            result = db.updateWithOnConflict(tableName, contentValues,
+                    selection, selectionArgs, SQLiteDatabase.CONFLICT_REPLACE);
+            getContext().getContentResolver().notifyChange(uri, null);
+            Log.d(TAG, "Update uri: " + match + " result: " + result);
+        } else {
+            Log.d(TAG, "Update. Not support URI.");
+        }
+        return result;
+    }
+
+    @Override
+    public String getType(Uri uri) {
+        return null;
+    }
+
+    /** Define columns for the chat table. */
+    public static class RcsColumns implements BaseColumns {
+        public static final String SRC_PHONE_NUMBER = "source_phone_number";
+        public static final String DEST_PHONE_NUMBER = "destination_phone_number";
+        public static final String CHAT_MESSAGE = "chat_message";
+        public static final String SUBSCRIPTION_ID = "subscription_id";
+        public static final String MSG_TIMESTAMP = "msg_timestamp";
+        public static final String IS_READ = "is_read";
+    }
+
+    /** Define columns for the summary table. */
+    public static class SummaryColumns implements BaseColumns {
+        public static final String REMOTE_PHONE_NUMBER = "remote_phone_number";
+        public static final String LATEST_MESSAGE = "latest_message";
+        public static final String MSG_TIMESTAMP = "msg_timestamp";
+        public static final String IS_READ = "is_read";
+    }
+
+    /** Database helper */
+    public static final class RcsDatabaseHelper extends SQLiteOpenHelper {
+        public static final String SQL_CREATE_RCS_TABLE = "CREATE TABLE "
+                + CHAT_TABLE_NAME
+                + " ("
+                + RcsColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
+                + RcsColumns.SRC_PHONE_NUMBER + " Text DEFAULT NULL, "
+                + RcsColumns.DEST_PHONE_NUMBER + " Text DEFAULT NULL, "
+                + RcsColumns.CHAT_MESSAGE + " Text DEFAULT NULL, "
+                + RcsColumns.MSG_TIMESTAMP + " LONG DEFAULT NULL, "
+                + RcsColumns.IS_READ + " BOOLEAN DEFAULT false);";
+        public static final String SQL_CREATE_SUMMARY_TABLE = "CREATE TABLE "
+                + SUMMARY_TABLE_NAME
+                + " ("
+                + SummaryColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
+                + SummaryColumns.REMOTE_PHONE_NUMBER + " Text DEFAULT NULL, "
+                + SummaryColumns.LATEST_MESSAGE + " Text DEFAULT NULL, "
+                + SummaryColumns.MSG_TIMESTAMP + " LONG DEFAULT NULL, "
+                + SummaryColumns.IS_READ + " BOOLEAN DEFAULT false);";
+        private static final String DB_NAME = "RcsDatabase";
+
+        public RcsDatabaseHelper(Context context) {
+            super(context, DB_NAME, null, DATABASE_VERSION);
+        }
+
+        @Override
+        public void onCreate(SQLiteDatabase db) {
+            db.execSQL(SQL_CREATE_RCS_TABLE);
+            db.execSQL(SQL_CREATE_SUMMARY_TABLE);
+        }
+
+        @Override
+        public void onUpgrade(SQLiteDatabase sqLiteDatabase, int oldVersion, int newVersion) {
+            Log.d(TAG, "DB upgrade from " + oldVersion + " to " + newVersion);
+        }
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/Android.bp b/testapps/TestRcsApp/aosp_test_rcsclient/Android.bp
new file mode 100644
index 0000000..eef34c8
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/Android.bp
@@ -0,0 +1,27 @@
+
+
+android_library {
+    name: "aosp_test_rcs_client_base",
+
+    srcs: ["src/com/android/libraries/rcs/**/*.java"],
+
+    static_libs: [
+        "androidx.annotation_annotation",
+        "androidx.concurrent_concurrent-futures",
+        "guava",
+        "nist-sip",
+    ],
+
+    libs: [
+        "auto_value_annotations",
+    ],
+
+    plugins: [
+        "auto_value_plugin",
+    ],
+
+    sdk_version: "system_current",
+    min_sdk_version: "30",
+}
+
+
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/AndroidManifest.xml b/testapps/TestRcsApp/aosp_test_rcsclient/AndroidManifest.xml
new file mode 100644
index 0000000..b167aa8
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/AndroidManifest.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/* //packages/services/Telephony/testapps/TestRcsApp/aosp_test_rcsclient/AndroidManifest.xml
+**
+** Copyright 2020, The Android Open Source Project
+**
+** Licensed under the Apache License, Version 2.0 (the "License");
+** you may not use this file except in compliance with the License.
+** You may obtain a copy of the License at
+**
+**     http://www.apache.org/licenses/LICENSE-2.0
+**
+** Unless required by applicable law or agreed to in writing, software
+** distributed under the License is distributed on an "AS IS" BASIS,
+** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+** See the License for the specific language governing permissions and
+** limitations under the License.
+*/
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.libraries.rcs.simpleclient"
+    android:versionCode="1"
+    android:versionName="1.0">
+
+    <uses-sdk
+        android:minSdkVersion="21"
+        android:targetSdkVersion="23" />
+
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.CONNECTIVITY_USE_RESTRICTED_NETWORKS" />
+</manifest>
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/LICENSE b/testapps/TestRcsApp/aosp_test_rcsclient/LICENSE
new file mode 100644
index 0000000..b9b9d2a
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/LICENSE
@@ -0,0 +1,176 @@
+Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
\ No newline at end of file
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpChunkTest.java b/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpChunkTest.java
new file mode 100644
index 0000000..90f1714
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpChunkTest.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.msrp;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpChunk.Continuation;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class MsrpChunkTest {
+
+    private static MsrpChunk writeAndReadChunk(MsrpChunk chunk) throws IOException {
+        ByteArrayOutputStream bo = new ByteArrayOutputStream();
+        MsrpSerializer.serialize(bo, chunk);
+
+        ByteArrayInputStream bi = new ByteArrayInputStream(bo.toByteArray());
+        return MsrpParser.parse(bi);
+    }
+
+    @Test
+    public void whenSerializeParseRequest_success() throws IOException {
+        MsrpChunk chunk = MsrpChunk.newBuilder()
+                .method(MsrpChunk.Method.SEND)
+                .addHeader("To-Path", "msrp://123.1.11:9/testreceiver;tcp")
+                .addHeader("From-Path", "msrp://123.1.11:9/testsender;tcp")
+                .addHeader("Byte-Range", "1-*/*")
+                .transactionId("123123")
+                .addHeader("Content-Type", "text/plain")
+                .content("Hallo Welt\r\n".getBytes(UTF_8))
+                .continuation(Continuation.COMPLETE)
+                .build();
+
+        MsrpChunk chunk2 = writeAndReadChunk(chunk);
+
+        assertThat(chunk2).isEqualTo(chunk);
+    }
+
+    @Test
+    public void whenSerializeParseEmptyRequest_success() throws IOException {
+        MsrpChunk chunk = MsrpChunk.newBuilder()
+                .method(MsrpChunk.Method.SEND)
+                .transactionId("testtransaction")
+                .addHeader("To-Path", "msrp://123.1.11:9/testreceiver;tcp")
+                .addHeader("From-Path", "msrp://123.1.11:9/testsender;tcp")
+                .continuation(Continuation.COMPLETE)
+                .build();
+
+        MsrpChunk chunk2 = writeAndReadChunk(chunk);
+
+        assertThat(chunk2).isEqualTo(chunk);
+    }
+
+    @Test
+    public void whenSerializeParseResponse_success() throws IOException {
+        MsrpChunk chunk = MsrpChunk.newBuilder()
+                .responseCode(200)
+                .responseReason("OK")
+                .transactionId("testtransaction")
+                .addHeader("To-Path", "msrp://123.1.11:9/testreceiver;tcp")
+                .addHeader("From-Path", "msrp://123.1.11:9/testsender;tcp")
+                .continuation(Continuation.COMPLETE)
+                .build();
+
+        MsrpChunk chunk2 = writeAndReadChunk(chunk);
+
+        assertThat(chunk2).isEqualTo(chunk);
+    }
+
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpSessionTest.java b/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpSessionTest.java
new file mode 100644
index 0000000..dc60d37
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpSessionTest.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.msrp;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpChunk.Continuation;
+
+import java.io.IOException;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+/**
+ *
+ */
+@RunWith(AndroidJUnit4.class)
+public class MsrpSessionTest {
+    @Rule
+    public final MockitoRule mockito = MockitoJUnit.rule();
+
+    @Mock
+    private Socket socket;
+
+    @Before
+    public void setUp() throws IOException {
+        PipedInputStream input = new PipedInputStream();
+        when(socket.getInputStream()).thenReturn(input);
+        when(socket.getOutputStream()).thenReturn(new PipedOutputStream(input));
+        when(socket.isConnected()).thenReturn(true);
+    }
+
+    @Test
+    public void foo() throws IOException, ExecutionException, InterruptedException {
+
+        AtomicReference<MsrpChunk> receivedRequest = new AtomicReference<>();
+
+        final MsrpSession session =
+                new MsrpSession(
+                        socket,
+                        (m) -> {
+                            receivedRequest.set(m);
+                        });
+
+        MsrpChunk request = generateRequest();
+        Future<MsrpChunk> future = session.send(request);
+
+        Executors.newSingleThreadExecutor().execute(session::run);
+
+        MsrpChunk response = future.get();
+
+        assertThat(request).isEqualTo(receivedRequest.get());
+        assertThat(response).isEqualTo(generateSuccessResponse());
+    }
+
+    private MsrpChunk generateRequest() {
+        return MsrpChunk.newBuilder()
+                .transactionId("txid")
+                .method(MsrpChunk.Method.SEND)
+                .addHeader(MsrpConstants.HEADER_TO_PATH, "msrp://test:1234/sessionA;tcp")
+                .addHeader(MsrpConstants.HEADER_FROM_PATH, "msrp://test:1234/sessionB;tcp")
+                .addHeader(MsrpConstants.HEADER_BYTE_RANGE, "1-*/*")
+                .addHeader(MsrpConstants.HEADER_MESSAGE_ID, "abcde")
+                .addHeader(MsrpConstants.HEADER_CONTENT_TYPE, "text/plain")
+                .content("Hallo Welt\r\n".getBytes(StandardCharsets.UTF_8))
+                .continuation(Continuation.COMPLETE)
+                .build();
+    }
+
+    private MsrpChunk generateSuccessResponse() {
+        return MsrpChunk.newBuilder()
+                .transactionId("txid")
+                .responseCode(200)
+                .responseReason("OK")
+                .addHeader(MsrpConstants.HEADER_TO_PATH, "msrp://test:1234/sessionB;tcp")
+                .addHeader(MsrpConstants.HEADER_FROM_PATH, "msrp://test:1234/sessionA;tcp")
+                .continuation(Continuation.COMPLETE)
+                .build();
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/protocol/sdp/SimpleSdpMessageTest.java b/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/protocol/sdp/SimpleSdpMessageTest.java
new file mode 100644
index 0000000..4b5f31a
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/protocol/sdp/SimpleSdpMessageTest.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.sdp;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import java.io.ByteArrayInputStream;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SimpleSdpMessageTest {
+    private static final String SAMPLE_SDP_REGEX =
+            "v=0\r\n"
+                    + "o=TestRcsClient .+ .+ IN IP4 192.168.1.1\r\n"
+                    + "s=-\r\n"
+                    + "c=IN IP4 192.168.1.1\r\n"
+                    + "t=0 0\r\n"
+                    + "m=message 9 TCP/MSRP \\*\r\n"
+                    + "a=path:msrp://192.168.1.1:9/.+;tcp\r\n"
+                    + "a=setup:active\r\n"
+                    + "a=accept-types:message/cpim application/im-iscomposing\\+xml\r\n"
+                    + "a=accept-wrapped-types:text/plain message/imdn\\+xml"
+                    + " application/vnd.gsma.rcs-ft-http\\+xml application/vnd.gsma"
+                    + ".rcspushlocation\\+xml\r\n"
+                    + "a=sendrecv\r\n";
+
+    @Test
+    public void createForMsrp_returnExpectedSdpString() {
+        SimpleSdpMessage sdp =
+                SdpUtils.createSdpForMsrp(/* address= */ "192.168.1.1", /* isTls= */ false);
+
+        assertThat(sdp.encode()).matches(SAMPLE_SDP_REGEX);
+    }
+
+    @Test
+    public void encodeAndParse_shouldBeEqualToOriginal() throws Exception {
+        SimpleSdpMessage original =
+                SdpUtils.createSdpForMsrp(/* address= */ "192.168.1.1", /* isTls= */ false);
+
+        SimpleSdpMessage parsedSdp =
+                SimpleSdpMessage.parse(new ByteArrayInputStream(original.encode().getBytes(UTF_8)));
+
+        assertThat(parsedSdp).isEqualTo(original);
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/protocol/sip/SipUtilsTest.java b/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/protocol/sip/SipUtilsTest.java
new file mode 100644
index 0000000..be043f5
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/protocol/sip/SipUtilsTest.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.sip;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.google.common.collect.Lists;
+
+import gov.nist.javax.sip.message.SIPRequest;
+
+import java.util.List;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SipUtilsTest {
+    private static final String LOCAL_URI = "tel:+1234567890";
+    private static final String REMOTE_URI = "tel:+1234567891";
+    private static final String CONVERSATION_ID = "abcd-1234";
+
+    SipSessionConfiguration configuration =
+            new SipSessionConfiguration() {
+                @Override
+                public long getVersion() {
+                    return 0;
+                }
+
+                @Override
+                public String getOutboundProxyAddr() {
+                    return "3001:4870:e00b:5e94:21b8:8d20:c425:5e6c";
+                }
+
+                @Override
+                public int getOutboundProxyPort() {
+                    return 5060;
+                }
+
+                @Override
+                public String getLocalIpAddress() {
+                    return "2001:4870:e00b:5e94:21b8:8d20:c425:5e6c";
+                }
+
+                @Override
+                public int getLocalPort() {
+                    return 5060;
+                }
+
+                @Override
+                public String getSipTransport() {
+                    return "TCP";
+                }
+
+                @Override
+                public String getPublicUserIdentity() {
+                    return "sip:+1234567890@foo.bar";
+                }
+
+                @Override
+                public String getDomain() {
+                    return "foo.bar";
+                }
+
+                @Override
+                public List<String> getAssociatedUris() {
+                    return Lists.newArrayList(LOCAL_URI, "sip:+1234567890@foo.bar");
+                }
+
+                @Override
+                public String getSecurityVerifyHeader() {
+                    return "ipsec-3gpp;q=0.5;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;"
+                            + "spi-c=983227540;spi-s=2427966379;port-c=65528;port-s=65529";
+                }
+
+                @Override
+                public List<String> getServiceRouteHeaders() {
+                    return Lists.newArrayList();
+                }
+
+                @Override
+                public String getContactUser() {
+                    return "abcd-efgh";
+                }
+
+                @Override
+                public String getImei() {
+                    return "35293211-111080-0";
+                }
+
+                @Override
+                public String getPaniHeader() {
+                    return null;
+                }
+
+                @Override
+                public String getPlaniHeader() {
+                    return null;
+                }
+
+                @Override
+                public int getMaxPayloadSizeOnUdp() {
+                    return 0;
+                }
+            };
+
+    @Test
+    public void buildInvite_returnExpectedInviteMessage() throws Exception {
+        SIPRequest request = SipUtils.buildInvite(configuration, REMOTE_URI, CONVERSATION_ID);
+
+        assertThat(request.getRequestURI().toString()).isEqualTo(REMOTE_URI);
+        assertThat(request.getFrom().getAddress().getURI().toString()).isEqualTo(LOCAL_URI);
+        assertThat(request.getTo().getAddress().getURI().toString()).isEqualTo(REMOTE_URI);
+        assertThat(request.hasHeader("Conversation-ID")).isTrue();
+        assertThat(request.hasHeader("Contribution-ID")).isTrue();
+        assertThat(request.hasHeader("Accept-Contact")).isTrue();
+        assertThat(request.hasHeader("Security-Verify")).isTrue();
+    }
+
+    @Test
+    public void buildInvite_sizeIsGreaterThanMaxPayloadSize_transportShouldBeTcp()
+            throws Exception {
+        SIPRequest request = SipUtils.buildInvite(configuration, REMOTE_URI, CONVERSATION_ID);
+
+        // The size is always greater than maxPayloadSizeOnUdp = 0
+        assertThat(request.getTopmostVia().getTransport()).isEqualTo("TCP");
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/provisioning/StaticConfigProvisioningControllerTest.java b/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/provisioning/StaticConfigProvisioningControllerTest.java
new file mode 100644
index 0000000..b9065de
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/provisioning/StaticConfigProvisioningControllerTest.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.provisioning;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.support.annotation.RequiresPermission;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import java.util.Optional;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class StaticConfigProvisioningControllerTest {
+
+    private static final byte[] CONFIG_DATA = "<xml></xml>".getBytes();
+
+    private StaticConfigProvisioningController client;
+    private Optional<byte[]> configXmlData = Optional.empty();
+    private ProvisioningStateChangeCallback cb =
+            configXml -> configXmlData = Optional.ofNullable(configXml);
+
+    @Before
+    public void setUp() {
+        client = StaticConfigProvisioningController.createForSubscriptionId(/*subscriptionId=*/ 2);
+        client.onConfigurationChange(cb);
+    }
+
+    @Test
+    @RequiresPermission(value = "Manifest.permission.READ_PRIVILEGED_PHONE_STATE")
+    public void whenGetConfigCalled_returnsCorrectXmlData() throws Exception {
+        client.register();
+        client.getProvisioningManager().getCallbackForTests().onConfigurationChanged(CONFIG_DATA);
+
+        assertThat(client.isRcsVolteSingleRegistrationCapable()).isTrue();
+        assertThat(client.getLatestConfiguration()).isEqualTo(CONFIG_DATA);
+        assertThat(configXmlData.get()).isEqualTo(CONFIG_DATA);
+        client.unRegister();
+    }
+
+    @Test
+    @RequiresPermission(value = "Manifest.permission.READ_PRIVILEGED_PHONE_STATE")
+    public void whenGetConfigCalled_throwsErrorWhenNoConfigPresent() throws Exception {
+        client.register();
+        client.triggerReconfiguration();
+
+        assertThat(client.isRcsVolteSingleRegistrationCapable()).isTrue();
+        assertThrows(IllegalStateException.class, () -> client.getLatestConfiguration());
+        assertThat(configXmlData.isPresent()).isFalse();
+
+        client.unRegister();
+    }
+
+    @Test
+    @RequiresPermission(value = "Manifest.permission.READ_PRIVILEGED_PHONE_STATE")
+    public void unRegister_failsWhenCalledWithoutRegister() {
+        assertThrows(IllegalStateException.class, () -> client.unRegister());
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/service/chat/SimpleChatSessionTest.java b/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/service/chat/SimpleChatSessionTest.java
new file mode 100644
index 0000000..5c2e995
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/javatests/com/android/libraries/rcs/simpleclient/service/chat/SimpleChatSessionTest.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.service.chat;
+
+import static com.google.common.labs.truth.FutureSubject.assertThat;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.libraries.rcs.simpleclient.SimpleRcsClientContext;
+import com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpManager;
+import com.android.libraries.rcs.simpleclient.protocol.sdp.SdpUtils;
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipSession;
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipSessionConfiguration;
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipSessionListener;
+
+import com.google.common.collect.Lists;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import gov.nist.javax.sip.message.SIPRequest;
+import gov.nist.javax.sip.message.SIPResponse;
+
+import java.util.List;
+
+import javax.sip.message.Message;
+import javax.sip.message.Request;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SimpleChatSessionTest {
+    private static final String LOCAL_URI = "tel:+1234567890";
+    private static final String REMOTE_URI = "tel:+1234567891";
+    private final MsrpManager msrpManager =
+            new MsrpManager(ApplicationProvider.getApplicationContext());
+    SipSessionConfiguration configuration =
+            new SipSessionConfiguration() {
+                @Override
+                public long getVersion() {
+                    return 0;
+                }
+
+                @Override
+                public String getOutboundProxyAddr() {
+                    return "3001:4870:e00b:5e94:21b8:8d20:c425:5e6c";
+                }
+
+                @Override
+                public int getOutboundProxyPort() {
+                    return 5060;
+                }
+
+                @Override
+                public String getLocalIpAddress() {
+                    return "2001:4870:e00b:5e94:21b8:8d20:c425:5e6c";
+                }
+
+                @Override
+                public int getLocalPort() {
+                    return 5060;
+                }
+
+                @Override
+                public String getSipTransport() {
+                    return "TCP";
+                }
+
+                @Override
+                public String getPublicUserIdentity() {
+                    return "sip:+1234567890@foo.bar";
+                }
+
+                @Override
+                public String getDomain() {
+                    return "foo.bar";
+                }
+
+                @Override
+                public List<String> getAssociatedUris() {
+                    return Lists.newArrayList(LOCAL_URI, "sip:+1234567890@foo.bar");
+                }
+
+                @Override
+                public String getSecurityVerifyHeader() {
+                    return "ipsec-3gpp;q=0.5;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;"
+                            + "spi-c=983227540;spi-s=2427966379;port-c=65528;port-s=65529";
+                }
+
+                @Override
+                public List<String> getServiceRouteHeaders() {
+                    return Lists.newArrayList();
+                }
+
+                @Override
+                public String getContactUser() {
+                    return "abcd-efgh";
+                }
+
+                @Override
+                public String getImei() {
+                    return "35293211-111080-0";
+                }
+
+                @Override
+                public String getPaniHeader() {
+                    return "IEEE-802.11;i-wlan-node-id=PANIC01EB5B0";
+                }
+
+                @Override
+                public String getPlaniHeader() {
+                    return "IEEE-802.11;i-wlan-node-id=PLANI01EB5B0";
+                }
+            };
+    private final SimpleRcsClientContext context =
+            new SimpleRcsClientContext(
+                    /* provisioningController= */ null,
+                    /* registrationController= */ null,
+                    /* imsService= */ null,
+                    new SipSession() {
+                        @Override
+                        public SipSessionConfiguration getSessionConfiguration() {
+                            return configuration;
+                        }
+
+                        @Override
+                        public ListenableFuture<Boolean> send(Message message) {
+                            return Futures.immediateFuture(true);
+                        }
+
+                        @Override
+                        public void setSessionListener(SipSessionListener listener) {
+                        }
+                    });
+
+    @Test
+    public void start_reply200_returnSuccessfulFuture() throws Exception {
+        SimpleChatSession session =
+                new SimpleChatSession(
+                        context,
+                        new MinimalCpmChatService(ApplicationProvider.getApplicationContext()) {
+                            @Override
+                            ListenableFuture<Boolean> sendSipRequest(SIPRequest msg,
+                                    SimpleChatSession session) {
+                                if (msg.getMethod().equals(Request.INVITE)) {
+                                    SIPResponse response = msg.createResponse(/* statusCode= */
+                                            200);
+                                    response.setMessageContent(
+                                            /* type= */ "application",
+                                            /* subType= */ "sdp",
+                                            SdpUtils.createSdpForMsrp(/* address= */
+                                                    "127.0.0.1", /* isTls= */ false)
+                                                    .encode());
+                                    session.receiveMessage(response);
+                                }
+                                return Futures.immediateFuture(true);
+                            }
+                        },
+                        msrpManager);
+
+        // session.start should return the successful void future.
+        assertThat(session.start(REMOTE_URI)).whenDone().isSuccessful();
+    }
+
+    @Test
+    public void start_reply404_returnFailedFuture() throws Exception {
+        SimpleChatSession session =
+                new SimpleChatSession(
+                        context,
+                        new MinimalCpmChatService(ApplicationProvider.getApplicationContext()) {
+                            @Override
+                            ListenableFuture<Boolean> sendSipRequest(SIPRequest msg,
+                                    SimpleChatSession session) {
+                                if (msg.getMethod().equals(Request.INVITE)) {
+                                    SIPResponse response = msg.createResponse(/* statusCode= */
+                                            404);
+                                    session.receiveMessage(response);
+                                }
+                                return Futures.immediateFuture(true);
+                            }
+                        },
+                        msrpManager);
+
+        // session.start should return the failed future with the exception.
+        assertThat(session.start(REMOTE_URI)).whenDone().isFailedWith(ChatServiceException.class);
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/SimpleRcsClient.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/SimpleRcsClient.java
new file mode 100644
index 0000000..c299cc9
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/SimpleRcsClient.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient;
+
+import android.os.Build.VERSION_CODES;
+import android.telephony.ims.ImsException;
+import android.util.Log;
+
+import androidx.annotation.RequiresApi;
+
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipSession;
+import com.android.libraries.rcs.simpleclient.provisioning.ProvisioningController;
+import com.android.libraries.rcs.simpleclient.provisioning.StaticConfigProvisioningController;
+import com.android.libraries.rcs.simpleclient.registration.RegistrationController;
+import com.android.libraries.rcs.simpleclient.service.ImsService;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Simple RCS client implementation.
+ *
+ * State is covered by a context instance.
+ */
+@RequiresApi(api = VERSION_CODES.R)
+public class SimpleRcsClient {
+    private static final String TAG = SimpleRcsClient.class.getSimpleName();
+    private final AtomicReference<State> state = new AtomicReference<>(State.NEW);
+    private ProvisioningController provisioningController;
+    private RegistrationController registrationController;
+    private ImsService imsService;
+    private Executor executor;
+    private SimpleRcsClientContext context;
+    private StateChangedCallback stateChangedCallback;
+
+    public static Builder newBuilder() {
+        return new Builder();
+    }
+
+    public SimpleRcsClientContext getContext() {
+        return context;
+    }
+
+    public void start() {
+        provision();
+    }
+
+    public void stop() {
+        Log.i(TAG, "stop..");
+        registrationController.deregister();
+        provisioningController.unRegister();
+        provisioningController = null;
+        registrationController = null;
+        imsService = null;
+    }
+
+    public void onStateChanged(StateChangedCallback cb) {
+        this.stateChangedCallback = cb;
+    }
+
+    private boolean enterState(State expected, State newState) {
+        boolean result = state.compareAndSet(expected, newState);
+
+        if (result && stateChangedCallback != null) {
+            try {
+                stateChangedCallback.notifyStateChange(expected, newState);
+            } catch (Exception e) {
+                Log.e(TAG, "Exception on calling state change callback", e);
+            }
+        }
+        Log.i(TAG, "expected:" + expected + " new:" + newState + " res:" + result);
+        return result;
+    }
+
+    private void provision() {
+        if (!enterState(State.NEW, State.PROVISIONING)) {
+            return;
+        }
+        provisioningController.onConfigurationChange(configXml -> {
+            register();
+        });
+        try {
+            provisioningController.triggerProvisioning();
+        } catch (ImsException e) {
+            // TODO: ...
+        }
+    }
+
+    private void register() {
+        if (!enterState(State.PROVISIONING, State.REGISTERING)) {
+            return;
+        }
+
+        Futures.addCallback(registrationController.register(imsService),
+                new FutureCallback<SipSession>() {
+                    @Override
+                    public void onSuccess(SipSession result) {
+                        Log.i(TAG, "onSuccess:" + result);
+                        registered(result);
+                    }
+
+                    @Override
+                    public void onFailure(Throwable t) {
+                        Log.i(TAG, "onFailure:" + t);
+                    }
+                }, executor);
+    }
+
+    private void registered(SipSession session) {
+        enterState(State.REGISTERING, State.REGISTERED);
+
+        context = new SimpleRcsClientContext(provisioningController, registrationController,
+                imsService,
+                session);
+
+        imsService.start(context);
+    }
+
+    /**
+     * Possible client states.
+     */
+    public enum State {
+        NEW,
+        PROVISIONING,
+        REGISTERING,
+        REGISTERED,
+    }
+
+    /**
+     * Builder for creating new SimpleRcsClient instances.
+     */
+    public static class Builder {
+
+        private ProvisioningController provisioningController;
+        private RegistrationController registrationController;
+        private ImsService imsService;
+        private Executor executor;
+
+        public Builder provisioningController(ProvisioningController controller) {
+            this.provisioningController = controller;
+            return this;
+        }
+
+        public Builder registrationController(RegistrationController controller) {
+            this.registrationController = controller;
+            return this;
+        }
+
+        public Builder imsService(ImsService imsService) {
+            this.imsService = imsService;
+            return this;
+        }
+
+        public Builder executor(Executor executor) {
+            this.executor = executor;
+            return this;
+        }
+
+        public SimpleRcsClient build() {
+            SimpleRcsClient client = new SimpleRcsClient();
+            client.registrationController = registrationController;
+            client.provisioningController = provisioningController;
+            client.imsService = imsService;
+            client.executor = executor;
+
+            return client;
+        }
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/SimpleRcsClientContext.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/SimpleRcsClientContext.java
new file mode 100644
index 0000000..1be6403
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/SimpleRcsClientContext.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient;
+
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipSession;
+import com.android.libraries.rcs.simpleclient.provisioning.ProvisioningController;
+import com.android.libraries.rcs.simpleclient.registration.RegistrationController;
+import com.android.libraries.rcs.simpleclient.service.ImsService;
+
+/**
+ * State container for a {@link SimpleRcsClient} instance.
+ */
+public class SimpleRcsClientContext {
+
+    private final ProvisioningController provisioningController;
+
+    private final RegistrationController registrationController;
+
+    private final ImsService imsService;
+
+    private final SipSession sipSession;
+
+    public SimpleRcsClientContext(
+            ProvisioningController provisioningController,
+            RegistrationController registrationController,
+            ImsService imsService,
+            SipSession sipSession) {
+        this.provisioningController = provisioningController;
+        this.registrationController = registrationController;
+        this.imsService = imsService;
+        this.sipSession = sipSession;
+    }
+
+    public ProvisioningController getProvisioningController() {
+        return provisioningController;
+    }
+
+    public RegistrationController getRegistrationController() {
+        return registrationController;
+    }
+
+    public ImsService getImsService() {
+        return imsService;
+    }
+
+    public SipSession getSipSession() {
+        return sipSession;
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/StateChangedCallback.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/StateChangedCallback.java
new file mode 100644
index 0000000..87f6566
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/StateChangedCallback.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient;
+
+import com.android.libraries.rcs.simpleclient.SimpleRcsClient.State;
+
+/**
+ * Callback for processing state changes.
+ */
+public interface StateChangedCallback {
+    void notifyStateChange(State oldState, State newState);
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/cpim/CpimUtils.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/cpim/CpimUtils.java
new file mode 100644
index 0000000..b621257
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/cpim/CpimUtils.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.cpim;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Random;
+
+/** Collections of utility functions for CPIM */
+public class CpimUtils {
+    private static final String ANONYMOUS_URI = "<sip:anonymous@anonymous.invalid>";
+    public static final String CPIM_CONTENT_TYPE = "message/cpim";
+
+    private CpimUtils() {
+    }
+
+    public static SimpleCpimMessage createForText(String text) {
+        return SimpleCpimMessage.newBuilder()
+                .addNamespace("imdn", "urn:ietf:params:imdn")
+                .addHeader("imdn.Message-ID", generateImdnMessageId())
+                .addHeader("imdn.Disposition-Notification", "positive-delivery, display")
+                .addHeader("To", ANONYMOUS_URI)
+                .addHeader("From", ANONYMOUS_URI)
+                .addHeader("DateTime", LocalDate.now(ZoneId.systemDefault()).toString())
+                .setContentType("text/plain")
+                .setContent(text)
+                .build();
+    }
+
+    private static String generateImdnMessageId() {
+        Random random = new Random();
+        return "Test_" + random.nextLong();
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/cpim/SimpleCpimMessage.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/cpim/SimpleCpimMessage.java
new file mode 100644
index 0000000..aeb6b11
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/cpim/SimpleCpimMessage.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.cpim;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Utf8;
+import com.google.common.collect.ImmutableMap;
+
+import java.util.Map;
+
+/**
+ * The CPIM implementation as per RFC 3862. This class supports minimal fields that is required to
+ * represent a simple message for test purpose.
+ */
+@AutoValue
+public abstract class SimpleCpimMessage {
+    private static final String CRLF = "\r\n";
+    private static final String COLSP = ": ";
+
+    public static SimpleCpimMessage.Builder newBuilder() {
+        return new AutoValue_SimpleCpimMessage.Builder();
+    }
+
+    public abstract ImmutableMap<String, String> namespaces();
+
+    public abstract ImmutableMap<String, String> headers();
+
+    public abstract String contentType();
+
+    public abstract String content();
+
+    public String encode() {
+        StringBuilder builder = new StringBuilder();
+        for (Map.Entry<String, String> entry : namespaces().entrySet()) {
+            builder
+                    .append("NS: ")
+                    .append(entry.getKey())
+                    .append(" <")
+                    .append(entry.getValue())
+                    .append(">")
+                    .append(CRLF);
+        }
+
+        for (Map.Entry<String, String> entry : headers().entrySet()) {
+            builder.append(entry.getKey()).append(COLSP).append(entry.getValue()).append(CRLF);
+        }
+
+        builder.append(CRLF);
+        builder.append("Content-Type").append(COLSP).append(contentType());
+        builder.append("Content-Length").append(COLSP).append(Utf8.encodedLength(content()));
+        builder.append(CRLF);
+        builder.append(content());
+
+        return builder.toString();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+        public abstract ImmutableMap.Builder<String, String> namespacesBuilder();
+
+        public abstract ImmutableMap.Builder<String, String> headersBuilder();
+
+        public abstract Builder setContentType(String value);
+
+        public abstract Builder setContent(String value);
+
+        public abstract SimpleCpimMessage build();
+
+        public Builder addNamespace(String name, String value) {
+            namespacesBuilder().put(name, value);
+            return this;
+        }
+
+        public Builder addHeader(String name, String value) {
+            headersBuilder().put(name, value);
+            return this;
+        }
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/ImsPdnNetworkFetcher.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/ImsPdnNetworkFetcher.java
new file mode 100644
index 0000000..0011011
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/ImsPdnNetworkFetcher.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.msrp;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkRequest;
+
+import androidx.annotation.RequiresPermission;
+
+import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.SettableFuture;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/** A really incomplete implementation for fetching networks from ConnectivityManager. */
+public final class ImsPdnNetworkFetcher {
+
+    private final Context context;
+
+    public ImsPdnNetworkFetcher(Context context) {
+        this.context = context;
+    }
+
+    private static NetworkRequest createNetworkRequest() {
+        NetworkRequest.Builder builder =
+                new NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_IMS);
+        return builder.build();
+    }
+
+    ListenableFuture<Network> getImsPdnNetwork() {
+        return requestNetwork();
+    }
+
+    ListenableFuture<List<String>> getImsPdnIpAddresses() {
+        return FluentFuture.from(getImsPdnNetwork())
+                .transform(this::getNetworkIpAddresses, MoreExecutors.directExecutor());
+    }
+
+    @RequiresPermission("android.permission.ACCESS_NETWORK_STATE")
+    List<String> getNetworkIpAddresses(Network network) {
+        return getConnectivityManager().getLinkProperties(network).getLinkAddresses().stream()
+                .map(link -> link.getAddress().getHostAddress())
+                .collect(Collectors.toList());
+    }
+
+    private ListenableFuture<Network> requestNetwork() {
+        SettableFuture<Network> result = SettableFuture.create();
+        ConnectivityManager cm = getConnectivityManager();
+
+        ConnectivityManager.NetworkCallback cb =
+                new ConnectivityManager.NetworkCallback() {
+                    @Override
+                    public void onAvailable(Network network) {
+                        if (!result.isDone() && !result.isCancelled()) {
+                            result.set(network);
+                        }
+                        cm.unregisterNetworkCallback(this);
+                    }
+                };
+
+        cm.requestNetwork(createNetworkRequest(), cb);
+        return result;
+    }
+
+    private ConnectivityManager getConnectivityManager() {
+        return context.getSystemService(ConnectivityManager.class);
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpChunk.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpChunk.java
new file mode 100644
index 0000000..0d9e62f
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpChunk.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.msrp;
+
+import static com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpConstants.FLAG_ABORT_CHUNK;
+import static com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpConstants.FLAG_LAST_CHUNK;
+import static com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpConstants.FLAG_MORE_CHUNK;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+
+/**
+ * Single MSRP chunk containing a request or a response.
+ */
+@AutoValue
+public abstract class MsrpChunk {
+
+    public static Builder newBuilder() {
+        return new AutoValue_MsrpChunk.Builder()
+                .method(Method.UNKNOWN)
+                .responseCode(0)
+                .responseReason("")
+                .content(new byte[0])
+                .continuation(Continuation.UNKNOWN);
+    }
+
+    public abstract Method method();
+
+    public abstract String transactionId();
+
+    public abstract Continuation continuation();
+
+    public abstract int responseCode();
+
+    public abstract String responseReason();
+
+    public abstract ImmutableList<MsrpChunkHeader> headers();
+
+    public abstract byte[] content();
+
+    public MsrpChunkHeader header(String headerName) {
+        for (MsrpChunkHeader header : headers()) {
+            if (header.name().equals(headerName)) {
+                return header;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Methods for requests
+     */
+    public enum Method {
+        UNKNOWN,
+        SEND,
+        REPORT,
+    }
+
+
+    /**
+     * Continuation flag for the chunk
+     */
+    public enum Continuation {
+        UNKNOWN(0),
+        COMPLETE(FLAG_LAST_CHUNK),
+        MORE(FLAG_MORE_CHUNK),
+        ABORTED(FLAG_ABORT_CHUNK);
+
+        private final int value;
+
+        Continuation(int value) {
+            this.value = value;
+        }
+
+        public static Continuation valueOf(int read) {
+            if (read == COMPLETE.value) {
+                return COMPLETE;
+            }
+            if (read == MORE.value) {
+                return MORE;
+            }
+            if (read == ABORTED.value) {
+                return ABORTED;
+            }
+            return UNKNOWN;
+        }
+
+        public byte toByte() {
+            return (byte) value;
+        }
+    }
+
+    /**
+     * Builder for new MSRP chunk.
+     */
+    @AutoValue.Builder
+    public abstract static class Builder {
+
+        public abstract Builder method(Method method);
+
+        public abstract Builder transactionId(String id);
+
+        public abstract String transactionId();
+
+        public abstract Continuation continuation();
+
+        public abstract Builder continuation(Continuation continuation);
+
+        public abstract Builder responseCode(int continuation);
+
+        public abstract Builder responseReason(String reason);
+
+        public abstract Builder content(byte[] content);
+
+        public Builder addHeader(MsrpChunkHeader header) {
+            headersBuilder().add(header);
+            return this;
+        }
+
+        public Builder addHeader(String name, String value) {
+            headersBuilder().add(MsrpChunkHeader.newBuilder().name(name).value(value).build());
+            return this;
+        }
+
+        abstract ImmutableList.Builder<MsrpChunkHeader> headersBuilder();
+
+        MsrpChunkHeader header(String name) {
+            for (MsrpChunkHeader header : headersBuilder().build()) {
+                if (header.name().equals(name)) {
+                    return header;
+                }
+            }
+            return null;
+        }
+
+        public abstract MsrpChunk build();
+
+
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpChunkHeader.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpChunkHeader.java
new file mode 100644
index 0000000..fad17e0
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpChunkHeader.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.msrp;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * Single header in MSRP chunk (From-Path, To-Path, ...)
+ */
+@AutoValue
+public abstract class MsrpChunkHeader {
+
+    public static Builder newBuilder() {
+        return new AutoValue_MsrpChunkHeader.Builder();
+    }
+
+    public abstract String name();
+
+    public abstract String value();
+
+    /**
+     * Builder for  new MSRP header.
+     */
+    @AutoValue.Builder
+    public static abstract class Builder {
+
+        public abstract Builder name(String name);
+
+        public abstract Builder value(String value);
+
+        public abstract MsrpChunkHeader build();
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpConstants.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpConstants.java
new file mode 100644
index 0000000..ad1b98e
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpConstants.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.msrp;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+/**
+ * Several constants used for MSRP parsing and serializing.
+ */
+public class MsrpConstants {
+    public static final byte[] HEADER_DELIMITER_BYTES = ": ".getBytes();
+    public static final String MSRP_PROTOCOL = "MSRP";
+    public static final byte[] MSRP_PROTOCOL_BYTES = MSRP_PROTOCOL.getBytes(UTF_8);
+    public static final String NEW_LINE = "\r\n";
+    public static final byte[] NEW_LINE_BYTES = NEW_LINE.getBytes(UTF_8);
+    public static final String END_MSRP_MSG = "-------";
+    public static final byte[] END_MSRP_MSG_BYTES = END_MSRP_MSG.getBytes(UTF_8);
+    public static final String NEW_LINE_END_MSRP_MSG = NEW_LINE + END_MSRP_MSG;
+    public static final int END_MSRP_MSG_LENGTH = END_MSRP_MSG.length();
+    public static final int FLAG_LAST_CHUNK = '$';
+    public static final int FLAG_MORE_CHUNK = '+';
+    public static final int FLAG_ABORT_CHUNK = '#';
+    public static final byte CHAR_SP = ' ';
+    public static final byte CHAR_LF = '\r';
+    public static final byte CHAR_MIN = '-';
+    public static final byte CHAR_DOUBLE_POINT = ':';
+    public static final String HEADER_BYTE_RANGE = "Byte-Range";
+    public static final String HEADER_CONTENT_TYPE = "Content-Type";
+    public static final String HEADER_MESSAGE_ID = "Message-ID";
+    public static final String HEADER_TO_PATH = "To-Path";
+    public static final String HEADER_FROM_PATH = "From-Path";
+    public static final String HEADER_FAILURE_REPORT = "Failure-Report";
+    public static final int RESPONSE_CODE_OK = 200;
+
+    private MsrpConstants() {
+    }
+}
\ No newline at end of file
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpManager.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpManager.java
new file mode 100644
index 0000000..47326bd
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpManager.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.msrp;
+
+import android.content.Context;
+import android.net.Network;
+
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import java.io.IOException;
+import java.net.Socket;
+
+/** Provides creating and managing {@link MsrpSession} */
+public class MsrpManager {
+    private final ImsPdnNetworkFetcher imsPdnNetworkFetcher;
+
+    public MsrpManager(Context context) {
+        imsPdnNetworkFetcher = new ImsPdnNetworkFetcher(context);
+    }
+
+    private static MsrpSession createMsrpSession(
+            Network network, String host, int port, MsrpSessionListener listener)
+            throws IOException {
+        Socket socket = network.getSocketFactory().createSocket(host, port);
+        MsrpSession msrpSession = new MsrpSession(socket, listener);
+        Thread thread = new Thread(msrpSession::run);
+        thread.start();
+        return msrpSession;
+    }
+
+    public ListenableFuture<MsrpSession> createMsrpSession(
+            String host, int port, MsrpSessionListener listener) {
+        return Futures.transformAsync(
+                imsPdnNetworkFetcher.getImsPdnNetwork(),
+                network -> {
+                    if (network != null) {
+                        return Futures.immediateFuture(
+                                createMsrpSession(network, host, port, listener));
+                    } else {
+                        return Futures.immediateFailedFuture(
+                                new IllegalStateException("Network is null"));
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpParser.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpParser.java
new file mode 100644
index 0000000..3376544
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpParser.java
@@ -0,0 +1,355 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.msrp;
+
+import static com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpConstants.CHAR_DOUBLE_POINT;
+import static com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpConstants.CHAR_LF;
+import static com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpConstants.CHAR_MIN;
+import static com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpConstants.CHAR_SP;
+import static com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpConstants.END_MSRP_MSG_LENGTH;
+import static com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpConstants.HEADER_BYTE_RANGE;
+import static com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpConstants.NEW_LINE_END_MSRP_MSG;
+
+import com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpChunk.Continuation;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+
+/**
+ * Simple parser for reading MSRP messages from a stream.
+ */
+public final class MsrpParser {
+
+    private MsrpParser() {
+    }
+
+    public static MsrpChunk parse(final InputStream stream) throws IOException {
+        MsrpChunk.Builder transaction = MsrpChunk.newBuilder();
+
+        // Read a chunk (blocking method)
+        int i = stream.read();
+
+        final StringBuilder value = new StringBuilder();
+        // Read MSRP tag
+        skipWithDelimiter(stream, CHAR_SP);
+
+        if (i == -1) {
+            // End of stream
+            return null;
+        }
+
+        // Read the transaction ID
+        do {
+            i = stream.read();
+            if (i != CHAR_SP) {
+                value.append((char) i);
+            }
+        } while ((i != CHAR_SP) && (i != -1));
+
+        if (i == -1) {
+            return null;
+        }
+
+        final String txId = value.toString();
+        value.setLength(0);
+
+        // Read response code or method name
+        MsrpChunk.Method method = MsrpChunk.Method.UNKNOWN;
+        int responseCode = -1;
+        for (i = stream.read(); (i != CHAR_LF) && (i != -1); i = stream.read()) {
+            if (i == CHAR_SP && responseCode == -1) {
+                // There is a space -> it's a response
+                try {
+                    responseCode = Integer.parseInt(value.toString());
+                } catch (NumberFormatException nfe) {
+                    // This is an invalid response.
+                    return null;
+                }
+                value.setLength(0);
+                continue;
+            }
+            value.append((char) i);
+        }
+
+        if (responseCode == -1) {
+            try {
+                responseCode = Integer.parseInt(value.toString());
+                value.setLength(0);
+            } catch (final NumberFormatException e) {
+                method = MsrpChunk.Method.valueOf(value.toString());
+            }
+        }
+
+        i = stream.read();
+
+        if (i == -1) {
+            // End of stream
+            return null;
+        }
+
+        final boolean isResponse = responseCode > -1;
+        if (isResponse) {
+            transaction.transactionId(txId).responseCode(responseCode).responseReason(
+                    value.toString());
+        } else {
+            transaction.transactionId(txId).method(method);
+        }
+
+        value.setLength(0);
+
+        // Read MSRP headers
+        readHeaders(stream, transaction, value);
+
+        // We already received end of message
+        if (transaction.continuation() != Continuation.UNKNOWN) {
+            return transaction.build();
+        }
+
+        i = stream.read();
+        if (i == -1) {
+            // End of stream
+            return null;
+        }
+
+        // Process MSRP request
+        if (method == MsrpChunk.Method.SEND) {
+            readChunk(stream, transaction);
+        }
+
+        return transaction.build();
+    }
+
+    private static void readHeaders(
+            final InputStream stream, final MsrpChunk.Builder transaction,
+            final StringBuilder value)
+            throws IOException {
+        for (int i = stream.read(); (i != CHAR_LF) && (i != -1); ) {
+
+            for (; (i != CHAR_DOUBLE_POINT) && (i != -1); i = stream.read()) {
+                value.append((char) i);
+            }
+
+            final String headerName = value.toString();
+            value.setLength(0);
+
+            stream.read(); // skip space
+
+            for (i = stream.read(); (i != CHAR_LF) && (i != -1); i = stream.read()) {
+                value.append((char) i);
+            }
+
+            final String headerValue = value.toString();
+            value.setLength(0);
+
+            transaction.addHeader(headerName, headerValue);
+
+            stream.read();
+
+            // It's the end of the header part
+            i = stream.read();
+            if (i == CHAR_MIN) {
+                final int length = END_MSRP_MSG_LENGTH - 1 + transaction.transactionId().length();
+                stream.skip(length);
+                transaction.continuation(Continuation.valueOf(stream.read()));
+
+                // For response
+                for (; (i != CHAR_LF) && (i != -1); i = stream.read()) {
+                }
+                break;
+            }
+        }
+    }
+
+    private static void readChunk(final InputStream stream, final MsrpChunk.Builder chunk)
+            throws IOException {
+        final String byteRange = chunk.header(HEADER_BYTE_RANGE).value();
+
+        if (byteRange == null) {
+            throw new IllegalStateException("expected non-null byteRange");
+        }
+        final int chunkSize = getChunkSize(byteRange);
+        final long totalSize = getTotalSize(byteRange);
+
+        if (totalSize == Integer.MIN_VALUE || chunkSize < -1) {
+            throw new IOException("Invalid byte range: " + byteRange);
+        }
+
+        if (chunkSize == -1) {
+            readUnknownChunk(stream, chunk);
+        } else {
+            readKnownChunk(stream, chunk, chunkSize);
+            skipEndLine(stream, chunk);
+        }
+
+        readContinuationFlag(stream, chunk);
+    }
+
+    private static void readKnownChunk(
+            final InputStream stream, final MsrpChunk.Builder chunk, final int chunkSize)
+            throws IOException {
+        // Read the data
+        final byte[] data = new byte[chunkSize];
+        int nbRead = 0;
+        int nbData = -1;
+        while ((nbRead < chunkSize)
+                && ((nbData = stream.read(data, nbRead, chunkSize - nbRead)) != -1)) {
+            nbRead += nbData;
+        }
+
+        chunk.content(data);
+
+        stream.read();
+        stream.read();
+    }
+
+    private static void readUnknownChunk(final InputStream stream, final MsrpChunk.Builder chunk)
+            throws IOException {
+
+        final byte[] bufferArray = new byte[4096];
+        final byte[] endOfChunkPattern =
+                (NEW_LINE_END_MSRP_MSG + chunk.transactionId()).getBytes();
+        int pp = 0;
+
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+
+        final ByteBuffer buffer = ByteBuffer.wrap(bufferArray);
+        while (true) {
+            final int i = stream.read();
+
+            if (i < 0) {
+                throw new IOException("EOS reached");
+            }
+
+            if (i == endOfChunkPattern[pp]) {
+                pp++;
+            } else if (i == endOfChunkPattern[0]) {
+                pp = 1;
+            } else {
+                pp = 0;
+            }
+
+            buffer.put((byte) i);
+
+            if (pp == endOfChunkPattern.length) {
+                outputStream.write(bufferArray, 0, buffer.position() - endOfChunkPattern.length);
+                break;
+            }
+
+            if (buffer.remaining() == 0) {
+                if (pp > 0) {
+                    outputStream.write(bufferArray, 0, bufferArray.length - pp);
+                    System.arraycopy(endOfChunkPattern, 0, bufferArray, 0, pp);
+                    buffer.position(pp);
+                } else {
+                    outputStream.write(bufferArray, 0, bufferArray.length);
+                    buffer.rewind();
+                }
+            }
+        }
+
+        chunk.content(outputStream.toByteArray());
+    }
+
+    private static void skipEndLine(final InputStream stream, final MsrpChunk.Builder chunk)
+            throws IOException {
+        // skip the "-------" + txid
+        final int length = END_MSRP_MSG_LENGTH + chunk.transactionId().length();
+        final byte[] endline = new byte[256];
+        readFromStream(stream, endline, 0, length);
+    }
+
+    private static void readContinuationFlag(
+            final InputStream stream, final MsrpChunk.Builder transaction) throws IOException {
+        transaction.continuation(Continuation.valueOf(stream.read()));
+        stream.read();
+        stream.read();
+    }
+
+    /**
+     * Get the chunk size
+     *
+     * @param header MSRP header
+     * @return Size in bytes
+     */
+    private static int getChunkSize(final String header) {
+        final int index1 = header.indexOf("-");
+        final int index2 = header.indexOf("/");
+        if ((index1 != -1) && (index2 != -1)) {
+            final String lowByteValue = header.substring(0, index1);
+            final String highByteValue = header.substring(index1 + 1, index2);
+
+            if ("*".equals(highByteValue)) {
+                return -1;
+            } else {
+                try {
+                    final int lowByte = Integer.parseInt(lowByteValue);
+                    final int highByte = Integer.parseInt(highByteValue);
+                    if (lowByte > highByte) {
+                        return Integer.MIN_VALUE;
+                    }
+                    return (highByte - lowByte) + 1;
+                } catch (NumberFormatException e) {
+                    throw new IllegalStateException("Could not read chunksize!");
+                }
+            }
+        }
+        return Integer.MIN_VALUE;
+    }
+
+    /**
+     * Get the total size
+     *
+     * @param header MSRP header
+     * @return Size in bytes
+     */
+    private static long getTotalSize(final String header) {
+        final int index = header.indexOf("/");
+        if (index != -1) {
+            if ("*".equals(header.substring(index + 1))) {
+                return -1;
+            }
+            try {
+                return Long.parseLong(header.substring(index + 1));
+            } catch (NumberFormatException e) {
+                throw new IllegalStateException("Could not read total size!");
+            }
+        }
+        return Integer.MIN_VALUE;
+    }
+
+    private static void readFromStream(
+            InputStream stream, final byte[] buffer, final int offset, final int length)
+            throws IOException {
+        int read = 0;
+        while (read < length) {
+            try {
+                read += stream.read(buffer, offset + read, length - read);
+            } catch (IndexOutOfBoundsException e) {
+                throw new IOException("Invalid ID length", e);
+            }
+        }
+    }
+
+    private static int skipWithDelimiter(InputStream stream, byte delimiter) throws IOException {
+        int i = stream.read();
+        for (; (i != delimiter) && (i != -1); i = stream.read()) {
+        }
+        return i;
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpSerializer.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpSerializer.java
new file mode 100644
index 0000000..bd4daa5
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpSerializer.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.msrp;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Serializer for writing messages
+ */
+public final class MsrpSerializer {
+
+    private MsrpSerializer() {
+    }
+
+    public static void serialize(OutputStream outputStream, MsrpChunk message) throws IOException {
+
+        writeRequestLine(outputStream, message);
+        for (MsrpChunkHeader header : message.headers()) {
+            writeHeader(outputStream, header);
+        }
+
+        if (message.content().length > 0) {
+            outputStream.write(MsrpConstants.NEW_LINE_BYTES);
+            outputStream.write(message.content());
+            outputStream.write(MsrpConstants.NEW_LINE_BYTES);
+        }
+
+        writeEndLine(outputStream, message);
+    }
+
+    private static void writeRequestLine(OutputStream outputStream, MsrpChunk chunk)
+            throws IOException {
+
+        outputStream.write(MsrpConstants.MSRP_PROTOCOL_BYTES);
+        outputStream.write(MsrpConstants.CHAR_SP);
+        outputStream.write(chunk.transactionId().getBytes());
+        outputStream.write(MsrpConstants.CHAR_SP);
+
+        if (chunk.method() != MsrpChunk.Method.UNKNOWN) {
+            outputStream.write(chunk.method().name().getBytes(UTF_8));
+        } else {
+            outputStream.write(
+                    (chunk.responseCode() + " " + chunk.responseReason()).getBytes(UTF_8));
+        }
+
+        outputStream.write(MsrpConstants.NEW_LINE_BYTES);
+    }
+
+    private static void writeHeader(OutputStream outputStream, MsrpChunkHeader header)
+            throws IOException {
+        outputStream.write(header.name().getBytes(UTF_8));
+        outputStream.write(MsrpConstants.HEADER_DELIMITER_BYTES);
+        outputStream.write(header.value().getBytes(UTF_8));
+        outputStream.write(MsrpConstants.NEW_LINE_BYTES);
+    }
+
+    private static void writeEndLine(OutputStream outputStream, MsrpChunk chunk)
+            throws IOException {
+        outputStream.write(MsrpConstants.END_MSRP_MSG_BYTES);
+        outputStream.write(chunk.transactionId().getBytes(UTF_8));
+        outputStream.write(chunk.continuation().toByte());
+        outputStream.write(MsrpConstants.NEW_LINE_BYTES);
+    }
+}
\ No newline at end of file
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpSession.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpSession.java
new file mode 100644
index 0000000..96ca19c
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpSession.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.msrp;
+
+import static com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpChunk.Method.SEND;
+import static com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpChunk.Method.UNKNOWN;
+
+import androidx.concurrent.futures.CallbackToFutureAdapter;
+import androidx.concurrent.futures.CallbackToFutureAdapter.Completer;
+
+import com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpChunk.Continuation;
+
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.Socket;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Provides MSRP sending and receiving messages ability.
+ */
+public class MsrpSession {
+    private final Socket socket;
+    private final InputStream input;
+    private final OutputStream output;
+    private final AtomicBoolean isOpen = new AtomicBoolean(true);
+    private final ConcurrentHashMap<String, MsrpTransaction> transactions =
+            new ConcurrentHashMap<>();
+    private final MsrpSessionListener listener;
+
+    /** Creates a new MSRP session on the given listener and the provided streams. */
+    MsrpSession(Socket socket, MsrpSessionListener listener) throws IOException {
+        this.socket = socket;
+        this.input = socket.getInputStream();
+        this.output = socket.getOutputStream();
+        this.listener = listener;
+    }
+
+    /**
+     * Sends the given MSRP chunk.
+     */
+    public ListenableFuture<MsrpChunk> send(MsrpChunk request) {
+        if (request.method() == UNKNOWN) {
+            throw new IllegalArgumentException("Given chunk must be a request");
+        }
+
+        if (!isOpen.get()) {
+            throw new IllegalStateException("Session terminated");
+        }
+
+        if (!socket.isConnected()) {
+            throw new IllegalStateException("Socket is not connected");
+        }
+
+        if (request.method() == SEND) {
+            return CallbackToFutureAdapter.getFuture(
+                    completer -> {
+                        final MsrpTransaction transaction = new MsrpTransaction(completer);
+                        transactions.put(request.transactionId(), transaction);
+                        try {
+                            synchronized (output) {
+                                MsrpSerializer.serialize(output, request);
+                            }
+                            output.flush();
+                        } catch (IOException e) {
+                            completer.setException(e);
+                        }
+                        return "MsrpSession.send(" + request.transactionId() + ")";
+                    }
+            );
+        } else {
+            try {
+                synchronized (output) {
+                    MsrpSerializer.serialize(output, request);
+                }
+                return Futures.immediateFuture(request);
+            } catch (IOException e) {
+                return Futures.immediateFailedFuture(e);
+            }
+        }
+    }
+
+    /**
+     * Blocking method which reads from the provided InputStream until the session
+     * is terminated or the stream read throws an exception.
+     */
+    public void run() {
+        new StreamReader(this).run();
+    }
+
+    public void terminate() throws IOException {
+        if (isOpen.getAndSet(false)) {
+            output.flush();
+        }
+        socket.close();
+    }
+
+    /**
+     * Reads and parses MSRP messages from the session input stream.
+     */
+    private static class StreamReader {
+
+        private final MsrpSession session;
+        private final InputStream stream;
+        private final AtomicBoolean active;
+
+        StreamReader(MsrpSession session) {
+            this.session = session;
+            this.stream = session.input;
+            this.active = session.isOpen;
+        }
+
+        void run() {
+            while (active.get()) {
+                MsrpChunk chunk = null;
+                try {
+                    chunk = MsrpParser.parse(stream);
+
+                    if (chunk.method() == UNKNOWN) {
+                        completeTransaction(chunk);
+                    } else {
+                        receiveRequest(chunk);
+                    }
+                } catch (IOException e) {
+                    active.compareAndSet(true, false);
+                }
+            }
+        }
+
+        private void receiveRequest(MsrpChunk chunk) throws IOException {
+            sendResponse(chunk);
+            session.listener.onChunkReceived(chunk);
+        }
+
+        private void completeTransaction(MsrpChunk chunk) {
+            MsrpTransaction transaction = session.transactions.remove(chunk.transactionId());
+            if (transaction != null) {
+                transaction.complete(chunk);
+            }
+        }
+
+        private void sendResponse(MsrpChunk chunk) throws IOException {
+            // check if response is required
+            MsrpChunkHeader failureReport = chunk.header(MsrpConstants.HEADER_FAILURE_REPORT);
+            if (failureReport == null || failureReport.value().equals("yes")) {
+                MsrpChunkHeader toPath = chunk.header(MsrpConstants.HEADER_TO_PATH);
+                MsrpChunkHeader fromPath = chunk.header(MsrpConstants.HEADER_FROM_PATH);
+
+                MsrpChunk response = MsrpChunk.newBuilder()
+                        .transactionId(chunk.transactionId())
+                        .responseCode(200)
+                        .responseReason("OK")
+                        .addHeader(MsrpConstants.HEADER_TO_PATH, fromPath.value())
+                        .addHeader(MsrpConstants.HEADER_FROM_PATH, toPath.value())
+                        .continuation(Continuation.COMPLETE)
+                        .build();
+
+                synchronized (session.output) {
+                    MsrpSerializer.serialize(session.output, response);
+                    session.output.flush();
+                }
+            }
+        }
+    }
+
+    /**
+     * Transaction holder.
+     */
+    private static class MsrpTransaction {
+        private final Completer<MsrpChunk> completed;
+
+        public MsrpTransaction(Completer<MsrpChunk> chunkCompleter) {
+            this.completed = chunkCompleter;
+        }
+
+        public void complete(MsrpChunk response) {
+            completed.set(response);
+        }
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpSessionListener.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpSessionListener.java
new file mode 100644
index 0000000..4235c25
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpSessionListener.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.msrp;
+
+/**
+ * Listener for MSRP session events.
+ */
+public interface MsrpSessionListener {
+    void onChunkReceived(MsrpChunk chunk);
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpUtils.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpUtils.java
new file mode 100644
index 0000000..c135bc0
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/msrp/MsrpUtils.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.msrp;
+
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipUtils;
+import java.security.SecureRandom;
+
+/** Collections of utility functions for MSRP */
+public final class MsrpUtils {
+
+    private static final SecureRandom random = new SecureRandom();
+
+    private MsrpUtils() {
+    }
+
+    /** Generate a path attribute defined in RFC 4975 for the given address, port. */
+    public static String generatePath(String address, int port, boolean isSecure) {
+        StringBuilder builder = new StringBuilder();
+
+        if (SipUtils.isIPv6Address(address)) {
+            address = "[" + address + "]";
+        }
+
+        builder
+                .append(isSecure ? "msrps" : "msrp")
+                .append("://")
+                .append(address)
+                .append(":")
+                .append(port)
+                .append("/")
+                .append(System.currentTimeMillis())
+                .append(";tcp");
+
+        return builder.toString();
+    }
+
+    public static String generateRandomId() {
+        byte[] randomBytes = new byte[8];
+        random.nextBytes(randomBytes);
+        String hex = "";
+        for (byte b : randomBytes) {
+            hex = hex + String.format("%02x", b);
+        }
+        return hex;
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sdp/SdpMedia.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sdp/SdpMedia.java
new file mode 100644
index 0000000..bdb34ba
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sdp/SdpMedia.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.sdp;
+
+import android.text.TextUtils;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableMap;
+
+import java.text.ParseException;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The media part of SDP implementation as per RFC 4566. This class supports minimal fields that is
+ * required to represent MSRP session.
+ */
+@AutoValue
+public abstract class SdpMedia {
+    private static final String CRLF = "\r\n";
+
+    public static Builder parseMediaLine(String line) throws ParseException {
+        List<String> elements = Splitter.on(" ").limit(4).splitToList(line);
+
+        // The valid media line should have 4 elements:
+        // m=<name> <port> <protocol> <format>
+        if (elements.size() != 4) {
+            throw new ParseException("Invalid media line", 0);
+        }
+
+        // Parse each field from the media line.
+        Builder builder = SdpMedia.newBuilder();
+        builder
+                .setName(elements.get(0))
+                .setPort(Integer.parseInt(elements.get(1)))
+                .setProtocol(elements.get(2))
+                .setFormat(elements.get(3));
+
+        return builder;
+    }
+
+    public static Builder newBuilder() {
+        return new AutoValue_SdpMedia.Builder();
+    }
+
+    public abstract String name();
+
+    public abstract int port();
+
+    public abstract String protocol();
+
+    public abstract String format();
+
+    public abstract ImmutableMap<String, String> attributes();
+
+    /** Encode the media section as a string. */
+    public String encode() {
+        StringBuilder builder = new StringBuilder();
+        builder
+                .append("m=")
+                .append(name())
+                .append(" ")
+                .append(port())
+                .append(" ")
+                .append(protocol())
+                .append(" ")
+                .append(format())
+                .append(CRLF);
+
+        for (Map.Entry<String, String> attribute : attributes().entrySet()) {
+            builder.append("a=").append(attribute.getKey());
+            if (!TextUtils.isEmpty(attribute.getValue())) {
+                builder.append(":").append(attribute.getValue());
+            }
+            builder.append(CRLF);
+        }
+
+        return builder.toString();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+        public abstract Builder setName(String name);
+
+        public abstract Builder setPort(int port);
+
+        public abstract Builder setProtocol(String protocol);
+
+        public abstract Builder setFormat(String payload);
+
+        public abstract ImmutableMap.Builder<String, String> attributesBuilder();
+
+        public Builder addAttribute(String name, String value) {
+            attributesBuilder().put(name, value);
+            return this;
+        }
+
+        public Builder addAttribute(String name) {
+            return addAttribute(name, "");
+        }
+
+        public abstract SdpMedia build();
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sdp/SdpUtils.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sdp/SdpUtils.java
new file mode 100644
index 0000000..e290b29
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sdp/SdpUtils.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.sdp;
+
+import static com.android.libraries.rcs.simpleclient.protocol.sip.SipUtils.isIPv6Address;
+
+import com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpUtils;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableSet;
+
+/** Collections of utility functions for SDP */
+public final class SdpUtils {
+    public static final String SDP_CONTENT_TYPE = "application";
+    public static final String SDP_CONTENT_SUB_TYPE = "sdp";
+
+    private static final ImmutableSet<String> DEFAULT_ACCEPT_TYPES =
+            ImmutableSet.of("message/cpim", "application/im-iscomposing+xml");
+    private static final ImmutableSet<String> DEFAULT_ACCEPT_WRAPPED_TYPES =
+            ImmutableSet.of(
+                    "text/plain",
+                    "message/imdn+xml",
+                    "application/vnd.gsma.rcs-ft-http+xml",
+                    "application/vnd.gsma.rcspushlocation+xml");
+
+    private static final String DEFAULT_NAME = "message";
+    private static final String DEFAULT_SETUP = "active";
+    private static final String DEFAULT_DIRECTION = "sendrecv";
+    private static final int DEFAULT_MSRP_PORT = 9;
+    private static final String PROTOCOL_TCP_MSRP = "TCP/MSRP";
+    private static final String PROTOCOL_TLS_MSRP = "TCP/TLS/MSRP";
+    private static final String DEFAULT_FORMAT = "*";
+
+    private static final String ATTRIBUTE_PATH = "path";
+    private static final String ATTRIBUTE_SETUP = "setup";
+    private static final String ATTRIBUTE_ACCEPT_TYPES = "accept-types";
+    private static final String ATTRIBUTE_ACCEPT_WRAPPED_TYPES = "accept-wrapped-types";
+
+    private SdpUtils() {
+    }
+
+    /**
+     * Create a simple SDP message for MSRP. Most attributes except address and transport type
+     * will be
+     * generated automatically.
+     *
+     * @param address The local IP address of the MSRP connection.
+     * @param isTls   True if the MSRP connection uses TLS.
+     */
+    public static SimpleSdpMessage createSdpForMsrp(String address, boolean isTls) {
+        return SimpleSdpMessage.newBuilder()
+                .setVersion("0")
+                .setOrigin(generateOrigin(address))
+                .setSession("-")
+                .setConnection(generateConnection(address))
+                .setTime("0 0")
+                .addMedia(createSdpMediaForMsrp(address, isTls))
+                .build();
+    }
+
+    private static String generateOrigin(String address) {
+        StringBuilder builder = new StringBuilder();
+        builder
+                .append("TestRcsClient ")
+                .append(System.currentTimeMillis())
+                .append(" ")
+                .append(System.currentTimeMillis())
+                .append(" IN ")
+                .append(isIPv6Address(address) ? "IP6 " : "IP4 ")
+                .append(address);
+
+        return builder.toString();
+    }
+
+    private static String generateConnection(String address) {
+        return "IN " + (isIPv6Address(address) ? "IP6 " : "IP4 ") + address;
+    }
+
+    /**
+     * Create a media part of the SDP message for MSRP. Most attributes except address and transport
+     * type will be generated automatically.
+     *
+     * @param address The local IP address of the MSRP connection.
+     * @param isTls   True if the MSRP connection uses TLS.
+     */
+    public static SdpMedia createSdpMediaForMsrp(String address, boolean isTls) {
+        return SdpMedia.newBuilder()
+                .setName(DEFAULT_NAME)
+                .setPort(DEFAULT_MSRP_PORT)
+                .setProtocol(isTls ? PROTOCOL_TLS_MSRP : PROTOCOL_TCP_MSRP)
+                .setFormat(DEFAULT_FORMAT)
+                .addAttribute(ATTRIBUTE_PATH,
+                        MsrpUtils.generatePath(address, DEFAULT_MSRP_PORT, isTls))
+                .addAttribute(ATTRIBUTE_SETUP, DEFAULT_SETUP)
+                .addAttribute(ATTRIBUTE_ACCEPT_TYPES, Joiner.on(" ").join(DEFAULT_ACCEPT_TYPES))
+                .addAttribute(
+                        ATTRIBUTE_ACCEPT_WRAPPED_TYPES,
+                        Joiner.on(" ").join(DEFAULT_ACCEPT_WRAPPED_TYPES))
+                .addAttribute(DEFAULT_DIRECTION)
+                .build();
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sdp/SimpleSdpMessage.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sdp/SimpleSdpMessage.java
new file mode 100644
index 0000000..4abbf87
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sdp/SimpleSdpMessage.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.sdp;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.text.ParseException;
+import java.util.List;
+import java.util.Optional;
+import java.util.OptionalInt;
+
+/**
+ * The SDP implementation as per RFC 4566. This class supports minimal fields that is required to
+ * represent MSRP session.
+ */
+@AutoValue
+public abstract class SimpleSdpMessage {
+    private static final String CRLF = "\r\n";
+
+    private static final String PREFIX_VERSION = "v";
+    private static final String PREFIX_ORIGIN = "o";
+    private static final String PREFIX_SESSION = "s";
+    private static final String PREFIX_CONNECTION = "c";
+    private static final String PREFIX_TIME = "t";
+    private static final String PREFIX_MEDIA = "m";
+    private static final String PREFIX_ATTRIBUTE = "a";
+    private static final String EQUAL = "=";
+
+    public static SimpleSdpMessage parse(InputStream stream) throws ParseException, IOException {
+        Builder builder = new AutoValue_SimpleSdpMessage.Builder();
+        SdpMedia.Builder currentMediaBuilder = null;
+        BufferedReader reader = new BufferedReader(new InputStreamReader(stream));
+
+        String line = reader.readLine();
+        while (line != null) {
+            List<String> parts = Splitter.on("=").trimResults().limit(2).splitToList(line);
+            if (parts.size() != 2) {
+                throw new ParseException("Invalid SDP format", 0);
+            }
+            String prefix = parts.get(0);
+            String value = parts.get(1);
+
+            switch (prefix) {
+                case PREFIX_VERSION:
+                    builder.setVersion(value);
+                    break;
+                case PREFIX_ORIGIN:
+                    builder.setOrigin(value);
+                    break;
+                case PREFIX_SESSION:
+                    builder.setSession(value);
+                    break;
+                case PREFIX_CONNECTION:
+                    builder.setConnection(value);
+                    break;
+                case PREFIX_TIME:
+                    builder.setTime(value);
+                    break;
+                case PREFIX_MEDIA:
+                    if (currentMediaBuilder != null) {
+                        builder.addMedia(currentMediaBuilder.build());
+                    }
+                    currentMediaBuilder = SdpMedia.parseMediaLine(value);
+                    break;
+                case PREFIX_ATTRIBUTE:
+                    if (currentMediaBuilder != null) {
+                        List<String> kv = Splitter.on(":").trimResults().limit(2).splitToList(
+                                value);
+                        currentMediaBuilder.addAttribute(kv.get(0), kv.size() < 2 ? "" : kv.get(1));
+                    }
+                    break;
+                default:
+                    // Rest of the fields are ignored as they're not used for describing MSRP
+                    // session.
+                    break;
+            }
+            line = reader.readLine();
+        }
+
+        if (currentMediaBuilder != null) {
+            builder.addMedia(currentMediaBuilder.build());
+        }
+
+        return builder.build();
+    }
+
+    private static String encodeLine(String prefix, String value) {
+        return prefix + EQUAL + value + CRLF;
+    }
+
+    public static Builder newBuilder() {
+        return new AutoValue_SimpleSdpMessage.Builder();
+    }
+
+    public abstract String version();
+
+    public abstract String origin();
+
+    public abstract String session();
+
+    public abstract String connection();
+
+    public abstract String time();
+
+    public abstract ImmutableList<SdpMedia> media();
+
+    /** Return the IP address in the connection line. */
+    public Optional<String> getAddress() {
+        if (connection() == null) {
+            return Optional.empty();
+        }
+
+        List<String> parts = Splitter.on(" ").limit(3).trimResults().splitToList(connection());
+        if (parts.size() != 3) {
+            return Optional.empty();
+        }
+
+        return Optional.of(parts.get(2));
+    }
+
+    /** Return the port in the first media line. */
+    public OptionalInt getPort() {
+        if (media().isEmpty()) {
+            return OptionalInt.empty();
+        }
+
+        return OptionalInt.of(media().get(0).port());
+    }
+
+    public Optional<String> getPath() {
+        if (media().isEmpty()) {
+            return Optional.empty();
+        }
+
+        return Optional.ofNullable(media().get(0).attributes().get("path"));
+    }
+
+    /** Encode the entire SDP fields as a string. */
+    public String encode() {
+        StringBuilder builder = new StringBuilder();
+        builder
+                .append(encodeLine(PREFIX_VERSION, version()))
+                .append(encodeLine(PREFIX_ORIGIN, origin()))
+                .append(encodeLine(PREFIX_SESSION, session()))
+                .append(encodeLine(PREFIX_CONNECTION, connection()))
+                .append(encodeLine(PREFIX_TIME, time()));
+
+        for (SdpMedia media : media()) {
+            builder.append(media.encode());
+        }
+
+        return builder.toString();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+        public abstract Builder setVersion(String version);
+
+        public abstract Builder setOrigin(String origin);
+
+        public abstract Builder setSession(String session);
+
+        public abstract Builder setConnection(String connection);
+
+        public abstract Builder setTime(String connection);
+
+        public abstract ImmutableList.Builder<SdpMedia> mediaBuilder();
+
+        public Builder addMedia(SdpMedia media) {
+            mediaBuilder().add(media);
+            return this;
+        }
+
+        public abstract SimpleSdpMessage build();
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sip/SipSession.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sip/SipSession.java
new file mode 100644
index 0000000..9629961
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sip/SipSession.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.sip;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+import javax.sip.message.Message;
+
+/**
+ * Abstraction of the underlying SIP channel for sending and receiving SIP messages.
+ */
+public interface SipSession {
+
+    SipSessionConfiguration getSessionConfiguration();
+
+    ListenableFuture<Boolean> send(Message message);
+
+    void setSessionListener(SipSessionListener listener);
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sip/SipSessionConfiguration.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sip/SipSessionConfiguration.java
new file mode 100644
index 0000000..59a0541
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sip/SipSessionConfiguration.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.sip;
+
+import java.util.List;
+
+public interface SipSessionConfiguration {
+    public long getVersion();
+
+    String getOutboundProxyAddr();
+
+    int getOutboundProxyPort();
+
+    String getLocalIpAddress();
+
+    int getLocalPort();
+
+    String getSipTransport();
+
+    String getPublicUserIdentity();
+
+    String getDomain();
+
+    List<String> getAssociatedUris();
+
+    String getSecurityVerifyHeader();
+
+    List<String> getServiceRouteHeaders();
+
+    String getContactUser();
+
+    String getImei();
+
+    String getPaniHeader();
+
+    String getPlaniHeader();
+
+    /**
+     * @return the user agent header from the  ims config.
+     */
+    String getUserAgentHeader();
+
+    default int getMaxPayloadSizeOnUdp() {
+        return 0;
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sip/SipSessionListener.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sip/SipSessionListener.java
new file mode 100644
index 0000000..5fe61e6
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sip/SipSessionListener.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.sip;
+
+import javax.sip.message.Message;
+
+/**
+ * Listener for incoming messages on a {@link SipSession}.
+ */
+public interface SipSessionListener {
+
+    void onMessageReceived(Message sipMessage);
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sip/SipUtils.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sip/SipUtils.java
new file mode 100644
index 0000000..2f95bef
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/protocol/sip/SipUtils.java
@@ -0,0 +1,323 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.protocol.sip;
+
+import static com.android.libraries.rcs.simpleclient.protocol.sdp.SdpUtils.SDP_CONTENT_SUB_TYPE;
+import static com.android.libraries.rcs.simpleclient.protocol.sdp.SdpUtils.SDP_CONTENT_TYPE;
+
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.libraries.rcs.simpleclient.protocol.sdp.SdpUtils;
+import com.android.libraries.rcs.simpleclient.protocol.sdp.SimpleSdpMessage;
+
+import com.google.common.base.Ascii;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.net.InetAddresses;
+
+import gov.nist.javax.sip.Utils;
+import gov.nist.javax.sip.address.AddressFactoryImpl;
+import gov.nist.javax.sip.header.ContentType;
+import gov.nist.javax.sip.header.HeaderFactoryImpl;
+import gov.nist.javax.sip.header.Via;
+import gov.nist.javax.sip.header.extensions.SessionExpires;
+import gov.nist.javax.sip.header.ims.PPreferredIdentityHeader;
+import gov.nist.javax.sip.header.ims.PPreferredServiceHeader;
+import gov.nist.javax.sip.header.ims.SecurityVerifyHeader;
+import gov.nist.javax.sip.message.SIPMessage;
+import gov.nist.javax.sip.message.SIPRequest;
+import gov.nist.javax.sip.message.SIPResponse;
+
+import java.net.Inet6Address;
+import java.text.ParseException;
+import java.util.List;
+import java.util.UUID;
+
+import javax.sip.InvalidArgumentException;
+import javax.sip.address.AddressFactory;
+import javax.sip.address.SipURI;
+import javax.sip.address.URI;
+import javax.sip.header.ContactHeader;
+import javax.sip.header.HeaderFactory;
+import javax.sip.header.ViaHeader;
+import javax.sip.message.Request;
+import javax.sip.message.Response;
+
+/** Collections of utility functions for SIP */
+public final class SipUtils {
+    private static final String TAG = "SipUtils";
+    private static final String SUPPORTED_TIMER_TAG = "timer";
+    private static final String ICSI_REF_PARAM_NAME = "+g.3gpp.icsi-ref";
+    private static final String SIP_INSTANCE_PARAM_NAME = "+sip.instance";
+    private static final String CPM_SESSION_FEATURE_TAG_PARAM_VALUE =
+            "\"urn%3Aurn-7%3A3gpp-service.ims.icsi.oma.cpm.session\"";
+    private static final String CPM_SESSION_FEATURE_TAG_FULL_STRING =
+            "+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.oma.cpm.session\"";
+    private static final String CPM_SESSION_SERVICE_NAME =
+            "urn:urn-7:3gpp-service.ims.icsi.oma.cpm.session";
+    private static final String CONTRIBUTION_ID_HEADER_NAME = "Contribution-ID";
+    private static final String CONVERSATION_ID_HEADER_NAME = "Conversation-ID";
+    private static final String ACCEPT_CONTACT_HEADER_NAME = "Accept-Contact";
+    private static final String PANI_HEADER_NAME = "P-Access-Network-Info";
+    private static final String PLANI_HEADER_NAME = "P-Last-Access-Network-Info";
+    private static final String USER_AGENT_HEADER = "RcsTestClient";
+
+    private static AddressFactory sAddressFactory = new AddressFactoryImpl();
+    private static HeaderFactory sHeaderFactory = new HeaderFactoryImpl();
+
+    private SipUtils() {
+    }
+
+    /**
+     * Try to parse the given uri.
+     *
+     * @throws IllegalArgumentException in case of parsing error.
+     */
+    public static URI createUri(String uri) {
+        try {
+            return sAddressFactory.createURI(uri);
+        } catch (ParseException exception) {
+            throw new IllegalArgumentException("URI cannot be created", exception);
+        }
+    }
+
+    /**
+     * Create SIP INVITE request for a CPM 1:1 chat.
+     *
+     * @param configuration  The SipSessionConfiguration instance used for populating SIP headers.
+     * @param targetUri      The uri to be targeted.
+     * @param conversationId The id to be contained in Conversation-ID header.
+     */
+    public static SIPRequest buildInvite(
+            SipSessionConfiguration configuration,
+            String targetUri,
+            String conversationId,
+            byte[] content)
+            throws ParseException {
+        String address = configuration.getLocalIpAddress();
+        int port = configuration.getLocalPort();
+        String transport = configuration.getSipTransport();
+        List<String> associatedUris = configuration.getAssociatedUris();
+        String preferredUri = Iterables.getFirst(associatedUris,
+                configuration.getPublicUserIdentity());
+
+        SIPRequest request = new SIPRequest();
+        request.setMethod(Request.INVITE);
+
+        URI remoteUri = createUri(targetUri);
+        request.setRequestURI(remoteUri);
+        request.setFrom(
+                sHeaderFactory.createFromHeader(
+                        sAddressFactory.createAddress(preferredUri),
+                        Utils.getInstance().generateTag()));
+        request.setTo(
+                sHeaderFactory.createToHeader(sAddressFactory.createAddress(remoteUri), null));
+
+        ViaHeader viaHeader = null;
+
+        try {
+            // Set a default Max-Forwards header.
+            request.setMaxForwards(sHeaderFactory.createMaxForwardsHeader(70));
+            request.setCSeq(sHeaderFactory.createCSeqHeader(1L, Request.INVITE));
+            viaHeader =
+                    sHeaderFactory.createViaHeader(
+                            address, port, transport, Utils.getInstance().generateBranchId());
+            request.setVia(ImmutableList.of(viaHeader));
+
+            // Set a default Session-Expires header.
+            SessionExpires sessionExpires = new SessionExpires();
+            sessionExpires.setRefresher("uac");
+            sessionExpires.setExpires(1800);
+            request.setHeader(sessionExpires);
+
+            // Set a Contact header.
+            request.setHeader(generateContactHeader(configuration));
+
+            // Set PANI and PLANI if exists
+            if (configuration.getPaniHeader() != null) {
+                request.setHeader(
+                        sHeaderFactory.createHeader(PANI_HEADER_NAME,
+                                configuration.getPaniHeader()));
+            }
+            if (configuration.getPlaniHeader() != null) {
+                request.setHeader(
+                        sHeaderFactory.createHeader(PLANI_HEADER_NAME,
+                                configuration.getPlaniHeader()));
+            }
+        } catch (InvalidArgumentException e) {
+            // Nothing to do here
+            Log.e(TAG, e.getMessage());
+        }
+
+        request.setCallId(UUID.randomUUID().toString());
+        request.setHeader(sHeaderFactory.createHeader(CONVERSATION_ID_HEADER_NAME, conversationId));
+        request.setHeader(
+                sHeaderFactory.createHeader(CONTRIBUTION_ID_HEADER_NAME,
+                        UUID.randomUUID().toString()));
+
+        String acceptContact = "*;" + CPM_SESSION_FEATURE_TAG_FULL_STRING;
+        request.setHeader(sHeaderFactory.createHeader(ACCEPT_CONTACT_HEADER_NAME, acceptContact));
+        request.setHeader(sHeaderFactory.createSupportedHeader(SUPPORTED_TIMER_TAG));
+        request.setHeader(sHeaderFactory.createHeader(PPreferredIdentityHeader.NAME, preferredUri));
+        request.setHeader(
+                sHeaderFactory.createHeader(PPreferredServiceHeader.NAME,
+                        CPM_SESSION_SERVICE_NAME));
+
+        // Set a Security-Verify header if exist.
+        String securityVerify = configuration.getSecurityVerifyHeader();
+        if (!TextUtils.isEmpty(securityVerify)) {
+            request.setHeader(
+                    sHeaderFactory.createHeader(SecurityVerifyHeader.NAME, securityVerify));
+        }
+
+        // Add Route headers.
+        List<String> serviceRoutes = configuration.getServiceRouteHeaders();
+        if (!serviceRoutes.isEmpty()) {
+            for (String sr : serviceRoutes) {
+                request.addHeader(
+                        sHeaderFactory.createRouteHeader(sAddressFactory.createAddress(sr)));
+            }
+        }
+
+        String userAgent = configuration.getUserAgentHeader();
+        userAgent = (userAgent == null) ? USER_AGENT_HEADER : userAgent;
+        request.addHeader(sHeaderFactory.createUserAgentHeader(ImmutableList.of(userAgent)));
+
+        request.setMessageContent(SDP_CONTENT_TYPE, SDP_CONTENT_SUB_TYPE, content);
+
+        if (viaHeader != null && Ascii.equalsIgnoreCase("udp", transport)) {
+            String newTransport =
+                    determineTransportBySize(configuration, request.encodeAsBytes("udp").length);
+            if (!Ascii.equalsIgnoreCase(transport, newTransport)) {
+                viaHeader.setTransport(newTransport);
+            }
+        }
+
+        return request;
+    }
+
+    private static ContactHeader generateContactHeader(SipSessionConfiguration configuration)
+            throws ParseException {
+        String host = configuration.getLocalIpAddress();
+        if (isIPv6Address(host)) {
+            host = "[" + host + "]";
+        }
+
+        String userPart = configuration.getContactUser();
+        SipURI uri = sAddressFactory.createSipURI(userPart, host);
+        try {
+            uri.setPort(configuration.getLocalPort());
+            uri.setTransportParam(configuration.getSipTransport());
+        } catch (Exception e) {
+            // Shouldn't be here.
+        }
+
+        ContactHeader contactHeader =
+                sHeaderFactory.createContactHeader(sAddressFactory.createAddress(uri));
+
+        // Add +sip.instance param.
+        String sipInstance = "\"<urn:gsma:imei:" + configuration.getImei() + ">\"";
+        contactHeader.setParameter(SIP_INSTANCE_PARAM_NAME, sipInstance);
+
+        // Add CPM feature tag.
+        uri.setTransportParam(configuration.getSipTransport());
+        contactHeader.setParameter(ICSI_REF_PARAM_NAME, CPM_SESSION_FEATURE_TAG_PARAM_VALUE);
+
+        return contactHeader;
+    }
+
+    /**
+     * Create a SIP BYE request for terminating the chat session.
+     *
+     * @param invite the initial INVITE request of the chat session.
+     */
+    public static SIPRequest buildBye(SIPRequest invite) throws ParseException {
+        SIPRequest request = new SIPRequest();
+        request.setRequestURI(invite.getRequestURI());
+        request.setMethod(Request.BYE);
+        try {
+            long cSeqNumber = invite.getCSeq().getSeqNumber();
+            request.setHeader(sHeaderFactory.createCSeqHeader(cSeqNumber, Request.BYE));
+        } catch (InvalidArgumentException e) {
+            // Nothing to do here
+        }
+
+        request.setCallId(invite.getCallId());
+
+        Via via = (Via) request.getTopmostVia().clone();
+        via.removeParameter("branch");
+        request.addHeader(via);
+        request.addHeader(
+                sHeaderFactory.createFromHeader(invite.getFrom().getAddress(),
+                        invite.getFrom().getTag()));
+        request.addHeader(
+                sHeaderFactory.createToHeader(invite.getTo().getAddress(),
+                        invite.getTo().getTag()));
+
+        return request;
+    }
+
+    /**
+     * Create SIP INVITE response for a CPM 1:1 chat.
+     *
+     * @param configuration The SipSessionConfiguration instance used for populating SIP headers.
+     * @param invite        the initial INVITE request of the chat session.
+     * @param code          The status code of the response.
+     */
+    public static SIPResponse buildInviteResponse(
+            SipSessionConfiguration configuration, SIPRequest invite, int code)
+            throws ParseException {
+        SIPResponse response = invite.createResponse(code);
+        if (code == Response.OK) {
+            SimpleSdpMessage sdp = SdpUtils.createSdpForMsrp(configuration.getLocalIpAddress(),
+                    false);
+            response.setMessageContent(SDP_CONTENT_TYPE, SDP_CONTENT_SUB_TYPE, sdp.encode());
+        }
+
+        // Set PANI and PLANI if exists
+        if (configuration.getPaniHeader() != null) {
+            response.setHeader(
+                    sHeaderFactory.createHeader(PANI_HEADER_NAME, configuration.getPaniHeader()));
+        }
+        if (configuration.getPlaniHeader() != null) {
+            response.setHeader(
+                    sHeaderFactory.createHeader(PLANI_HEADER_NAME, configuration.getPlaniHeader()));
+        }
+        return response;
+    }
+
+    public static boolean isIPv6Address(String address) {
+        return InetAddresses.forString(address) instanceof Inet6Address;
+    }
+
+    /** Return whether the SIP message has a SDP content or not */
+    public static boolean hasSdpContent(SIPMessage message) {
+        ContentType contentType = message.getContentTypeHeader();
+        return contentType != null
+                && TextUtils.equals(contentType.getContentType(), SDP_CONTENT_TYPE)
+                && TextUtils.equals(contentType.getContentSubType(), SDP_CONTENT_SUB_TYPE);
+    }
+
+    private static String determineTransportBySize(SipSessionConfiguration configuration,
+            int size) {
+        if (size > configuration.getMaxPayloadSizeOnUdp()) {
+            return "tcp";
+        }
+        return "udp";
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/provisioning/ProvisioningController.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/provisioning/ProvisioningController.java
new file mode 100644
index 0000000..f987c67
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/provisioning/ProvisioningController.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.provisioning;
+
+import android.telephony.ims.ImsException;
+
+/**
+ * Access to provisioning functionality and data.
+ */
+public interface ProvisioningController {
+
+    /**
+     * Triggers a new provisioning request. If the device is not already provisioned, it requests
+     * the
+     * provisioning flow and sets up callbacks. If the provisioning is already present, it
+     * requests a
+     * new provisioning config from the server.
+     *
+     * @throws ImsException if there is an error.
+     */
+    void triggerProvisioning() throws ImsException;
+
+    /** Is Single-Reg enabled for the default call SIM ? */
+    boolean isRcsVolteSingleRegistrationCapable() throws ImsException;
+
+    void onConfigurationChange(ProvisioningStateChangeCallback cb);
+
+    // Unregister the callback to the framework's provisioning change.
+    void unRegister();
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/provisioning/ProvisioningControllerImpl.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/provisioning/ProvisioningControllerImpl.java
new file mode 100644
index 0000000..06d3835
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/provisioning/ProvisioningControllerImpl.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.provisioning;
+
+
+/**
+ * Actual implementation build upon ProvisioningManager.
+ */
+public class ProvisioningControllerImpl implements ProvisioningController {
+
+    @Override
+    public void triggerProvisioning() {
+        throw new IllegalStateException("Not implemented!");
+    }
+
+    @Override
+    public void onConfigurationChange(ProvisioningStateChangeCallback cb) {
+        throw new IllegalStateException("Not implemented!");
+    }
+
+    @Override
+    public boolean isRcsVolteSingleRegistrationCapable() {
+        throw new IllegalStateException("Not implemented.");
+    }
+
+    @Override
+    public void unRegister() {
+        throw new IllegalStateException("Not implemented.");
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/provisioning/ProvisioningStateChangeCallback.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/provisioning/ProvisioningStateChangeCallback.java
new file mode 100644
index 0000000..17a0291
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/provisioning/ProvisioningStateChangeCallback.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.provisioning;
+
+/**
+ * A callback for provisioning state change notifications.
+ */
+public interface ProvisioningStateChangeCallback {
+    void notifyConfigChanged(byte[] configXml);
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/provisioning/StaticConfigProvisioningController.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/provisioning/StaticConfigProvisioningController.java
new file mode 100644
index 0000000..350f43c
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/provisioning/StaticConfigProvisioningController.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.provisioning;
+
+import android.os.Build.VERSION_CODES;
+import android.telephony.SubscriptionManager;
+import android.telephony.ims.ImsException;
+import android.telephony.ims.ProvisioningManager;
+import android.telephony.ims.ProvisioningManager.RcsProvisioningCallback;
+import android.telephony.ims.RcsClientConfiguration;
+import android.util.Log;
+
+import java.util.Optional;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RequiresPermission;
+import androidx.annotation.VisibleForTesting;
+
+/**
+ * "Fake" provisioning implementation for supplying a static config when testing ProvisioningManager
+ * is unnecessary. State changes are invoked manually.
+ */
+public class StaticConfigProvisioningController implements ProvisioningController {
+
+    private static final String TAG = StaticConfigProvisioningController.class.getSimpleName();
+    private final ProvisioningManager provisioningManager;
+    private final ExecutorService executorService = Executors.newSingleThreadExecutor();
+    private Optional<RcsProvisioningCallback> storedCallback = Optional.empty();
+    private Optional<ProvisioningStateChangeCallback> stateChangeCallback = Optional.empty();
+    private Optional<byte[]> configXmlData = Optional.empty();
+
+    private StaticConfigProvisioningController(int subId) {
+        this.provisioningManager = ProvisioningManager.createForSubscriptionId(subId);
+    }
+
+    @RequiresApi(api = VERSION_CODES.R)
+    public static StaticConfigProvisioningController createWithDefaultSubscriptionId() {
+        return new StaticConfigProvisioningController(
+                SubscriptionManager.getActiveDataSubscriptionId());
+    }
+
+    public static StaticConfigProvisioningController createForSubscriptionId(int subscriptionId) {
+        return new StaticConfigProvisioningController(subscriptionId);
+    }
+
+    // Static configuration.
+    private static RcsClientConfiguration getDefaultClientConfiguration() {
+
+        return new RcsClientConfiguration(
+                /*rcsVersion=*/ "6.0",
+                /*rcsProfile=*/ "UP_2.3",
+                /*clientVendor=*/ "Goog",
+                /*clientVersion=*/ "RCSAndrd-1.0");//"RCS fake library 1.0");
+    }
+
+    @Override
+    @RequiresPermission(value = "Manifest.permission.READ_PRIVILEGED_PHONE_STATE")
+    public void triggerProvisioning() throws ImsException {
+        boolean isRegistered = false;
+        synchronized (this) {
+            isRegistered = storedCallback.isPresent();
+        }
+
+        if (isRegistered) {
+            triggerReconfiguration();
+        } else {
+            register();
+        }
+    }
+
+    @Override
+    public void onConfigurationChange(ProvisioningStateChangeCallback cb) {
+        stateChangeCallback = Optional.of(cb);
+    }
+
+    @RequiresPermission(value = "Manifest.permission.READ_PRIVILEGED_PHONE_STATE")
+    public void register() throws ImsException {
+        register(getDefaultClientConfiguration());
+    }
+
+    @SuppressWarnings("LogConditional")
+    // TODO(b/171976006) Use 'tools:ignore=' in manifest instead.
+    @RequiresPermission(value = "Manifest.permission.READ_PRIVILEGED_PHONE_STATE")
+    public void register(@NonNull RcsClientConfiguration clientConfiguration) throws ImsException {
+        Log.i(TAG, "Using configuration: " + clientConfiguration.toString());
+        provisioningManager.setRcsClientConfiguration(clientConfiguration);
+
+        RcsProvisioningCallback callback =
+                new RcsProvisioningCallback() {
+                    @Override
+                    public void onConfigurationChanged(@NonNull byte[] configXml) {
+                        Log.i(TAG, "RcsProvisioningCallback.onConfigurationChanged called.");
+                        synchronized (this) {
+                            configXmlData = Optional.of(configXml);
+                        }
+                        stateChangeCallback.ifPresent(cb -> cb.notifyConfigChanged(configXml));
+                    }
+
+                    @RequiresApi(api = VERSION_CODES.R)
+                    @Override
+                    public void onConfigurationReset() {
+                        Log.i(TAG, "RcsProvisioningCallback.onConfigurationReset called.");
+                        synchronized (this) {
+                            configXmlData = Optional.empty();
+                        }
+                        stateChangeCallback.ifPresent(cb -> cb.notifyConfigChanged(null));
+                    }
+
+                    @RequiresApi(api = VERSION_CODES.R)
+                    @Override
+                    public void onRemoved() {
+                        Log.i(TAG, "RcsProvisioningCallback.onRemoved called.");
+                        synchronized (this) {
+                            configXmlData = Optional.empty();
+                        }
+                        stateChangeCallback.ifPresent(cb -> cb.notifyConfigChanged(null));
+                    }
+                };
+
+        Log.i(TAG, "Registering the callback.");
+        synchronized (this) {
+            provisioningManager.registerRcsProvisioningChangedCallback(executorService, callback);
+            storedCallback = Optional.of(callback);
+        }
+    }
+
+    @RequiresPermission(value = "Manifest.permission.READ_PRIVILEGED_PHONE_STATE")
+    public void unRegister() {
+        synchronized (this) {
+            RcsProvisioningCallback callback =
+                    storedCallback.orElseThrow(
+                            () -> new IllegalStateException("No callback present."));
+            provisioningManager.unregisterRcsProvisioningChangedCallback(callback);
+            storedCallback = Optional.empty();
+        }
+    }
+
+    @Override
+    @RequiresPermission(value = "Manifest.permission.READ_PRIVILEGED_PHONE_STATE")
+    public boolean isRcsVolteSingleRegistrationCapable() throws ImsException {
+        return provisioningManager.isRcsVolteSingleRegistrationCapable();
+    }
+
+    public synchronized byte[] getLatestConfiguration() {
+        return configXmlData.orElseThrow(() -> new IllegalStateException("No config present"));
+    }
+
+    @VisibleForTesting
+    @RequiresPermission(value = "Manifest.permission.READ_PRIVILEGED_PHONE_STATE")
+    void triggerReconfiguration() {
+        provisioningManager.triggerRcsReconfiguration();
+    }
+
+    @VisibleForTesting
+    ProvisioningManager getProvisioningManager() {
+        return provisioningManager;
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/registration/MessageConverter.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/registration/MessageConverter.java
new file mode 100644
index 0000000..75eb48d
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/registration/MessageConverter.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.registration;
+
+import android.telephony.ims.SipMessage;
+
+import gov.nist.javax.sip.header.SIPHeader;
+import gov.nist.javax.sip.message.SIPMessage;
+import gov.nist.javax.sip.parser.ParseExceptionListener;
+import gov.nist.javax.sip.parser.StringMsgParser;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.text.ParseException;
+import java.util.Iterator;
+
+import javax.sip.header.ContentLengthHeader;
+import javax.sip.message.Message;
+import javax.sip.message.Request;
+import javax.sip.message.Response;
+
+/***
+ * Class responsible of converting an RCS SIP Message
+ * {@link Message} to a Platform SIP message
+ * {@link SipMessage} and vice versa.
+ */
+public final class MessageConverter {
+
+    private MessageConverter() {
+    }
+
+    public static SipMessage toPlatformMessage(Message message) {
+        String startLine;
+        if (message instanceof Request) {
+            startLine = getRequestStartLine((Request) message);
+        } else {
+            startLine = getResponseStartLine((Response) message);
+        }
+
+        StringBuilder headers = new StringBuilder();
+        for (Iterator<SIPHeader> it = ((SIPMessage) message).getHeaders(); it.hasNext(); ) {
+            SIPHeader header = it.next();
+            if (header instanceof ContentLengthHeader) {
+                continue;
+            }
+            headers.append(header);
+        }
+
+        int length = message.getRawContent() != null ? message.getRawContent().length : 0;
+        headers
+                .append(SIPHeader.CONTENT_LENGTH)
+                .append(": ")
+                .append(length)
+                .append("\r\n");
+
+        byte[] rawContent = message.getRawContent();
+        rawContent = rawContent == null ? new byte[0] : message.getRawContent();
+        return new SipMessage(startLine, headers.toString(), rawContent);
+    }
+
+    public static Message toStackMessage(SipMessage message) throws ParseException {
+        // The AOSP version of nist-sip has a parseSIPMessage() method that has a different
+        // contract.
+        // Fallback to parseSIPMessage(byte[] msgBuffer) in case the first attempt fails.
+        Method method;
+        try {
+            method =
+                    StringMsgParser.class.getDeclaredMethod(
+                            "parseSIPMessage",
+                            byte[].class,
+                            boolean.class,
+                            boolean.class,
+                            ParseExceptionListener.class);
+            return (Message)
+                    method.invoke(
+                            new StringMsgParser(),
+                            message.getEncodedMessage(),
+                            true,
+                            false,
+                            (ParseExceptionListener)
+                                    (ex, sipMessage, headerClass, headerText, messageText) -> {
+                                        throw ex;
+                                    });
+        } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
+            try {
+                method = StringMsgParser.class.getDeclaredMethod("parseSIPMessage", byte[].class);
+                return (Message) method.invoke(new StringMsgParser(), message.getEncodedMessage());
+            } catch (IllegalAccessException | InvocationTargetException
+                    | NoSuchMethodException ex) {
+                ex.printStackTrace();
+                throw new ParseException("Failed to invoke parseSIPMessage", 0);
+            }
+        }
+    }
+
+    private static String getRequestStartLine(Request request) {
+        StringBuilder startLine = new StringBuilder();
+
+        startLine.append(request.getMethod());
+        startLine.append(" ");
+        startLine.append(request.getRequestURI());
+        startLine.append(" SIP/2.0\r\n");
+
+        return startLine.toString();
+    }
+
+    private static String getResponseStartLine(Response response) {
+        StringBuilder startLine = new StringBuilder();
+
+        startLine.append("SIP/2.0 ");
+        startLine.append(response.getStatusCode());
+        startLine.append(" ");
+        startLine.append(response.getReasonPhrase());
+        startLine.append("\r\n");
+
+        return startLine.toString();
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/registration/RegistrationController.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/registration/RegistrationController.java
new file mode 100644
index 0000000..64d93b2
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/registration/RegistrationController.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.registration;
+
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipSession;
+import com.android.libraries.rcs.simpleclient.service.ImsService;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+/**
+ * Access to registration functionality.
+ */
+public interface RegistrationController {
+
+    /**
+     * Registers the given ImsService with the backend and returns a SipSession for sending and
+     * receiving SIP messages.
+     */
+    ListenableFuture<SipSession> register(ImsService imsService);
+
+    void deregister();
+
+    void onRegistrationStateChange(RegistrationStateChangeCallback callback);
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/registration/RegistrationControllerImpl.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/registration/RegistrationControllerImpl.java
new file mode 100644
index 0000000..ac37110
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/registration/RegistrationControllerImpl.java
@@ -0,0 +1,429 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.registration;
+
+import android.os.Build.VERSION_CODES;
+import android.telephony.ims.DelegateRegistrationState;
+import android.telephony.ims.DelegateRequest;
+import android.telephony.ims.FeatureTagState;
+import android.telephony.ims.ImsException;
+import android.telephony.ims.ImsManager;
+import android.telephony.ims.SipDelegateConnection;
+import android.telephony.ims.SipDelegateImsConfiguration;
+import android.telephony.ims.SipDelegateManager;
+import android.telephony.ims.SipMessage;
+import android.telephony.ims.stub.DelegateConnectionMessageCallback;
+import android.telephony.ims.stub.DelegateConnectionStateCallback;
+import android.text.TextUtils;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipSession;
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipSessionConfiguration;
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipSessionListener;
+import com.android.libraries.rcs.simpleclient.service.ImsService;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+
+import java.text.ParseException;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Executor;
+
+import javax.sip.message.Message;
+
+/**
+ * Actual implementation built upon SipDelegateConnection as a SIP transport.
+ * Feature tag registration state changes will trigger callbacks SimpleRcsClient to
+ * enable/disable related ImsServices.
+ */
+@RequiresApi(api = VERSION_CODES.R)
+public class RegistrationControllerImpl implements RegistrationController {
+    private static final String TAG = RegistrationControllerImpl.class.getCanonicalName();
+
+    private final Executor executor;
+    private final int subscriptionId;
+    private SipDelegateManager sipDelegateManager;
+    private RegistrationContext context;
+
+    public RegistrationControllerImpl(int subscriptionId, Executor executor,
+            ImsManager imsManager) {
+        this.subscriptionId = subscriptionId;
+        this.executor = executor;
+        this.sipDelegateManager = imsManager.getSipDelegateManager(subscriptionId);
+    }
+
+    @Override
+    public ListenableFuture<SipSession> register(ImsService imsService) {
+        Log.i(TAG, "register");
+        context = new RegistrationContext(this, imsService);
+        context.register();
+        return context.getFuture();
+    }
+
+    @Override
+    public void deregister() {
+        Log.i(TAG, "deregister");
+        if (context != null && context.sipDelegateConnection != null) {
+            sipDelegateManager.destroySipDelegate(context.sipDelegateConnection,
+                    SipDelegateManager.SIP_DELEGATE_DESTROY_REASON_REQUESTED_BY_APP);
+        }
+    }
+
+    @Override
+    public void onRegistrationStateChange(RegistrationStateChangeCallback callback) {
+        throw new IllegalStateException("Not implemented!");
+    }
+
+    /**
+     * Envelopes the registration data for a single ImsService instance.
+     */
+    private static class RegistrationContext implements SipSession, SipSessionConfiguration {
+
+        private final RegistrationControllerImpl controller;
+        private final ImsService imsService;
+        private final SettableFuture<SipSession> sessionFuture = SettableFuture.create();
+
+        protected SipDelegateConnection sipDelegateConnection;
+        private SipDelegateImsConfiguration configuration;
+        private final DelegateConnectionStateCallback connectionCallback =
+                new DelegateConnectionStateCallback() {
+
+                    @Override
+                    public void onCreated(SipDelegateConnection c) {
+                        sipDelegateConnection = c;
+                    }
+
+                    @Override
+                    public void onImsConfigurationChanged(
+                            SipDelegateImsConfiguration registeredSipConfig) {
+                        Log.d(
+                                TAG,
+                                "onSipConfigurationChanged: version="
+                                        + registeredSipConfig.getVersion()
+                                        + " bundle="
+                                        + registeredSipConfig.copyBundle());
+                        dumpConfig(registeredSipConfig);
+                        RegistrationContext.this.configuration = registeredSipConfig;
+                    }
+
+                    @Override
+                    public void onFeatureTagStatusChanged(
+                            @NonNull DelegateRegistrationState registrationState,
+                            @NonNull Set<FeatureTagState> deniedFeatureTags) {
+                        dumpFeatureTagState(registrationState, deniedFeatureTags);
+                        if (registrationState
+                                .getRegisteredFeatureTags()
+                                .containsAll(imsService.getFeatureTags())) {
+                            // registered;
+                            sessionFuture.set(RegistrationContext.this);
+                        }
+                    }
+
+                    @Override
+                    public void onDestroyed(int reason) {
+                    }
+                };
+        private SipSessionListener sipSessionListener;
+        // Callback for incoming messages on the modem connection
+        private final DelegateConnectionMessageCallback messageCallback =
+                new DelegateConnectionMessageCallback() {
+                    @Override
+                    public void onMessageReceived(@NonNull SipMessage message) {
+                        SipSessionListener listener = sipSessionListener;
+                        if (listener != null) {
+                            try {
+                                listener.onMessageReceived(
+                                        MessageConverter.toStackMessage(message));
+                            } catch (ParseException e) {
+                                // TODO: logging here
+                            }
+                        }
+                    }
+
+                    @Override
+                    public void onMessageSendFailure(@NonNull String viaTransactionId, int reason) {
+                    }
+
+                    @Override
+                    public void onMessageSent(@NonNull String viaTransactionId) {
+                    }
+
+                };
+
+        public RegistrationContext(RegistrationControllerImpl controller,
+                ImsService imsService) {
+            this.controller = controller;
+            this.imsService = imsService;
+        }
+
+        public ListenableFuture<SipSession> getFuture() {
+            return sessionFuture;
+        }
+
+        @Override
+        public SipSessionConfiguration getSessionConfiguration() {
+            return this;
+        }
+
+        public void register() {
+            Log.i(TAG, "createSipDelegate");
+            DelegateRequest request = new DelegateRequest(imsService.getFeatureTags());
+            try {
+                controller.sipDelegateManager.createSipDelegate(
+                        request, controller.executor, connectionCallback, messageCallback);
+            } catch (ImsException e) {
+                // TODO: ...
+            }
+        }
+
+        private void dumpFeatureTagState(DelegateRegistrationState registrationState,
+                Set<FeatureTagState> deniedFeatureTags) {
+            StringBuilder stringBuilder = new StringBuilder(
+                    "onFeatureTagStatusChanged ").append(
+                    " deniedFeatureTags:[");
+            Iterator<FeatureTagState> iterator = deniedFeatureTags.iterator();
+            while (iterator.hasNext()) {
+                FeatureTagState featureTagState = iterator.next();
+                stringBuilder.append(featureTagState.getFeatureTag()).append(" ").append(
+                        featureTagState.getState());
+            }
+            Set<String> registeredFt = registrationState.getRegisteredFeatureTags();
+            Iterator<String> iteratorStr = registeredFt.iterator();
+            stringBuilder.append("] registeredFT:[");
+            while (iteratorStr.hasNext()) {
+                String ft = iteratorStr.next();
+                stringBuilder.append(ft).append(" ");
+            }
+            stringBuilder.append("]");
+            String result = stringBuilder.toString();
+            Log.i(TAG, result);
+        }
+
+        private void dumpConfig(SipDelegateImsConfiguration config) {
+            Log.i(TAG, "KEY_SIP_CONFIG_TRANSPORT_TYPE_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_TRANSPORT_TYPE_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_UE_PUBLIC_USER_ID_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_PUBLIC_USER_ID_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_UE_PRIVATE_USER_ID_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_PRIVATE_USER_ID_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_HOME_DOMAIN_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_HOME_DOMAIN_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_IMEI_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_IMEI_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_IPTYPE_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_IPTYPE_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_UE_DEFAULT_IPADDRESS_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_DEFAULT_IPADDRESS_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_SERVER_DEFAULT_IPADDRESS_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_SERVER_DEFAULT_IPADDRESS_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_UE_PUBLIC_IPADDRESS_WITH_NAT_STRING:" +
+                    config.getString(SipDelegateImsConfiguration.
+                            KEY_SIP_CONFIG_UE_PUBLIC_IPADDRESS_WITH_NAT_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_UE_PUBLIC_GRUU_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_PUBLIC_GRUU_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_AUTHENTICATION_HEADER_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_AUTHENTICATION_HEADER_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_AUTHENTICATION_NONCE_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_AUTHENTICATION_NONCE_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_SERVICE_ROUTE_HEADER_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_SERVICE_ROUTE_HEADER_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_SECURITY_VERIFY_HEADER_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_SECURITY_VERIFY_HEADER_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_PATH_HEADER_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_PATH_HEADER_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_URI_USER_PART_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_URI_USER_PART_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_P_ACCESS_NETWORK_INFO_HEADER_STRING:" +
+                    config.getString(SipDelegateImsConfiguration.
+                            KEY_SIP_CONFIG_P_ACCESS_NETWORK_INFO_HEADER_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_P_LAST_ACCESS_NETWORK_INFO_HEADER_STRING:" +
+                    config.getString(SipDelegateImsConfiguration.
+                            KEY_SIP_CONFIG_P_LAST_ACCESS_NETWORK_INFO_HEADER_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_P_ASSOCIATED_URI_HEADER_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_P_ASSOCIATED_URI_HEADER_STRING));
+            Log.i(TAG, "KEY_SIP_CONFIG_USER_AGENT_HEADER_STRING:" + config.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_USER_AGENT_HEADER_STRING));
+
+            Log.i(TAG, "KEY_SIP_CONFIG_MAX_PAYLOAD_SIZE_ON_UDP_INT:" + config.getInt(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_MAX_PAYLOAD_SIZE_ON_UDP_INT, -99));
+            Log.i(TAG, "KEY_SIP_CONFIG_UE_DEFAULT_PORT_INT:" + config.getInt(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_DEFAULT_PORT_INT, -99));
+            Log.i(TAG, "KEY_SIP_CONFIG_SERVER_DEFAULT_PORT_INT:" + config.getInt(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_SERVER_DEFAULT_PORT_INT, -99));
+            Log.i(TAG, "KEY_SIP_CONFIG_UE_PUBLIC_PORT_WITH_NAT_INT:" + config.getInt(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_PUBLIC_PORT_WITH_NAT_INT, -99));
+            Log.i(TAG, "KEY_SIP_CONFIG_UE_IPSEC_CLIENT_PORT_INT:" + config.getInt(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_IPSEC_CLIENT_PORT_INT, -99));
+            Log.i(TAG, "KEY_SIP_CONFIG_UE_IPSEC_SERVER_PORT_INT:" + config.getInt(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_IPSEC_SERVER_PORT_INT, -99));
+            Log.i(TAG, "KEY_SIP_CONFIG_UE_IPSEC_OLD_CLIENT_PORT_INT:" + config.getInt(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_IPSEC_OLD_CLIENT_PORT_INT, -99));
+            Log.i(TAG, "KEY_SIP_CONFIG_SERVER_IPSEC_CLIENT_PORT_INT:" + config.getInt(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_SERVER_IPSEC_CLIENT_PORT_INT, -99));
+            Log.i(TAG, "KEY_SIP_CONFIG_SERVER_IPSEC_SERVER_PORT_INT:" + config.getInt(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_SERVER_IPSEC_SERVER_PORT_INT, -99));
+            Log.i(TAG, "KEY_SIP_CONFIG_SERVER_IPSEC_OLD_CLIENT_PORT_INT:" + config.getInt(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_SERVER_IPSEC_OLD_CLIENT_PORT_INT,
+                    -99));
+
+            Log.i(TAG, "KEY_SIP_CONFIG_IS_COMPACT_FORM_ENABLED_BOOL:" + config.getBoolean(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_IS_COMPACT_FORM_ENABLED_BOOL,
+                    false));
+            Log.i(TAG, "KEY_SIP_CONFIG_IS_KEEPALIVE_ENABLED_BOOL:" + config.getBoolean(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_IS_KEEPALIVE_ENABLED_BOOL, false));
+            Log.i(TAG, "KEY_SIP_CONFIG_IS_NAT_ENABLED_BOOL:" + config.getBoolean(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_IS_NAT_ENABLED_BOOL, false));
+            Log.i(TAG, "KEY_SIP_CONFIG_IS_GRUU_ENABLED_BOOL:" + config.getBoolean(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_IS_GRUU_ENABLED_BOOL, false));
+            Log.i(TAG, "KEY_SIP_CONFIG_IS_IPSEC_ENABLED_BOOL:" + config.getBoolean(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_IS_IPSEC_ENABLED_BOOL, false));
+        }
+
+        @Override
+        public void setSessionListener(SipSessionListener listener) {
+            sipSessionListener = listener;
+        }
+
+        @Override
+        public ListenableFuture<Boolean> send(Message message) {
+            sipDelegateConnection.sendMessage(MessageConverter.toPlatformMessage(message),
+                    getVersion());
+            // TODO: check on transaction
+            return Futures.immediateFuture(true);
+        }
+
+        // Config values here.
+
+        @Override
+        public long getVersion() {
+            return configuration.getVersion();
+        }
+
+        @Override
+        public String getOutboundProxyAddr() {
+            return configuration.getString(SipDelegateImsConfiguration.
+                    KEY_SIP_CONFIG_SERVER_DEFAULT_IPADDRESS_STRING);
+        }
+
+        @Override
+        public int getOutboundProxyPort() {
+            return configuration.getInt(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_SERVER_DEFAULT_PORT_INT, -1);
+        }
+
+        @Override
+        public String getLocalIpAddress() {
+            return configuration.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_DEFAULT_IPADDRESS_STRING);
+        }
+
+        @Override
+        public int getLocalPort() {
+            return configuration.getInt(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_UE_DEFAULT_PORT_INT, -1);
+        }
+
+        @Override
+        public String getSipTransport() {
+            return configuration.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_TRANSPORT_TYPE_STRING);
+        }
+
+        @Override
+        public String getPublicUserIdentity() {
+            return null;
+        }
+
+        @Override
+        public String getDomain() {
+            return configuration.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_HOME_DOMAIN_STRING);
+        }
+
+        @Override
+        public List<String> getAssociatedUris() {
+            String associatedUris = configuration.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_P_ASSOCIATED_URI_HEADER_STRING);
+            if (!TextUtils.isEmpty(associatedUris)) {
+                return Splitter.on(',').trimResults(CharMatcher.anyOf("<>")).splitToList(
+                        associatedUris);
+            }
+
+            return ImmutableList.of();
+        }
+
+        @Override
+        public String getSecurityVerifyHeader() {
+            return configuration.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_SECURITY_VERIFY_HEADER_STRING);
+        }
+
+        @Override
+        public List<String> getServiceRouteHeaders() {
+            String serviceRoutes =
+                    configuration.getString(
+                            SipDelegateImsConfiguration.KEY_SIP_CONFIG_SERVICE_ROUTE_HEADER_STRING);
+            return Splitter.on(',').trimResults().splitToList(serviceRoutes);
+        }
+
+        @Override
+        public String getContactUser() {
+            return configuration.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_URI_USER_PART_STRING);
+        }
+
+        @Override
+        public String getImei() {
+            return configuration.getString(SipDelegateImsConfiguration.KEY_SIP_CONFIG_IMEI_STRING);
+        }
+
+        @Override
+        public String getPaniHeader() {
+            return configuration.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_P_ACCESS_NETWORK_INFO_HEADER_STRING);
+        }
+
+        @Override
+        public String getPlaniHeader() {
+            return configuration.getString(
+                    SipDelegateImsConfiguration.
+                            KEY_SIP_CONFIG_P_LAST_ACCESS_NETWORK_INFO_HEADER_STRING);
+        }
+
+        @Override
+        public String getUserAgentHeader() {
+            return configuration.getString(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_USER_AGENT_HEADER_STRING);
+        }
+
+        @Override
+        public int getMaxPayloadSizeOnUdp() {
+            return configuration.getInt(
+                    SipDelegateImsConfiguration.KEY_SIP_CONFIG_MAX_PAYLOAD_SIZE_ON_UDP_INT, 1500);
+        }
+    }
+}
+
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/registration/RegistrationStateChangeCallback.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/registration/RegistrationStateChangeCallback.java
new file mode 100644
index 0000000..4f36ce5
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/registration/RegistrationStateChangeCallback.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.registration;
+
+import com.android.libraries.rcs.simpleclient.service.ImsService;
+
+/**
+ * Callback for Registration state changes.
+ */
+public interface RegistrationStateChangeCallback {
+
+    /**
+     * The given feature tags are registered with the backend and the service would be able to
+     * send and receive messages.
+     *
+     * @param imsService the newly registered service.
+     */
+    void notifyRegStateChanged(ImsService imsService);
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/ImsService.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/ImsService.java
new file mode 100644
index 0000000..e4dca1a
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/ImsService.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.service;
+
+import com.android.libraries.rcs.simpleclient.SimpleRcsClientContext;
+
+import java.util.Set;
+
+/**
+ * Covers service state and feature tag association.
+ */
+public interface ImsService {
+
+    /**
+     * Associated feature tags.
+     * Services will started and stopped according to the registration state of the feature tags.
+     */
+    Set<String> getFeatureTags();
+
+    /**
+     * Services started when their feature tags are enabled from the
+     * {@link com.android.libraries.rcs.simpleclient.registration.RegistrationController}.
+     * Context is made available to the ImsService here.
+     */
+    void start(SimpleRcsClientContext context);
+
+    /**
+     * Services stopped when their feature tags are disabled from
+     * {@link com.android.libraries.rcs.simpleclient.registration.RegistrationController}
+     */
+    void stop();
+
+    /**
+     * Simple callback mechanism for monitoring feature tag/ims service state.
+     */
+    void onStateChange(StateChangeCallback cb);
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/StateChangeCallback.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/StateChangeCallback.java
new file mode 100644
index 0000000..038023a
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/StateChangeCallback.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.service;
+
+/**
+ * Callback for ImsService state changes.
+ */
+public interface StateChangeCallback {
+
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/ChatServiceException.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/ChatServiceException.java
new file mode 100644
index 0000000..94850fd
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/ChatServiceException.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.service.chat;
+
+import android.text.TextUtils;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * This class defines an exception that can be thrown during the operation in {@link
+ * MinimalCpmChatService}
+ */
+public final class ChatServiceException extends Exception {
+
+    public static final int CODE_ERROR_UNSPECIFIED = 0;
+    private int mCode = CODE_ERROR_UNSPECIFIED;
+
+    /**
+     * A new {@link ChatServiceException} with an unspecified {@link ErrorCode} code.
+     *
+     * @param message an optional message to detail the error condition more specifically.
+     */
+    public ChatServiceException(@Nullable String message) {
+        super(getMessage(message, CODE_ERROR_UNSPECIFIED));
+    }
+
+    /**
+     * A new {@link ChatServiceException} that includes an {@link ErrorCode} error code.
+     *
+     * @param message an optional message to detail the error condition more specifically.
+     */
+    public ChatServiceException(@Nullable String message, @ErrorCode int code) {
+        super(getMessage(message, code));
+        mCode = code;
+    }
+
+    /**
+     * A new {@link ChatServiceException} that includes an {@link ErrorCode} error code and a {@link
+     * Throwable} that contains the original error that was thrown to lead to this Exception.
+     *
+     * @param message an optional message to detail the error condition more specifically.
+     * @param cause   the {@link Throwable} that caused this {@link ChatServiceException} to be
+     *                created.
+     */
+    public ChatServiceException(
+            @Nullable String message, @ErrorCode int code, @Nullable Throwable cause) {
+        super(getMessage(message, code), cause);
+        mCode = code;
+    }
+
+    private static String getMessage(String message, int code) {
+        StringBuilder builder;
+        if (!TextUtils.isEmpty(message)) {
+            builder = new StringBuilder(message);
+            builder.append(" (code: ");
+            builder.append(code);
+            builder.append(")");
+            return builder.toString();
+        } else {
+            return "code: " + code;
+        }
+    }
+
+    @ErrorCode
+    public int getCode() {
+        return mCode;
+    }
+
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({
+            CODE_ERROR_UNSPECIFIED,
+    })
+    public @interface ErrorCode {
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/ChatServiceListener.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/ChatServiceListener.java
new file mode 100644
index 0000000..5fb9dee
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/ChatServiceListener.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.service.chat;
+
+/** Listener for chat service events */
+public interface ChatServiceListener {
+
+    /**
+     * Received a new incoming chat session from the RCS server. The session is ready to exchange
+     * messages since it is already established once this callback is called.
+     */
+    void onIncomingSession(SimpleChatSession session);
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/ChatSessionListener.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/ChatSessionListener.java
new file mode 100644
index 0000000..eab571e
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/ChatSessionListener.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.service.chat;
+
+import com.android.libraries.rcs.simpleclient.protocol.cpim.SimpleCpimMessage;
+
+/** Listener for chat session events */
+public interface ChatSessionListener {
+
+    /**
+     * Received a new CPIM message via the {@link SimpleChatSession} associated with this listener.
+     *
+     * @param message Received message in the form of {@link SimpleCpimMessage}
+     */
+    void onMessageReceived(SimpleCpimMessage message);
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/MinimalCpmChatService.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/MinimalCpmChatService.java
new file mode 100644
index 0000000..01a1061
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/MinimalCpmChatService.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.service.chat;
+
+import android.content.Context;
+import android.telephony.ims.SipDelegateConnection;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.libraries.rcs.simpleclient.SimpleRcsClientContext;
+import com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpManager;
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipSession;
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipSessionListener;
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipUtils;
+import com.android.libraries.rcs.simpleclient.service.ImsService;
+import com.android.libraries.rcs.simpleclient.service.StateChangeCallback;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import gov.nist.javax.sip.message.SIPRequest;
+import gov.nist.javax.sip.message.SIPResponse;
+
+import java.text.ParseException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import javax.sip.message.Request;
+import javax.sip.message.Response;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Minimal CPM chat session service that provides the interface creating a {@link SimpleChatSession}
+ * instance using {@link SipDelegateConnection}.
+ */
+public class MinimalCpmChatService implements ImsService {
+    public static final String CPM_SESSION_TAG =
+            "+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.oma.cpm.session\"";
+    private static final String TAG = SimpleChatSession.class.getSimpleName();
+    private final Map<String, SimpleChatSession> mTransactions = new HashMap<>();
+    private final Map<String, SimpleChatSession> mDialogs = new HashMap<>();
+
+    private final MsrpManager mMsrpManager;
+    private SimpleRcsClientContext mContext;
+
+    @Nullable
+    private ChatServiceListener mListener;
+
+    private final SipSessionListener mSipSessionListener =
+            sipMessage -> {
+                if (sipMessage instanceof SIPRequest) {
+                    handleRequest((SIPRequest) sipMessage);
+                } else if (sipMessage instanceof SIPResponse) {
+                    handleResponse((SIPResponse) sipMessage);
+                }
+            };
+
+    public MinimalCpmChatService(Context context) {
+        mMsrpManager = new MsrpManager(context);
+    }
+
+    @Override
+    public Set<String> getFeatureTags() {
+        return ImmutableSet.of(CPM_SESSION_TAG);
+    }
+
+    @Override
+    public void start(SimpleRcsClientContext context) {
+        mContext = context;
+        context.getSipSession().setSessionListener(mSipSessionListener);
+    }
+
+    @Override
+    public void stop() {
+    }
+
+    @Override
+    public void onStateChange(StateChangeCallback cb) {
+    }
+
+    /**
+     * Start an originating 1:1 chat session interacting with the RCS server.
+     *
+     * @param telUriContact The remote contact in the from of TEL URI
+     * @return The future will be completed with SimpleChatSession once the session is established
+     * successfully. If the session fails for any reason, return the failed future with {@link
+     * ChatServiceException}
+     */
+    public ListenableFuture<SimpleChatSession> startOriginatingChatSession(String telUriContact) {
+        Log.i(TAG, "startOriginatingChatSession");
+        SimpleChatSession session = new SimpleChatSession(mContext, this, mMsrpManager);
+        return Futures.transform(
+                session.start(telUriContact), v -> session, MoreExecutors.directExecutor());
+    }
+
+    ListenableFuture<Boolean> sendSipRequest(SIPRequest msg, SimpleChatSession session) {
+        Log.i(TAG, "sendSipRequest");
+        if (!TextUtils.equals(msg.getMethod(), Request.ACK)) {
+            mTransactions.put(msg.getTransactionId(), session);
+        }
+
+        if (TextUtils.equals(msg.getMethod(), Request.BYE)) {
+            mDialogs.remove(msg.getDialogId(/* isServer= */ false));
+        }
+
+        SipSession sipSession = mContext.getSipSession();
+        return sipSession.send(msg);
+    }
+
+    ListenableFuture<Boolean> sendSipResponse(SIPResponse msg, SimpleChatSession session) {
+        Log.i(TAG, "sendSipRequest");
+        if (TextUtils.equals(msg.getCSeq().getMethod(), Request.BYE)) {
+            mDialogs.remove(msg.getDialogId(/* isServer= */ true));
+        } else if (TextUtils.equals(msg.getCSeq().getMethod(), Request.INVITE)
+                && msg.getStatusCode() == Response.OK) {
+            // Cache the dialog in order to route in-dialog request to the corresponding session.
+            mDialogs.put(msg.getDialogId(/* isServer= */ true), session);
+        }
+        SipSession sipSession = mContext.getSipSession();
+        return sipSession.send(msg);
+    }
+
+    private void handleRequest(SIPRequest request) {
+        String dialogId = request.getDialogId(/* isServer= */ true);
+        if (mDialogs.containsKey(dialogId)) {
+            SimpleChatSession session = mDialogs.get(dialogId);
+            session.receiveMessage(request);
+        } else if (TextUtils.equals(request.getMethod(), Request.INVITE)) {
+            SimpleChatSession session = new SimpleChatSession(mContext, this, mMsrpManager);
+            session
+                    .start(request)
+                    .addListener(
+                            () -> {
+                                ChatServiceListener listener = mListener;
+                                if (listener != null) {
+                                    listener.onIncomingSession(session);
+                                }
+                            },
+                            MoreExecutors.directExecutor());
+        } else {
+            // Reject non-INVITE request.
+            try {
+                SIPResponse response =
+                        SipUtils.buildInviteResponse(
+                                mContext.getSipSession().getSessionConfiguration(),
+                                request,
+                                Response.METHOD_NOT_ALLOWED);
+                sendSipResponse(response, /* session= */ null)
+                        .addListener(() -> {
+                        }, MoreExecutors.directExecutor());
+            } catch (ParseException e) {
+                Log.e(TAG, "Exception while sending response", e);
+            }
+        }
+    }
+
+    private void handleResponse(SIPResponse response) {
+        Log.i(TAG, "handleResponse:\r\n" + response);
+        // catch the exception because abnormal response always causes App to crash.
+        try {
+            SimpleChatSession session = mTransactions.get(response.getTransactionId());
+            if (session != null) {
+                if (response.isFinalResponse()) {
+                    mTransactions.remove(response.getTransactionId());
+
+                    // Cache the dialog in order to route in-dialog request to the corresponding
+                    // session.
+                    if (TextUtils.equals(response.getCSeq().getMethod(), Request.INVITE)
+                            && response.getStatusCode() == Response.OK) {
+                        mDialogs.put(response.getDialogId(/* isServer= */ false), session);
+                    }
+                }
+
+                session.receiveMessage(response);
+            }
+        } catch (Exception e) {
+            Log.e(TAG, e.getMessage());
+            e.printStackTrace();
+        }
+    }
+
+    /** Set new listener for the chat service. */
+    public void setListener(@Nullable ChatServiceListener listener) {
+        mListener = listener;
+    }
+}
diff --git a/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/SimpleChatSession.java b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/SimpleChatSession.java
new file mode 100644
index 0000000..74472d7
--- /dev/null
+++ b/testapps/TestRcsApp/aosp_test_rcsclient/src/com/android/libraries/rcs/simpleclient/service/chat/SimpleChatSession.java
@@ -0,0 +1,430 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.libraries.rcs.simpleclient.service.chat;
+
+import static com.android.libraries.rcs.simpleclient.protocol.cpim.CpimUtils.CPIM_CONTENT_TYPE;
+import static com.android.libraries.rcs.simpleclient.service.chat.ChatServiceException.CODE_ERROR_UNSPECIFIED;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.text.TextUtils;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.android.libraries.rcs.simpleclient.SimpleRcsClientContext;
+import com.android.libraries.rcs.simpleclient.protocol.cpim.CpimUtils;
+import com.android.libraries.rcs.simpleclient.protocol.cpim.SimpleCpimMessage;
+import com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpChunk;
+import com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpChunk.Continuation;
+import com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpConstants;
+import com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpManager;
+import com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpSession;
+import com.android.libraries.rcs.simpleclient.protocol.msrp.MsrpUtils;
+import com.android.libraries.rcs.simpleclient.protocol.sdp.SdpUtils;
+import com.android.libraries.rcs.simpleclient.protocol.sdp.SimpleSdpMessage;
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipSessionConfiguration;
+import com.android.libraries.rcs.simpleclient.protocol.sip.SipUtils;
+import com.android.libraries.rcs.simpleclient.service.chat.ChatServiceException.ErrorCode;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.SettableFuture;
+
+import gov.nist.javax.sip.header.To;
+import gov.nist.javax.sip.header.ims.PAssertedIdentityHeader;
+import gov.nist.javax.sip.message.SIPRequest;
+import gov.nist.javax.sip.message.SIPResponse;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.text.ParseException;
+import java.util.UUID;
+
+import javax.sip.address.URI;
+import javax.sip.message.Message;
+import javax.sip.message.Request;
+import javax.sip.message.Response;
+
+/**
+ * Simple chat session implementation in order to send/receive a text message via SIP/MSRP
+ * connection. Currently, this supports only a outgoing CPM session.
+ */
+public class SimpleChatSession {
+    private static final String TAG = SimpleChatSession.class.getSimpleName();
+    private final SimpleRcsClientContext mContext;
+    private final MinimalCpmChatService mService;
+    private final MsrpManager mMsrpManager;
+    private final String mConversationId = UUID.randomUUID().toString();
+    private SettableFuture<Void> mStartFuture;
+    @Nullable
+    private SIPRequest mInviteRequest;
+    @Nullable
+    private URI mRemoteUri;
+    @Nullable
+    private SimpleSdpMessage mRemoteSdp;
+    @Nullable
+    private SimpleSdpMessage mLocalSdp;
+    @Nullable
+    private MsrpSession mMsrpSession;
+    @Nullable
+    private ChatSessionListener mListener;
+
+
+    public SimpleChatSession(
+            SimpleRcsClientContext context, MinimalCpmChatService service,
+            MsrpManager msrpManager) {
+        mService = service;
+        mContext = context;
+        mMsrpManager = msrpManager;
+    }
+
+    public URI getRemoteUri() {
+        return mRemoteUri;
+    }
+
+    /** Send a text message via MSRP session associated with this session. */
+    public void sendMessage(String msg) {
+        MsrpSession session = mMsrpSession;
+        if (session == null || mRemoteSdp == null || mLocalSdp == null) {
+            Log.e(TAG, "Session is not established");
+            return;
+        }
+
+        // Build a new CPIM message and send it out through the MSRP session.
+        SimpleCpimMessage cpim = CpimUtils.createForText(msg);
+        byte[] content = cpim.encode().getBytes(UTF_8);
+        MsrpChunk msrpChunk =
+                MsrpChunk.newBuilder()
+                        .method(MsrpChunk.Method.SEND)
+                        .transactionId(MsrpUtils.generateRandomId())
+                        .content(content)
+                        .continuation(Continuation.COMPLETE)
+                        .addHeader(MsrpConstants.HEADER_TO_PATH, mRemoteSdp.getPath().get())
+                        .addHeader(MsrpConstants.HEADER_FROM_PATH, mLocalSdp.getPath().get())
+                        .addHeader(
+                                MsrpConstants.HEADER_BYTE_RANGE,
+                                String.format("1-%d/%d", content.length, content.length))
+                        .addHeader(MsrpConstants.HEADER_MESSAGE_ID, MsrpUtils.generateRandomId())
+                        .addHeader(MsrpConstants.HEADER_CONTENT_TYPE, CPIM_CONTENT_TYPE)
+                        .build();
+        Futures.addCallback(
+                session.send(msrpChunk),
+                new FutureCallback<MsrpChunk>() {
+                    @Override
+                    public void onSuccess(MsrpChunk result) {
+                        if (result.responseCode() != 200) {
+                            Log.d(
+                                    TAG,
+                                    "Received error response id="
+                                            + result.transactionId()
+                                            + " code="
+                                            + result.responseCode());
+                        }
+                    }
+
+                    @Override
+                    public void onFailure(Throwable t) {
+                        Log.d(TAG, "Failed to send msrp chunk", t);
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    /** Start outgoing chat session. */
+    ListenableFuture<Void> start(String telUriContact) {
+        if (mStartFuture != null) {
+            return Futures.immediateFailedFuture(
+                    new ChatServiceException("Session already started"));
+        }
+
+        SettableFuture<Void> future = SettableFuture.create();
+        mStartFuture = future;
+        mRemoteUri = SipUtils.createUri(telUriContact);
+        try {
+            SipSessionConfiguration configuration = mContext.getSipSession().getSessionConfiguration();
+            SimpleSdpMessage sdp = SdpUtils.createSdpForMsrp(configuration.getLocalIpAddress(), false);
+            SIPRequest invite =
+                    SipUtils.buildInvite(
+                            mContext.getSipSession().getSessionConfiguration(),
+                            telUriContact,
+                            mConversationId,
+                            sdp.encode().getBytes(UTF_8));
+            mInviteRequest = invite;
+            mLocalSdp = sdp;
+            Futures.addCallback(
+                    mService.sendSipRequest(invite, this),
+                    new FutureCallback<Boolean>() {
+                        @Override
+                        public void onSuccess(Boolean result) {
+                            Log.i(TAG, "onSuccess:" + result);
+                            if (!result) {
+                                notifyFailure("Failed to send INVITE", CODE_ERROR_UNSPECIFIED);
+                            }
+                        }
+
+                        @Override
+                        public void onFailure(Throwable t) {
+                            Log.i(TAG, "onFailure:" + t.getMessage());
+                            notifyFailure("Failed to send INVITE", CODE_ERROR_UNSPECIFIED);
+                        }
+                    },
+                    MoreExecutors.directExecutor());
+        } catch (ParseException e) {
+            Log.e(TAG, e.getMessage());
+            e.printStackTrace();
+            return Futures.immediateFailedFuture(
+                    new ChatServiceException("Failed to build INVITE"));
+        }
+
+        return future;
+    }
+
+    /** Start incoming chat session. */
+    ListenableFuture<Void> start(SIPRequest invite) {
+        mInviteRequest = invite;
+        int statusCode = Response.OK;
+        if (!SipUtils.hasSdpContent(invite)) {
+            statusCode = Response.NOT_ACCEPTABLE_HERE;
+        } else {
+            try {
+                mRemoteSdp = SimpleSdpMessage.parse(
+                        new ByteArrayInputStream(invite.getRawContent()));
+            } catch (ParseException | IOException e) {
+                statusCode = Response.BAD_REQUEST;
+            }
+        }
+
+        updateRemoteUri(mInviteRequest);
+
+        // Automatically reply back to the invite by building a pre-canned response.
+        try {
+            SIPResponse response =
+                    SipUtils.buildInviteResponse(
+                            mContext.getSipSession().getSessionConfiguration(), invite, statusCode);
+            return Futures.transform(
+                    mService.sendSipResponse(response, this), result -> null,
+                    MoreExecutors.directExecutor());
+        } catch (ParseException e) {
+            Log.e(TAG, "Exception while building response", e);
+            return Futures.immediateFailedFuture(e);
+        }
+    }
+
+    /** Terminate the current SIP session. */
+    public ListenableFuture<Void> terminate() {
+        if (mInviteRequest == null) {
+            return Futures.immediateFuture(null);
+        }
+        try {
+            if (mMsrpSession != null) {
+                mMsrpSession.terminate();
+            }
+        } catch (IOException e) {
+            return Futures.immediateFailedFuture(
+                    new ChatServiceException(
+                            "Exception while terminating MSRP session", CODE_ERROR_UNSPECIFIED));
+        }
+        try {
+
+            SettableFuture<Void> future = SettableFuture.create();
+            Futures.addCallback(
+                    mService.sendSipRequest(SipUtils.buildBye(mInviteRequest), this),
+                    new FutureCallback<Boolean>() {
+                        @Override
+                        public void onSuccess(Boolean result) {
+                            future.set(null);
+                        }
+
+                        @Override
+                        public void onFailure(Throwable t) {
+                            future.setException(
+                                    new ChatServiceException("Failed to send BYE",
+                                            CODE_ERROR_UNSPECIFIED, t));
+                        }
+                    },
+                    MoreExecutors.directExecutor());
+            return future;
+        } catch (ParseException e) {
+            return Futures.immediateFailedFuture(
+                    new ChatServiceException("Failed to build BYE", CODE_ERROR_UNSPECIFIED));
+        }
+    }
+
+    void receiveMessage(Message msg) {
+        if (msg instanceof SIPRequest) {
+            handleSipRequest((SIPRequest) msg);
+        } else {
+            handleSipResponse((SIPResponse) msg);
+        }
+    }
+
+    private void handleSipRequest(SIPRequest request) {
+        SIPResponse response;
+        if (TextUtils.equals(request.getMethod(), Request.ACK)) {
+            // Terminating session established, start a msrp session.
+            if (mRemoteSdp != null) {
+                startMsrpSession(mRemoteSdp);
+            }
+            return;
+        }
+
+        if (TextUtils.equals(request.getMethod(), Request.BYE)) {
+            response = request.createResponse(Response.OK);
+        } else {
+            // Currently we support only INVITE and BYE.
+            response = request.createResponse(Response.METHOD_NOT_ALLOWED);
+        }
+        Futures.addCallback(
+                mService.sendSipResponse(response, this),
+                new FutureCallback<Boolean>() {
+                    @Override
+                    public void onSuccess(Boolean result) {
+                        if (result) {
+                            Log.d(
+                                    TAG,
+                                    "Response to Call-Id: "
+                                            + response.getCallId().getCallId()
+                                            + " sent successfully");
+                        } else {
+                            Log.d(TAG, "Failed to send response");
+                        }
+                    }
+
+                    @Override
+                    public void onFailure(Throwable t) {
+                        Log.d(TAG, "Exception while sending response: ", t);
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private void handleSipResponse(SIPResponse response) {
+        int code = response.getStatusCode();
+
+        // Nothing to do for a provisional response.
+        if (response.isFinalResponse()) {
+            if (code == Response.OK) {
+                handle200OK(response);
+            } else {
+                handleNon200(response);
+            }
+        }
+    }
+
+    private void handleNon200(SIPResponse response) {
+        Log.d(TAG, "Received error response code=" + response.getStatusCode());
+        notifyFailure("Received non-200 INVITE response", CODE_ERROR_UNSPECIFIED);
+    }
+
+    private void handle200OK(SIPResponse response) {
+        if (!SipUtils.hasSdpContent(response)) {
+            notifyFailure("Content is not a SDP", CODE_ERROR_UNSPECIFIED);
+            return;
+        }
+
+        try {
+            SimpleSdpMessage sdp =
+                    SimpleSdpMessage.parse(new ByteArrayInputStream(response.getRawContent()));
+            startMsrpSession(sdp);
+        } catch (ParseException | IOException e) {
+            notifyFailure("Invalid SDP in INVITE", CODE_ERROR_UNSPECIFIED);
+        }
+
+        if (mInviteRequest != null) {
+            SIPRequest ack = mInviteRequest.createAckRequest((To) response.getToHeader());
+            Futures.addCallback(
+                    mService.sendSipRequest(ack, this),
+                    new FutureCallback<Boolean>() {
+                        @Override
+                        public void onSuccess(Boolean result) {
+                            if (result) {
+                                mStartFuture.set(null);
+                                mStartFuture = null;
+                            } else {
+                                notifyFailure("Failed to send ACK", CODE_ERROR_UNSPECIFIED);
+                            }
+                        }
+
+                        @Override
+                        public void onFailure(Throwable t) {
+                            notifyFailure("Failed to send ACK", CODE_ERROR_UNSPECIFIED);
+                        }
+                    },
+                    MoreExecutors.directExecutor());
+        }
+    }
+
+    private void notifyFailure(String message, @ErrorCode int code) {
+        mStartFuture.setException(new ChatServiceException(message, code));
+        mStartFuture = null;
+    }
+
+    private void startMsrpSession(SimpleSdpMessage remoteSdp) {
+        Log.d(TAG, "Start MSRP session: " + remoteSdp);
+        if (remoteSdp.getAddress().isPresent() && remoteSdp.getPort().isPresent()) {
+            Futures.addCallback(
+                    mMsrpManager.createMsrpSession(
+                            remoteSdp.getAddress().get(), remoteSdp.getPort().getAsInt(),
+                            this::receiveMsrpChunk),
+                    new FutureCallback<MsrpSession>() {
+                        @Override
+                        public void onSuccess(MsrpSession result) {
+                            mMsrpSession = result;
+                        }
+
+                        @Override
+                        public void onFailure(Throwable t) {
+                            Log.e(TAG, "Failed to create msrp session", t);
+                            terminate()
+                                    .addListener(
+                                            () -> {
+                                                Log.d(TAG, "Session terminated");
+                                            },
+                                            MoreExecutors.directExecutor());
+                        }
+                    },
+                    MoreExecutors.directExecutor());
+        } else {
+            Log.e(TAG, "Address or port is not present");
+        }
+    }
+
+    private void receiveMsrpChunk(MsrpChunk chunk) {
+        Log.d(TAG, "Received msrp= " + chunk + " conversation=" + mConversationId);
+        if (mListener != null) {
+            // TODO(b/173186571): Parse CPIM and invoke onMessageReceived()
+        }
+    }
+
+    /** Set new listener for this session. */
+    public void setListener(@Nullable ChatSessionListener listener) {
+        mListener = listener;
+    }
+
+    private void updateRemoteUri(SIPRequest request) {
+        PAssertedIdentityHeader pAssertedIdentityHeader =
+                (PAssertedIdentityHeader) request.getHeader("P-Asserted-Identity");
+        if (pAssertedIdentityHeader == null) {
+            mRemoteUri = request.getFrom().getAddress().getURI();
+        } else {
+            mRemoteUri = pAssertedIdentityHeader.getAddress().getURI();
+        }
+    }
+}
diff --git a/tests/Android.bp b/tests/Android.bp
index 13c0dc8..4eacf6d 100644
--- a/tests/Android.bp
+++ b/tests/Android.bp
@@ -33,9 +33,13 @@
     instrumentation_for: "TeleService",
 
     static_libs: [
+        "androidx.test.core",
+        "androidx.test.espresso.core",
+        "androidx.test.ext.junit",
         "androidx.test.rules",
         "mockito-target-minus-junit4",
-        "androidx.test.espresso.core",
+        "telephony-common-testing",
+        "testng",
         "truth-prebuilt",
 	"testables",
     ],
@@ -46,3 +50,4 @@
     ],
 
 }
+
diff --git a/tests/src/com/android/phone/RcsProvisioningMonitorTest.java b/tests/src/com/android/phone/RcsProvisioningMonitorTest.java
index 02d2f8a..6c36c2c 100644
--- a/tests/src/com/android/phone/RcsProvisioningMonitorTest.java
+++ b/tests/src/com/android/phone/RcsProvisioningMonitorTest.java
@@ -38,6 +38,7 @@
 import android.content.ContentValues;
 import android.content.Context;
 import android.content.Intent;
+import android.content.pm.PackageManager;
 import android.content.res.Resources;
 import android.database.Cursor;
 import android.net.Uri;
@@ -138,6 +139,8 @@
     private PhoneGlobals mPhone;
     @Mock
     private IRcsConfigCallback mCallback;
+    @Mock
+    private PackageManager mPackageManager;
 
     private Executor mExecutor = new Executor() {
         @Override
@@ -181,8 +184,9 @@
         MockitoAnnotations.initMocks(this);
 
         when(mPhone.getResources()).thenReturn(mResources);
-        when(mResources.getBoolean(
-                eq(R.bool.config_rcsVolteSingleRegistrationEnabled))).thenReturn(true);
+        when(mPhone.getPackageManager()).thenReturn(mPackageManager);
+        when(mPackageManager.hasSystemFeature(
+                eq(PackageManager.FEATURE_TELEPHONY_IMS_SINGLE_REGISTRATION))).thenReturn(true);
         when(mPhone.getMainExecutor()).thenReturn(mExecutor);
         when(mPhone.getSystemServiceName(eq(CarrierConfigManager.class)))
                 .thenReturn(Context.CARRIER_CONFIG_SERVICE);
@@ -361,8 +365,8 @@
     @SmallTest
     public void testCarrierConfigChanged() throws Exception {
         createMonitor(1);
-        when(mResources.getBoolean(
-                eq(R.bool.config_rcsVolteSingleRegistrationEnabled))).thenReturn(true);
+        when(mPackageManager.hasSystemFeature(
+                eq(PackageManager.FEATURE_TELEPHONY_IMS_SINGLE_REGISTRATION))).thenReturn(true);
         ArgumentCaptor<Intent> captorIntent = ArgumentCaptor.forClass(Intent.class);
         mBundle.putBoolean(
                 CarrierConfigManager.Ims.KEY_IMS_SINGLE_REGISTRATION_REQUIRED_BOOL, true);
@@ -391,8 +395,8 @@
                 capturedIntent.getIntExtra(ProvisioningManager.EXTRA_STATUS, -1));
 
 
-        when(mResources.getBoolean(
-                eq(R.bool.config_rcsVolteSingleRegistrationEnabled))).thenReturn(false);
+        when(mPackageManager.hasSystemFeature(
+                eq(PackageManager.FEATURE_TELEPHONY_IMS_SINGLE_REGISTRATION))).thenReturn(false);
         broadcastCarrierConfigChange(FAKE_SUB_ID_BASE);
         processAllMessages();
         verify(mPhone, atLeastOnce()).sendBroadcast(captorIntent.capture());
@@ -441,8 +445,8 @@
     public void testIsRcsVolteSingleRegistrationEnabled() throws Exception {
         createMonitor(1);
 
-        when(mResources.getBoolean(
-                eq(R.bool.config_rcsVolteSingleRegistrationEnabled))).thenReturn(true);
+        when(mPackageManager.hasSystemFeature(
+                eq(PackageManager.FEATURE_TELEPHONY_IMS_SINGLE_REGISTRATION))).thenReturn(true);
         mBundle.putBoolean(
                 CarrierConfigManager.Ims.KEY_IMS_SINGLE_REGISTRATION_REQUIRED_BOOL, true);
         broadcastCarrierConfigChange(FAKE_SUB_ID_BASE);
@@ -456,8 +460,8 @@
         assertFalse(mRcsProvisioningMonitor.isRcsVolteSingleRegistrationEnabled(FAKE_SUB_ID_BASE));
 
 
-        when(mResources.getBoolean(
-                eq(R.bool.config_rcsVolteSingleRegistrationEnabled))).thenReturn(false);
+        when(mPackageManager.hasSystemFeature(
+                eq(PackageManager.FEATURE_TELEPHONY_IMS_SINGLE_REGISTRATION))).thenReturn(false);
         mBundle.putBoolean(
                 CarrierConfigManager.Ims.KEY_IMS_SINGLE_REGISTRATION_REQUIRED_BOOL, true);
         broadcastCarrierConfigChange(FAKE_SUB_ID_BASE);
diff --git a/tests/src/com/android/phone/SimPhonebookProviderTest.java b/tests/src/com/android/phone/SimPhonebookProviderTest.java
new file mode 100644
index 0000000..1d48694
--- /dev/null
+++ b/tests/src/com/android/phone/SimPhonebookProviderTest.java
@@ -0,0 +1,1490 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.phone;
+
+import static android.provider.SimPhonebookContract.ElementaryFiles.EF_ADN;
+import static android.provider.SimPhonebookContract.ElementaryFiles.EF_FDN;
+import static android.provider.SimPhonebookContract.ElementaryFiles.EF_SDN;
+
+import static com.android.internal.telephony.testing.CursorSubject.assertThat;
+import static com.android.internal.telephony.testing.TelephonyAssertions.assertThrows;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.SimPhonebookContract;
+import android.provider.SimPhonebookContract.ElementaryFiles;
+import android.provider.SimPhonebookContract.SimRecords;
+import android.telephony.SubscriptionInfo;
+import android.telephony.SubscriptionManager;
+import android.util.Pair;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.rule.provider.ProviderTestRule;
+
+import com.android.internal.telephony.IIccPhoneBook;
+import com.android.internal.telephony.uicc.AdnRecord;
+import com.android.internal.telephony.uicc.IccConstants;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.Correspondence;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.AdditionalAnswers;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+@RunWith(AndroidJUnit4.class)
+public final class SimPhonebookProviderTest {
+
+    // Emojis aren't currently supported for the ADN record label.
+    private static final String EMOJI = new String(Character.toChars(0x1F642));
+    private static final String UNSUPPORTED_NAME = ":)=" + EMOJI + ";ni=日;hon=本;";
+    private static final String UNSUPPORTED_NAME2 = "日本" + EMOJI;
+    private static final Correspondence<AdnRecord, AdnRecord> ADN_RECORD_IS_EQUAL =
+            Correspondence.from(AdnRecord::isEqual, "isEqual");
+
+    @Rule
+    public final ProviderTestRule mProviderRule = new ProviderTestRule.Builder(
+            TestableSimPhonebookProvider.class, SimPhonebookContract.AUTHORITY).build();
+
+    private ContentResolver mResolver;
+    private FakeIccPhoneBook mIccPhoneBook;
+    private SubscriptionManager mMockSubscriptionManager;
+
+    private static List<SubscriptionInfo> createSubscriptionsWithIds(int... subscriptionIds) {
+        ImmutableList.Builder<SubscriptionInfo> builder = ImmutableList.builderWithExpectedSize(
+                subscriptionIds.length);
+        for (int i = 0; i < subscriptionIds.length; i++) {
+            builder.add(createSubscriptionInfo(i, subscriptionIds[i]));
+        }
+        return builder.build();
+    }
+
+    private static SubscriptionInfo createSubscriptionInfo(int slotIndex, int subscriptiondId) {
+        return new SubscriptionInfo(
+                subscriptiondId, "", slotIndex, null, null, 0, 0, null, 0, null, null, null, null,
+                false, null, null);
+    }
+
+    @Before
+    public void setUp() {
+        mMockSubscriptionManager = spy(
+                Objects.requireNonNull(ApplicationProvider.getApplicationContext()
+                        .getSystemService(SubscriptionManager.class)));
+        mIccPhoneBook = new FakeIccPhoneBook();
+        mResolver = mProviderRule.getResolver();
+
+        TestableSimPhonebookProvider.setup(mResolver, mMockSubscriptionManager, mIccPhoneBook);
+    }
+
+    @Test
+    public void query_entityFiles_returnsCursorWithCorrectProjection() {
+        // Empty projection
+        try (Cursor cursor = mResolver.query(ElementaryFiles.CONTENT_URI, new String[0], null,
+                null)) {
+            assertThat(cursor).hasColumnNames();
+        }
+
+        // Single column
+        try (Cursor cursor = mResolver.query(ElementaryFiles.CONTENT_URI, new String[]{
+                ElementaryFiles.EF_TYPE
+        }, null, null)) {
+            assertThat(cursor).hasColumnNames(ElementaryFiles.EF_TYPE);
+        }
+
+        // Duplicate column
+        try (Cursor cursor = mResolver.query(ElementaryFiles.CONTENT_URI, new String[]{
+                ElementaryFiles.SUBSCRIPTION_ID, ElementaryFiles.SUBSCRIPTION_ID
+        }, null, null)) {
+            assertThat(cursor).hasColumnNames(ElementaryFiles.SUBSCRIPTION_ID,
+                    ElementaryFiles.SUBSCRIPTION_ID);
+        }
+
+        // Random order of all columns
+        String[] projection = Arrays.copyOf(
+                SimPhonebookProvider.ELEMENTARY_FILES_ALL_COLUMNS,
+                SimPhonebookProvider.ELEMENTARY_FILES_ALL_COLUMNS.length);
+        Collections.shuffle(Arrays.asList(projection));
+        try (Cursor cursor = mResolver.query(ElementaryFiles.CONTENT_URI, projection, null, null)) {
+            assertThat(cursor).hasColumnNames(projection);
+        }
+    }
+
+    @Test
+    public void query_entityFiles_unrecognizedColumn_throwsIllegalArgumentException() {
+        assertThrows(IllegalArgumentException.class, () ->
+                mResolver.query(ElementaryFiles.CONTENT_URI, new String[]{"invalid_column"}, null,
+                        null));
+    }
+
+    @Test
+    public void query_entityFiles_noSim_returnsEmptyCursor() {
+        when(mMockSubscriptionManager.getActiveSubscriptionInfoList()).thenReturn(
+                ImmutableList.of());
+
+        try (Cursor cursor = mResolver.query(ElementaryFiles.CONTENT_URI, null, null, null)) {
+            assertThat(cursor).hasCount(0);
+        }
+    }
+
+    @Test
+    public void query_entityFiles_multiSim_returnsCursorWithRowForEachSimEf() {
+        setupSimsWithSubscriptionIds(2, 3, 7);
+
+        mIccPhoneBook.setRecordsSize(2, IccConstants.EF_ADN, 10, 25);
+        mIccPhoneBook.setRecordsSize(2, IccConstants.EF_FDN, 5, 20);
+        mIccPhoneBook.setRecordsSize(2, IccConstants.EF_SDN, 15, 20);
+        mIccPhoneBook.setRecordsSize(3, IccConstants.EF_ADN, 100, 30);
+        // These Will be omitted from results because zero size indicates the EF is not supported.
+        mIccPhoneBook.setRecordsSize(3, IccConstants.EF_FDN, 0, 0);
+        mIccPhoneBook.setRecordsSize(3, IccConstants.EF_SDN, 0, 0);
+        mIccPhoneBook.setRecordsSize(7, IccConstants.EF_ADN, 0, 0);
+        mIccPhoneBook.setRecordsSize(7, IccConstants.EF_FDN, 0, 0);
+        mIccPhoneBook.setRecordsSize(7, IccConstants.EF_SDN, 0, 0);
+
+        String[] projection = {
+                ElementaryFiles.SLOT_INDEX, ElementaryFiles.SUBSCRIPTION_ID,
+                ElementaryFiles.EF_TYPE, ElementaryFiles.MAX_RECORDS,
+                ElementaryFiles.NAME_MAX_LENGTH, ElementaryFiles.PHONE_NUMBER_MAX_LENGTH
+        };
+        try (Cursor cursor = mResolver.query(ElementaryFiles.CONTENT_URI, projection, null, null)) {
+            assertThat(cursor).hasColumnNames(projection);
+
+            assertThat(cursor)
+                    .atRow(0).hasRowValues(0, 2, ElementaryFiles.EF_ADN, 10, 11, 20)
+                    .atRow(1).hasRowValues(0, 2, ElementaryFiles.EF_FDN, 5, 6, 20)
+                    .atRow(2).hasRowValues(0, 2, ElementaryFiles.EF_SDN, 15, 6, 20)
+                    .atRow(3).hasRowValues(1, 3, ElementaryFiles.EF_ADN, 100, 16, 20);
+        }
+    }
+
+    @Test
+    public void query_entityFiles_simWithZeroSizes_returnsEmptyCursor() {
+        setupSimsWithSubscriptionIds(1);
+
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 0, 0);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_FDN, 0, 0);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_SDN, 0, 0);
+
+        try (Cursor cursor = mResolver.query(ElementaryFiles.CONTENT_URI, null, null, null)) {
+            assertThat(cursor).hasCount(0);
+        }
+    }
+
+    @Test
+    public void query_adnRecords_returnsCursorWithMatchingProjection() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+        Uri contentAdn = SimRecords.getContentUri(1, EF_ADN);
+
+        // Empty projection
+        try (Cursor cursor = mResolver.query(contentAdn, new String[0], null, null)) {
+            assertThat(cursor).hasColumnNames();
+        }
+
+        // Single column
+        try (Cursor cursor = mResolver.query(contentAdn, new String[] {
+                SimRecords.PHONE_NUMBER
+        }, null, null)) {
+            assertThat(cursor).hasColumnNames(SimRecords.PHONE_NUMBER);
+        }
+
+        // Duplicate column
+        try (Cursor cursor = mResolver.query(contentAdn, new String[]{
+                SimRecords.PHONE_NUMBER, SimRecords.PHONE_NUMBER
+        }, null, null)) {
+            assertThat(cursor).hasColumnNames(SimRecords.PHONE_NUMBER, SimRecords.PHONE_NUMBER);
+        }
+
+        // Random order of all columns
+        String[] projection = Arrays.copyOf(
+                SimPhonebookProvider.SIM_RECORDS_ALL_COLUMNS,
+                SimPhonebookProvider.SIM_RECORDS_ALL_COLUMNS.length);
+        Collections.shuffle(Arrays.asList(projection));
+        try (Cursor cursor = mResolver.query(contentAdn, projection, null, null)) {
+            assertThat(cursor).hasColumnNames(projection);
+        }
+    }
+
+    @Test
+    public void query_adnRecords_noRecords_returnsEmptyCursor() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+
+        try (Cursor cursor = mResolver.query(SimRecords.getContentUri(1, EF_ADN), null, null,
+                null)) {
+            assertThat(cursor).hasCount(0);
+        }
+    }
+
+    @Test
+    public void query_simRecords_nullRecordList_returnsEmptyCursor() throws Exception {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+        // Use a mock so that a null list can be returned
+        IIccPhoneBook mockIccPhoneBook = mock(
+                IIccPhoneBook.class, AdditionalAnswers.delegatesTo(mIccPhoneBook));
+        when(mockIccPhoneBook.getAdnRecordsInEfForSubscriber(anyInt(), anyInt())).thenReturn(null);
+        TestableSimPhonebookProvider.setup(mResolver, mMockSubscriptionManager, mockIccPhoneBook);
+
+        try (Cursor adnCursor = mResolver.query(SimRecords.getContentUri(1, EF_ADN), null, null,
+                null);
+             Cursor fdnCursor = mResolver.query(SimRecords.getContentUri(1, EF_FDN), null, null,
+                     null);
+             Cursor sdnCursor = mResolver.query(SimRecords.getContentUri(1, EF_SDN), null, null,
+                     null)
+        ) {
+            assertThat(adnCursor).hasCount(0);
+            assertThat(fdnCursor).hasCount(0);
+            assertThat(sdnCursor).hasCount(0);
+        }
+    }
+
+    @Test
+    public void query_simRecords_singleSim_returnsDataForCorrectEf() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.addRecord(1, IccConstants.EF_ADN, "Person Adn1", "8005550101");
+        mIccPhoneBook.addRecord(1, IccConstants.EF_ADN, "Person Adn2", "8005550102");
+        mIccPhoneBook.addRecord(1, IccConstants.EF_FDN, "Person Fdn", "8005550103");
+        mIccPhoneBook.addRecord(1, IccConstants.EF_SDN, "Person Sdn", "8005550104");
+        mIccPhoneBook.setDefaultSubscriptionId(1);
+
+        String[] projection = {
+                SimRecords.SUBSCRIPTION_ID,
+                SimRecords.ELEMENTARY_FILE_TYPE,
+                SimRecords.RECORD_NUMBER,
+                SimRecords.NAME,
+                SimRecords.PHONE_NUMBER
+        };
+        try (Cursor adnCursor = mResolver.query(SimRecords.getContentUri(1, EF_ADN),
+                projection, null, null);
+             Cursor fdnCursor = mResolver.query(SimRecords.getContentUri(1, EF_FDN),
+                     projection, null, null);
+             Cursor sdnCursor = mResolver.query(SimRecords.getContentUri(1, EF_SDN),
+                     projection, null, null)
+        ) {
+
+            assertThat(adnCursor)
+                    .atRow(0).hasRowValues(1, ElementaryFiles.EF_ADN, 1, "Person Adn1",
+                    "8005550101")
+                    .atRow(1).hasRowValues(1, ElementaryFiles.EF_ADN, 2, "Person Adn2",
+                    "8005550102");
+            assertThat(fdnCursor)
+                    .atRow(0).hasRowValues(1, ElementaryFiles.EF_FDN, 1, "Person Fdn",
+                    "8005550103");
+            assertThat(sdnCursor)
+                    .atRow(0).hasRowValues(1, ElementaryFiles.EF_SDN, 1, "Person Sdn",
+                    "8005550104");
+        }
+    }
+
+    @Test
+    public void query_adnRecords_returnsAdnData() {
+        setupSimsWithSubscriptionIds(1, 2, 4);
+        mIccPhoneBook.addRecord(1, IccConstants.EF_ADN, "Person Sim1", "8005550101");
+        mIccPhoneBook.addRecord(1, IccConstants.EF_FDN, "Omitted Sim1", "8005550199");
+        mIccPhoneBook.addRecord(2, IccConstants.EF_ADN, "Person Sim2a", "8005550103");
+        mIccPhoneBook.addRecord(2, IccConstants.EF_ADN, "Person Sim2b", "8005550104");
+        mIccPhoneBook.addRecord(2, IccConstants.EF_ADN, "Person Sim2c", "8005550105");
+        mIccPhoneBook.addRecord(2, IccConstants.EF_SDN, "Omitted Sim2", "8005550198");
+        mIccPhoneBook.addRecord(4, IccConstants.EF_ADN, "Person Sim4", "8005550106");
+        mIccPhoneBook.setDefaultSubscriptionId(1);
+
+        String[] projection = {
+                SimRecords.SUBSCRIPTION_ID,
+                SimRecords.ELEMENTARY_FILE_TYPE,
+                SimRecords.RECORD_NUMBER,
+                SimRecords.NAME,
+                SimRecords.PHONE_NUMBER
+        };
+        try (Cursor cursorSim1 = mResolver.query(SimRecords.getContentUri(1, EF_ADN),
+                projection, null, null);
+             Cursor cursorSim2 = mResolver.query(SimRecords.getContentUri(2, EF_ADN),
+                     projection, null, null);
+             Cursor cursorSim4 = mResolver.query(SimRecords.getContentUri(4, EF_ADN),
+                     projection, null, null)
+        ) {
+
+            assertThat(cursorSim1).hasData(new Object[][]{
+                    {1, ElementaryFiles.EF_ADN, 1, "Person Sim1", "8005550101"},
+            });
+            assertThat(cursorSim2).hasData(new Object[][]{
+                    {2, ElementaryFiles.EF_ADN, 1, "Person Sim2a", "8005550103"},
+                    {2, ElementaryFiles.EF_ADN, 2, "Person Sim2b", "8005550104"},
+                    {2, ElementaryFiles.EF_ADN, 3, "Person Sim2c", "8005550105"},
+            });
+            assertThat(cursorSim4).hasData(new Object[][]{
+                    {4, ElementaryFiles.EF_ADN, 1, "Person Sim4", "8005550106"},
+            });
+        }
+    }
+
+    @Test
+    public void query_fdnRecords_returnsFdnData() {
+        setupSimsWithSubscriptionIds(1, 2, 4);
+        mIccPhoneBook.makeAllEfsSupported(1, 2, 4);
+        mIccPhoneBook.addRecord(1, IccConstants.EF_ADN, "Person Sim1", "8005550101");
+        mIccPhoneBook.addRecord(2, IccConstants.EF_ADN, "Person Sim2a", "8005550103");
+        mIccPhoneBook.addRecord(2, IccConstants.EF_FDN, "Person Sim2b", "8005550104");
+        mIccPhoneBook.addRecord(2, IccConstants.EF_FDN, "Person Sim2c", "8005550105");
+        mIccPhoneBook.addRecord(4, IccConstants.EF_SDN, "Person Sim4", "8005550106");
+        mIccPhoneBook.setDefaultSubscriptionId(1);
+
+        String[] projection = {
+                SimRecords.SUBSCRIPTION_ID,
+                SimRecords.ELEMENTARY_FILE_TYPE,
+                SimRecords.RECORD_NUMBER,
+                SimRecords.NAME,
+                SimRecords.PHONE_NUMBER
+        };
+        try (Cursor cursorSim1Fdn = mResolver.query(SimRecords.getContentUri(1, EF_FDN),
+                projection, null, null);
+             Cursor cursorSim2Fdn = mResolver.query(SimRecords.getContentUri(2, EF_FDN),
+                     projection, null, null);
+             Cursor cursorSim4Fdn = mResolver.query(SimRecords.getContentUri(4, EF_FDN),
+                     projection, null, null)
+        ) {
+
+            assertThat(cursorSim1Fdn).hasCount(0);
+            assertThat(cursorSim2Fdn).hasData(new Object[][]{
+                    {2, ElementaryFiles.EF_FDN, 1, "Person Sim2b", "8005550104"},
+                    {2, ElementaryFiles.EF_FDN, 2, "Person Sim2c", "8005550105"},
+            });
+            assertThat(cursorSim4Fdn).hasCount(0);
+        }
+    }
+
+    @Test
+    public void query_sdnRecords_returnsSdnData() {
+        setupSimsWithSubscriptionIds(1, 2, 4);
+        mIccPhoneBook.makeAllEfsSupported(1, 2, 4);
+        mIccPhoneBook.addRecord(1, IccConstants.EF_ADN, "Person Adn1", "8005550101");
+        mIccPhoneBook.addRecord(1, IccConstants.EF_FDN, "Person Fdn1", "8005550102");
+        mIccPhoneBook.addRecord(1, IccConstants.EF_SDN, "Person Sdn1", "8005550103");
+        mIccPhoneBook.addRecord(2, IccConstants.EF_ADN, "Person Adn2a", "8005550104");
+        mIccPhoneBook.addRecord(2, IccConstants.EF_FDN, "Person Fdn2b", "8005550105");
+        mIccPhoneBook.addRecord(4, IccConstants.EF_SDN, "Person Sdn4a", "8005550106");
+        mIccPhoneBook.addRecord(4, IccConstants.EF_SDN, "Person Sdn4b", "8005550107");
+        mIccPhoneBook.setDefaultSubscriptionId(1);
+
+        String[] projection = {
+                SimRecords.SUBSCRIPTION_ID,
+                SimRecords.ELEMENTARY_FILE_TYPE,
+                SimRecords.RECORD_NUMBER,
+                SimRecords.NAME,
+                SimRecords.PHONE_NUMBER
+        };
+        try (Cursor cursorSim1Sdn = mResolver.query(SimRecords.getContentUri(1, EF_SDN),
+                projection, null, null);
+             Cursor cursorSim2Sdn = mResolver.query(SimRecords.getContentUri(2, EF_SDN),
+                     projection, null, null);
+             Cursor cursorSim4Sdn = mResolver.query(SimRecords.getContentUri(4, EF_SDN),
+                     projection, null, null)
+        ) {
+
+            assertThat(cursorSim1Sdn)
+                    .atRow(0).hasRowValues(1, ElementaryFiles.EF_SDN, 1, "Person Sdn1",
+                    "8005550103");
+            assertThat(cursorSim2Sdn).hasCount(0);
+            assertThat(cursorSim4Sdn)
+                    .atRow(0).hasRowValues(4, ElementaryFiles.EF_SDN, 1, "Person Sdn4a",
+                    "8005550106")
+                    .atRow(1).hasRowValues(4, ElementaryFiles.EF_SDN, 2, "Person Sdn4b",
+                    "8005550107");
+        }
+    }
+
+    @Test
+    public void query_adnRecords_nonExistentSim_throwsCorrectException() {
+        setupSimsWithSubscriptionIds(1);
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.query(SimRecords.getContentUri(123, EF_ADN), null, null, null));
+        assertThat(e).hasMessageThat().isEqualTo("No active SIM with subscription ID 123");
+    }
+
+    @Test
+    public void insert_nonExistentSim_throwsCorrectException() {
+        setupSimsWithSubscriptionIds(1);
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "Name");
+        values.put(SimRecords.PHONE_NUMBER, "123");
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.insert(SimRecords.getContentUri(123, EF_ADN), values));
+        assertThat(e).hasMessageThat().isEqualTo("No active SIM with subscription ID 123");
+    }
+
+    @Test
+    public void update_nonExistentSim_throwsCorrectException() {
+        setupSimsWithSubscriptionIds(1);
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "Name");
+        values.put(SimRecords.PHONE_NUMBER, "123");
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.update(SimRecords.getItemUri(123, EF_ADN, 1), values, null));
+        assertThat(e).hasMessageThat().isEqualTo("No active SIM with subscription ID 123");
+    }
+
+    @Test
+    public void delete_nonExistentSim_throwsCorrectException() {
+        setupSimsWithSubscriptionIds(1);
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.delete(SimRecords.getItemUri(123, EF_ADN, 1), null));
+        assertThat(e).hasMessageThat().isEqualTo("No active SIM with subscription ID 123");
+    }
+
+    @Test
+    public void query_adnRecords_zeroSizeEf_throwsCorrectException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 0, 0);
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.query(SimRecords.getContentUri(1, EF_ADN), null, null, null));
+        assertThat(e).hasMessageThat().isEqualTo(
+                "adn is not supported for SIM with subscription ID 1");
+    }
+
+    @Test
+    public void query_itemUri_returnsCorrectRow() {
+        setupSimsWithSubscriptionIds(1, 2);
+        mIccPhoneBook.addRecord(1,
+                new AdnRecord(IccConstants.EF_ADN, 1, "Name@Adn1[1]", "8005550101"));
+        mIccPhoneBook.addRecord(1,
+                new AdnRecord(IccConstants.EF_ADN, 2, "Name@Adn1[2]", "8005550102"));
+        mIccPhoneBook.addRecord(1,
+                new AdnRecord(IccConstants.EF_ADN, 3, "Name@Adn1[3]", "8005550103"));
+        mIccPhoneBook.addRecord(2,
+                new AdnRecord(IccConstants.EF_ADN, 3, "Name@Adn2[3]", "8005550104"));
+        mIccPhoneBook.addRecord(1,
+                new AdnRecord(IccConstants.EF_FDN, 1, "Name@Fdn1[1]", "8005550105"));
+        mIccPhoneBook.addRecord(2,
+                new AdnRecord(IccConstants.EF_SDN, 1, "Name@Sdn2[1]", "8005550106"));
+
+        String[] projection = {
+                SimRecords.SUBSCRIPTION_ID, SimRecords.ELEMENTARY_FILE_TYPE,
+                SimRecords.RECORD_NUMBER, SimRecords.NAME, SimRecords.PHONE_NUMBER
+        };
+        try (Cursor item1 = mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 1),
+                projection, null, null);
+             Cursor item2 = mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 3),
+                     projection, null, null);
+             Cursor item3 = mResolver.query(SimRecords.getItemUri(2, ElementaryFiles.EF_ADN, 3),
+                     projection, null, null);
+             Cursor item4 = mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_FDN, 1),
+                     projection, null, null);
+             Cursor item5 = mResolver.query(SimRecords.getItemUri(2, ElementaryFiles.EF_SDN, 1),
+                     projection, null, null)
+        ) {
+            assertThat(item1).hasSingleRow(1, ElementaryFiles.EF_ADN, 1, "Name@Adn1[1]",
+                    "8005550101");
+            assertThat(item2).hasSingleRow(1, ElementaryFiles.EF_ADN, 3, "Name@Adn1[3]",
+                    "8005550103");
+            assertThat(item3).hasSingleRow(2, ElementaryFiles.EF_ADN, 3, "Name@Adn2[3]",
+                    "8005550104");
+            assertThat(item4).hasSingleRow(1, ElementaryFiles.EF_FDN, 1, "Name@Fdn1[1]",
+                    "8005550105");
+            assertThat(item5).hasSingleRow(2, ElementaryFiles.EF_SDN, 1, "Name@Sdn2[1]",
+                    "8005550106");
+        }
+    }
+
+    @Test
+    public void query_itemUriEmptyRecord_returnsEmptyCursor() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 1, 30);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_FDN, 1, 30);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_SDN, 1, 30);
+
+        try (Cursor adnItem = mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 1),
+                null, null, null);
+             Cursor fdnItem = mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_FDN, 1),
+                     null, null, null);
+             Cursor sdnItem = mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_SDN, 1),
+                     null, null, null)
+        ) {
+            assertThat(adnItem).hasCount(0);
+            assertThat(fdnItem).hasCount(0);
+            assertThat(sdnItem).hasCount(0);
+        }
+    }
+
+    @Test
+    public void query_itemUriIndexExceedsMax_returnsEmptyCursor() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 1, 30);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_FDN, 1, 30);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_SDN, 1, 30);
+
+        try (Cursor adnItem = mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 2),
+                null, null, null);
+             Cursor fdnItem = mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_FDN, 2),
+                     null, null, null);
+             Cursor sdnItem = mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_SDN, 2),
+                     null, null, null)
+        ) {
+            assertThat(adnItem).hasCount(0);
+            assertThat(fdnItem).hasCount(0);
+            assertThat(sdnItem).hasCount(0);
+        }
+    }
+
+    @Test
+    public void query_invalidItemIndex_throwsIllegalArgumentException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+
+        assertThrows(IllegalArgumentException.class, () ->
+                mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, -1),
+                        null, null, null));
+        assertThrows(IllegalArgumentException.class, () ->
+                mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_FDN, -1),
+                        null, null, null));
+        assertThrows(IllegalArgumentException.class, () ->
+                mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_SDN, -1),
+                        null, null, null));
+        assertThrows(IllegalArgumentException.class, () ->
+                mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 0),
+                        null, null, null));
+        assertThrows(IllegalArgumentException.class, () ->
+                mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_FDN, 0),
+                        null, null, null));
+        assertThrows(IllegalArgumentException.class, () ->
+                mResolver.query(SimRecords.getItemUri(1, ElementaryFiles.EF_SDN, 0),
+                        null, null, null));
+    }
+
+    @Test
+    public void insert_adnRecord_addsAdnRecordAndReturnsUriForNewRecord() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "First Last");
+        values.put(SimRecords.PHONE_NUMBER, "8005550101");
+
+        Uri uri = mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values);
+
+        List<AdnRecord> records = mIccPhoneBook.getAdnRecordsInEfForSubscriber(
+                1, IccConstants.EF_ADN).stream()
+                .filter(((Predicate<AdnRecord>) AdnRecord::isEmpty).negate())
+                .collect(Collectors.toList());
+
+        assertThat(records)
+                .comparingElementsUsing(ADN_RECORD_IS_EQUAL)
+                .containsExactly(new AdnRecord(IccConstants.EF_ADN, 1, "First Last", "8005550101"));
+
+        assertThat(uri).isEqualTo(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 1));
+    }
+
+    @Test
+    public void insert_adnRecordWithExistingRecords_returnsUriWithCorrectIndex() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setDefaultSubscriptionId(1);
+        mIccPhoneBook.addRecord(new AdnRecord(IccConstants.EF_ADN, 2, "Existing1", "8005550101"));
+        mIccPhoneBook.addRecord(new AdnRecord(IccConstants.EF_ADN, 3, "Existing2", "8005550102"));
+        mIccPhoneBook.addRecord(new AdnRecord(IccConstants.EF_ADN, 5, "Existing3", "8005550103"));
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "New1");
+        values.put(SimRecords.PHONE_NUMBER, "8005550104");
+
+        Uri insert1 = mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values);
+        values.put(SimRecords.NAME, "New2");
+        values.put(SimRecords.PHONE_NUMBER, "8005550105");
+        Uri insert2 = mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values);
+        values.put(SimRecords.NAME, "New3");
+        values.put(SimRecords.PHONE_NUMBER, "8005550106");
+        Uri insert3 = mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values);
+
+        assertThat(
+                mIccPhoneBook.getAdnRecordsInEfForSubscriber(1, IccConstants.EF_ADN).subList(0, 6))
+                .comparingElementsUsing(ADN_RECORD_IS_EQUAL)
+                .containsExactly(
+                        new AdnRecord(IccConstants.EF_ADN, 1, "New1", "8005550104"),
+                        new AdnRecord(IccConstants.EF_ADN, 2, "Existing1", "8005550101"),
+                        new AdnRecord(IccConstants.EF_ADN, 3, "Existing2", "8005550102"),
+                        new AdnRecord(IccConstants.EF_ADN, 4, "New2", "8005550105"),
+                        new AdnRecord(IccConstants.EF_ADN, 5, "Existing3", "8005550103"),
+                        new AdnRecord(IccConstants.EF_ADN, 6, "New3", "8005550106"));
+        assertThat(insert1).isEqualTo(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 1));
+        assertThat(insert2).isEqualTo(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 4));
+        assertThat(insert3).isEqualTo(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 6));
+    }
+
+    @Test
+    public void insert_efFull_throwsCorrectException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 1, 30);
+        mIccPhoneBook.addRecord(1, IccConstants.EF_ADN, "Existing", "8005550101");
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "New");
+        values.put(SimRecords.PHONE_NUMBER, "8005550102");
+
+        Uri uri = SimRecords.getContentUri(1, EF_ADN);
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> mResolver.insert(uri, values));
+        assertThat(e).hasMessageThat().isEqualTo(
+                uri + " is full. Please delete records to add new ones.");
+    }
+
+    @Test
+    public void insert_nullValues_returnsNull() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+
+        Uri result = mResolver.insert(SimRecords.getContentUri(1, EF_ADN), null);
+
+        assertThat(result).isNull();
+    }
+
+    @Test
+    public void update_nullValues_returnsZero() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+        mIccPhoneBook.addAdnRecord(1, "Name", "5550101");
+
+        int result = mResolver.update(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 1), null,
+                null);
+
+        assertThat(result).isEqualTo(0);
+    }
+
+    @Test
+    public void insert_emptyValues_returnsNull() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+
+        Uri result = mResolver.insert(SimRecords.getContentUri(1, EF_ADN), new ContentValues());
+
+        assertThat(result).isNull();
+    }
+
+    @Test
+    public void insert_nameOmitted_createsRecordWithJustPhoneNumber() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+
+        ContentValues values = new ContentValues();
+        // No name
+        values.put(SimRecords.PHONE_NUMBER, "18005550101");
+
+        mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values);
+
+        // Null name
+        values.putNull(SimRecords.NAME);
+        values.put(SimRecords.PHONE_NUMBER, "18005550102");
+        mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values);
+
+        // Empty name
+        values.put(SimRecords.NAME, "");
+        values.put(SimRecords.PHONE_NUMBER, "18005550103");
+        mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values);
+
+        assertThat(mIccPhoneBook.getAllValidRecords())
+                .comparingElementsUsing(ADN_RECORD_IS_EQUAL)
+                .containsExactly(
+                        new AdnRecord(IccConstants.EF_ADN, 1, "", "18005550101"),
+                        new AdnRecord(IccConstants.EF_ADN, 2, "", "18005550102"),
+                        new AdnRecord(IccConstants.EF_ADN, 3, "", "18005550103"));
+    }
+
+    @Test
+    public void insert_phoneNumberOmitted_throwsCorrectException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 1, 25);
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "Name");
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values));
+        assertThat(e).hasMessageThat().isEqualTo(SimRecords.PHONE_NUMBER + " is required.");
+    }
+
+    @Test
+    public void insert_nameTooLong_throwsCorrectException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 1, 25);
+
+        ContentValues values = new ContentValues();
+        // Name is limited to 11 characters
+        values.put(SimRecords.NAME, "1234567890ab");
+        values.put(SimRecords.PHONE_NUMBER, "8005550102");
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values));
+
+        assertThat(e).hasMessageThat().isEqualTo(SimRecords.NAME + " is too long.");
+    }
+
+    @Test
+    public void insert_phoneNumberTooLong_throwsCorrectException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 1, 25);
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "Name");
+        // 21 digits is longer than max of 20
+        values.put(SimRecords.PHONE_NUMBER, "123456789012345678901");
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values));
+
+        assertThat(e).hasMessageThat().isEqualTo(SimRecords.PHONE_NUMBER + " is too long.");
+    }
+
+    @Test
+    public void insert_illegalCharacters_throwsCorrectException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 1, 32);
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "Name");
+        values.put(SimRecords.PHONE_NUMBER, "1800J550A0B");
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values));
+        assertThat(e).hasMessageThat().isEqualTo(
+                SimRecords.PHONE_NUMBER + " contains unsupported characters.");
+
+        values.put(SimRecords.NAME, UNSUPPORTED_NAME);
+        values.put(SimRecords.PHONE_NUMBER, "18005550101");
+
+        e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values));
+        assertThat(e).hasMessageThat().isEqualTo(
+                SimRecords.NAME + " contains unsupported characters.");
+
+        values.put(SimRecords.NAME, UNSUPPORTED_NAME2);
+        values.put(SimRecords.PHONE_NUMBER, "18005550101");
+
+        e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values));
+        assertThat(e).hasMessageThat().isEqualTo(
+                SimRecords.NAME + " contains unsupported characters.");
+
+        // The inserts didn't actually add any data.
+        assertThat(mIccPhoneBook.getAllValidRecords()).isEmpty();
+    }
+
+    @Test
+    public void insert_unsupportedColumn_throwsCorrectException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 1, 25);
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "Name");
+        values.put(SimRecords.PHONE_NUMBER, "18005550101");
+        values.put(SimRecords.RECORD_NUMBER, 8);
+        values.put("extra_phone2", "18005550102");
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values));
+        assertThat(e).hasMessageThat().isEqualTo("Unsupported columns: "
+                + SimRecords.RECORD_NUMBER + ",extra_phone2");
+    }
+
+    @Test
+    public void update_existingRecord_updatesRecord() {
+        setupSimsWithSubscriptionIds(1, 2);
+        AdnRecord[] unchanged = new AdnRecord[]{
+                new AdnRecord(IccConstants.EF_ADN, 3, "Other1", "8005550102"),
+                new AdnRecord(IccConstants.EF_ADN, 2, "Other2", "8005550103"),
+                new AdnRecord(IccConstants.EF_FDN, 2, "Other3", "8005550104")
+        };
+        mIccPhoneBook.addRecord(1, unchanged[0]);
+        mIccPhoneBook.addRecord(2, unchanged[1]);
+        mIccPhoneBook.addRecord(2, unchanged[2]);
+        mIccPhoneBook.addRecord(1,
+                new AdnRecord(IccConstants.EF_ADN, 2, "Initial Name", "8005550101"));
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "Updated Name");
+        values.put(SimRecords.PHONE_NUMBER, "8005550105");
+
+        int result = mResolver.update(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 2), values,
+                null);
+
+        assertThat(result).isEqualTo(1);
+
+        List<AdnRecord> finalRecords = mIccPhoneBook.getAllValidRecords();
+
+        assertThat(finalRecords).comparingElementsUsing(ADN_RECORD_IS_EQUAL)
+                .containsAtLeastElementsIn(unchanged);
+        assertThat(finalRecords).comparingElementsUsing(ADN_RECORD_IS_EQUAL)
+                .doesNotContain(
+                        new AdnRecord(IccConstants.EF_ADN, 2, "Initial Name", "80005550101"));
+        assertThat(finalRecords).comparingElementsUsing(ADN_RECORD_IS_EQUAL)
+                .contains(new AdnRecord(IccConstants.EF_ADN, 2, "Updated Name", "8005550105"));
+    }
+
+    @Test
+    public void update_emptyRecord_updatesRecord() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "name");
+        values.put(SimRecords.PHONE_NUMBER, "18005550101");
+        // No record actually exists with record number 10 but we allow clients to update it
+        // as a way to set the information at a specific record number.
+        int result = mResolver.update(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 10),
+                values, null);
+
+        assertThat(result).isEqualTo(1);
+        List<AdnRecord> finalRecords = mIccPhoneBook.getAllValidRecords();
+        assertThat(finalRecords).comparingElementsUsing(ADN_RECORD_IS_EQUAL)
+                .containsExactly(new AdnRecord(IccConstants.EF_ADN, 10, "name", "18005550101"));
+    }
+
+    @Test
+    public void delete_existingRecord_deletesRecord() {
+        setupSimsWithSubscriptionIds(1, 2);
+        AdnRecord[] unchanged = new AdnRecord[]{
+                new AdnRecord(IccConstants.EF_ADN, 3, "Other1", "8005550102"),
+                new AdnRecord(IccConstants.EF_ADN, 2, "Other2", "8005550103"),
+                new AdnRecord(IccConstants.EF_FDN, 2, "Other3", "8005550104")
+        };
+        mIccPhoneBook.addRecord(1,
+                new AdnRecord(IccConstants.EF_ADN, 2, "Initial Name", "8005550101"));
+        mIccPhoneBook.addRecord(1, unchanged[0]);
+        mIccPhoneBook.addRecord(2, unchanged[1]);
+        mIccPhoneBook.addRecord(2, unchanged[2]);
+
+        int result = mResolver.delete(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 2), null);
+
+        assertThat(result).isEqualTo(1);
+
+        assertThat(mIccPhoneBook.getAllValidRecords()).comparingElementsUsing(ADN_RECORD_IS_EQUAL)
+                .containsExactlyElementsIn(unchanged);
+    }
+
+    @Test
+    public void update_indexExceedingMax_returnsZero() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 1, 30);
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "name");
+        values.put(SimRecords.PHONE_NUMBER, "18005551010");
+        int result = mResolver.update(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 2),
+                values, null);
+
+        assertThat(result).isEqualTo(0);
+    }
+
+    @Test
+    public void update_indexOverflow_throwsIllegalArgumentException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "name");
+        values.put(SimRecords.PHONE_NUMBER, "18005551010");
+        assertThrows(IllegalArgumentException.class, () -> mResolver.update(
+                SimRecords.getContentUri(1, EF_ADN).buildUpon().appendPath(
+                        String.valueOf((Long.MAX_VALUE))).build(),
+                values, null));
+    }
+
+    @Test
+    public void delete_emptyRecord_returnsZero() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+
+        int result = mResolver.delete(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 2), null);
+
+        assertThat(result).isEqualTo(0);
+    }
+
+    @Test
+    public void delete_indexExceedingMax_returnsZero() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 1, 30);
+
+        int result = mResolver.delete(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 2), null);
+
+        assertThat(result).isEqualTo(0);
+    }
+
+    @Test
+    public void delete_indexOverflow_throwsIllegalArgumentException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+
+        assertThrows(IllegalArgumentException.class, () -> mResolver.delete(
+                SimRecords.getContentUri(1, EF_ADN).buildUpon().appendPath(
+                        String.valueOf((Long.MAX_VALUE))).build(),
+                null));
+    }
+
+    @Test
+    public void update_nameOrNumberTooLong_throwsCorrectException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 1, 25);
+        mIccPhoneBook.addRecord(1, IccConstants.EF_ADN, "Initial", "8005550101");
+
+        ContentValues values = new ContentValues();
+        // Name is limited to 11 characters
+        values.put(SimRecords.NAME, "1234567890ab");
+        values.put(SimRecords.PHONE_NUMBER, "8005550102");
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.update(SimRecords.getItemUri(
+                        1, ElementaryFiles.EF_ADN, 1), values, null));
+        assertThat(e).hasMessageThat().isEqualTo(SimRecords.NAME + " is too long.");
+
+        values.put(SimRecords.NAME, "abc");
+        values.put(SimRecords.PHONE_NUMBER, "123456789012345678901");
+
+        e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.update(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 1),
+                        values,
+                        null));
+        assertThat(e).hasMessageThat().isEqualTo(SimRecords.PHONE_NUMBER + " is too long.");
+        // The updates didn't actually change the data
+        assertThat(mIccPhoneBook.getAllValidRecords())
+                .comparingElementsUsing(Correspondence.from(AdnRecord::isEqual, "isEqual"))
+                .containsExactly(new AdnRecord(IccConstants.EF_ADN, 1, "Initial", "8005550101"));
+    }
+
+    @Test
+    public void update_nameOrNumberWithInvalidCharacters_throwsCorrectException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 1, 32);
+        mIccPhoneBook.addRecord(1, IccConstants.EF_ADN, "Initial", "8005550101");
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "Name");
+        values.put(SimRecords.PHONE_NUMBER, "(800)555-0190 x7777");
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.update(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 1),
+                        values,
+                        null));
+        assertThat(e).hasMessageThat().isEqualTo(
+                SimRecords.PHONE_NUMBER + " contains unsupported characters.");
+
+        // Unicode fffe is a unicode non-character
+        values.put(SimRecords.NAME, UNSUPPORTED_NAME);
+        values.put(SimRecords.PHONE_NUMBER, "18005550102");
+
+        e = assertThrows(IllegalArgumentException.class,
+                () -> mResolver.update(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 1),
+                        values,
+                        null));
+        assertThat(e).hasMessageThat().isEqualTo(
+                SimRecords.NAME + " contains unsupported characters.");
+
+        // The updates didn't actually change the data.
+        assertThat(mIccPhoneBook.getAllValidRecords())
+                .comparingElementsUsing(Correspondence.from(AdnRecord::isEqual, "isEqual"))
+                .containsExactly(new AdnRecord(IccConstants.EF_ADN, 1, "Initial", "8005550101"));
+    }
+
+    @Test
+    public void insert_nonAdnDirUris_throwsUnsupportedOperationException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "Name");
+        values.put(SimRecords.PHONE_NUMBER, "8005550101");
+
+        assertThrows(UnsupportedOperationException.class, () ->
+                mResolver.insert(ElementaryFiles.CONTENT_URI, values));
+        assertThrows(UnsupportedOperationException.class,
+                () -> mResolver.insert(SimRecords.getContentUri(1, EF_FDN), values));
+        assertThrows(UnsupportedOperationException.class,
+                () -> mResolver.insert(SimRecords.getContentUri(1, EF_SDN), values));
+        assertThrows(UnsupportedOperationException.class, () ->
+                mResolver.insert(SimRecords.getItemUri(1, ElementaryFiles.EF_FDN, 1), values));
+        assertThrows(UnsupportedOperationException.class, () ->
+                mResolver.insert(SimRecords.getItemUri(1, ElementaryFiles.EF_SDN, 1), values));
+    }
+
+    @Test
+    public void update_nonAdnDirUris_throwsUnsupportedOperationException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "Name");
+        values.put(SimRecords.PHONE_NUMBER, "8005550101");
+
+        assertThrows(UnsupportedOperationException.class, () ->
+                mResolver.update(ElementaryFiles.CONTENT_URI, values, null));
+        assertThrows(UnsupportedOperationException.class,
+                () -> mResolver.update(SimRecords.getContentUri(1, EF_FDN), values, null));
+        assertThrows(UnsupportedOperationException.class,
+                () -> mResolver.update(SimRecords.getContentUri(1, EF_SDN), values, null));
+        assertThrows(UnsupportedOperationException.class,
+                () -> mResolver.update(SimRecords.getContentUri(1, EF_SDN), values, null));
+        assertThrows(UnsupportedOperationException.class, () ->
+                mResolver.update(SimRecords.getItemUri(1, ElementaryFiles.EF_FDN, 1), values,
+                        null));
+        assertThrows(UnsupportedOperationException.class, () ->
+                mResolver.update(SimRecords.getItemUri(1, ElementaryFiles.EF_SDN, 1), values,
+                        null));
+    }
+
+    @Test
+    public void delete_nonAdnDirUris_throwsUnsupportedOperationException() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "Name");
+        values.put(SimRecords.PHONE_NUMBER, "8005550101");
+
+        assertThrows(UnsupportedOperationException.class, () ->
+                mResolver.delete(ElementaryFiles.CONTENT_URI, null));
+        assertThrows(UnsupportedOperationException.class,
+                () -> mResolver.delete(SimRecords.getContentUri(1, EF_FDN), null));
+        assertThrows(UnsupportedOperationException.class,
+                () -> mResolver.delete(SimRecords.getContentUri(1, EF_SDN), null));
+        assertThrows(UnsupportedOperationException.class,
+                () -> mResolver.delete(SimRecords.getContentUri(1, EF_SDN), null));
+        assertThrows(UnsupportedOperationException.class, () ->
+                mResolver.delete(SimRecords.getItemUri(1, ElementaryFiles.EF_FDN, 1), null));
+        assertThrows(UnsupportedOperationException.class, () ->
+                mResolver.delete(SimRecords.getItemUri(1, ElementaryFiles.EF_SDN, 1), null));
+    }
+
+    @Test
+    public void subscriptionsChange_callsNotifyChange() {
+        // Clear invocations that happened in setUp
+        Mockito.reset(mMockSubscriptionManager);
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+        SimPhonebookProvider.ContentNotifier mockNotifier = mock(
+                SimPhonebookProvider.ContentNotifier.class);
+        ArgumentCaptor<SubscriptionManager.OnSubscriptionsChangedListener> listenerCaptor =
+                ArgumentCaptor.forClass(SubscriptionManager.OnSubscriptionsChangedListener.class);
+
+        TestableSimPhonebookProvider.setup(
+                mResolver, mMockSubscriptionManager, mIccPhoneBook, mockNotifier);
+        verify(mMockSubscriptionManager).addOnSubscriptionsChangedListener(
+                any(), listenerCaptor.capture());
+        listenerCaptor.getValue().onSubscriptionsChanged();
+        setupSimsWithSubscriptionIds(1, 2);
+        listenerCaptor.getValue().onSubscriptionsChanged();
+        listenerCaptor.getValue().onSubscriptionsChanged();
+
+        verify(mockNotifier, times(2)).notifyChange(eq(SimPhonebookContract.AUTHORITY_URI));
+    }
+
+    @Test
+    public void insert_callsNotifyChange() {
+        // Clear invocations that happened in setUp
+        Mockito.reset(mMockSubscriptionManager);
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.makeAllEfsSupported(1);
+        SimPhonebookProvider.ContentNotifier mockNotifier = mock(
+                SimPhonebookProvider.ContentNotifier.class);
+
+        TestableSimPhonebookProvider.setup(
+                mResolver, mMockSubscriptionManager, mIccPhoneBook, mockNotifier);
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "name");
+        values.put(SimRecords.PHONE_NUMBER, "5550101");
+        mResolver.insert(SimRecords.getContentUri(1, EF_ADN), values);
+
+        verify(mockNotifier).notifyChange(eq(SimPhonebookContract.AUTHORITY_URI));
+    }
+
+    @Test
+    public void update_callsNotifyChange() {
+        // Clear invocations that happened in setUp
+        Mockito.reset(mMockSubscriptionManager);
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.addAdnRecord(1, "Initial", "5550101");
+        SimPhonebookProvider.ContentNotifier mockNotifier = mock(
+                SimPhonebookProvider.ContentNotifier.class);
+
+        TestableSimPhonebookProvider.setup(
+                mResolver, mMockSubscriptionManager, mIccPhoneBook, mockNotifier);
+
+        ContentValues values = new ContentValues();
+        values.put(SimRecords.NAME, "Updated");
+        values.put(SimRecords.PHONE_NUMBER, "5550102");
+        mResolver.update(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 1), values, null);
+
+        verify(mockNotifier).notifyChange(eq(SimPhonebookContract.AUTHORITY_URI));
+    }
+
+    @Test
+    public void delete_callsNotifyChange() {
+        // Clear invocations that happened in setUp
+        Mockito.reset(mMockSubscriptionManager);
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.addAdnRecord(1, "Initial", "5550101");
+        SimPhonebookProvider.ContentNotifier mockNotifier = mock(
+                SimPhonebookProvider.ContentNotifier.class);
+
+        TestableSimPhonebookProvider.setup(
+                mResolver, mMockSubscriptionManager, mIccPhoneBook, mockNotifier);
+
+        mResolver.delete(SimRecords.getItemUri(1, ElementaryFiles.EF_ADN, 1), null);
+
+        verify(mockNotifier).notifyChange(eq(SimPhonebookContract.AUTHORITY_URI));
+    }
+
+    @Test
+    public void validateName_validName_returnsValueIsCorrect() {
+        setupSimsWithSubscriptionIds(1);
+        String validName = "First Last";
+        // See AdnRecord#FOOTER_SIZE_BYTES
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 10, validName.length() + 14);
+        SimRecords.NameValidationResult validationResult = SimRecords.validateName(mResolver, 1,
+                EF_ADN, validName);
+
+        assertThat(validationResult.isValid()).isTrue();
+        assertThat(validationResult.getName()).isEqualTo(validName);
+        assertThat(validationResult.getSanitizedName()).isEqualTo(validName);
+        assertThat(validationResult.getEncodedLength()).isEqualTo(validName.length());
+        assertThat(validationResult.getMaxEncodedLength()).isEqualTo(validName.length());
+
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 10, 40);
+        validationResult = SimRecords.validateName(mResolver, 1, EF_ADN, validName);
+        assertThat(validationResult.getMaxEncodedLength()).isEqualTo(40 - 14);
+    }
+
+    @Test
+    public void validateName_nameTooLong_returnsValueIsCorrect() {
+        setupSimsWithSubscriptionIds(1);
+        String tooLongName = "First Last";
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 10, tooLongName.length() + 14 - 1);
+        SimRecords.NameValidationResult validationResult = SimRecords.validateName(mResolver, 1,
+                EF_ADN, tooLongName);
+
+        assertThat(validationResult.isValid()).isFalse();
+        assertThat(validationResult.getName()).isEqualTo(tooLongName);
+        assertThat(validationResult.getSanitizedName()).isEqualTo(tooLongName);
+        assertThat(validationResult.getEncodedLength()).isEqualTo(tooLongName.length());
+        assertThat(validationResult.getMaxEncodedLength()).isEqualTo(tooLongName.length() - 1);
+    }
+
+    @Test
+    public void validateName_nameWithUnsupportedCharacters_returnsValueIsCorrect() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 10, 40);
+        SimRecords.NameValidationResult validationResult = SimRecords.validateName(mResolver, 1,
+                EF_ADN, UNSUPPORTED_NAME);
+
+        assertThat(validationResult.isValid()).isFalse();
+        assertThat(validationResult.getName()).isEqualTo(UNSUPPORTED_NAME);
+        assertThat(validationResult.getSanitizedName()).isEqualTo(":)=  ;ni= ;hon= ;");
+        assertThat(validationResult.getEncodedLength()).isEqualTo(UNSUPPORTED_NAME.length());
+        assertThat(validationResult.getMaxEncodedLength()).isEqualTo(
+                AdnRecord.getMaxAlphaTagBytes(40));
+    }
+
+    @Test
+    public void validateName_emptyString_returnsValueIsCorrect() {
+        setupSimsWithSubscriptionIds(1);
+        mIccPhoneBook.setRecordsSize(1, IccConstants.EF_ADN, 10, 40);
+        SimRecords.NameValidationResult validationResult = SimRecords.validateName(mResolver, 1,
+                EF_ADN, "");
+
+        assertThat(validationResult.isValid()).isTrue();
+        assertThat(validationResult.getName()).isEqualTo("");
+        assertThat(validationResult.getSanitizedName()).isEqualTo("");
+        assertThat(validationResult.getEncodedLength()).isEqualTo(0);
+        assertThat(validationResult.getMaxEncodedLength()).isEqualTo(
+                AdnRecord.getMaxAlphaTagBytes(40));
+
+        // Null is equivalent to empty
+        validationResult = SimRecords.validateName(mResolver, 1, EF_ADN, null);
+        assertThat(validationResult.getName()).isEqualTo("");
+        assertThat(validationResult.getSanitizedName()).isEqualTo("");
+        assertThat(validationResult.getEncodedLength()).isEqualTo(0);
+        assertThat(validationResult.getMaxEncodedLength()).isEqualTo(
+                AdnRecord.getMaxAlphaTagBytes(40));
+    }
+
+    private void setupSimsWithSubscriptionIds(int... subscriptionIds) {
+        when(mMockSubscriptionManager.getActiveSubscriptionIdList()).thenReturn(subscriptionIds);
+        when(mMockSubscriptionManager.getActiveSubscriptionInfoCount())
+                .thenReturn(subscriptionIds.length);
+        List<SubscriptionInfo> subscriptions = createSubscriptionsWithIds(subscriptionIds);
+        when(mMockSubscriptionManager.getActiveSubscriptionInfoList()).thenReturn(subscriptions);
+        for (SubscriptionInfo info : subscriptions) {
+            when(mMockSubscriptionManager.getActiveSubscriptionInfo(info.getSubscriptionId()))
+                    .thenReturn(info);
+        }
+    }
+
+    public static class FakeIccPhoneBook extends IIccPhoneBook.Default {
+
+        private static final int DEFAULT_RECORD_SIZE = 30;
+        private static final int DEFAULT_RECORDS_COUNT = 100;
+
+        // The key for both maps is the (subscription ID, efid)
+        private Map<Pair<Integer, Integer>, AdnRecord[]> mRecords = new HashMap<>();
+        // The value is the single record size
+        private Map<Pair<Integer, Integer>, Integer> mRecordSizes = new HashMap<>();
+
+        private int mDefaultSubscriptionId = 101;
+
+        private void addRecord(Pair<Integer, Integer> key, AdnRecord record) {
+            // Assume that if records are being added then the test wants it to be a valid
+            // elementary file so set sizes as well.
+            if (!mRecordSizes.containsKey(key)) {
+                setRecordsSize(key.first, key.second,
+                        Math.max(record.getRecId(), DEFAULT_RECORDS_COUNT), DEFAULT_RECORD_SIZE);
+            }
+            mRecords.get(key)[record.getRecId() - 1] = record;
+        }
+
+        public void addRecord(AdnRecord record) {
+            addRecord(Pair.create(mDefaultSubscriptionId, record.getEfid()), record);
+        }
+
+        public void addRecord(int subscriptionId, AdnRecord record) {
+            addRecord(Pair.create(subscriptionId, record.getEfid()), record);
+        }
+
+        public void addRecord(int subscriptionId, int efId, String name, String phoneNumber) {
+            Pair<Integer, Integer> key = Pair.create(subscriptionId, efId);
+            AdnRecord[] records = mRecords.computeIfAbsent(key, unused ->
+                    createEmptyRecords(efId, 100));
+            int recordIndex = -1;
+            for (int i = 0; i < records.length; i++) {
+                if (records[i].isEmpty()) {
+                    recordIndex = i;
+                    break;
+                }
+            }
+            if (recordIndex == -1) {
+                throw new IllegalStateException("");
+            }
+            addRecord(key, new AdnRecord(efId, recordIndex + 1, name, phoneNumber));
+        }
+
+        public void addAdnRecord(int subscriptionId, String name, String phoneNumber) {
+            addRecord(subscriptionId, IccConstants.EF_ADN, name, phoneNumber);
+        }
+
+        public void addAdnRecord(String name, String phoneNumber) {
+            addRecord(mDefaultSubscriptionId, IccConstants.EF_ADN, name, phoneNumber);
+        }
+
+        public List<AdnRecord> getAllValidRecords() {
+            List<AdnRecord> result = new ArrayList<>();
+            for (AdnRecord[] records : mRecords.values()) {
+                for (AdnRecord record : records) {
+                    if (!record.isEmpty()) {
+                        result.add(record);
+                    }
+                }
+            }
+            return result;
+        }
+
+        public void makeAllEfsSupported() {
+            makeAllEfsSupported(mDefaultSubscriptionId);
+        }
+
+        /**
+         * Sets up the fake to return valid records size for all elementary files for the provided
+         * subscription IDs.
+         */
+        public void makeAllEfsSupported(int... subscriptionIds) {
+            for (int subId : subscriptionIds) {
+                makeAllEfsSupported(subId);
+            }
+        }
+
+        /**
+         * Sets up the fake to return valid records size for all elementary files for the provided
+         * subscription IDs.
+         */
+        public void makeAllEfsSupported(int subscriptionId) {
+            setRecordsSize(subscriptionId, IccConstants.EF_ADN, DEFAULT_RECORDS_COUNT,
+                    DEFAULT_RECORD_SIZE);
+            setRecordsSize(subscriptionId, IccConstants.EF_FDN, DEFAULT_RECORDS_COUNT,
+                    DEFAULT_RECORD_SIZE);
+            setRecordsSize(subscriptionId, IccConstants.EF_SDN, DEFAULT_RECORDS_COUNT,
+                    DEFAULT_RECORD_SIZE);
+        }
+
+        public void setRecordsSize(int subscriptionId, int efid, int maxRecordCount,
+                int maxRecordSize) {
+            Pair<Integer, Integer> key = Pair.create(subscriptionId, efid);
+            mRecordSizes.put(key, maxRecordSize);
+            AdnRecord[] records = mRecords.computeIfAbsent(key, unused ->
+                    createEmptyRecords(efid, maxRecordCount));
+            if (records.length < maxRecordCount) {
+                throw new IllegalStateException("Records already initialized with a smaller size");
+            }
+        }
+
+        private AdnRecord[] createEmptyRecords(int efid, int count) {
+            AdnRecord[] records = new AdnRecord[count];
+            for (int i = 0; i < records.length; i++) {
+                if (records[i] == null) {
+                    records[i] = new AdnRecord(efid, i + 1, "", "");
+                }
+            }
+            return records;
+        }
+
+        public void setDefaultSubscriptionId(int defaultSubscriptionId) {
+            mDefaultSubscriptionId = defaultSubscriptionId;
+        }
+
+        public void reset() {
+            mRecords.clear();
+            mRecordSizes.clear();
+        }
+
+        @Override
+        public List<AdnRecord> getAdnRecordsInEf(int efid) {
+            return getAdnRecordsInEfForSubscriber(mDefaultSubscriptionId, efid);
+        }
+
+        @Override
+        public List<AdnRecord> getAdnRecordsInEfForSubscriber(int subId, int efid) {
+            return Arrays.asList(
+                    mRecords.getOrDefault(Pair.create(subId, efid), new AdnRecord[0]));
+        }
+
+        @Override
+        public boolean updateAdnRecordsInEfBySearch(int efid, String oldTag, String oldPhoneNumber,
+                String newTag, String newPhoneNumber, String pin2) {
+            return updateAdnRecordsInEfBySearchForSubscriber(
+                    mDefaultSubscriptionId, efid,
+                    oldTag, oldPhoneNumber, newTag, newPhoneNumber, pin2);
+        }
+
+        @Override
+        public boolean updateAdnRecordsInEfBySearchForSubscriber(int subId, int efid, String oldTag,
+                String oldPhoneNumber, String newTag, String newPhoneNumber, String pin2) {
+            if (!oldTag.isEmpty() || !oldPhoneNumber.isEmpty()) {
+                throw new IllegalArgumentException(
+                        "updateAdnRecordsInEfBySearchForSubscriber only supports insert");
+            }
+            addRecord(subId, efid, newTag, newPhoneNumber);
+            return true;
+        }
+
+        @Override
+        public boolean updateAdnRecordsInEfByIndex(int efid, String newTag, String newPhoneNumber,
+                int index, String pin2) {
+            return updateAdnRecordsInEfByIndexForSubscriber(mDefaultSubscriptionId,
+                    efid, newTag, newPhoneNumber, index, pin2);
+        }
+
+        @Override
+        public boolean updateAdnRecordsInEfByIndexForSubscriber(int subId, int efid, String newTag,
+                String newPhoneNumber, int index, String pin2) {
+            AdnRecord[] records = mRecords.computeIfAbsent(Pair.create(subId, efid), unused ->
+                    createEmptyRecords(efid, 100));
+            records[index - 1] = new AdnRecord(efid, index, newTag, newPhoneNumber);
+            return true;
+        }
+
+        @Override
+        public int[] getAdnRecordsSize(int efid) {
+            return getAdnRecordsSizeForSubscriber(mDefaultSubscriptionId, efid);
+        }
+
+        @Override
+        public int[] getAdnRecordsSizeForSubscriber(int subId, int efid) {
+            Pair<Integer, Integer> key = Pair.create(subId, efid);
+            Integer recordSize = mRecordSizes.get(key);
+            if (recordSize == null) {
+                return new int[]{0, 0, 0};
+            }
+            int count = mRecords.get(key).length;
+            return new int[]{recordSize, recordSize * count, count};
+        }
+    }
+
+    /**
+     * Implementation of SimPhonebookProvider that allows test-doubles to be injected.
+     *
+     * <p>The ProviderTestRule doesn't seem to allow a better way to do this since it just
+     * invokes the constructor.
+     */
+    public static class TestableSimPhonebookProvider extends SimPhonebookProvider {
+
+        public static void setup(
+                ContentResolver resolver,
+                SubscriptionManager subscriptionManager,
+                IIccPhoneBook iccPhoneBook) {
+            setup(resolver, subscriptionManager, iccPhoneBook, uri -> {
+            });
+        }
+
+        public static void setup(
+                ContentResolver resolver,
+                SubscriptionManager subscriptionManager,
+                IIccPhoneBook iccPhoneBook,
+                ContentNotifier notifier) {
+            TestableSimPhonebookProvider provider =
+                    (TestableSimPhonebookProvider) Objects.requireNonNull(
+                            resolver.acquireContentProviderClient(
+                                    SimPhonebookContract.AUTHORITY))
+                            .getLocalContentProvider();
+            InstrumentationRegistry.getInstrumentation().runOnMainSync(() ->
+                    provider.onCreate(subscriptionManager, () -> iccPhoneBook, notifier));
+        }
+
+        @Override
+        public boolean onCreate() {
+            // We stub super.onCreate because it initializes services which causes an
+            // IllegalArgumentException because of the context used for the test.
+            return true;
+        }
+    }
+}
diff --git a/tests/src/com/android/services/telephony/rcs/RcsFeatureControllerTest.java b/tests/src/com/android/services/telephony/rcs/RcsFeatureControllerTest.java
index 7e87dc7..eecbd2e 100644
--- a/tests/src/com/android/services/telephony/rcs/RcsFeatureControllerTest.java
+++ b/tests/src/com/android/services/telephony/rcs/RcsFeatureControllerTest.java
@@ -31,6 +31,7 @@
 import android.telephony.AccessNetworkConstants;
 import android.telephony.ims.ImsException;
 import android.telephony.ims.ImsReasonInfo;
+import android.telephony.ims.ImsRegistrationAttributes;
 import android.telephony.ims.RegistrationManager;
 import android.telephony.ims.aidl.IImsCapabilityCallback;
 import android.telephony.ims.aidl.IImsRegistrationCallback;
@@ -135,7 +136,8 @@
             controller.registerRcsAvailabilityCallback(0 /*subId*/, capCb);
             controller.isCapable(RcsFeature.RcsImsCapabilities.CAPABILITY_TYPE_PRESENCE_UCE,
                     ImsRegistrationImplBase.REGISTRATION_TECH_LTE);
-            controller.isAvailable(RcsFeature.RcsImsCapabilities.CAPABILITY_TYPE_PRESENCE_UCE);
+            controller.isAvailable(RcsFeature.RcsImsCapabilities.CAPABILITY_TYPE_PRESENCE_UCE,
+                    ImsRegistrationImplBase.REGISTRATION_TECH_LTE);
             controller.getRegistrationTech(integer -> {
             });
             verify(mFeatureManager).registerImsRegistrationCallback(0, regCb);
@@ -144,7 +146,8 @@
                     RcsFeature.RcsImsCapabilities.CAPABILITY_TYPE_PRESENCE_UCE,
                     ImsRegistrationImplBase.REGISTRATION_TECH_LTE);
             verify(mFeatureManager).isAvailable(
-                    RcsFeature.RcsImsCapabilities.CAPABILITY_TYPE_PRESENCE_UCE);
+                    RcsFeature.RcsImsCapabilities.CAPABILITY_TYPE_PRESENCE_UCE,
+                    ImsRegistrationImplBase.REGISTRATION_TECH_LTE);
             verify(mFeatureManager).getImsRegistrationTech(any());
         } catch (ImsException e) {
             fail("ImsException not expected.");
@@ -173,7 +176,9 @@
         });
         verify(mRegistrationCallback).handleImsUnregistered(REASON_DISCONNECTED);
 
-        captor.getValue().onRegistering(ImsRegistrationImplBase.REGISTRATION_TECH_LTE);
+        ImsRegistrationAttributes attr = new ImsRegistrationAttributes.Builder(
+                ImsRegistrationImplBase.REGISTRATION_TECH_LTE).build();
+        captor.getValue().onRegistering(attr);
         controller.getRegistrationState(result -> {
             assertNotNull(result);
             assertEquals(RegistrationManager.REGISTRATION_STATE_REGISTERING, result.intValue());
@@ -181,7 +186,7 @@
         verify(mRegistrationCallback).handleImsRegistering(
                 AccessNetworkConstants.TRANSPORT_TYPE_WWAN);
 
-        captor.getValue().onRegistered(ImsRegistrationImplBase.REGISTRATION_TECH_LTE);
+        captor.getValue().onRegistered(attr);
         controller.getRegistrationState(result -> {
             assertNotNull(result);
             assertEquals(RegistrationManager.REGISTRATION_STATE_REGISTERED, result.intValue());
@@ -230,7 +235,8 @@
             //expected
         }
         try {
-            controller.isAvailable(RcsFeature.RcsImsCapabilities.CAPABILITY_TYPE_PRESENCE_UCE);
+            controller.isAvailable(RcsFeature.RcsImsCapabilities.CAPABILITY_TYPE_PRESENCE_UCE,
+                    ImsRegistrationImplBase.REGISTRATION_TECH_LTE);
             fail("ImsException expected for availability check");
         } catch (ImsException e) {
             //expected
diff --git a/tests/src/com/android/services/telephony/rcs/TelephonyRcsServiceTest.java b/tests/src/com/android/services/telephony/rcs/TelephonyRcsServiceTest.java
index a1f97a0..c367af3 100644
--- a/tests/src/com/android/services/telephony/rcs/TelephonyRcsServiceTest.java
+++ b/tests/src/com/android/services/telephony/rcs/TelephonyRcsServiceTest.java
@@ -50,6 +50,7 @@
 
     @Captor ArgumentCaptor<BroadcastReceiver> mReceiverCaptor;
     @Mock TelephonyRcsService.FeatureFactory mFeatureFactory;
+    @Mock TelephonyRcsService.ResourceProxy mResourceProxy;
     @Mock UceControllerManager mMockUceSlot0;
     @Mock UceControllerManager mMockUceSlot1;
     @Mock SipTransportController mMockSipTransportSlot0;
@@ -78,6 +79,7 @@
                 eq(0), anyInt());
         doReturn(mMockSipTransportSlot1).when(mFeatureFactory).createSipTransportController(any(),
                 eq(1), anyInt());
+        doReturn(true).when(mResourceProxy).getDeviceUceEnabled(any());
         //set up default slot-> sub ID mappings.
         setSlotToSubIdMapping(0 /*slotId*/, 1/*subId*/);
         setSlotToSubIdMapping(1 /*slotId*/, 2/*subId*/);
@@ -325,7 +327,7 @@
     }
 
     private TelephonyRcsService createRcsService(int numSlots) {
-        TelephonyRcsService service = new TelephonyRcsService(mContext, numSlots);
+        TelephonyRcsService service = new TelephonyRcsService(mContext, numSlots, mResourceProxy);
         service.setFeatureFactory(mFeatureFactory);
         service.initialize();
         verify(mContext).registerReceiver(mReceiverCaptor.capture(), any());