1 /*
2  * Copyright (C) 2015 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.app.contactinfo;
18 
19 import android.os.Handler;
20 import android.os.Message;
21 import android.os.SystemClock;
22 import android.support.annotation.NonNull;
23 import android.support.annotation.VisibleForTesting;
24 import android.text.TextUtils;
25 import com.android.dialer.common.LogUtil;
26 import com.android.dialer.logging.ContactSource.Type;
27 import com.android.dialer.oem.CequintCallerIdManager;
28 import com.android.dialer.phonenumbercache.ContactInfo;
29 import com.android.dialer.phonenumbercache.ContactInfoHelper;
30 import com.android.dialer.util.ExpirableCache;
31 import java.lang.ref.WeakReference;
32 import java.util.Objects;
33 import java.util.concurrent.BlockingQueue;
34 import java.util.concurrent.PriorityBlockingQueue;
35 
36 /**
37  * This is a cache of contact details for the phone numbers in the call log. The key is the phone
38  * number with the country in which the call was placed or received. The content of the cache is
39  * expired (but not purged) whenever the application comes to the foreground.
40  *
41  * <p>This cache queues request for information and queries for information on a background thread,
42  * so {@code start()} and {@code stop()} must be called to initiate or halt that thread's exeuction
43  * as needed.
44  *
45  * <p>TODO: Explore whether there is a pattern to remove external dependencies for starting and
46  * stopping the query thread.
47  */
48 public class ContactInfoCache {
49 
50   private static final int REDRAW = 1;
51   private static final int START_THREAD = 2;
52   private static final int START_PROCESSING_REQUESTS_DELAY_MS = 1000;
53 
54   private final ExpirableCache<NumberWithCountryIso, ContactInfo> cache;
55   private final ContactInfoHelper contactInfoHelper;
56   private final OnContactInfoChangedListener onContactInfoChangedListener;
57   private final BlockingQueue<ContactInfoRequest> updateRequests;
58   private final Handler handler;
59   private CequintCallerIdManager cequintCallerIdManager;
60   private QueryThread contactInfoQueryThread;
61   private volatile boolean requestProcessingDisabled = false;
62 
63   private static class InnerHandler extends Handler {
64 
65     private final WeakReference<ContactInfoCache> contactInfoCacheWeakReference;
66 
InnerHandler(WeakReference<ContactInfoCache> contactInfoCacheWeakReference)67     public InnerHandler(WeakReference<ContactInfoCache> contactInfoCacheWeakReference) {
68       this.contactInfoCacheWeakReference = contactInfoCacheWeakReference;
69     }
70 
71     @Override
handleMessage(Message msg)72     public void handleMessage(Message msg) {
73       ContactInfoCache reference = contactInfoCacheWeakReference.get();
74       if (reference == null) {
75         return;
76       }
77       switch (msg.what) {
78         case REDRAW:
79           reference.onContactInfoChangedListener.onContactInfoChanged();
80           break;
81         case START_THREAD:
82           reference.startRequestProcessing();
83           break;
84         default: // fall out
85       }
86     }
87   }
88 
ContactInfoCache( @onNull ExpirableCache<NumberWithCountryIso, ContactInfo> internalCache, @NonNull ContactInfoHelper contactInfoHelper, @NonNull OnContactInfoChangedListener listener)89   public ContactInfoCache(
90       @NonNull ExpirableCache<NumberWithCountryIso, ContactInfo> internalCache,
91       @NonNull ContactInfoHelper contactInfoHelper,
92       @NonNull OnContactInfoChangedListener listener) {
93     cache = internalCache;
94     this.contactInfoHelper = contactInfoHelper;
95     onContactInfoChangedListener = listener;
96     updateRequests = new PriorityBlockingQueue<>();
97     handler = new InnerHandler(new WeakReference<>(this));
98   }
99 
setCequintCallerIdManager(CequintCallerIdManager cequintCallerIdManager)100   public void setCequintCallerIdManager(CequintCallerIdManager cequintCallerIdManager) {
101     this.cequintCallerIdManager = cequintCallerIdManager;
102   }
103 
getValue( String number, String countryIso, ContactInfo callLogContactInfo, boolean remoteLookupIfNotFoundLocally)104   public ContactInfo getValue(
105       String number,
106       String countryIso,
107       ContactInfo callLogContactInfo,
108       boolean remoteLookupIfNotFoundLocally) {
109     NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso);
110     ExpirableCache.CachedValue<ContactInfo> cachedInfo = cache.getCachedValue(numberCountryIso);
111     ContactInfo info = cachedInfo == null ? null : cachedInfo.getValue();
112     int requestType =
113         remoteLookupIfNotFoundLocally
114             ? ContactInfoRequest.TYPE_LOCAL_AND_REMOTE
115             : ContactInfoRequest.TYPE_LOCAL;
116     if (cachedInfo == null) {
117       cache.put(numberCountryIso, ContactInfo.EMPTY);
118       // Use the cached contact info from the call log.
119       info = callLogContactInfo;
120       // The db request should happen on a non-UI thread.
121       // Request the contact details immediately since they are currently missing.
122       enqueueRequest(number, countryIso, callLogContactInfo, /* immediate */ true, requestType);
123       // We will format the phone number when we make the background request.
124     } else {
125       if (cachedInfo.isExpired()) {
126         // The contact info is no longer up to date, we should request it. However, we
127         // do not need to request them immediately.
128         enqueueRequest(number, countryIso, callLogContactInfo, /* immediate */ false, requestType);
129       } else if (!callLogInfoMatches(callLogContactInfo, info)) {
130         // The call log information does not match the one we have, look it up again.
131         // We could simply update the call log directly, but that needs to be done in a
132         // background thread, so it is easier to simply request a new lookup, which will, as
133         // a side-effect, update the call log.
134         enqueueRequest(number, countryIso, callLogContactInfo, /* immediate */ false, requestType);
135       }
136 
137       if (Objects.equals(info, ContactInfo.EMPTY)) {
138         // Use the cached contact info from the call log.
139         info = callLogContactInfo;
140       }
141     }
142     return info;
143   }
144 
145   /**
146    * Queries the appropriate content provider for the contact associated with the number.
147    *
148    * <p>Upon completion it also updates the cache in the call log, if it is different from {@code
149    * callLogInfo}.
150    *
151    * <p>The number might be either a SIP address or a phone number.
152    *
153    * <p>It returns true if it updated the content of the cache and we should therefore tell the view
154    * to update its content.
155    */
queryContactInfo(ContactInfoRequest request)156   private boolean queryContactInfo(ContactInfoRequest request) {
157     LogUtil.d(
158         "ContactInfoCache.queryContactInfo",
159         "request number: %s, type: %d",
160         LogUtil.sanitizePhoneNumber(request.number),
161         request.type);
162     ContactInfo info;
163     if (request.isLocalRequest()) {
164       info = contactInfoHelper.lookupNumber(request.number, request.countryIso);
165       if (info != null && !info.contactExists) {
166         // TODO(wangqi): Maybe skip look up if it's already available in cached number lookup
167         // service.
168         long start = SystemClock.elapsedRealtime();
169         contactInfoHelper.updateFromCequintCallerId(cequintCallerIdManager, info, request.number);
170         long time = SystemClock.elapsedRealtime() - start;
171         LogUtil.d(
172             "ContactInfoCache.queryContactInfo", "Cequint Caller Id look up takes %d ms", time);
173       }
174       if (request.type == ContactInfoRequest.TYPE_LOCAL_AND_REMOTE) {
175         if (!contactInfoHelper.hasName(info)) {
176           enqueueRequest(
177               request.number,
178               request.countryIso,
179               request.callLogInfo,
180               true,
181               ContactInfoRequest.TYPE_REMOTE);
182           return false;
183         }
184       }
185     } else {
186       info = contactInfoHelper.lookupNumberInRemoteDirectory(request.number, request.countryIso);
187     }
188 
189     if (info == null) {
190       // The lookup failed, just return without requesting to update the view.
191       return false;
192     }
193 
194     // Check the existing entry in the cache: only if it has changed we should update the
195     // view.
196     NumberWithCountryIso numberCountryIso =
197         new NumberWithCountryIso(request.number, request.countryIso);
198     ContactInfo existingInfo = cache.getPossiblyExpired(numberCountryIso);
199 
200     final boolean isRemoteSource = info.sourceType != Type.UNKNOWN_SOURCE_TYPE;
201 
202     // Don't force redraw if existing info in the cache is equal to {@link ContactInfo#EMPTY}
203     // to avoid updating the data set for every new row that is scrolled into view.
204 
205     // Exception: Photo uris for contacts from remote sources are not cached in the call log
206     // cache, so we have to force a redraw for these contacts regardless.
207     boolean updated =
208         (!Objects.equals(existingInfo, ContactInfo.EMPTY) || isRemoteSource)
209             && !info.equals(existingInfo);
210 
211     // Store the data in the cache so that the UI thread can use to display it. Store it
212     // even if it has not changed so that it is marked as not expired.
213     cache.put(numberCountryIso, info);
214 
215     // Update the call log even if the cache it is up-to-date: it is possible that the cache
216     // contains the value from a different call log entry.
217     contactInfoHelper.updateCallLogContactInfo(
218         request.number, request.countryIso, info, request.callLogInfo);
219     if (!request.isLocalRequest()) {
220       contactInfoHelper.updateCachedNumberLookupService(info);
221     }
222     return updated;
223   }
224 
225   /**
226    * After a delay, start the thread to begin processing requests. We perform lookups on a
227    * background thread, but this must be called to indicate the thread should be running.
228    */
start()229   public void start() {
230     // Schedule a thread-creation message if the thread hasn't been created yet, as an
231     // optimization to queue fewer messages.
232     if (contactInfoQueryThread == null) {
233       // TODO: Check whether this delay before starting to process is necessary.
234       handler.sendEmptyMessageDelayed(START_THREAD, START_PROCESSING_REQUESTS_DELAY_MS);
235     }
236   }
237 
238   /**
239    * Stops the thread and clears the queue of messages to process. This cleans up the thread for
240    * lookups so that it is not perpetually running.
241    */
stop()242   public void stop() {
243     stopRequestProcessing();
244   }
245 
246   /**
247    * Starts a background thread to process contact-lookup requests, unless one has already been
248    * started.
249    */
startRequestProcessing()250   private synchronized void startRequestProcessing() {
251     // For unit-testing.
252     if (requestProcessingDisabled) {
253       return;
254     }
255 
256     // If a thread is already started, don't start another.
257     if (contactInfoQueryThread != null) {
258       return;
259     }
260 
261     contactInfoQueryThread = new QueryThread();
262     contactInfoQueryThread.setPriority(Thread.MIN_PRIORITY);
263     contactInfoQueryThread.start();
264   }
265 
invalidate()266   public void invalidate() {
267     cache.expireAll();
268     stopRequestProcessing();
269   }
270 
271   /**
272    * Stops the background thread that processes updates and cancels any pending requests to start
273    * it.
274    */
stopRequestProcessing()275   private synchronized void stopRequestProcessing() {
276     // Remove any pending requests to start the processing thread.
277     handler.removeMessages(START_THREAD);
278     if (contactInfoQueryThread != null) {
279       // Stop the thread; we are finished with it.
280       contactInfoQueryThread.stopProcessing();
281       contactInfoQueryThread.interrupt();
282       contactInfoQueryThread = null;
283     }
284   }
285 
286   /**
287    * Enqueues a request to look up the contact details for the given phone number.
288    *
289    * <p>It also provides the current contact info stored in the call log for this number.
290    *
291    * <p>If the {@code immediate} parameter is true, it will start immediately the thread that looks
292    * up the contact information (if it has not been already started). Otherwise, it will be started
293    * with a delay. See {@link #START_PROCESSING_REQUESTS_DELAY_MS}.
294    */
enqueueRequest( String number, String countryIso, ContactInfo callLogInfo, boolean immediate, @ContactInfoRequest.TYPE int type)295   private void enqueueRequest(
296       String number,
297       String countryIso,
298       ContactInfo callLogInfo,
299       boolean immediate,
300       @ContactInfoRequest.TYPE int type) {
301     ContactInfoRequest request = new ContactInfoRequest(number, countryIso, callLogInfo, type);
302     if (!updateRequests.contains(request)) {
303       updateRequests.offer(request);
304     }
305 
306     if (immediate) {
307       startRequestProcessing();
308     }
309   }
310 
311   /** Checks whether the contact info from the call log matches the one from the contacts db. */
callLogInfoMatches(ContactInfo callLogInfo, ContactInfo info)312   private boolean callLogInfoMatches(ContactInfo callLogInfo, ContactInfo info) {
313     // The call log only contains a subset of the fields in the contacts db. Only check those.
314     return TextUtils.equals(callLogInfo.name, info.name)
315         && callLogInfo.type == info.type
316         && TextUtils.equals(callLogInfo.label, info.label);
317   }
318 
319   /** Sets whether processing of requests for contact details should be enabled. */
disableRequestProcessing()320   public void disableRequestProcessing() {
321     requestProcessingDisabled = true;
322   }
323 
324   @VisibleForTesting
injectContactInfoForTest(String number, String countryIso, ContactInfo contactInfo)325   public void injectContactInfoForTest(String number, String countryIso, ContactInfo contactInfo) {
326     NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso);
327     cache.put(numberCountryIso, contactInfo);
328   }
329 
330   public interface OnContactInfoChangedListener {
331 
onContactInfoChanged()332     void onContactInfoChanged();
333   }
334 
335   /*
336    * Handles requests for contact name and number type.
337    */
338   private class QueryThread extends Thread {
339 
340     private volatile boolean done = false;
341 
QueryThread()342     public QueryThread() {
343       super("ContactInfoCache.QueryThread");
344     }
345 
stopProcessing()346     public void stopProcessing() {
347       done = true;
348     }
349 
350     @Override
run()351     public void run() {
352       boolean shouldRedraw = false;
353       while (true) {
354         // Check if thread is finished, and if so return immediately.
355         if (done) {
356           return;
357         }
358 
359         try {
360           ContactInfoRequest request = updateRequests.take();
361           shouldRedraw |= queryContactInfo(request);
362           if (shouldRedraw
363               && (updateRequests.isEmpty()
364                   || (request.isLocalRequest() && !updateRequests.peek().isLocalRequest()))) {
365             shouldRedraw = false;
366             handler.sendEmptyMessage(REDRAW);
367           }
368         } catch (InterruptedException e) {
369           // Ignore and attempt to continue processing requests
370         }
371       }
372     }
373   }
374 }
375