/* * Copyright (C) 2017 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.dialer.searchfragment.cp2; import android.content.ContentResolver; import android.database.CharArrayBuffer; import android.database.ContentObserver; import android.database.Cursor; import android.database.DataSetObserver; import android.database.MatrixCursor; import android.net.Uri; import android.os.Bundle; import android.provider.ContactsContract.CommonDataKinds.Nickname; import android.provider.ContactsContract.CommonDataKinds.Organization; import android.provider.ContactsContract.CommonDataKinds.Phone; import android.support.annotation.IntDef; import android.support.annotation.Nullable; import android.support.v4.util.ArraySet; import android.text.TextUtils; import com.android.dialer.common.Assert; import com.android.dialer.searchfragment.common.Projections; import com.android.dialer.searchfragment.common.QueryFilteringUtil; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Set; /** * Wrapper for a cursor containing all on device contacts. * *

This cursor removes duplicate phone numbers associated with the same contact and can filter * contacts based on a query by calling {@link #filter(String)}. */ final class ContactFilterCursor implements Cursor { private final Cursor cursor; // List of cursor ids that are valid for displaying after filtering. private final List queryFilteredPositions = new ArrayList<>(); private int currentPosition = 0; @Retention(RetentionPolicy.SOURCE) @IntDef({ Qualification.NUMBERS_ARE_NOT_DUPLICATES, Qualification.NEW_NUMBER_IS_MORE_QUALIFIED, Qualification.CURRENT_MORE_QUALIFIED }) private @interface Qualification { /** Numbers are not duplicates (i.e. neither is more qualified than the other). */ int NUMBERS_ARE_NOT_DUPLICATES = 0; /** Number are duplicates and new number is more qualified than the existing number. */ int NEW_NUMBER_IS_MORE_QUALIFIED = 1; /** Numbers are duplicates but current/existing number is more qualified than new number. */ int CURRENT_MORE_QUALIFIED = 2; } /** * @param cursor with projection {@link Projections#DATA_PROJECTION}. * @param query to filter cursor results. */ ContactFilterCursor(Cursor cursor, @Nullable String query) { this.cursor = createCursor(cursor); filter(query); } /** * Returns a new cursor with contact information coalesced. * *

Here are some sample rows and columns that might exist in cp2 database: * *

* *

These rows would be coalesced into new rows like so: * *

*/ private static Cursor createCursor(Cursor cursor) { // Convert cursor rows into Cp2Contacts List cp2Contacts = new ArrayList<>(); Set contactIds = new ArraySet<>(); cursor.moveToPosition(-1); while (cursor.moveToNext()) { Cp2Contact contact = Cp2Contact.fromCursor(cursor); cp2Contacts.add(contact); contactIds.add(contact.contactId()); } cursor.close(); // Group then combine contact data List coalescedContacts = new ArrayList<>(); for (Integer contactId : contactIds) { List duplicateContacts = getAllContactsWithContactId(contactId, cp2Contacts); coalescedContacts.addAll(coalesceContacts(duplicateContacts)); } // Sort by display name, then build new cursor from coalesced contacts. // We sort the contacts so that they are displayed to the user in lexicographic order. Collections.sort(coalescedContacts, (o1, o2) -> o1.displayName().compareTo(o2.displayName())); MatrixCursor newCursor = new MatrixCursor(Projections.DATA_PROJECTION, coalescedContacts.size()); for (Cp2Contact contact : coalescedContacts) { newCursor.addRow(contact.toCursorRow()); } return newCursor; } private static List coalesceContacts(List contactsWithSameContactId) { String companyName = null; String nickName = null; List phoneContacts = new ArrayList<>(); for (Cp2Contact contact : contactsWithSameContactId) { if (contact.mimeType().equals(Phone.CONTENT_ITEM_TYPE)) { phoneContacts.add(contact); } else if (contact.mimeType().equals(Organization.CONTENT_ITEM_TYPE)) { Assert.checkArgument(TextUtils.isEmpty(companyName)); companyName = contact.companyName(); } else if (contact.mimeType().equals(Nickname.CONTENT_ITEM_TYPE)) { Assert.checkArgument(TextUtils.isEmpty(nickName)); nickName = contact.nickName(); } } removeDuplicatePhoneNumbers(phoneContacts); List coalescedContacts = new ArrayList<>(); for (Cp2Contact phoneContact : phoneContacts) { coalescedContacts.add( phoneContact.toBuilder().setCompanyName(companyName).setNickName(nickName).build()); } return coalescedContacts; } private static void removeDuplicatePhoneNumbers(List phoneContacts) { for (int i = 0; i < phoneContacts.size(); i++) { Cp2Contact contact1 = phoneContacts.get(i); for (int j = i + 1; j < phoneContacts.size(); /* don't iterate by default */ ) { Cp2Contact contact2 = phoneContacts.get(j); int qualification = getQualification(contact2.phoneNumber(), contact1.phoneNumber()); if (qualification == Qualification.CURRENT_MORE_QUALIFIED) { phoneContacts.remove(contact2); } else if (qualification == Qualification.NEW_NUMBER_IS_MORE_QUALIFIED) { phoneContacts.remove(contact1); break; } else if (qualification == Qualification.NUMBERS_ARE_NOT_DUPLICATES) { // Keep both contacts j++; } } } } /** * @param number that may or may not be more qualified than the existing most qualified number * @param mostQualifiedNumber currently most qualified number associated with same contact * @return {@link Qualification} where the more qualified number is the number with the most * digits. If the digits are the same, the number with the most formatting is more qualified. */ private static @Qualification int getQualification(String number, String mostQualifiedNumber) { // Ignore formatting String numberDigits = QueryFilteringUtil.digitsOnly(number); String qualifiedNumberDigits = QueryFilteringUtil.digitsOnly(mostQualifiedNumber); // If the numbers are identical, return version with more formatting if (qualifiedNumberDigits.equals(numberDigits)) { if (mostQualifiedNumber.length() >= number.length()) { return Qualification.CURRENT_MORE_QUALIFIED; } else { return Qualification.NEW_NUMBER_IS_MORE_QUALIFIED; } } // If one number is a suffix of another, then return the longer one. // If they are equal, then return the current most qualified number. if (qualifiedNumberDigits.endsWith(numberDigits)) { return Qualification.CURRENT_MORE_QUALIFIED; } if (numberDigits.endsWith(qualifiedNumberDigits)) { return Qualification.NEW_NUMBER_IS_MORE_QUALIFIED; } return Qualification.NUMBERS_ARE_NOT_DUPLICATES; } private static List getAllContactsWithContactId( int contactId, List contacts) { List contactIdContacts = new ArrayList<>(); for (Cp2Contact contact : contacts) { if (contact.contactId() == contactId) { contactIdContacts.add(contact); } } return contactIdContacts; } /** * Filters out contacts that do not match the query. * *

The query can have at least 1 of 3 forms: * *

    *
  • A phone number *
  • A T9 representation of a name (matches {@link QueryFilteringUtil#T9_PATTERN}). *
  • A name *
* *

A contact is considered a match if: * *

    *
  • Its phone number contains the phone number query *
  • Its name represented in T9 contains the T9 query *
  • Its name contains the query *
  • Its company contains the query *
*/ public void filter(@Nullable String query) { if (query == null) { query = ""; } queryFilteredPositions.clear(); query = query.toLowerCase(); cursor.moveToPosition(-1); while (cursor.moveToNext()) { int position = cursor.getPosition(); String number = cursor.getString(Projections.PHONE_NUMBER); String name = cursor.getString(Projections.DISPLAY_NAME); String companyName = cursor.getString(Projections.COMPANY_NAME); String nickName = cursor.getString(Projections.NICKNAME); if (TextUtils.isEmpty(query) || QueryFilteringUtil.nameMatchesT9Query(query, name) || QueryFilteringUtil.numberMatchesNumberQuery(query, number) || QueryFilteringUtil.nameContainsQuery(query, name) || QueryFilteringUtil.nameContainsQuery(query, companyName) || QueryFilteringUtil.nameContainsQuery(query, nickName)) { queryFilteredPositions.add(position); } } currentPosition = 0; cursor.moveToFirst(); } @Override public boolean moveToPosition(int position) { currentPosition = position; return currentPosition < getCount() && cursor.moveToPosition(queryFilteredPositions.get(currentPosition)); } @Override public boolean move(int offset) { currentPosition += offset; return moveToPosition(currentPosition); } @Override public int getCount() { return queryFilteredPositions.size(); } @Override public boolean isFirst() { return currentPosition == 0; } @Override public boolean isLast() { return currentPosition == getCount() - 1; } @Override public int getPosition() { return currentPosition; } @Override public boolean moveToFirst() { return moveToPosition(0); } @Override public boolean moveToLast() { return moveToPosition(getCount() - 1); } @Override public boolean moveToNext() { return moveToPosition(++currentPosition); } @Override public boolean moveToPrevious() { return moveToPosition(--currentPosition); } // Methods below simply call the corresponding method in cursor. @Override public boolean isBeforeFirst() { return cursor.isBeforeFirst(); } @Override public boolean isAfterLast() { return cursor.isAfterLast(); } @Override public int getColumnIndex(String columnName) { return cursor.getColumnIndex(columnName); } @Override public int getColumnIndexOrThrow(String columnName) { return cursor.getColumnIndexOrThrow(columnName); } @Override public String getColumnName(int columnIndex) { return cursor.getColumnName(columnIndex); } @Override public String[] getColumnNames() { return cursor.getColumnNames(); } @Override public int getColumnCount() { return cursor.getColumnCount(); } @Override public byte[] getBlob(int columnIndex) { return cursor.getBlob(columnIndex); } @Override public String getString(int columnIndex) { return cursor.getString(columnIndex); } @Override public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) { cursor.copyStringToBuffer(columnIndex, buffer); } @Override public short getShort(int columnIndex) { return cursor.getShort(columnIndex); } @Override public int getInt(int columnIndex) { return cursor.getInt(columnIndex); } @Override public long getLong(int columnIndex) { return cursor.getLong(columnIndex); } @Override public float getFloat(int columnIndex) { return cursor.getFloat(columnIndex); } @Override public double getDouble(int columnIndex) { return cursor.getDouble(columnIndex); } @Override public int getType(int columnIndex) { return cursor.getType(columnIndex); } @Override public boolean isNull(int columnIndex) { return cursor.isNull(columnIndex); } @Override public void deactivate() { cursor.deactivate(); } @Override public boolean requery() { return cursor.requery(); } @Override public void close() { cursor.close(); } @Override public boolean isClosed() { return cursor.isClosed(); } @Override public void registerContentObserver(ContentObserver observer) { cursor.registerContentObserver(observer); } @Override public void unregisterContentObserver(ContentObserver observer) { cursor.unregisterContentObserver(observer); } @Override public void registerDataSetObserver(DataSetObserver observer) { cursor.registerDataSetObserver(observer); } @Override public void unregisterDataSetObserver(DataSetObserver observer) { cursor.unregisterDataSetObserver(observer); } @Override public void setNotificationUri(ContentResolver cr, Uri uri) { cursor.setNotificationUri(cr, uri); } @Override public Uri getNotificationUri() { return cursor.getNotificationUri(); } @Override public boolean getWantsAllOnMoveCalls() { return cursor.getWantsAllOnMoveCalls(); } @Override public void setExtras(Bundle extras) { cursor.setExtras(extras); } @Override public Bundle getExtras() { return cursor.getExtras(); } @Override public Bundle respond(Bundle extras) { return cursor.respond(extras); } }