BlockedNumberProvider.java revision 28b6a0f277a997f5c117c3c5be3de604c7a38d12
1/*
2 * Copyright (C) 2016 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License
15 */
16package com.android.providers.blockednumber;
17
18import android.annotation.NonNull;
19import android.annotation.Nullable;
20import android.app.AppOpsManager;
21import android.content.*;
22import android.database.Cursor;
23import android.database.sqlite.SQLiteDatabase;
24import android.database.sqlite.SQLiteQueryBuilder;
25import android.net.Uri;
26import android.os.*;
27import android.os.Process;
28import android.provider.BlockedNumberContract;
29import android.telecom.TelecomManager;
30import android.text.TextUtils;
31import android.util.Log;
32import com.android.common.content.ProjectionMap;
33import com.android.providers.blockednumber.BlockedNumberDatabaseHelper.Tables;
34
35/**
36 * Blocked phone number provider.
37 *
38 * <p>Note the provider allows emergency numbers.  The caller (telecom) should never call it with
39 * emergency numbers.
40 */
41public class BlockedNumberProvider extends ContentProvider {
42    static final String TAG = "BlockedNumbers";
43
44    private static final boolean DEBUG = true; // DO NOT SUBMIT WITH TRUE.
45
46    private static final int BLOCKED_LIST = 1000;
47    private static final int BLOCKED_ID = 1001;
48
49    private static final UriMatcher sUriMatcher;
50
51    static {
52        sUriMatcher = new UriMatcher(0);
53        sUriMatcher.addURI(BlockedNumberContract.AUTHORITY, "blocked", BLOCKED_LIST);
54        sUriMatcher.addURI(BlockedNumberContract.AUTHORITY, "blocked/#", BLOCKED_ID);
55    }
56
57    private static final ProjectionMap sBlockedNumberColumns = ProjectionMap.builder()
58            .add(BlockedNumberContract.BlockedNumbers.COLUMN_ID)
59            .add(BlockedNumberContract.BlockedNumbers.COLUMN_ORIGINAL_NUMBER)
60            .add(BlockedNumberContract.BlockedNumbers.COLUMN_STRIPPED_NUMBER)
61            .add(BlockedNumberContract.BlockedNumbers.COLUMN_E164_NUMBER)
62            .build();
63
64    private static final String ID_SELECTION =
65            BlockedNumberContract.BlockedNumbers.COLUMN_ID + "=?";
66
67    @Override
68    public boolean onCreate() {
69        return true;
70    }
71
72    BlockedNumberDatabaseHelper getDbHelper() {
73        return BlockedNumberDatabaseHelper.getInstance(getContext());
74    }
75
76    /**
77     * TODO CTS:
78     * - BLOCKED_LIST
79     * - BLOCKED_ID
80     * - Other random URLs should fail
81     */
82    @Override
83    public String getType(@NonNull Uri uri) {
84        final int match = sUriMatcher.match(uri);
85        switch (match) {
86            case BLOCKED_LIST:
87                return BlockedNumberContract.BlockedNumbers.CONTENT_TYPE;
88            case BLOCKED_ID:
89                return BlockedNumberContract.BlockedNumbers.CONTENT_ITEM_TYPE;
90            default:
91                throw new IllegalArgumentException("Unsupported URI: " + uri);
92        }
93    }
94
95    /**
96     * TODO CTS:
97     * - BLOCKED_LIST
98     *   With no columns should fail
99     *   With COLUMN_INDEX_ORIGINAL only
100     *   With COLUMN_INDEX_E164 only should fail
101     *   With COLUMN_INDEX_ORIGINAL + COLUMN_INDEX_E164
102     *   With with throwIfSpecified columns, should fail.
103     *
104     * - BLOCKED_ID should fail
105     * - Other random URLs should fail
106     */
107    @Override
108    public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
109        enforceWritePermission();
110
111        final int match = sUriMatcher.match(uri);
112        switch (match) {
113            case BLOCKED_LIST:
114                return insertBlockedNumber(values);
115            default:
116                throw new IllegalArgumentException("Unsupported URI: " + uri);
117        }
118    }
119
120    /**
121     * Implements the "blocked/" insert.
122     */
123    private Uri insertBlockedNumber(ContentValues cv) {
124        throwIfSpecified(cv, BlockedNumberContract.BlockedNumbers.COLUMN_ID);
125        throwIfSpecified(cv, BlockedNumberContract.BlockedNumbers.COLUMN_STRIPPED_NUMBER);
126
127        final String phoneNumber = cv.getAsString(
128                BlockedNumberContract.BlockedNumbers.COLUMN_ORIGINAL_NUMBER);
129
130        if (TextUtils.isEmpty(phoneNumber)) {
131            throw new IllegalArgumentException("Missing a required column " +
132                    BlockedNumberContract.BlockedNumbers.COLUMN_ORIGINAL_NUMBER);
133        }
134
135        // Sanitize the input and fill in with autogenerated columns.
136        final String strippedNumber = Utils.stripPhoneNumber(phoneNumber);
137        final String e164Number = Utils.getE164Number(getContext(), strippedNumber,
138                cv.getAsString(BlockedNumberContract.BlockedNumbers.COLUMN_E164_NUMBER));
139
140        cv.put(BlockedNumberContract.BlockedNumbers.COLUMN_STRIPPED_NUMBER, strippedNumber);
141        cv.put(BlockedNumberContract.BlockedNumbers.COLUMN_E164_NUMBER, e164Number);
142
143        // Then insert.
144        final long id = getDbHelper().getWritableDatabase().insertOrThrow(
145                BlockedNumberDatabaseHelper.Tables.BLOCKED_NUMBERS, null, cv);
146
147        return ContentUris.withAppendedId(BlockedNumberContract.BlockedNumbers.CONTENT_URI, id);
148    }
149
150    private static void throwIfSpecified(ContentValues cv, String column) {
151        if (cv.containsKey(column)) {
152            throw new IllegalArgumentException("Column " + column + " must not be specified");
153        }
154    }
155
156    /**
157     * TODO CTS:
158     * - Any call should fail
159     */
160    @Override
161    public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection,
162            @Nullable String[] selectionArgs) {
163        throw new UnsupportedOperationException(
164                "Update is not supported.  Use delete + insert instead");
165    }
166
167    /**
168     * TODO CTS:
169     * - BLOCKED_LIST, with selection and without.
170     * - BLOCKED_ID , with selection and without.  With should fail.
171     */
172    @Override
173    public int delete(@NonNull Uri uri, @Nullable String selection,
174            @Nullable String[] selectionArgs) {
175        enforceWritePermission();
176
177        final int match = sUriMatcher.match(uri);
178        switch (match) {
179            case BLOCKED_LIST:
180                return deleteBlockedNumber(selection, selectionArgs);
181            case BLOCKED_ID:
182                return deleteBlockedNumberWithId(ContentUris.parseId(uri), selection);
183            default:
184                throw new IllegalArgumentException("Unsupported URI: " + uri);
185        }
186    }
187
188    /**
189     * Implements the "blocked/#" delete.
190     */
191    private int deleteBlockedNumberWithId(long id, String selection) {
192        throwForNonEmptySelection(selection);
193
194        return deleteBlockedNumber(ID_SELECTION, new String[]{Long.toString(id)});
195    }
196
197    /**
198     * Implements the "blocked/" delete.
199     */
200    private int deleteBlockedNumber(String selection, String[] selectionArgs) {
201        final SQLiteDatabase db = getDbHelper().getWritableDatabase();
202
203        // When selection is specified, compile it within (...) to detect SQL injection.
204        if (!TextUtils.isEmpty(selection)) {
205            db.validateSql("select 1 FROM " + Tables.BLOCKED_NUMBERS + " WHERE " +
206                    Utils.wrapSelectionWithParens(selection),
207                    /* cancellationSignal =*/ null);
208        }
209
210        return getDbHelper().getWritableDatabase().delete(
211                BlockedNumberDatabaseHelper.Tables.BLOCKED_NUMBERS,
212                selection, selectionArgs);
213    }
214
215    @Override
216    public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
217            @Nullable String[] selectionArgs, @Nullable String sortOrder) {
218        enforceReadPermission();
219
220        return query(uri, projection, selection, selectionArgs, sortOrder, null);
221    }
222
223    @Override
224    public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
225            @Nullable String[] selectionArgs, @Nullable String sortOrder,
226            @Nullable CancellationSignal cancellationSignal) {
227        enforceReadPermission();
228
229        final int match = sUriMatcher.match(uri);
230        switch (match) {
231            case BLOCKED_LIST:
232                return queryBlockedList(projection, selection, selectionArgs, sortOrder,
233                        cancellationSignal);
234            case BLOCKED_ID:
235                return queryBlockedListWithId(ContentUris.parseId(uri), projection, selection,
236                        cancellationSignal);
237            default:
238                throw new IllegalArgumentException("Unsupported URI: " + uri);
239        }
240    }
241
242    /**
243     * Implements the "blocked/#" query.
244     */
245    private Cursor queryBlockedListWithId(long id, String[] projection, String selection,
246            CancellationSignal cancellationSignal) {
247        throwForNonEmptySelection(selection);
248
249        return queryBlockedList(projection, ID_SELECTION, new String[]{Long.toString(id)},
250                null, cancellationSignal);
251    }
252
253    /**
254     * Implements the "blocked/" query.
255     */
256    private Cursor queryBlockedList(String[] projection, String selection, String[] selectionArgs,
257            String sortOrder, CancellationSignal cancellationSignal) {
258        SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
259        qb.setStrict(true);
260        qb.setTables(BlockedNumberDatabaseHelper.Tables.BLOCKED_NUMBERS);
261        qb.setProjectionMap(sBlockedNumberColumns);
262
263        return qb.query(getDbHelper().getReadableDatabase(), projection, selection, selectionArgs,
264                /* groupBy =*/ null, /* having =*/null, sortOrder,
265                /* limit =*/ null, cancellationSignal);
266    }
267
268    private void throwForNonEmptySelection(String selection) {
269        if (!TextUtils.isEmpty(selection)) {
270            throw new IllegalArgumentException(
271                    "When ID is specified in URI, selection must be null");
272        }
273    }
274
275    /**
276     * TODO CTS:
277     * - METHOD_IS_BLOCKED with various matching / non-matching arguments.
278     *
279     * - other random methods should fail
280     */
281    @Override
282    public Bundle call(@NonNull String method, @Nullable String arg, @Nullable Bundle extras) {
283        enforceReadPermission();
284
285        final Bundle res = new Bundle();
286        switch (method) {
287            case BlockedNumberContract.METHOD_IS_BLOCKED:
288                res.putBoolean(BlockedNumberContract.RES_NUMBER_IS_BLOCKED, isBlocked(arg));
289                break;
290            default:
291                throw new IllegalArgumentException("Unsupported method " + method);
292        }
293        return res;
294    }
295
296    private boolean isBlocked(String phoneNumber) {
297        final String inStripped = Utils.stripPhoneNumber(phoneNumber);
298        if (TextUtils.isEmpty(inStripped)) {
299            return false;
300        }
301
302        final String inE164 = Utils.getE164Number(getContext(), inStripped, null); // may be empty.
303
304        if (DEBUG) {
305            Log.d(TAG, String.format("isBlocked: in=%s, stripped=%s, e164=%s", phoneNumber,
306                    inStripped, inE164));
307        }
308
309        final Cursor c = getDbHelper().getReadableDatabase().rawQuery(
310                "SELECT " +
311                BlockedNumberContract.BlockedNumbers.COLUMN_ORIGINAL_NUMBER + "," +
312                BlockedNumberContract.BlockedNumbers.COLUMN_STRIPPED_NUMBER + "," +
313                BlockedNumberContract.BlockedNumbers.COLUMN_E164_NUMBER +
314                " FROM " + BlockedNumberDatabaseHelper.Tables.BLOCKED_NUMBERS +
315                " WHERE " + BlockedNumberContract.BlockedNumbers.COLUMN_STRIPPED_NUMBER + "=?1" +
316                " OR (?2 != '' AND " +
317                        BlockedNumberContract.BlockedNumbers.COLUMN_E164_NUMBER + "=?2)",
318                new String[] {inStripped, inE164}
319                );
320        try {
321            while (c.moveToNext()) {
322                if (DEBUG) {
323                    final String original = c.getString(0);
324                    final String stripped = c.getString(1);
325                    final String e164 = c.getString(2);
326
327                    Log.d(TAG, String.format("  match found: original=%s, stripped=%s, e164=%s",
328                            original, stripped, e164));
329                }
330                return true;
331            }
332        } finally {
333            c.close();
334        }
335        // No match found.
336        return false;
337    }
338
339    /**
340     * Throws {@link SecurityException} when the caller is not root, system, the system dialer,
341     * the user selected dialer, or the default SMS app.
342     *
343     * NOT TESTED YET
344     *
345     * TODO CTS:
346     * - Call should fail for random 3p apps.
347     *
348     * TODO Add a permission to allow the contacts app to access?
349     * TODO Add a permission to allow carrier apps?
350     */
351    public void enforceReadPermission() {
352        final int callingUid = Binder.getCallingUid();
353
354        // System and root can always call it. (and myself)
355        if (UserHandle.isSameApp(callingUid, android.os.Process.SYSTEM_UID)
356                || (callingUid == Process.ROOT_UID)
357                || (callingUid == Process.myUid())) {
358            return;
359        }
360
361        final String callingPackage = getCallingPackage();
362        if (TextUtils.isEmpty(callingPackage)) {
363            Log.w(TAG, "callingPackage not accessible");
364        } else {
365
366            final TelecomManager telecom = getContext().getSystemService(TelecomManager.class);
367
368            if (callingPackage.equals(telecom.getDefaultDialerPackage())
369                || callingPackage.equals(telecom.getSystemDialerPackage())) {
370                return;
371            }
372
373            // Allow the default SMS app and the dialer app to access it.
374            final AppOpsManager appOps = getContext().getSystemService(AppOpsManager.class);
375
376            if (appOps.noteOp(AppOpsManager.OP_WRITE_SMS,
377                    Binder.getCallingUid(), callingPackage) == AppOpsManager.MODE_ALLOWED) {
378                return;
379            }
380        }
381        throw new SecurityException("Caller must be system, default dialer or default SMS app");
382    }
383
384    /**
385     * TODO CTS:
386     * - Call should fail for random 3p apps.
387     */
388    public void enforceWritePermission() {
389        // Same check as read.
390        enforceReadPermission();
391    }
392}
393