• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
17 package com.android.dialer.phonelookup.cp2;
18 
19 import android.content.Context;
20 import android.content.SharedPreferences;
21 import android.database.Cursor;
22 import android.net.Uri;
23 import android.provider.ContactsContract;
24 import android.provider.ContactsContract.CommonDataKinds.Phone;
25 import android.provider.ContactsContract.Contacts;
26 import android.provider.ContactsContract.DeletedContacts;
27 import android.support.annotation.Nullable;
28 import android.support.v4.util.ArrayMap;
29 import android.support.v4.util.ArraySet;
30 import android.text.TextUtils;
31 import com.android.dialer.DialerPhoneNumber;
32 import com.android.dialer.common.Assert;
33 import com.android.dialer.common.LogUtil;
34 import com.android.dialer.common.concurrent.Annotations.BackgroundExecutor;
35 import com.android.dialer.common.concurrent.Annotations.LightweightExecutor;
36 import com.android.dialer.inject.ApplicationContext;
37 import com.android.dialer.phonelookup.PhoneLookup;
38 import com.android.dialer.phonelookup.PhoneLookupInfo;
39 import com.android.dialer.phonelookup.PhoneLookupInfo.Cp2Info;
40 import com.android.dialer.phonelookup.PhoneLookupInfo.Cp2Info.Cp2ContactInfo;
41 import com.android.dialer.phonelookup.database.contract.PhoneLookupHistoryContract.PhoneLookupHistory;
42 import com.android.dialer.phonenumberproto.PartitionedNumbers;
43 import com.android.dialer.storage.Unencrypted;
44 import com.google.common.collect.ImmutableMap;
45 import com.google.common.collect.ImmutableSet;
46 import com.google.common.collect.Iterables;
47 import com.google.common.collect.Maps;
48 import com.google.common.util.concurrent.Futures;
49 import com.google.common.util.concurrent.ListenableFuture;
50 import com.google.common.util.concurrent.ListeningExecutorService;
51 import com.google.common.util.concurrent.MoreExecutors;
52 import com.google.protobuf.InvalidProtocolBufferException;
53 import java.util.ArrayList;
54 import java.util.List;
55 import java.util.Map;
56 import java.util.Map.Entry;
57 import java.util.Set;
58 import java.util.concurrent.Callable;
59 import javax.inject.Inject;
60 
61 /** PhoneLookup implementation for contacts in the default directory. */
62 public final class Cp2DefaultDirectoryPhoneLookup implements PhoneLookup<Cp2Info> {
63 
64   private static final String PREF_LAST_TIMESTAMP_PROCESSED =
65       "cp2DefaultDirectoryPhoneLookupLastTimestampProcessed";
66 
67   // We cannot efficiently process invalid numbers because batch queries cannot be constructed which
68   // accomplish the necessary loose matching. We'll attempt to process a limited number of them,
69   // but if there are too many we fall back to querying CP2 at render time.
70   private static final int MAX_SUPPORTED_INVALID_NUMBERS = 5;
71 
72   private final Context appContext;
73   private final SharedPreferences sharedPreferences;
74   private final ListeningExecutorService backgroundExecutorService;
75   private final ListeningExecutorService lightweightExecutorService;
76 
77   @Nullable private Long currentLastTimestampProcessed;
78 
79   @Inject
Cp2DefaultDirectoryPhoneLookup( @pplicationContext Context appContext, @Unencrypted SharedPreferences sharedPreferences, @BackgroundExecutor ListeningExecutorService backgroundExecutorService, @LightweightExecutor ListeningExecutorService lightweightExecutorService)80   Cp2DefaultDirectoryPhoneLookup(
81       @ApplicationContext Context appContext,
82       @Unencrypted SharedPreferences sharedPreferences,
83       @BackgroundExecutor ListeningExecutorService backgroundExecutorService,
84       @LightweightExecutor ListeningExecutorService lightweightExecutorService) {
85     this.appContext = appContext;
86     this.sharedPreferences = sharedPreferences;
87     this.backgroundExecutorService = backgroundExecutorService;
88     this.lightweightExecutorService = lightweightExecutorService;
89   }
90 
91   @Override
lookup(DialerPhoneNumber dialerPhoneNumber)92   public ListenableFuture<Cp2Info> lookup(DialerPhoneNumber dialerPhoneNumber) {
93     return backgroundExecutorService.submit(() -> lookupInternal(dialerPhoneNumber));
94   }
95 
lookupInternal(DialerPhoneNumber dialerPhoneNumber)96   private Cp2Info lookupInternal(DialerPhoneNumber dialerPhoneNumber) {
97     String number = dialerPhoneNumber.getNormalizedNumber();
98     if (TextUtils.isEmpty(number)) {
99       return Cp2Info.getDefaultInstance();
100     }
101 
102     Set<Cp2ContactInfo> cp2ContactInfos = new ArraySet<>();
103 
104     // Even though this is only a single number, use PartitionedNumbers to mimic the logic used
105     // during getMostRecentInfo.
106     PartitionedNumbers partitionedNumbers =
107         new PartitionedNumbers(ImmutableSet.of(dialerPhoneNumber));
108 
109     Cursor cursor = null;
110     try {
111       // Note: It would make sense to use PHONE_LOOKUP for valid numbers as well, but we use PHONE
112       // to ensure consistency when the batch methods are used to update data.
113       if (!partitionedNumbers.validE164Numbers().isEmpty()) {
114         cursor =
115             queryPhoneTableBasedOnE164(
116                 Cp2Projections.getProjectionForPhoneTable(), partitionedNumbers.validE164Numbers());
117       } else {
118         cursor =
119             queryPhoneLookup(
120                 Cp2Projections.getProjectionForPhoneLookupTable(),
121                 Iterables.getOnlyElement(partitionedNumbers.invalidNumbers()));
122       }
123       if (cursor == null) {
124         LogUtil.w("Cp2DefaultDirectoryPhoneLookup.lookupInternal", "null cursor");
125         return Cp2Info.getDefaultInstance();
126       }
127       while (cursor.moveToNext()) {
128         cp2ContactInfos.add(Cp2Projections.buildCp2ContactInfoFromCursor(appContext, cursor));
129       }
130     } finally {
131       if (cursor != null) {
132         cursor.close();
133       }
134     }
135     return Cp2Info.newBuilder().addAllCp2ContactInfo(cp2ContactInfos).build();
136   }
137 
138   @Override
isDirty(ImmutableSet<DialerPhoneNumber> phoneNumbers)139   public ListenableFuture<Boolean> isDirty(ImmutableSet<DialerPhoneNumber> phoneNumbers) {
140     PartitionedNumbers partitionedNumbers = new PartitionedNumbers(phoneNumbers);
141     if (partitionedNumbers.invalidNumbers().size() > MAX_SUPPORTED_INVALID_NUMBERS) {
142       // If there are N invalid numbers, we can't determine determine dirtiness without running N
143       // queries; since running this many queries is not feasible for the (lightweight) isDirty
144       // check, simply return true. The expectation is that this should rarely be the case as the
145       // vast majority of numbers in call logs should be valid.
146       LogUtil.v(
147           "Cp2DefaultDirectoryPhoneLookup.isDirty",
148           "returning true because too many invalid numbers (%d)",
149           partitionedNumbers.invalidNumbers().size());
150       return Futures.immediateFuture(true);
151     }
152 
153     ListenableFuture<Long> lastModifiedFuture =
154         backgroundExecutorService.submit(
155             () -> sharedPreferences.getLong(PREF_LAST_TIMESTAMP_PROCESSED, 0L));
156     return Futures.transformAsync(
157         lastModifiedFuture,
158         lastModified -> {
159           // We are always going to need to do this check and it is pretty cheap so do it first.
160           ListenableFuture<Boolean> anyContactsDeletedFuture =
161               anyContactsDeletedSince(lastModified);
162           return Futures.transformAsync(
163               anyContactsDeletedFuture,
164               anyContactsDeleted -> {
165                 if (anyContactsDeleted) {
166                   LogUtil.v(
167                       "Cp2DefaultDirectoryPhoneLookup.isDirty",
168                       "returning true because contacts deleted");
169                   return Futures.immediateFuture(true);
170                 }
171                 // Hopefully the most common case is there are no contacts updated; we can detect
172                 // this cheaply.
173                 ListenableFuture<Boolean> noContactsModifiedSinceFuture =
174                     noContactsModifiedSince(lastModified);
175                 return Futures.transformAsync(
176                     noContactsModifiedSinceFuture,
177                     noContactsModifiedSince -> {
178                       if (noContactsModifiedSince) {
179                         LogUtil.v(
180                             "Cp2DefaultDirectoryPhoneLookup.isDirty",
181                             "returning false because no contacts modified since last run");
182                         return Futures.immediateFuture(false);
183                       }
184                       // This method is more expensive but is probably the most likely scenario; we
185                       // are looking for changes to contacts which have been called.
186                       ListenableFuture<Set<Long>> contactIdsFuture =
187                           queryPhoneTableForContactIds(phoneNumbers);
188                       ListenableFuture<Boolean> contactsUpdatedFuture =
189                           Futures.transformAsync(
190                               contactIdsFuture,
191                               contactIds -> contactsUpdated(contactIds, lastModified),
192                               MoreExecutors.directExecutor());
193                       return Futures.transformAsync(
194                           contactsUpdatedFuture,
195                           contactsUpdated -> {
196                             if (contactsUpdated) {
197                               LogUtil.v(
198                                   "Cp2DefaultDirectoryPhoneLookup.isDirty",
199                                   "returning true because a previously called contact was updated");
200                               return Futures.immediateFuture(true);
201                             }
202                             // This is the most expensive method so do it last; the scenario is that
203                             // a contact which has been called got disassociated with a number and
204                             // we need to clear their information.
205                             ListenableFuture<Set<Long>> phoneLookupContactIdsFuture =
206                                 queryPhoneLookupHistoryForContactIds();
207                             return Futures.transformAsync(
208                                 phoneLookupContactIdsFuture,
209                                 phoneLookupContactIds ->
210                                     contactsUpdated(phoneLookupContactIds, lastModified),
211                                 MoreExecutors.directExecutor());
212                           },
213                           MoreExecutors.directExecutor());
214                     },
215                     MoreExecutors.directExecutor());
216               },
217               MoreExecutors.directExecutor());
218         },
219         MoreExecutors.directExecutor());
220   }
221 
222   /**
223    * Returns set of contact ids that correspond to {@code dialerPhoneNumbers} if the contact exists.
224    */
225   private ListenableFuture<Set<Long>> queryPhoneTableForContactIds(
226       ImmutableSet<DialerPhoneNumber> dialerPhoneNumbers) {
227     PartitionedNumbers partitionedNumbers = new PartitionedNumbers(dialerPhoneNumbers);
228 
229     List<ListenableFuture<Set<Long>>> queryFutures = new ArrayList<>();
230 
231     // First use the valid E164 numbers to query the NORMALIZED_NUMBER column.
232     queryFutures.add(
233         queryPhoneTableForContactIdsBasedOnE164(partitionedNumbers.validE164Numbers()));
234 
235     // Then run a separate query for each invalid number. Separate queries are done to accomplish
236     // loose matching which couldn't be accomplished with a batch query.
237     Assert.checkState(partitionedNumbers.invalidNumbers().size() <= MAX_SUPPORTED_INVALID_NUMBERS);
238     for (String invalidNumber : partitionedNumbers.invalidNumbers()) {
239       queryFutures.add(queryPhoneLookupTableForContactIdsBasedOnRawNumber(invalidNumber));
240     }
241     return Futures.transform(
242         Futures.allAsList(queryFutures),
243         listOfSets -> {
244           Set<Long> contactIds = new ArraySet<>();
245           for (Set<Long> ids : listOfSets) {
246             contactIds.addAll(ids);
247           }
248           return contactIds;
249         },
250         lightweightExecutorService);
251   }
252 
253   /** Gets all of the contact ids from PhoneLookupHistory. */
254   private ListenableFuture<Set<Long>> queryPhoneLookupHistoryForContactIds() {
255     return backgroundExecutorService.submit(
256         () -> {
257           Set<Long> contactIds = new ArraySet<>();
258           try (Cursor cursor =
259               appContext
260                   .getContentResolver()
261                   .query(
262                       PhoneLookupHistory.CONTENT_URI,
263                       new String[] {
264                         PhoneLookupHistory.PHONE_LOOKUP_INFO,
265                       },
266                       null,
267                       null,
268                       null)) {
269 
270             if (cursor == null) {
271               LogUtil.w(
272                   "Cp2DefaultDirectoryPhoneLookup.queryPhoneLookupHistoryForContactIds",
273                   "null cursor");
274               return contactIds;
275             }
276 
277             if (cursor.moveToFirst()) {
278               int phoneLookupInfoColumn =
279                   cursor.getColumnIndexOrThrow(PhoneLookupHistory.PHONE_LOOKUP_INFO);
280               do {
281                 PhoneLookupInfo phoneLookupInfo;
282                 try {
283                   phoneLookupInfo =
284                       PhoneLookupInfo.parseFrom(cursor.getBlob(phoneLookupInfoColumn));
285                 } catch (InvalidProtocolBufferException e) {
286                   throw new IllegalStateException(e);
287                 }
288                 for (Cp2ContactInfo info :
289                     phoneLookupInfo.getDefaultCp2Info().getCp2ContactInfoList()) {
290                   contactIds.add(info.getContactId());
291                 }
292               } while (cursor.moveToNext());
293             }
294           }
295           return contactIds;
296         });
297   }
298 
299   private ListenableFuture<Set<Long>> queryPhoneTableForContactIdsBasedOnE164(
300       Set<String> validE164Numbers) {
301     return backgroundExecutorService.submit(
302         () -> {
303           Set<Long> contactIds = new ArraySet<>();
304           if (validE164Numbers.isEmpty()) {
305             return contactIds;
306           }
307           try (Cursor cursor =
308               queryPhoneTableBasedOnE164(new String[] {Phone.CONTACT_ID}, validE164Numbers)) {
309             if (cursor == null) {
310               LogUtil.w(
311                   "Cp2DefaultDirectoryPhoneLookup.queryPhoneTableForContactIdsBasedOnE164",
312                   "null cursor");
313               return contactIds;
314             }
315             while (cursor.moveToNext()) {
316               contactIds.add(cursor.getLong(0 /* columnIndex */));
317             }
318           }
319           return contactIds;
320         });
321   }
322 
323   private ListenableFuture<Set<Long>> queryPhoneLookupTableForContactIdsBasedOnRawNumber(
324       String rawNumber) {
325     if (TextUtils.isEmpty(rawNumber)) {
326       return Futures.immediateFuture(new ArraySet<>());
327     }
328     return backgroundExecutorService.submit(
329         () -> {
330           Set<Long> contactIds = new ArraySet<>();
331           try (Cursor cursor =
332               queryPhoneLookup(new String[] {ContactsContract.PhoneLookup.CONTACT_ID}, rawNumber)) {
333             if (cursor == null) {
334               LogUtil.w(
335                   "Cp2DefaultDirectoryPhoneLookup.queryPhoneLookupTableForContactIdsBasedOnRawNumber",
336                   "null cursor");
337               return contactIds;
338             }
339             while (cursor.moveToNext()) {
340               contactIds.add(cursor.getLong(0 /* columnIndex */));
341             }
342           }
343           return contactIds;
344         });
345   }
346 
347   /** Returns true if any contacts were modified after {@code lastModified}. */
348   private ListenableFuture<Boolean> contactsUpdated(Set<Long> contactIds, long lastModified) {
349     return backgroundExecutorService.submit(
350         () -> {
351           try (Cursor cursor = queryContactsTableForContacts(contactIds, lastModified)) {
352             return cursor.getCount() > 0;
353           }
354         });
355   }
356 
357   private Cursor queryContactsTableForContacts(Set<Long> contactIds, long lastModified) {
358     // Filter to after last modified time based only on contacts we care about
359     String where =
360         Contacts.CONTACT_LAST_UPDATED_TIMESTAMP
361             + " > ?"
362             + " AND "
363             + Contacts._ID
364             + " IN ("
365             + questionMarks(contactIds.size())
366             + ")";
367 
368     String[] args = new String[contactIds.size() + 1];
369     args[0] = Long.toString(lastModified);
370     int i = 1;
371     for (Long contactId : contactIds) {
372       args[i++] = Long.toString(contactId);
373     }
374 
375     return appContext
376         .getContentResolver()
377         .query(
378             Contacts.CONTENT_URI,
379             new String[] {Contacts._ID, Contacts.CONTACT_LAST_UPDATED_TIMESTAMP},
380             where,
381             args,
382             null);
383   }
384 
385   private ListenableFuture<Boolean> noContactsModifiedSince(long lastModified) {
386     return backgroundExecutorService.submit(
387         () -> {
388           try (Cursor cursor =
389               appContext
390                   .getContentResolver()
391                   .query(
392                       Contacts.CONTENT_URI,
393                       new String[] {Contacts._ID},
394                       Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + " > ?",
395                       new String[] {Long.toString(lastModified)},
396                       Contacts._ID + " limit 1")) {
397             if (cursor == null) {
398               LogUtil.w("Cp2DefaultDirectoryPhoneLookup.noContactsModifiedSince", "null cursor");
399               return false;
400             }
401             return cursor.getCount() == 0;
402           }
403         });
404   }
405 
406   /** Returns true if any contacts were deleted after {@code lastModified}. */
407   private ListenableFuture<Boolean> anyContactsDeletedSince(long lastModified) {
408     return backgroundExecutorService.submit(
409         () -> {
410           try (Cursor cursor =
411               appContext
412                   .getContentResolver()
413                   .query(
414                       DeletedContacts.CONTENT_URI,
415                       new String[] {DeletedContacts.CONTACT_DELETED_TIMESTAMP},
416                       DeletedContacts.CONTACT_DELETED_TIMESTAMP + " > ?",
417                       new String[] {Long.toString(lastModified)},
418                       DeletedContacts.CONTACT_DELETED_TIMESTAMP + " limit 1")) {
419             if (cursor == null) {
420               LogUtil.w("Cp2DefaultDirectoryPhoneLookup.anyContactsDeletedSince", "null cursor");
421               return false;
422             }
423             return cursor.getCount() > 0;
424           }
425         });
426   }
427 
428   @Override
429   public void setSubMessage(PhoneLookupInfo.Builder destination, Cp2Info subMessage) {
430     destination.setDefaultCp2Info(subMessage);
431   }
432 
433   @Override
434   public Cp2Info getSubMessage(PhoneLookupInfo phoneLookupInfo) {
435     return phoneLookupInfo.getDefaultCp2Info();
436   }
437 
438   @Override
439   public ListenableFuture<ImmutableMap<DialerPhoneNumber, Cp2Info>> getMostRecentInfo(
440       ImmutableMap<DialerPhoneNumber, Cp2Info> existingInfoMap) {
441     currentLastTimestampProcessed = null;
442 
443     ListenableFuture<Long> lastModifiedFuture =
444         backgroundExecutorService.submit(
445             () -> sharedPreferences.getLong(PREF_LAST_TIMESTAMP_PROCESSED, 0L));
446     return Futures.transformAsync(
447         lastModifiedFuture,
448         lastModified -> {
449           // Build a set of each DialerPhoneNumber that was associated with a contact, and is no
450           // longer associated with that same contact.
451           ListenableFuture<Set<DialerPhoneNumber>> deletedPhoneNumbersFuture =
452               getDeletedPhoneNumbers(existingInfoMap, lastModified);
453 
454           return Futures.transformAsync(
455               deletedPhoneNumbersFuture,
456               deletedPhoneNumbers -> {
457 
458                 // If there are too many invalid numbers, just defer the work to render time.
459                 ArraySet<DialerPhoneNumber> unprocessableNumbers =
460                     findUnprocessableNumbers(existingInfoMap);
461                 Map<DialerPhoneNumber, Cp2Info> existingInfoMapToProcess = existingInfoMap;
462                 if (!unprocessableNumbers.isEmpty()) {
463                   existingInfoMapToProcess =
464                       Maps.filterKeys(
465                           existingInfoMap, number -> !unprocessableNumbers.contains(number));
466                 }
467 
468                 // For each DialerPhoneNumber that was associated with a contact or added to a
469                 // contact, build a map of those DialerPhoneNumbers to a set Cp2ContactInfos, where
470                 // each Cp2ContactInfo represents a contact.
471                 ListenableFuture<Map<DialerPhoneNumber, Set<Cp2ContactInfo>>>
472                     updatedContactsFuture =
473                         buildMapForUpdatedOrAddedContacts(
474                             existingInfoMapToProcess, lastModified, deletedPhoneNumbers);
475 
476                 return Futures.transform(
477                     updatedContactsFuture,
478                     updatedContacts -> {
479 
480                       // Start build a new map of updated info. This will replace existing info.
481                       ImmutableMap.Builder<DialerPhoneNumber, Cp2Info> newInfoMapBuilder =
482                           ImmutableMap.builder();
483 
484                       // For each DialerPhoneNumber in existing info...
485                       for (Entry<DialerPhoneNumber, Cp2Info> entry : existingInfoMap.entrySet()) {
486                         DialerPhoneNumber dialerPhoneNumber = entry.getKey();
487                         Cp2Info existingInfo = entry.getValue();
488 
489                         // Build off the existing info
490                         Cp2Info.Builder infoBuilder = Cp2Info.newBuilder(existingInfo);
491 
492                         // If the contact was updated, replace the Cp2ContactInfo list
493                         if (updatedContacts.containsKey(dialerPhoneNumber)) {
494                           infoBuilder
495                               .clear()
496                               .addAllCp2ContactInfo(updatedContacts.get(dialerPhoneNumber));
497                           // If it was deleted and not added to a new contact, clear all the CP2
498                           // information.
499                         } else if (deletedPhoneNumbers.contains(dialerPhoneNumber)) {
500                           infoBuilder.clear();
501                         } else if (unprocessableNumbers.contains(dialerPhoneNumber)) {
502                           // Don't ever set the "incomplete" bit for numbers which are empty; this
503                           // causes unnecessary render time work because there will never be contact
504                           // information for an empty number. It is also required to pass the
505                           // assertion check in the new voicemail fragment, which verifies that no
506                           // voicemails rows are considered "incomplete" (the voicemail fragment
507                           // does not have the ability to fetch information at render time).
508                           if (!dialerPhoneNumber.getNormalizedNumber().isEmpty()) {
509                             // Don't clear the existing info when the number is unprocessable. It's
510                             // likely that the existing info is up-to-date so keep it in place so
511                             // that the UI doesn't pop when the query is completed at display time.
512                             infoBuilder.setIsIncomplete(true);
513                           }
514                         }
515 
516                         // If the DialerPhoneNumber didn't change, add the unchanged existing info.
517                         newInfoMapBuilder.put(dialerPhoneNumber, infoBuilder.build());
518                       }
519                       return newInfoMapBuilder.build();
520                     },
521                     lightweightExecutorService);
522               },
523               lightweightExecutorService);
524         },
525         lightweightExecutorService);
526   }
527 
528   private ArraySet<DialerPhoneNumber> findUnprocessableNumbers(
529       ImmutableMap<DialerPhoneNumber, Cp2Info> existingInfoMap) {
530     ArraySet<DialerPhoneNumber> unprocessableNumbers = new ArraySet<>();
531     PartitionedNumbers partitionedNumbers = new PartitionedNumbers(existingInfoMap.keySet());
532     if (partitionedNumbers.invalidNumbers().size() > MAX_SUPPORTED_INVALID_NUMBERS) {
533       for (String invalidNumber : partitionedNumbers.invalidNumbers()) {
534         unprocessableNumbers.addAll(partitionedNumbers.dialerPhoneNumbersForInvalid(invalidNumber));
535       }
536     }
537     return unprocessableNumbers;
538   }
539 
540   @Override
541   public ListenableFuture<Void> onSuccessfulBulkUpdate() {
542     return backgroundExecutorService.submit(
543         () -> {
544           if (currentLastTimestampProcessed != null) {
545             sharedPreferences
546                 .edit()
547                 .putLong(PREF_LAST_TIMESTAMP_PROCESSED, currentLastTimestampProcessed)
548                 .apply();
549           }
550           return null;
551         });
552   }
553 
554   private ListenableFuture<Set<DialerPhoneNumber>> findNumbersToUpdate(
555       Map<DialerPhoneNumber, Cp2Info> existingInfoMap,
556       long lastModified,
557       Set<DialerPhoneNumber> deletedPhoneNumbers) {
558     return backgroundExecutorService.submit(
559         () -> {
560           Set<DialerPhoneNumber> updatedNumbers = new ArraySet<>();
561           Set<Long> contactIds = new ArraySet<>();
562           for (Entry<DialerPhoneNumber, Cp2Info> entry : existingInfoMap.entrySet()) {
563             DialerPhoneNumber dialerPhoneNumber = entry.getKey();
564             Cp2Info existingInfo = entry.getValue();
565 
566             // If the number was deleted, we need to check if it was added to a new contact.
567             if (deletedPhoneNumbers.contains(dialerPhoneNumber)) {
568               updatedNumbers.add(dialerPhoneNumber);
569               continue;
570             }
571 
572             // When the PhoneLookupHistory contains no information for a number, because for
573             // example the user just upgraded to the new UI, or cleared data, we need to check for
574             // updated info.
575             if (existingInfo.getCp2ContactInfoCount() == 0) {
576               updatedNumbers.add(dialerPhoneNumber);
577             } else {
578               // For each Cp2ContactInfo for each existing DialerPhoneNumber...
579               // Store the contact id if it exist, else automatically add the DialerPhoneNumber to
580               // our set of DialerPhoneNumbers we want to update.
581               for (Cp2ContactInfo cp2ContactInfo : existingInfo.getCp2ContactInfoList()) {
582                 long existingContactId = cp2ContactInfo.getContactId();
583                 if (existingContactId == 0) {
584                   // If the number doesn't have a contact id, for various reasons, we need to look
585                   // up the number to check if any exists. The various reasons this might happen
586                   // are:
587                   //  - An existing contact that wasn't in the call log is now in the call log.
588                   //  - A number was in the call log before but has now been added to a contact.
589                   //  - A number is in the call log, but isn't associated with any contact.
590                   updatedNumbers.add(dialerPhoneNumber);
591                 } else {
592                   contactIds.add(cp2ContactInfo.getContactId());
593                 }
594               }
595             }
596           }
597 
598           // Query the contacts table and get those that whose
599           // Contacts.CONTACT_LAST_UPDATED_TIMESTAMP is after lastModified, such that Contacts._ID
600           // is in our set of contact IDs we build above.
601           if (!contactIds.isEmpty()) {
602             try (Cursor cursor = queryContactsTableForContacts(contactIds, lastModified)) {
603               int contactIdIndex = cursor.getColumnIndex(Contacts._ID);
604               int lastUpdatedIndex = cursor.getColumnIndex(Contacts.CONTACT_LAST_UPDATED_TIMESTAMP);
605               cursor.moveToPosition(-1);
606               while (cursor.moveToNext()) {
607                 // Find the DialerPhoneNumber for each contact id and add it to our updated numbers
608                 // set. These, along with our number not associated with any Cp2ContactInfo need to
609                 // be updated.
610                 long contactId = cursor.getLong(contactIdIndex);
611                 updatedNumbers.addAll(
612                     findDialerPhoneNumbersContainingContactId(existingInfoMap, contactId));
613                 long lastUpdatedTimestamp = cursor.getLong(lastUpdatedIndex);
614                 if (currentLastTimestampProcessed == null
615                     || currentLastTimestampProcessed < lastUpdatedTimestamp) {
616                   currentLastTimestampProcessed = lastUpdatedTimestamp;
617                 }
618               }
619             }
620           }
621           return updatedNumbers;
622         });
623   }
624 
625   @Override
626   public void registerContentObservers(Context appContext) {
627     // Do nothing since CP2 changes are too noisy.
628   }
629 
630   /**
631    * 1. get all contact ids. if the id is unset, add the number to the list of contacts to look up.
632    * 2. reduce our list of contact ids to those that were updated after lastModified. 3. Now we have
633    * the smallest set of dialer phone numbers to query cp2 against. 4. build and return the map of
634    * dialerphonenumbers to their new Cp2ContactInfo
635    *
636    * @return Map of {@link DialerPhoneNumber} to {@link Cp2Info} with updated {@link
637    *     Cp2ContactInfo}.
638    */
639   private ListenableFuture<Map<DialerPhoneNumber, Set<Cp2ContactInfo>>>
640       buildMapForUpdatedOrAddedContacts(
641           Map<DialerPhoneNumber, Cp2Info> existingInfoMap,
642           long lastModified,
643           Set<DialerPhoneNumber> deletedPhoneNumbers) {
644     // Start by building a set of DialerPhoneNumbers that we want to update.
645     ListenableFuture<Set<DialerPhoneNumber>> updatedNumbersFuture =
646         findNumbersToUpdate(existingInfoMap, lastModified, deletedPhoneNumbers);
647 
648     return Futures.transformAsync(
649         updatedNumbersFuture,
650         updatedNumbers -> {
651           if (updatedNumbers.isEmpty()) {
652             return Futures.immediateFuture(new ArrayMap<>());
653           }
654 
655           // Divide the numbers into those that are valid and those that are not. Issue a single
656           // batch query for the valid numbers against the PHONE table, and in parallel issue
657           // individual queries against PHONE_LOOKUP for each invalid number.
658           // TODO(zachh): These queries are inefficient without a lastModified column to filter on.
659           PartitionedNumbers partitionedNumbers =
660               new PartitionedNumbers(ImmutableSet.copyOf(updatedNumbers));
661 
662           ListenableFuture<Map<String, Set<Cp2ContactInfo>>> validNumbersFuture =
663               batchQueryForValidNumbers(partitionedNumbers.validE164Numbers());
664 
665           List<ListenableFuture<Set<Cp2ContactInfo>>> invalidNumbersFuturesList = new ArrayList<>();
666           for (String invalidNumber : partitionedNumbers.invalidNumbers()) {
667             invalidNumbersFuturesList.add(individualQueryForInvalidNumber(invalidNumber));
668           }
669 
670           ListenableFuture<List<Set<Cp2ContactInfo>>> invalidNumbersFuture =
671               Futures.allAsList(invalidNumbersFuturesList);
672 
673           Callable<Map<DialerPhoneNumber, Set<Cp2ContactInfo>>> computeMap =
674               () -> {
675                 // These get() calls are safe because we are using whenAllSucceed below.
676                 Map<String, Set<Cp2ContactInfo>> validNumbersResult = validNumbersFuture.get();
677                 List<Set<Cp2ContactInfo>> invalidNumbersResult = invalidNumbersFuture.get();
678 
679                 Map<DialerPhoneNumber, Set<Cp2ContactInfo>> map = new ArrayMap<>();
680 
681                 // First update the map with the valid number results.
682                 for (Entry<String, Set<Cp2ContactInfo>> entry : validNumbersResult.entrySet()) {
683                   String validNumber = entry.getKey();
684                   Set<Cp2ContactInfo> cp2ContactInfos = entry.getValue();
685 
686                   Set<DialerPhoneNumber> dialerPhoneNumbers =
687                       partitionedNumbers.dialerPhoneNumbersForValidE164(validNumber);
688 
689                   addInfo(map, dialerPhoneNumbers, cp2ContactInfos);
690 
691                   // We are going to remove the numbers that we've handled so that we later can
692                   // detect numbers that weren't handled and therefore need to have their contact
693                   // information removed.
694                   updatedNumbers.removeAll(dialerPhoneNumbers);
695                 }
696 
697                 // Next update the map with the invalid results.
698                 int i = 0;
699                 for (String invalidNumber : partitionedNumbers.invalidNumbers()) {
700                   Set<Cp2ContactInfo> cp2Infos = invalidNumbersResult.get(i++);
701                   Set<DialerPhoneNumber> dialerPhoneNumbers =
702                       partitionedNumbers.dialerPhoneNumbersForInvalid(invalidNumber);
703 
704                   addInfo(map, dialerPhoneNumbers, cp2Infos);
705 
706                   // We are going to remove the numbers that we've handled so that we later can
707                   // detect numbers that weren't handled and therefore need to have their contact
708                   // information removed.
709                   updatedNumbers.removeAll(dialerPhoneNumbers);
710                 }
711 
712                 // The leftovers in updatedNumbers that weren't removed are numbers that were
713                 // previously associated with contacts, but are no longer. Remove the contact
714                 // information for them.
715                 for (DialerPhoneNumber dialerPhoneNumber : updatedNumbers) {
716                   map.put(dialerPhoneNumber, ImmutableSet.of());
717                 }
718                 LogUtil.v(
719                     "Cp2DefaultDirectoryPhoneLookup.buildMapForUpdatedOrAddedContacts",
720                     "found %d numbers that may need updating",
721                     updatedNumbers.size());
722                 return map;
723               };
724           return Futures.whenAllSucceed(validNumbersFuture, invalidNumbersFuture)
725               .call(computeMap, lightweightExecutorService);
726         },
727         lightweightExecutorService);
728   }
729 
730   private ListenableFuture<Map<String, Set<Cp2ContactInfo>>> batchQueryForValidNumbers(
731       Set<String> validE164Numbers) {
732     return backgroundExecutorService.submit(
733         () -> {
734           Map<String, Set<Cp2ContactInfo>> cp2ContactInfosByNumber = new ArrayMap<>();
735           if (validE164Numbers.isEmpty()) {
736             return cp2ContactInfosByNumber;
737           }
738           try (Cursor cursor =
739               queryPhoneTableBasedOnE164(
740                   Cp2Projections.getProjectionForPhoneTable(), validE164Numbers)) {
741             if (cursor == null) {
742               LogUtil.w("Cp2DefaultDirectoryPhoneLookup.batchQueryForValidNumbers", "null cursor");
743             } else {
744               while (cursor.moveToNext()) {
745                 String validE164Number = Cp2Projections.getNormalizedNumberFromCursor(cursor);
746                 Set<Cp2ContactInfo> cp2ContactInfos = cp2ContactInfosByNumber.get(validE164Number);
747                 if (cp2ContactInfos == null) {
748                   cp2ContactInfos = new ArraySet<>();
749                   cp2ContactInfosByNumber.put(validE164Number, cp2ContactInfos);
750                 }
751                 cp2ContactInfos.add(
752                     Cp2Projections.buildCp2ContactInfoFromCursor(appContext, cursor));
753               }
754             }
755           }
756           return cp2ContactInfosByNumber;
757         });
758   }
759 
760   private ListenableFuture<Set<Cp2ContactInfo>> individualQueryForInvalidNumber(
761       String invalidNumber) {
762     return backgroundExecutorService.submit(
763         () -> {
764           Set<Cp2ContactInfo> cp2ContactInfos = new ArraySet<>();
765           if (invalidNumber.isEmpty()) {
766             return cp2ContactInfos;
767           }
768           try (Cursor cursor =
769               queryPhoneLookup(Cp2Projections.getProjectionForPhoneLookupTable(), invalidNumber)) {
770             if (cursor == null) {
771               LogUtil.w(
772                   "Cp2DefaultDirectoryPhoneLookup.individualQueryForInvalidNumber", "null cursor");
773             } else {
774               while (cursor.moveToNext()) {
775                 cp2ContactInfos.add(
776                     Cp2Projections.buildCp2ContactInfoFromCursor(appContext, cursor));
777               }
778             }
779           }
780           return cp2ContactInfos;
781         });
782   }
783 
784   /**
785    * Adds the {@code cp2ContactInfo} to the entries for all specified {@code dialerPhoneNumbers} in
786    * the {@code map}.
787    */
788   private static void addInfo(
789       Map<DialerPhoneNumber, Set<Cp2ContactInfo>> map,
790       Set<DialerPhoneNumber> dialerPhoneNumbers,
791       Set<Cp2ContactInfo> cp2ContactInfos) {
792     for (DialerPhoneNumber dialerPhoneNumber : dialerPhoneNumbers) {
793       Set<Cp2ContactInfo> existingInfos = map.get(dialerPhoneNumber);
794       if (existingInfos == null) {
795         existingInfos = new ArraySet<>();
796         map.put(dialerPhoneNumber, existingInfos);
797       }
798       existingInfos.addAll(cp2ContactInfos);
799     }
800   }
801 
802   private Cursor queryPhoneTableBasedOnE164(String[] projection, Set<String> validE164Numbers) {
803     return appContext
804         .getContentResolver()
805         .query(
806             Phone.CONTENT_URI,
807             projection,
808             Phone.NORMALIZED_NUMBER + " IN (" + questionMarks(validE164Numbers.size()) + ")",
809             validE164Numbers.toArray(new String[validE164Numbers.size()]),
810             null);
811   }
812 
813   private Cursor queryPhoneLookup(String[] projection, String rawNumber) {
814     Uri uri =
815         Uri.withAppendedPath(
816             ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(rawNumber));
817     return appContext.getContentResolver().query(uri, projection, null, null, null);
818   }
819 
820   /** Returns set of DialerPhoneNumbers that were associated with now deleted contacts. */
821   private ListenableFuture<Set<DialerPhoneNumber>> getDeletedPhoneNumbers(
822       ImmutableMap<DialerPhoneNumber, Cp2Info> existingInfoMap, long lastModified) {
823     return backgroundExecutorService.submit(
824         () -> {
825           // Build set of all contact IDs from our existing data. We're going to use this set to
826           // query against the DeletedContacts table and see if any of them were deleted.
827           Set<Long> contactIds = findContactIdsIn(existingInfoMap);
828 
829           // Start building a set of DialerPhoneNumbers that were associated with now deleted
830           // contacts.
831           try (Cursor cursor = queryDeletedContacts(contactIds, lastModified)) {
832             // We now have a cursor/list of contact IDs that were associated with deleted contacts.
833             return findDeletedPhoneNumbersIn(existingInfoMap, cursor);
834           }
835         });
836   }
837 
838   private Set<Long> findContactIdsIn(ImmutableMap<DialerPhoneNumber, Cp2Info> map) {
839     Set<Long> contactIds = new ArraySet<>();
840     for (Cp2Info info : map.values()) {
841       for (Cp2ContactInfo cp2ContactInfo : info.getCp2ContactInfoList()) {
842         contactIds.add(cp2ContactInfo.getContactId());
843       }
844     }
845     return contactIds;
846   }
847 
848   private Cursor queryDeletedContacts(Set<Long> contactIds, long lastModified) {
849     String where =
850         DeletedContacts.CONTACT_DELETED_TIMESTAMP
851             + " > ?"
852             + " AND "
853             + DeletedContacts.CONTACT_ID
854             + " IN ("
855             + questionMarks(contactIds.size())
856             + ")";
857     String[] args = new String[contactIds.size() + 1];
858     args[0] = Long.toString(lastModified);
859     int i = 1;
860     for (Long contactId : contactIds) {
861       args[i++] = Long.toString(contactId);
862     }
863 
864     return appContext
865         .getContentResolver()
866         .query(
867             DeletedContacts.CONTENT_URI,
868             new String[] {DeletedContacts.CONTACT_ID, DeletedContacts.CONTACT_DELETED_TIMESTAMP},
869             where,
870             args,
871             null);
872   }
873 
874   /** Returns set of DialerPhoneNumbers that are associated with deleted contact IDs. */
875   private Set<DialerPhoneNumber> findDeletedPhoneNumbersIn(
876       ImmutableMap<DialerPhoneNumber, Cp2Info> existingInfoMap, Cursor cursor) {
877     int contactIdIndex = cursor.getColumnIndexOrThrow(DeletedContacts.CONTACT_ID);
878     int deletedTimeIndex = cursor.getColumnIndexOrThrow(DeletedContacts.CONTACT_DELETED_TIMESTAMP);
879     Set<DialerPhoneNumber> deletedPhoneNumbers = new ArraySet<>();
880     cursor.moveToPosition(-1);
881     while (cursor.moveToNext()) {
882       long contactId = cursor.getLong(contactIdIndex);
883       deletedPhoneNumbers.addAll(
884           findDialerPhoneNumbersContainingContactId(existingInfoMap, contactId));
885       long deletedTime = cursor.getLong(deletedTimeIndex);
886       if (currentLastTimestampProcessed == null || currentLastTimestampProcessed < deletedTime) {
887         // TODO(zachh): There's a problem here if a contact for a new row is deleted?
888         currentLastTimestampProcessed = deletedTime;
889       }
890     }
891     return deletedPhoneNumbers;
892   }
893 
894   private static Set<DialerPhoneNumber> findDialerPhoneNumbersContainingContactId(
895       Map<DialerPhoneNumber, Cp2Info> existingInfoMap, long contactId) {
896     Set<DialerPhoneNumber> matches = new ArraySet<>();
897     for (Entry<DialerPhoneNumber, Cp2Info> entry : existingInfoMap.entrySet()) {
898       for (Cp2ContactInfo cp2ContactInfo : entry.getValue().getCp2ContactInfoList()) {
899         if (cp2ContactInfo.getContactId() == contactId) {
900           matches.add(entry.getKey());
901         }
902       }
903     }
904     Assert.checkArgument(
905         matches.size() > 0, "Couldn't find DialerPhoneNumber for contact ID: " + contactId);
906     return matches;
907   }
908 
909   private static String questionMarks(int count) {
910     StringBuilder where = new StringBuilder();
911     for (int i = 0; i < count; i++) {
912       if (i != 0) {
913         where.append(", ");
914       }
915       where.append("?");
916     }
917     return where.toString();
918   }
919 }
920