diff options
author | Makoto Onuki <omakoto@google.com> | 2016-01-12 15:53:00 -0800 |
---|---|---|
committer | Makoto Onuki <omakoto@google.com> | 2016-01-14 17:43:50 -0800 |
commit | 28b6a0f277a997f5c117c3c5be3de604c7a38d12 (patch) | |
tree | 30c162929957234bc45ef6b98c233802b18d8b15 /src | |
parent | 963604b4286899279832d0a83e2dd275a4ea6ff5 (diff) | |
download | BlockedNumberProvider-28b6a0f277a997f5c117c3c5be3de604c7a38d12.tar.gz |
Introduce "blocked phone number" provider
Bug 26232372
Change-Id: I735d2949f45f533c26063d413dd3dfb72f455711
Diffstat (limited to 'src')
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 + ")"; + } +} |