1/*
2 * Copyright (C) 2017 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.searchfragment.cp2;
18
19import android.content.ContentResolver;
20import android.database.CharArrayBuffer;
21import android.database.ContentObserver;
22import android.database.Cursor;
23import android.database.DataSetObserver;
24import android.net.Uri;
25import android.os.Bundle;
26import android.support.annotation.IntDef;
27import android.support.annotation.Nullable;
28import android.text.TextUtils;
29import com.android.dialer.searchfragment.common.Projections;
30import com.android.dialer.searchfragment.common.QueryFilteringUtil;
31import java.lang.annotation.Retention;
32import java.lang.annotation.RetentionPolicy;
33import java.util.ArrayList;
34import java.util.List;
35
36/**
37 * Wrapper for a cursor returned by {@link SearchContactsCursorLoader}.
38 *
39 * <p>This cursor removes duplicate phone numbers associated with the same contact and can filter
40 * contacts based on a query by calling {@link #filter(String)}.
41 */
42public final class SearchContactCursor implements Cursor {
43
44  private final Cursor cursor;
45  // List of cursor ids that are valid for displaying after filtering.
46  private final List<Integer> queryFilteredPositions = new ArrayList<>();
47
48  private int currentPosition = 0;
49
50  @Retention(RetentionPolicy.SOURCE)
51  @IntDef({
52    Qualification.NUMBERS_ARE_NOT_DUPLICATES,
53    Qualification.NEW_NUMBER_IS_MORE_QUALIFIED,
54    Qualification.CURRENT_MORE_QUALIFIED
55  })
56  private @interface Qualification {
57    /** Numbers are not duplicates (i.e. neither is more qualified than the other). */
58    int NUMBERS_ARE_NOT_DUPLICATES = 0;
59    /** Number are duplicates and new number is more qualified than the existing number. */
60    int NEW_NUMBER_IS_MORE_QUALIFIED = 1;
61    /** Numbers are duplicates but current/existing number is more qualified than new number. */
62    int CURRENT_MORE_QUALIFIED = 2;
63  }
64
65  /**
66   * @param cursor with projection {@link Projections#PHONE_PROJECTION}.
67   * @param query to filter cursor results.
68   */
69  public SearchContactCursor(Cursor cursor, @Nullable String query) {
70    // TODO investigate copying this into a MatrixCursor and holding in memory
71    this.cursor = cursor;
72    filter(query);
73  }
74
75  /**
76   * Filters out contacts that do not match the query.
77   *
78   * <p>The query can have at least 1 of 3 forms:
79   *
80   * <ul>
81   *   <li>A phone number
82   *   <li>A T9 representation of a name (matches {@link QueryFilteringUtil#T9_PATTERN}).
83   *   <li>A name
84   * </ul>
85   *
86   * <p>A contact is considered a match if:
87   *
88   * <ul>
89   *   <li>Its phone number contains the phone number query
90   *   <li>Its name represented in T9 contains the T9 query
91   *   <li>Its name contains the query
92   * </ul>
93   */
94  public void filter(@Nullable String query) {
95    if (query == null) {
96      query = "";
97    }
98    queryFilteredPositions.clear();
99
100    // On some devices, contacts have multiple rows with identical phone numbers. These numbers are
101    // considered duplicates. Since the order might not be guaranteed, we compare all of the numbers
102    // and hold onto the most qualified one as the one we want to display to the user.
103    // See #getQualification for details on how qualification is determined.
104    int previousMostQualifiedPosition = 0;
105    String previousName = "";
106    String previousMostQualifiedNumber = "";
107
108    query = query.toLowerCase();
109    cursor.moveToPosition(-1);
110
111    while (cursor.moveToNext()) {
112      int position = cursor.getPosition();
113      String currentNumber = cursor.getString(Projections.PHONE_NUMBER);
114      String currentName = cursor.getString(Projections.PHONE_DISPLAY_NAME);
115
116      if (!previousName.equals(currentName)) {
117        previousName = currentName;
118        previousMostQualifiedNumber = currentNumber;
119        previousMostQualifiedPosition = position;
120      } else {
121        // Since the contact name is the same, check if this number is a duplicate
122        switch (getQualification(currentNumber, previousMostQualifiedNumber)) {
123          case Qualification.CURRENT_MORE_QUALIFIED:
124            // Number is a less qualified duplicate, ignore it.
125            continue;
126          case Qualification.NEW_NUMBER_IS_MORE_QUALIFIED:
127            // If number wasn't filtered out before, remove it and add it's more qualified version.
128            if (queryFilteredPositions.contains(previousMostQualifiedPosition)) {
129              queryFilteredPositions.remove(previousMostQualifiedPosition);
130              queryFilteredPositions.add(position);
131            }
132            previousMostQualifiedNumber = currentNumber;
133            previousMostQualifiedPosition = position;
134            continue;
135          case Qualification.NUMBERS_ARE_NOT_DUPLICATES:
136          default:
137            previousMostQualifiedNumber = currentNumber;
138            previousMostQualifiedPosition = position;
139        }
140      }
141
142      if (TextUtils.isEmpty(query)
143          || QueryFilteringUtil.nameMatchesT9Query(query, previousName)
144          || QueryFilteringUtil.numberMatchesNumberQuery(query, previousMostQualifiedNumber)
145          || previousName.contains(query)) {
146        queryFilteredPositions.add(previousMostQualifiedPosition);
147      }
148    }
149    currentPosition = 0;
150    cursor.moveToFirst();
151  }
152
153  /**
154   * @param number that may or may not be more qualified than the existing most qualified number
155   * @param mostQualifiedNumber currently most qualified number associated with same contact
156   * @return {@link Qualification} where the more qualified number is the number with the most
157   *     digits. If the digits are the same, the number with the most formatting is more qualified.
158   */
159  private @Qualification int getQualification(String number, String mostQualifiedNumber) {
160    // Ignore formatting
161    String numberDigits = QueryFilteringUtil.digitsOnly(number);
162    String qualifiedNumberDigits = QueryFilteringUtil.digitsOnly(mostQualifiedNumber);
163
164    // If the numbers are identical, return version with more formatting
165    if (qualifiedNumberDigits.equals(numberDigits)) {
166      if (mostQualifiedNumber.length() >= number.length()) {
167        return Qualification.CURRENT_MORE_QUALIFIED;
168      } else {
169        return Qualification.NEW_NUMBER_IS_MORE_QUALIFIED;
170      }
171    }
172
173    // If one number is a suffix of another, then return the longer one.
174    // If they are equal, then return the current most qualified number.
175    if (qualifiedNumberDigits.endsWith(numberDigits)) {
176      return Qualification.CURRENT_MORE_QUALIFIED;
177    }
178    if (numberDigits.endsWith(qualifiedNumberDigits)) {
179      return Qualification.NEW_NUMBER_IS_MORE_QUALIFIED;
180    }
181    return Qualification.NUMBERS_ARE_NOT_DUPLICATES;
182  }
183
184  @Override
185  public boolean moveToPosition(int position) {
186    currentPosition = position;
187    return currentPosition < getCount()
188        && cursor.moveToPosition(queryFilteredPositions.get(currentPosition));
189  }
190
191  @Override
192  public boolean move(int offset) {
193    currentPosition += offset;
194    return moveToPosition(currentPosition);
195  }
196
197  @Override
198  public int getCount() {
199    return queryFilteredPositions.size();
200  }
201
202  @Override
203  public boolean isFirst() {
204    return currentPosition == 0;
205  }
206
207  @Override
208  public boolean isLast() {
209    return currentPosition == getCount() - 1;
210  }
211
212  @Override
213  public int getPosition() {
214    return currentPosition;
215  }
216
217  @Override
218  public boolean moveToFirst() {
219    return moveToPosition(0);
220  }
221
222  @Override
223  public boolean moveToLast() {
224    return moveToPosition(getCount() - 1);
225  }
226
227  @Override
228  public boolean moveToNext() {
229    return moveToPosition(++currentPosition);
230  }
231
232  @Override
233  public boolean moveToPrevious() {
234    return moveToPosition(--currentPosition);
235  }
236
237  // Methods below simply call the corresponding method in cursor.
238  @Override
239  public boolean isBeforeFirst() {
240    return cursor.isBeforeFirst();
241  }
242
243  @Override
244  public boolean isAfterLast() {
245    return cursor.isAfterLast();
246  }
247
248  @Override
249  public int getColumnIndex(String columnName) {
250    return cursor.getColumnIndex(columnName);
251  }
252
253  @Override
254  public int getColumnIndexOrThrow(String columnName) {
255    return cursor.getColumnIndexOrThrow(columnName);
256  }
257
258  @Override
259  public String getColumnName(int columnIndex) {
260    return cursor.getColumnName(columnIndex);
261  }
262
263  @Override
264  public String[] getColumnNames() {
265    return cursor.getColumnNames();
266  }
267
268  @Override
269  public int getColumnCount() {
270    return cursor.getColumnCount();
271  }
272
273  @Override
274  public byte[] getBlob(int columnIndex) {
275    return cursor.getBlob(columnIndex);
276  }
277
278  @Override
279  public String getString(int columnIndex) {
280    return cursor.getString(columnIndex);
281  }
282
283  @Override
284  public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) {
285    cursor.copyStringToBuffer(columnIndex, buffer);
286  }
287
288  @Override
289  public short getShort(int columnIndex) {
290    return cursor.getShort(columnIndex);
291  }
292
293  @Override
294  public int getInt(int columnIndex) {
295    return cursor.getInt(columnIndex);
296  }
297
298  @Override
299  public long getLong(int columnIndex) {
300    return cursor.getLong(columnIndex);
301  }
302
303  @Override
304  public float getFloat(int columnIndex) {
305    return cursor.getFloat(columnIndex);
306  }
307
308  @Override
309  public double getDouble(int columnIndex) {
310    return cursor.getDouble(columnIndex);
311  }
312
313  @Override
314  public int getType(int columnIndex) {
315    return cursor.getType(columnIndex);
316  }
317
318  @Override
319  public boolean isNull(int columnIndex) {
320    return cursor.isNull(columnIndex);
321  }
322
323  @Override
324  public void deactivate() {
325    cursor.deactivate();
326  }
327
328  @Override
329  public boolean requery() {
330    return cursor.requery();
331  }
332
333  @Override
334  public void close() {
335    cursor.close();
336  }
337
338  @Override
339  public boolean isClosed() {
340    return cursor.isClosed();
341  }
342
343  @Override
344  public void registerContentObserver(ContentObserver observer) {
345    cursor.registerContentObserver(observer);
346  }
347
348  @Override
349  public void unregisterContentObserver(ContentObserver observer) {
350    cursor.unregisterContentObserver(observer);
351  }
352
353  @Override
354  public void registerDataSetObserver(DataSetObserver observer) {
355    cursor.registerDataSetObserver(observer);
356  }
357
358  @Override
359  public void unregisterDataSetObserver(DataSetObserver observer) {
360    cursor.unregisterDataSetObserver(observer);
361  }
362
363  @Override
364  public void setNotificationUri(ContentResolver cr, Uri uri) {
365    cursor.setNotificationUri(cr, uri);
366  }
367
368  @Override
369  public Uri getNotificationUri() {
370    return cursor.getNotificationUri();
371  }
372
373  @Override
374  public boolean getWantsAllOnMoveCalls() {
375    return cursor.getWantsAllOnMoveCalls();
376  }
377
378  @Override
379  public void setExtras(Bundle extras) {
380    cursor.setExtras(extras);
381  }
382
383  @Override
384  public Bundle getExtras() {
385    return cursor.getExtras();
386  }
387
388  @Override
389  public Bundle respond(Bundle extras) {
390    return cursor.respond(extras);
391  }
392}
393