summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMakoto Onuki <omakoto@google.com>2016-01-12 15:53:00 -0800
committerMakoto Onuki <omakoto@google.com>2016-01-14 17:43:50 -0800
commit28b6a0f277a997f5c117c3c5be3de604c7a38d12 (patch)
tree30c162929957234bc45ef6b98c233802b18d8b15 /src
parent963604b4286899279832d0a83e2dd275a4ea6ff5 (diff)
downloadBlockedNumberProvider-28b6a0f277a997f5c117c3c5be3de604c7a38d12.tar.gz
Introduce "blocked phone number" provider
Bug 26232372 Change-Id: I735d2949f45f533c26063d413dd3dfb72f455711
Diffstat (limited to 'src')
-rw-r--r--src/com/android/providers/blockednumber/BlockedNumberDatabaseHelper.java99
-rw-r--r--src/com/android/providers/blockednumber/BlockedNumberProvider.java392
-rw-r--r--src/com/android/providers/blockednumber/Utils.java94
3 files changed, 585 insertions, 0 deletions
diff --git a/src/com/android/providers/blockednumber/BlockedNumberDatabaseHelper.java b/src/com/android/providers/blockednumber/BlockedNumberDatabaseHelper.java
new file mode 100644
index 0000000..f3138fa
--- /dev/null
+++ b/src/com/android/providers/blockednumber/BlockedNumberDatabaseHelper.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2016 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.providers.blockednumber;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.provider.BlockedNumberContract.BlockedNumbers;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+public class BlockedNumberDatabaseHelper {
+ private static final String TAG = BlockedNumberProvider.TAG;
+
+ private static final int DATABASE_VERSION = 1;
+
+ private static final String DATABASE_NAME = "blockednumbers.db";
+
+ private static BlockedNumberDatabaseHelper sInstance;
+
+ private final Context mContext;
+
+ private final OpenHelper mOpenHelper;
+
+ public interface Tables {
+ String BLOCKED_NUMBERS = "blocked";
+ }
+
+ private static final class OpenHelper extends SQLiteOpenHelper {
+ public OpenHelper(Context context, String name, SQLiteDatabase.CursorFactory factory,
+ int version) {
+ super(context, name, factory, version);
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ db.execSQL("CREATE TABLE " + Tables.BLOCKED_NUMBERS + " (" +
+ BlockedNumbers.COLUMN_ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+ BlockedNumbers.COLUMN_ORIGINAL_NUMBER + " TEXT NOT NULL UNIQUE," +
+ BlockedNumbers.COLUMN_STRIPPED_NUMBER + " TEXT NOT NULL," +
+ BlockedNumbers.COLUMN_E164_NUMBER + " TEXT" +
+ ")");
+
+ db.execSQL("CREATE INDEX blocked_number_idx_stripped ON " + Tables.BLOCKED_NUMBERS +
+ " (" + BlockedNumbers.COLUMN_STRIPPED_NUMBER + ");");
+ db.execSQL("CREATE INDEX blocked_number_idx_e164 ON " + Tables.BLOCKED_NUMBERS + " (" +
+ BlockedNumbers.COLUMN_E164_NUMBER +
+ ");");
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ }
+ }
+
+ @VisibleForTesting
+ public static BlockedNumberDatabaseHelper newInstanceForTest(Context context) {
+ return new BlockedNumberDatabaseHelper(context, /* instanceIsForTesting =*/ true);
+ }
+
+ private BlockedNumberDatabaseHelper(Context context, boolean instanceIsForTesting) {
+ mContext = context;
+ mOpenHelper = new OpenHelper(mContext,
+ instanceIsForTesting ? null : DATABASE_NAME, null, DATABASE_VERSION);
+ }
+
+ public static synchronized BlockedNumberDatabaseHelper getInstance(Context context) {
+ if (sInstance == null) {
+ sInstance = new BlockedNumberDatabaseHelper(context.getApplicationContext(),
+ /* instanceIsForTesting =*/ false);
+ }
+ return sInstance;
+ }
+
+ public SQLiteDatabase getReadableDatabase() {
+ return mOpenHelper.getReadableDatabase();
+ }
+
+ public SQLiteDatabase getWritableDatabase() {
+ return mOpenHelper.getWritableDatabase();
+ }
+
+ public void wipeForTest() {
+ getWritableDatabase().execSQL("DELETE FROM " + Tables.BLOCKED_NUMBERS);
+ }
+}
diff --git a/src/com/android/providers/blockednumber/BlockedNumberProvider.java b/src/com/android/providers/blockednumber/BlockedNumberProvider.java
new file mode 100644
index 0000000..13e977c
--- /dev/null
+++ b/src/com/android/providers/blockednumber/BlockedNumberProvider.java
@@ -0,0 +1,392 @@
+/*
+ * Copyright (C) 2016 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.providers.blockednumber;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.AppOpsManager;
+import android.content.*;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.net.Uri;
+import android.os.*;
+import android.os.Process;
+import android.provider.BlockedNumberContract;
+import android.telecom.TelecomManager;
+import android.text.TextUtils;
+import android.util.Log;
+import com.android.common.content.ProjectionMap;
+import com.android.providers.blockednumber.BlockedNumberDatabaseHelper.Tables;
+
+/**
+ * Blocked phone number provider.
+ *
+ * <p>Note the provider allows emergency numbers. The caller (telecom) should never call it with
+ * emergency numbers.
+ */
+public class BlockedNumberProvider extends ContentProvider {
+ static final String TAG = "BlockedNumbers";
+
+ private static final boolean DEBUG = true; // DO NOT SUBMIT WITH TRUE.
+
+ private static final int BLOCKED_LIST = 1000;
+ private static final int BLOCKED_ID = 1001;
+
+ private static final UriMatcher sUriMatcher;
+
+ static {
+ sUriMatcher = new UriMatcher(0);
+ sUriMatcher.addURI(BlockedNumberContract.AUTHORITY, "blocked", BLOCKED_LIST);
+ sUriMatcher.addURI(BlockedNumberContract.AUTHORITY, "blocked/#", BLOCKED_ID);
+ }
+
+ private static final ProjectionMap sBlockedNumberColumns = ProjectionMap.builder()
+ .add(BlockedNumberContract.BlockedNumbers.COLUMN_ID)
+ .add(BlockedNumberContract.BlockedNumbers.COLUMN_ORIGINAL_NUMBER)
+ .add(BlockedNumberContract.BlockedNumbers.COLUMN_STRIPPED_NUMBER)
+ .add(BlockedNumberContract.BlockedNumbers.COLUMN_E164_NUMBER)
+ .build();
+
+ private static final String ID_SELECTION =
+ BlockedNumberContract.BlockedNumbers.COLUMN_ID + "=?";
+
+ @Override
+ public boolean onCreate() {
+ return true;
+ }
+
+ BlockedNumberDatabaseHelper getDbHelper() {
+ return BlockedNumberDatabaseHelper.getInstance(getContext());
+ }
+
+ /**
+ * TODO CTS:
+ * - BLOCKED_LIST
+ * - BLOCKED_ID
+ * - Other random URLs should fail
+ */
+ @Override
+ public String getType(@NonNull Uri uri) {
+ final int match = sUriMatcher.match(uri);
+ switch (match) {
+ case BLOCKED_LIST:
+ return BlockedNumberContract.BlockedNumbers.CONTENT_TYPE;
+ case BLOCKED_ID:
+ return BlockedNumberContract.BlockedNumbers.CONTENT_ITEM_TYPE;
+ default:
+ throw new IllegalArgumentException("Unsupported URI: " + uri);
+ }
+ }
+
+ /**
+ * TODO CTS:
+ * - BLOCKED_LIST
+ * With no columns should fail
+ * With COLUMN_INDEX_ORIGINAL only
+ * With COLUMN_INDEX_E164 only should fail
+ * With COLUMN_INDEX_ORIGINAL + COLUMN_INDEX_E164
+ * With with throwIfSpecified columns, should fail.
+ *
+ * - BLOCKED_ID should fail
+ * - Other random URLs should fail
+ */
+ @Override
+ public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
+ enforceWritePermission();
+
+ final int match = sUriMatcher.match(uri);
+ switch (match) {
+ case BLOCKED_LIST:
+ return insertBlockedNumber(values);
+ default:
+ throw new IllegalArgumentException("Unsupported URI: " + uri);
+ }
+ }
+
+ /**
+ * Implements the "blocked/" insert.
+ */
+ private Uri insertBlockedNumber(ContentValues cv) {
+ throwIfSpecified(cv, BlockedNumberContract.BlockedNumbers.COLUMN_ID);
+ throwIfSpecified(cv, BlockedNumberContract.BlockedNumbers.COLUMN_STRIPPED_NUMBER);
+
+ final String phoneNumber = cv.getAsString(
+ BlockedNumberContract.BlockedNumbers.COLUMN_ORIGINAL_NUMBER);
+
+ if (TextUtils.isEmpty(phoneNumber)) {
+ throw new IllegalArgumentException("Missing a required column " +
+ BlockedNumberContract.BlockedNumbers.COLUMN_ORIGINAL_NUMBER);
+ }
+
+ // Sanitize the input and fill in with autogenerated columns.
+ final String strippedNumber = Utils.stripPhoneNumber(phoneNumber);
+ final String e164Number = Utils.getE164Number(getContext(), strippedNumber,
+ cv.getAsString(BlockedNumberContract.BlockedNumbers.COLUMN_E164_NUMBER));
+
+ cv.put(BlockedNumberContract.BlockedNumbers.COLUMN_STRIPPED_NUMBER, strippedNumber);
+ cv.put(BlockedNumberContract.BlockedNumbers.COLUMN_E164_NUMBER, e164Number);
+
+ // Then insert.
+ final long id = getDbHelper().getWritableDatabase().insertOrThrow(
+ BlockedNumberDatabaseHelper.Tables.BLOCKED_NUMBERS, null, cv);
+
+ return ContentUris.withAppendedId(BlockedNumberContract.BlockedNumbers.CONTENT_URI, id);
+ }
+
+ private static void throwIfSpecified(ContentValues cv, String column) {
+ if (cv.containsKey(column)) {
+ throw new IllegalArgumentException("Column " + column + " must not be specified");
+ }
+ }
+
+ /**
+ * TODO CTS:
+ * - Any call should fail
+ */
+ @Override
+ public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection,
+ @Nullable String[] selectionArgs) {
+ throw new UnsupportedOperationException(
+ "Update is not supported. Use delete + insert instead");
+ }
+
+ /**
+ * TODO CTS:
+ * - BLOCKED_LIST, with selection and without.
+ * - BLOCKED_ID , with selection and without. With should fail.
+ */
+ @Override
+ public int delete(@NonNull Uri uri, @Nullable String selection,
+ @Nullable String[] selectionArgs) {
+ enforceWritePermission();
+
+ final int match = sUriMatcher.match(uri);
+ switch (match) {
+ case BLOCKED_LIST:
+ return deleteBlockedNumber(selection, selectionArgs);
+ case BLOCKED_ID:
+ return deleteBlockedNumberWithId(ContentUris.parseId(uri), selection);
+ default:
+ throw new IllegalArgumentException("Unsupported URI: " + uri);
+ }
+ }
+
+ /**
+ * Implements the "blocked/#" delete.
+ */
+ private int deleteBlockedNumberWithId(long id, String selection) {
+ throwForNonEmptySelection(selection);
+
+ return deleteBlockedNumber(ID_SELECTION, new String[]{Long.toString(id)});
+ }
+
+ /**
+ * Implements the "blocked/" delete.
+ */
+ private int deleteBlockedNumber(String selection, String[] selectionArgs) {
+ final SQLiteDatabase db = getDbHelper().getWritableDatabase();
+
+ // When selection is specified, compile it within (...) to detect SQL injection.
+ if (!TextUtils.isEmpty(selection)) {
+ db.validateSql("select 1 FROM " + Tables.BLOCKED_NUMBERS + " WHERE " +
+ Utils.wrapSelectionWithParens(selection),
+ /* cancellationSignal =*/ null);
+ }
+
+ return getDbHelper().getWritableDatabase().delete(
+ BlockedNumberDatabaseHelper.Tables.BLOCKED_NUMBERS,
+ selection, selectionArgs);
+ }
+
+ @Override
+ public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
+ @Nullable String[] selectionArgs, @Nullable String sortOrder) {
+ enforceReadPermission();
+
+ return query(uri, projection, selection, selectionArgs, sortOrder, null);
+ }
+
+ @Override
+ public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
+ @Nullable String[] selectionArgs, @Nullable String sortOrder,
+ @Nullable CancellationSignal cancellationSignal) {
+ enforceReadPermission();
+
+ final int match = sUriMatcher.match(uri);
+ switch (match) {
+ case BLOCKED_LIST:
+ return queryBlockedList(projection, selection, selectionArgs, sortOrder,
+ cancellationSignal);
+ case BLOCKED_ID:
+ return queryBlockedListWithId(ContentUris.parseId(uri), projection, selection,
+ cancellationSignal);
+ default:
+ throw new IllegalArgumentException("Unsupported URI: " + uri);
+ }
+ }
+
+ /**
+ * Implements the "blocked/#" query.
+ */
+ private Cursor queryBlockedListWithId(long id, String[] projection, String selection,
+ CancellationSignal cancellationSignal) {
+ throwForNonEmptySelection(selection);
+
+ return queryBlockedList(projection, ID_SELECTION, new String[]{Long.toString(id)},
+ null, cancellationSignal);
+ }
+
+ /**
+ * Implements the "blocked/" query.
+ */
+ private Cursor queryBlockedList(String[] projection, String selection, String[] selectionArgs,
+ String sortOrder, CancellationSignal cancellationSignal) {
+ SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+ qb.setStrict(true);
+ qb.setTables(BlockedNumberDatabaseHelper.Tables.BLOCKED_NUMBERS);
+ qb.setProjectionMap(sBlockedNumberColumns);
+
+ return qb.query(getDbHelper().getReadableDatabase(), projection, selection, selectionArgs,
+ /* groupBy =*/ null, /* having =*/null, sortOrder,
+ /* limit =*/ null, cancellationSignal);
+ }
+
+ private void throwForNonEmptySelection(String selection) {
+ if (!TextUtils.isEmpty(selection)) {
+ throw new IllegalArgumentException(
+ "When ID is specified in URI, selection must be null");
+ }
+ }
+
+ /**
+ * TODO CTS:
+ * - METHOD_IS_BLOCKED with various matching / non-matching arguments.
+ *
+ * - other random methods should fail
+ */
+ @Override
+ public Bundle call(@NonNull String method, @Nullable String arg, @Nullable Bundle extras) {
+ enforceReadPermission();
+
+ final Bundle res = new Bundle();
+ switch (method) {
+ case BlockedNumberContract.METHOD_IS_BLOCKED:
+ res.putBoolean(BlockedNumberContract.RES_NUMBER_IS_BLOCKED, isBlocked(arg));
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported method " + method);
+ }
+ return res;
+ }
+
+ private boolean isBlocked(String phoneNumber) {
+ final String inStripped = Utils.stripPhoneNumber(phoneNumber);
+ if (TextUtils.isEmpty(inStripped)) {
+ return false;
+ }
+
+ final String inE164 = Utils.getE164Number(getContext(), inStripped, null); // may be empty.
+
+ if (DEBUG) {
+ Log.d(TAG, String.format("isBlocked: in=%s, stripped=%s, e164=%s", phoneNumber,
+ inStripped, inE164));
+ }
+
+ final Cursor c = getDbHelper().getReadableDatabase().rawQuery(
+ "SELECT " +
+ BlockedNumberContract.BlockedNumbers.COLUMN_ORIGINAL_NUMBER + "," +
+ BlockedNumberContract.BlockedNumbers.COLUMN_STRIPPED_NUMBER + "," +
+ BlockedNumberContract.BlockedNumbers.COLUMN_E164_NUMBER +
+ " FROM " + BlockedNumberDatabaseHelper.Tables.BLOCKED_NUMBERS +
+ " WHERE " + BlockedNumberContract.BlockedNumbers.COLUMN_STRIPPED_NUMBER + "=?1" +
+ " OR (?2 != '' AND " +
+ BlockedNumberContract.BlockedNumbers.COLUMN_E164_NUMBER + "=?2)",
+ new String[] {inStripped, inE164}
+ );
+ try {
+ while (c.moveToNext()) {
+ if (DEBUG) {
+ final String original = c.getString(0);
+ final String stripped = c.getString(1);
+ final String e164 = c.getString(2);
+
+ Log.d(TAG, String.format(" match found: original=%s, stripped=%s, e164=%s",
+ original, stripped, e164));
+ }
+ return true;
+ }
+ } finally {
+ c.close();
+ }
+ // No match found.
+ return false;
+ }
+
+ /**
+ * Throws {@link SecurityException} when the caller is not root, system, the system dialer,
+ * the user selected dialer, or the default SMS app.
+ *
+ * NOT TESTED YET
+ *
+ * TODO CTS:
+ * - Call should fail for random 3p apps.
+ *
+ * TODO Add a permission to allow the contacts app to access?
+ * TODO Add a permission to allow carrier apps?
+ */
+ public void enforceReadPermission() {
+ final int callingUid = Binder.getCallingUid();
+
+ // System and root can always call it. (and myself)
+ if (UserHandle.isSameApp(callingUid, android.os.Process.SYSTEM_UID)
+ || (callingUid == Process.ROOT_UID)
+ || (callingUid == Process.myUid())) {
+ return;
+ }
+
+ final String callingPackage = getCallingPackage();
+ if (TextUtils.isEmpty(callingPackage)) {
+ Log.w(TAG, "callingPackage not accessible");
+ } else {
+
+ final TelecomManager telecom = getContext().getSystemService(TelecomManager.class);
+
+ if (callingPackage.equals(telecom.getDefaultDialerPackage())
+ || callingPackage.equals(telecom.getSystemDialerPackage())) {
+ return;
+ }
+
+ // Allow the default SMS app and the dialer app to access it.
+ final AppOpsManager appOps = getContext().getSystemService(AppOpsManager.class);
+
+ if (appOps.noteOp(AppOpsManager.OP_WRITE_SMS,
+ Binder.getCallingUid(), callingPackage) == AppOpsManager.MODE_ALLOWED) {
+ return;
+ }
+ }
+ throw new SecurityException("Caller must be system, default dialer or default SMS app");
+ }
+
+ /**
+ * TODO CTS:
+ * - Call should fail for random 3p apps.
+ */
+ public void enforceWritePermission() {
+ // Same check as read.
+ enforceReadPermission();
+ }
+}
diff --git a/src/com/android/providers/blockednumber/Utils.java b/src/com/android/providers/blockednumber/Utils.java
new file mode 100644
index 0000000..e890634
--- /dev/null
+++ b/src/com/android/providers/blockednumber/Utils.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2016 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.providers.blockednumber;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.location.Country;
+import android.location.CountryDetector;
+import android.telephony.PhoneNumberUtils;
+import android.text.TextUtils;
+
+import java.util.Locale;
+
+public class Utils {
+ private Utils() {
+ }
+
+ public static final int MIN_INDEX_LEN = 8;
+
+ /**
+ * @return The current country code.
+ */
+ public static @NonNull String getCurrentCountryIso(@NonNull Context context) {
+ final CountryDetector detector = (CountryDetector) context.getSystemService(
+ Context.COUNTRY_DETECTOR);
+ if (detector != null) {
+ final Country country = detector.detectCountry();
+ if (country != null) {
+ return country.getCountryIso();
+ }
+ }
+ final Locale locale = context.getResources().getConfiguration().locale;
+ return locale.getCountry();
+ }
+
+ /**
+ * Strip formatting characters and the non-phone number portion from a phone number. e.g.
+ * "+1-408-123-4444;123" to "+14081234444".
+ *
+ * <p>Special case: if a number contains '@', it's considered as an email address and returned
+ * unmodified.
+ */
+ public static @NonNull String stripPhoneNumber(@Nullable String phoneNumber) {
+ if (TextUtils.isEmpty(phoneNumber)) {
+ return "";
+ }
+ if (phoneNumber.contains("@")) {
+ return phoneNumber;
+ }
+ return PhoneNumberUtils.extractNetworkPortion(phoneNumber);
+ }
+
+ /**
+ * Converts a phone number to an E164 number, assuming the current country. If {@code
+ * incomingE16Number} is provided, it'll just strip it and returns. If the number is not valid,
+ * it'll return "".
+ *
+ * <p>Special case: if {@code rawNumber} contains '@', it's considered as an email address and
+ * returned unmodified.
+ */
+ public static @NonNull String getE164Number(@NonNull Context context,
+ @Nullable String rawNumber, @Nullable String incomingE16Number) {
+ if (rawNumber != null && rawNumber.contains("@")) {
+ return rawNumber;
+ }
+ if (!TextUtils.isEmpty(incomingE16Number)) {
+ return stripPhoneNumber(incomingE16Number);
+ }
+ if (TextUtils.isEmpty(rawNumber)) {
+ return "";
+ }
+ final String e164 =
+ PhoneNumberUtils.formatNumberToE164(rawNumber, getCurrentCountryIso(context));
+ return e164 == null ? "" : e164;
+ }
+
+ public static @Nullable String wrapSelectionWithParens(@Nullable String selection) {
+ return TextUtils.isEmpty(selection) ? null : "(" + selection + ")";
+ }
+}