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