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());