Adding SimPhonebookProvider
This is the provider that implements SimPhonebookContract.
Test: atest TeleServiceTests:SimPhonebookProviderTest
Bug: 154363919
Change-Id: I584150dd8352dbb49d70ea28c6d064a6336d018d
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());
+ }
+ }
+ }
+}