1/*
2 * Copyright (C) 2015 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 */
16
17package com.android.dialer.blocking;
18
19import android.annotation.TargetApi;
20import android.content.AsyncQueryHandler;
21import android.content.ContentValues;
22import android.content.Context;
23import android.database.Cursor;
24import android.database.DatabaseUtils;
25import android.database.sqlite.SQLiteDatabaseCorruptException;
26import android.net.Uri;
27import android.os.Build.VERSION_CODES;
28import android.support.annotation.Nullable;
29import android.support.annotation.VisibleForTesting;
30import android.support.v4.os.UserManagerCompat;
31import android.telephony.PhoneNumberUtils;
32import android.text.TextUtils;
33import com.android.dialer.common.Assert;
34import com.android.dialer.common.LogUtil;
35import com.android.dialer.database.FilteredNumberContract.FilteredNumberColumns;
36import com.android.dialer.database.FilteredNumberContract.FilteredNumberTypes;
37import java.util.Map;
38import java.util.concurrent.ConcurrentHashMap;
39
40public class FilteredNumberAsyncQueryHandler extends AsyncQueryHandler {
41
42  public static final int INVALID_ID = -1;
43  // Id used to replace null for blocked id since ConcurrentHashMap doesn't allow null key/value.
44  @VisibleForTesting static final int BLOCKED_NUMBER_CACHE_NULL_ID = -1;
45
46  @VisibleForTesting
47  static final Map<String, Integer> blockedNumberCache = new ConcurrentHashMap<>();
48
49  private static final int NO_TOKEN = 0;
50  private final Context context;
51
52  public FilteredNumberAsyncQueryHandler(Context context) {
53    super(context.getContentResolver());
54    this.context = context;
55  }
56
57  @Override
58  protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
59    try {
60      if (cookie != null) {
61        ((Listener) cookie).onQueryComplete(token, cookie, cursor);
62      }
63    } finally {
64      if (cursor != null) {
65        cursor.close();
66      }
67    }
68  }
69
70  @Override
71  protected void onInsertComplete(int token, Object cookie, Uri uri) {
72    if (cookie != null) {
73      ((Listener) cookie).onInsertComplete(token, cookie, uri);
74    }
75  }
76
77  @Override
78  protected void onUpdateComplete(int token, Object cookie, int result) {
79    if (cookie != null) {
80      ((Listener) cookie).onUpdateComplete(token, cookie, result);
81    }
82  }
83
84  @Override
85  protected void onDeleteComplete(int token, Object cookie, int result) {
86    if (cookie != null) {
87      ((Listener) cookie).onDeleteComplete(token, cookie, result);
88    }
89  }
90
91  void hasBlockedNumbers(final OnHasBlockedNumbersListener listener) {
92    if (!FilteredNumberCompat.canAttemptBlockOperations(context)) {
93      listener.onHasBlockedNumbers(false);
94      return;
95    }
96    startQuery(
97        NO_TOKEN,
98        new Listener() {
99          @Override
100          protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
101            listener.onHasBlockedNumbers(cursor != null && cursor.getCount() > 0);
102          }
103        },
104        FilteredNumberCompat.getContentUri(context, null),
105        new String[] {FilteredNumberCompat.getIdColumnName(context)},
106        FilteredNumberCompat.useNewFiltering(context)
107            ? null
108            : FilteredNumberColumns.TYPE + "=" + FilteredNumberTypes.BLOCKED_NUMBER,
109        null,
110        null);
111  }
112
113  /**
114   * Checks if the given number is blocked, calling the given {@link OnCheckBlockedListener} with
115   * the id for the blocked number, {@link #INVALID_ID}, or {@code null} based on the result of the
116   * check.
117   */
118  public void isBlockedNumber(
119      final OnCheckBlockedListener listener, @Nullable final String number, String countryIso) {
120    if (number == null) {
121      listener.onCheckComplete(INVALID_ID);
122      return;
123    }
124    if (!FilteredNumberCompat.canAttemptBlockOperations(context)) {
125      listener.onCheckComplete(null);
126      return;
127    }
128    Integer cachedId = blockedNumberCache.get(number);
129    if (cachedId != null) {
130      if (listener == null) {
131        return;
132      }
133      if (cachedId == BLOCKED_NUMBER_CACHE_NULL_ID) {
134        cachedId = null;
135      }
136      listener.onCheckComplete(cachedId);
137      return;
138    }
139
140    if (!UserManagerCompat.isUserUnlocked(context)) {
141      LogUtil.i(
142          "FilteredNumberAsyncQueryHandler.isBlockedNumber",
143          "Device locked in FBE mode, cannot access blocked number database");
144      listener.onCheckComplete(INVALID_ID);
145      return;
146    }
147
148    String e164Number = PhoneNumberUtils.formatNumberToE164(number, countryIso);
149    String formattedNumber = FilteredNumbersUtil.getBlockableNumber(context, e164Number, number);
150    if (TextUtils.isEmpty(formattedNumber)) {
151      listener.onCheckComplete(INVALID_ID);
152      blockedNumberCache.put(number, INVALID_ID);
153      return;
154    }
155
156    startQuery(
157        NO_TOKEN,
158        new Listener() {
159          @Override
160          protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
161            /*
162             * In the frameworking blocking, numbers can be blocked in both e164 format
163             * and not, resulting in multiple rows being returned for this query. For
164             * example, both '16502530000' and '6502530000' can exist at the same time
165             * and will be returned by this query.
166             */
167            if (cursor == null || cursor.getCount() == 0) {
168              blockedNumberCache.put(number, BLOCKED_NUMBER_CACHE_NULL_ID);
169              listener.onCheckComplete(null);
170              return;
171            }
172            cursor.moveToFirst();
173            // New filtering doesn't have a concept of type
174            if (!FilteredNumberCompat.useNewFiltering(context)
175                && cursor.getInt(cursor.getColumnIndex(FilteredNumberColumns.TYPE))
176                    != FilteredNumberTypes.BLOCKED_NUMBER) {
177              blockedNumberCache.put(number, BLOCKED_NUMBER_CACHE_NULL_ID);
178              listener.onCheckComplete(null);
179              return;
180            }
181            Integer blockedId = cursor.getInt(cursor.getColumnIndex(FilteredNumberColumns._ID));
182            blockedNumberCache.put(number, blockedId);
183            listener.onCheckComplete(blockedId);
184          }
185        },
186        FilteredNumberCompat.getContentUri(context, null),
187        FilteredNumberCompat.filter(
188            new String[] {
189              FilteredNumberCompat.getIdColumnName(context),
190              FilteredNumberCompat.getTypeColumnName(context)
191            }),
192        getIsBlockedNumberSelection(e164Number != null) + " = ?",
193        new String[] {formattedNumber},
194        null);
195  }
196
197  /**
198   * Synchronously check if this number has been blocked.
199   *
200   * @return blocked id.
201   */
202  @TargetApi(VERSION_CODES.M)
203  @Nullable
204  public Integer getBlockedIdSynchronous(@Nullable String number, String countryIso) {
205    Assert.isWorkerThread();
206    if (number == null) {
207      return null;
208    }
209    if (!FilteredNumberCompat.canAttemptBlockOperations(context)) {
210      return null;
211    }
212    Integer cachedId = blockedNumberCache.get(number);
213    if (cachedId != null) {
214      if (cachedId == BLOCKED_NUMBER_CACHE_NULL_ID) {
215        cachedId = null;
216      }
217      return cachedId;
218    }
219
220    String e164Number = PhoneNumberUtils.formatNumberToE164(number, countryIso);
221    String formattedNumber = FilteredNumbersUtil.getBlockableNumber(context, e164Number, number);
222    if (TextUtils.isEmpty(formattedNumber)) {
223      return null;
224    }
225
226    try (Cursor cursor =
227        context
228            .getContentResolver()
229            .query(
230                FilteredNumberCompat.getContentUri(context, null),
231                FilteredNumberCompat.filter(
232                    new String[] {
233                      FilteredNumberCompat.getIdColumnName(context),
234                      FilteredNumberCompat.getTypeColumnName(context)
235                    }),
236                getIsBlockedNumberSelection(e164Number != null) + " = ?",
237                new String[] {formattedNumber},
238                null)) {
239      /*
240       * In the frameworking blocking, numbers can be blocked in both e164 format
241       * and not, resulting in multiple rows being returned for this query. For
242       * example, both '16502530000' and '6502530000' can exist at the same time
243       * and will be returned by this query.
244       */
245      if (cursor == null || cursor.getCount() == 0) {
246        blockedNumberCache.put(number, BLOCKED_NUMBER_CACHE_NULL_ID);
247        return null;
248      }
249      cursor.moveToFirst();
250      int blockedId = cursor.getInt(cursor.getColumnIndex(FilteredNumberColumns._ID));
251      blockedNumberCache.put(number, blockedId);
252      return blockedId;
253    } catch (SecurityException e) {
254      LogUtil.e("FilteredNumberAsyncQueryHandler.getBlockedIdSynchronous", null, e);
255      return null;
256    }
257  }
258
259  @VisibleForTesting
260  public void clearCache() {
261    blockedNumberCache.clear();
262  }
263
264  /*
265   * TODO: b/27779827, non-e164 numbers can be blocked in the new form of blocking. As a
266   * temporary workaround, determine which column of the database to query based on whether the
267   * number is e164 or not.
268   */
269  private String getIsBlockedNumberSelection(boolean isE164Number) {
270    if (FilteredNumberCompat.useNewFiltering(context) && !isE164Number) {
271      return FilteredNumberCompat.getOriginalNumberColumnName(context);
272    }
273    return FilteredNumberCompat.getE164NumberColumnName(context);
274  }
275
276  public void blockNumber(
277      final OnBlockNumberListener listener, String number, @Nullable String countryIso) {
278    blockNumber(listener, null, number, countryIso);
279  }
280
281  /** Add a number manually blocked by the user. */
282  public void blockNumber(
283      final OnBlockNumberListener listener,
284      @Nullable String normalizedNumber,
285      String number,
286      @Nullable String countryIso) {
287    blockNumber(
288        listener,
289        FilteredNumberCompat.newBlockNumberContentValues(
290            context, number, normalizedNumber, countryIso));
291  }
292
293  /**
294   * Block a number with specified ContentValues. Can be manually added or a restored row from
295   * performing the 'undo' action after unblocking.
296   */
297  public void blockNumber(final OnBlockNumberListener listener, ContentValues values) {
298    blockedNumberCache.clear();
299    if (!FilteredNumberCompat.canAttemptBlockOperations(context)) {
300      listener.onBlockComplete(null);
301      return;
302    }
303    startInsert(
304        NO_TOKEN,
305        new Listener() {
306          @Override
307          public void onInsertComplete(int token, Object cookie, Uri uri) {
308            if (listener != null) {
309              listener.onBlockComplete(uri);
310            }
311          }
312        },
313        FilteredNumberCompat.getContentUri(context, null),
314        values);
315  }
316
317  /**
318   * Unblocks the number with the given id.
319   *
320   * @param listener (optional) The {@link OnUnblockNumberListener} called after the number is
321   *     unblocked.
322   * @param id The id of the number to unblock.
323   */
324  public void unblock(@Nullable final OnUnblockNumberListener listener, Integer id) {
325    if (id == null) {
326      throw new IllegalArgumentException("Null id passed into unblock");
327    }
328    unblock(listener, FilteredNumberCompat.getContentUri(context, id));
329  }
330
331  /**
332   * Removes row from database.
333   *
334   * @param listener (optional) The {@link OnUnblockNumberListener} called after the number is
335   *     unblocked.
336   * @param uri The uri of row to remove, from {@link FilteredNumberAsyncQueryHandler#blockNumber}.
337   */
338  public void unblock(@Nullable final OnUnblockNumberListener listener, final Uri uri) {
339    blockedNumberCache.clear();
340    if (!FilteredNumberCompat.canAttemptBlockOperations(context)) {
341      if (listener != null) {
342        listener.onUnblockComplete(0, null);
343      }
344      return;
345    }
346    startQuery(
347        NO_TOKEN,
348        new Listener() {
349          @Override
350          public void onQueryComplete(int token, Object cookie, Cursor cursor) {
351            int rowsReturned = cursor == null ? 0 : cursor.getCount();
352            if (rowsReturned != 1) {
353              throw new SQLiteDatabaseCorruptException(
354                  "Returned " + rowsReturned + " rows for uri " + uri + "where 1 expected.");
355            }
356            cursor.moveToFirst();
357            final ContentValues values = new ContentValues();
358            DatabaseUtils.cursorRowToContentValues(cursor, values);
359            values.remove(FilteredNumberCompat.getIdColumnName(context));
360
361            startDelete(
362                NO_TOKEN,
363                new Listener() {
364                  @Override
365                  public void onDeleteComplete(int token, Object cookie, int result) {
366                    if (listener != null) {
367                      listener.onUnblockComplete(result, values);
368                    }
369                  }
370                },
371                uri,
372                null,
373                null);
374          }
375        },
376        uri,
377        null,
378        null,
379        null,
380        null);
381  }
382
383  public interface OnCheckBlockedListener {
384
385    /**
386     * Invoked after querying if a number is blocked.
387     *
388     * @param id The ID of the row if blocked, null otherwise.
389     */
390    void onCheckComplete(Integer id);
391  }
392
393  public interface OnBlockNumberListener {
394
395    /**
396     * Invoked after inserting a blocked number.
397     *
398     * @param uri The uri of the newly created row.
399     */
400    void onBlockComplete(Uri uri);
401  }
402
403  public interface OnUnblockNumberListener {
404
405    /**
406     * Invoked after removing a blocked number
407     *
408     * @param rows The number of rows affected (expected value 1).
409     * @param values The deleted data (used for restoration).
410     */
411    void onUnblockComplete(int rows, ContentValues values);
412  }
413
414  interface OnHasBlockedNumbersListener {
415
416    /**
417     * @param hasBlockedNumbers {@code true} if any blocked numbers are stored. {@code false}
418     *     otherwise.
419     */
420    void onHasBlockedNumbers(boolean hasBlockedNumbers);
421  }
422
423  /** Methods for FilteredNumberAsyncQueryHandler result returns. */
424  private abstract static class Listener {
425
426    protected void onQueryComplete(int token, Object cookie, Cursor cursor) {}
427
428    protected void onInsertComplete(int token, Object cookie, Uri uri) {}
429
430    protected void onUpdateComplete(int token, Object cookie, int result) {}
431
432    protected void onDeleteComplete(int token, Object cookie, int result) {}
433  }
434}
435