/* * 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.phonelookup.cp2; import android.content.Context; import android.database.Cursor; import android.provider.ContactsContract.CommonDataKinds.Phone; import android.provider.ContactsContract.Contacts; import android.provider.ContactsContract.DeletedContacts; import android.support.annotation.NonNull; import android.support.v4.util.ArrayMap; import android.support.v4.util.ArraySet; import android.telecom.Call; import android.text.TextUtils; import com.android.dialer.DialerPhoneNumber; import com.android.dialer.common.Assert; import com.android.dialer.common.concurrent.DialerExecutors; import com.android.dialer.inject.ApplicationContext; import com.android.dialer.phonelookup.PhoneLookup; import com.android.dialer.phonelookup.PhoneLookupInfo; import com.android.dialer.phonelookup.PhoneLookupInfo.Cp2Info; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import java.util.Map; import java.util.Map.Entry; import java.util.Objects; import java.util.Set; import javax.inject.Inject; /** PhoneLookup implementation for local contacts. */ public final class Cp2PhoneLookup implements PhoneLookup { private static final String[] CP2_INFO_PROJECTION = new String[] { Phone.DISPLAY_NAME_PRIMARY, // 0 Phone.PHOTO_THUMBNAIL_URI, // 1 Phone.PHOTO_ID, // 2 Phone.LABEL, // 3 Phone.NORMALIZED_NUMBER, // 4 Phone.CONTACT_ID, // 5 }; private static final int CP2_INFO_NAME_INDEX = 0; private static final int CP2_INFO_PHOTO_URI_INDEX = 1; private static final int CP2_INFO_PHOTO_ID_INDEX = 2; private static final int CP2_INFO_LABEL_INDEX = 3; private static final int CP2_INFO_NUMBER_INDEX = 4; private static final int CP2_INFO_CONTACT_ID_INDEX = 5; private final Context appContext; @Inject Cp2PhoneLookup(@ApplicationContext Context appContext) { this.appContext = appContext; } @Override public ListenableFuture lookup(@NonNull Call call) { throw new UnsupportedOperationException(); } @Override public ListenableFuture isDirty( ImmutableSet phoneNumbers, long lastModified) { // TODO(calderwoodra): consider a different thread pool return MoreExecutors.listeningDecorator(DialerExecutors.getLowPriorityThreadPool(appContext)) .submit(() -> isDirtyInternal(phoneNumbers, lastModified)); } private boolean isDirtyInternal(ImmutableSet phoneNumbers, long lastModified) { return contactsUpdated(queryPhoneTableForContactIds(phoneNumbers), lastModified) || contactsDeleted(lastModified); } /** Returns set of contact ids that correspond to {@code phoneNumbers} if the contact exists. */ private Set queryPhoneTableForContactIds(ImmutableSet phoneNumbers) { Set contactIds = new ArraySet<>(); try (Cursor cursor = appContext .getContentResolver() .query( Phone.CONTENT_URI, new String[] {Phone.CONTACT_ID}, Phone.NORMALIZED_NUMBER + " IN (" + questionMarks(phoneNumbers.size()) + ")", contactIdsSelectionArgs(phoneNumbers), null)) { cursor.moveToPosition(-1); while (cursor.moveToNext()) { contactIds.add(cursor.getLong(0 /* columnIndex */)); } } return contactIds; } private static String[] contactIdsSelectionArgs(ImmutableSet phoneNumbers) { String[] args = new String[phoneNumbers.size()]; int i = 0; for (DialerPhoneNumber phoneNumber : phoneNumbers) { args[i++] = getNormalizedNumber(phoneNumber); } return args; } private static String getNormalizedNumber(DialerPhoneNumber phoneNumber) { // TODO(calderwoodra): implement normalization logic that matches contacts. return phoneNumber.getRawInput().getNumber(); } /** Returns true if any contacts were modified after {@code lastModified}. */ private boolean contactsUpdated(Set contactIds, long lastModified) { try (Cursor cursor = queryContactsTableForContacts(contactIds, lastModified)) { return cursor.getCount() > 0; } } private Cursor queryContactsTableForContacts(Set contactIds, long lastModified) { // Filter to after last modified time based only on contacts we care about String where = Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + " > ?" + " AND " + Contacts._ID + " IN (" + questionMarks(contactIds.size()) + ")"; String[] args = new String[contactIds.size() + 1]; args[0] = Long.toString(lastModified); int i = 1; for (Long contactId : contactIds) { args[i++] = Long.toString(contactId); } return appContext .getContentResolver() .query(Contacts.CONTENT_URI, new String[] {Contacts._ID}, where, args, null); } /** Returns true if any contacts were deleted after {@code lastModified}. */ private boolean contactsDeleted(long lastModified) { try (Cursor cursor = appContext .getContentResolver() .query( DeletedContacts.CONTENT_URI, new String[] {DeletedContacts.CONTACT_DELETED_TIMESTAMP}, DeletedContacts.CONTACT_DELETED_TIMESTAMP + " > ?", new String[] {Long.toString(lastModified)}, null)) { return cursor.getCount() > 0; } } @Override public ListenableFuture> bulkUpdate( ImmutableMap existingInfoMap, long lastModified) { return MoreExecutors.listeningDecorator(DialerExecutors.getLowPriorityThreadPool(appContext)) .submit(() -> bulkUpdateInternal(existingInfoMap, lastModified)); } private ImmutableMap bulkUpdateInternal( ImmutableMap existingInfoMap, long lastModified) { // Build a set of each DialerPhoneNumber that was associated with a contact, and is no longer // associated with that same contact. Set deletedPhoneNumbers = getDeletedPhoneNumbers(existingInfoMap, lastModified); // For each DialerPhoneNumber that was associated with a contact or added to a contact, // build a map of those DialerPhoneNumbers to a set Cp2Infos, where each Cp2Info represents a // contact. ImmutableMap> updatedContacts = buildMapForUpdatedOrAddedContacts(existingInfoMap, lastModified, deletedPhoneNumbers); // Start build a new map of updated info. This will replace existing info. ImmutableMap.Builder newInfoMapBuilder = ImmutableMap.builder(); // For each DialerPhoneNumber in existing info... for (Entry entry : existingInfoMap.entrySet()) { // Build off the existing info PhoneLookupInfo.Builder infoBuilder = PhoneLookupInfo.newBuilder(entry.getValue()); // If the contact was updated, replace the Cp2Info list if (updatedContacts.containsKey(entry.getKey())) { infoBuilder.clearCp2Info(); infoBuilder.addAllCp2Info(updatedContacts.get(entry.getKey())); // If it was deleted and not added to a new contact, replace the Cp2Info list with // the default instance of Cp2Info } else if (deletedPhoneNumbers.contains(entry.getKey())) { infoBuilder.clearCp2Info(); infoBuilder.addCp2Info(Cp2Info.getDefaultInstance()); } // If the DialerPhoneNumber didn't change, add the unchanged existing info. newInfoMapBuilder.put(entry.getKey(), infoBuilder.build()); } return newInfoMapBuilder.build(); } /** * 1. get all contact ids. if the id is unset, add the number to the list of contacts to look up. * 2. reduce our list of contact ids to those that were updated after lastModified. 3. Now we have * the smallest set of dialer phone numbers to query cp2 against. 4. build and return the map of * dialerphonenumbers to their new cp2info * * @return Map of {@link DialerPhoneNumber} to {@link PhoneLookupInfo} with updated {@link * Cp2Info}. */ private ImmutableMap> buildMapForUpdatedOrAddedContacts( ImmutableMap existingInfoMap, long lastModified, Set deletedPhoneNumbers) { // Start building a set of DialerPhoneNumbers that we want to update. Set updatedNumbers = new ArraySet<>(); Set contactIds = new ArraySet<>(); for (Entry entry : existingInfoMap.entrySet()) { // If the number was deleted, we need to check if it was added to a new contact. if (deletedPhoneNumbers.contains(entry.getKey())) { updatedNumbers.add(entry.getKey()); continue; } // For each Cp2Info for each existing DialerPhoneNumber... // Store the contact id if it exist, else automatically add the DialerPhoneNumber to our // set of DialerPhoneNumbers we want to update. for (Cp2Info cp2Info : entry.getValue().getCp2InfoList()) { if (Objects.equals(cp2Info, Cp2Info.getDefaultInstance())) { // If the number doesn't have any Cp2Info set to it, for various reasons, we need to look // up the number to check if any exists. // The various reasons this might happen are: // - An existing contact that wasn't in the call log is now in the call log. // - A number was in the call log before but has now been added to a contact. // - A number is in the call log, but isn't associated with any contact. updatedNumbers.add(entry.getKey()); } else { contactIds.add(cp2Info.getContactId()); } } } // Query the contacts table and get those that whose Contacts.CONTACT_LAST_UPDATED_TIMESTAMP is // after lastModified, such that Contacts._ID is in our set of contact IDs we build above. try (Cursor cursor = queryContactsTableForContacts(contactIds, lastModified)) { int contactIdIndex = cursor.getColumnIndex(Contacts._ID); cursor.moveToPosition(-1); while (cursor.moveToNext()) { // Find the DialerPhoneNumber for each contact id and add it to our updated numbers set. // These, along with our number not associated with any Cp2Info need to be updated. long contactId = cursor.getLong(contactIdIndex); updatedNumbers.addAll(getDialerPhoneNumber(existingInfoMap, contactId)); } } // Query the Phone table and build Cp2Info for each DialerPhoneNumber in our updatedNumbers set. Map> map = new ArrayMap<>(); try (Cursor cursor = getAllCp2Rows(updatedNumbers)) { cursor.moveToPosition(-1); while (cursor.moveToNext()) { // Map each dialer phone number to it's new cp2 info Set phoneNumbers = getDialerPhoneNumbers(updatedNumbers, cursor.getString(CP2_INFO_NUMBER_INDEX)); Cp2Info info = buildCp2InfoFromUpdatedContactsCursor(cursor); for (DialerPhoneNumber phoneNumber : phoneNumbers) { if (map.containsKey(phoneNumber)) { map.get(phoneNumber).add(info); } else { Set cp2Infos = new ArraySet<>(); cp2Infos.add(info); map.put(phoneNumber, cp2Infos); } } } } return ImmutableMap.copyOf(map); } /** * Returns cursor with projection {@link #CP2_INFO_PROJECTION} and only phone numbers that are in * {@code updateNumbers}. */ private Cursor getAllCp2Rows(Set updatedNumbers) { String where = Phone.NORMALIZED_NUMBER + " IN (" + questionMarks(updatedNumbers.size()) + ")"; String[] selectionArgs = new String[updatedNumbers.size()]; int i = 0; for (DialerPhoneNumber phoneNumber : updatedNumbers) { selectionArgs[i++] = getNormalizedNumber(phoneNumber); } return appContext .getContentResolver() .query(Phone.CONTENT_URI, CP2_INFO_PROJECTION, where, selectionArgs, null); } /** * @param cursor with projection {@link #CP2_INFO_PROJECTION}. * @return new {@link Cp2Info} based on current row of {@code cursor}. */ private static Cp2Info buildCp2InfoFromUpdatedContactsCursor(Cursor cursor) { String displayName = cursor.getString(CP2_INFO_NAME_INDEX); String photoUri = cursor.getString(CP2_INFO_PHOTO_URI_INDEX); String label = cursor.getString(CP2_INFO_LABEL_INDEX); Cp2Info.Builder infoBuilder = Cp2Info.newBuilder(); if (!TextUtils.isEmpty(displayName)) { infoBuilder.setName(displayName); } if (!TextUtils.isEmpty(photoUri)) { infoBuilder.setPhotoUri(photoUri); } if (!TextUtils.isEmpty(label)) { infoBuilder.setLabel(label); } infoBuilder.setPhotoId(cursor.getLong(CP2_INFO_PHOTO_ID_INDEX)); infoBuilder.setContactId(cursor.getLong(CP2_INFO_CONTACT_ID_INDEX)); return infoBuilder.build(); } /** Returns set of DialerPhoneNumbers that were associated with now deleted contacts. */ private Set getDeletedPhoneNumbers( ImmutableMap existingInfoMap, long lastModified) { // Build set of all contact IDs from our existing data. We're going to use this set to query // against the DeletedContacts table and see if any of them were deleted. Set contactIds = findContactIdsIn(existingInfoMap); // Start building a set of DialerPhoneNumbers that were associated with now deleted contacts. try (Cursor cursor = queryDeletedContacts(contactIds, lastModified)) { // We now have a cursor/list of contact IDs that were associated with deleted contacts. return findDeletedPhoneNumbersIn(existingInfoMap, cursor); } } private Set findContactIdsIn(ImmutableMap map) { Set contactIds = new ArraySet<>(); for (PhoneLookupInfo info : map.values()) { for (Cp2Info cp2Info : info.getCp2InfoList()) { contactIds.add(cp2Info.getContactId()); } } return contactIds; } private Cursor queryDeletedContacts(Set contactIds, long lastModified) { String where = DeletedContacts.CONTACT_DELETED_TIMESTAMP + " > ?" + " AND " + DeletedContacts.CONTACT_ID + " IN (" + questionMarks(contactIds.size()) + ")"; String[] args = new String[contactIds.size() + 1]; args[0] = Long.toString(lastModified); int i = 1; for (Long contactId : contactIds) { args[i++] = Long.toString(contactId); } return appContext .getContentResolver() .query( DeletedContacts.CONTENT_URI, new String[] {DeletedContacts.CONTACT_ID}, where, args, null); } /** Returns set of DialerPhoneNumbers that are associated with deleted contact IDs. */ private Set findDeletedPhoneNumbersIn( ImmutableMap existingInfoMap, Cursor cursor) { int contactIdIndex = cursor.getColumnIndexOrThrow(DeletedContacts.CONTACT_ID); Set deletedPhoneNumbers = new ArraySet<>(); cursor.moveToPosition(-1); while (cursor.moveToNext()) { long contactId = cursor.getLong(contactIdIndex); deletedPhoneNumbers.addAll(getDialerPhoneNumber(existingInfoMap, contactId)); } return deletedPhoneNumbers; } private static Set getDialerPhoneNumbers( Set phoneNumbers, String number) { Set matches = new ArraySet<>(); for (DialerPhoneNumber phoneNumber : phoneNumbers) { if (getNormalizedNumber(phoneNumber).equals(number)) { matches.add(phoneNumber); } } Assert.checkArgument( matches.size() > 0, "Couldn't find DialerPhoneNumber for number: " + number); return matches; } private static Set getDialerPhoneNumber( ImmutableMap existingInfoMap, long contactId) { Set matches = new ArraySet<>(); for (Entry entry : existingInfoMap.entrySet()) { for (Cp2Info cp2Info : entry.getValue().getCp2InfoList()) { if (cp2Info.getContactId() == contactId) { matches.add(entry.getKey()); } } } Assert.checkArgument( matches.size() > 0, "Couldn't find DialerPhoneNumber for contact ID: " + contactId); return matches; } private static String questionMarks(int count) { StringBuilder where = new StringBuilder(); for (int i = 0; i < count; i++) { if (i != 0) { where.append(", "); } where.append("?"); } return where.toString(); } }