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