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
17package com.android.dialer.app.contactinfo;
18
19import android.os.Handler;
20import android.os.Message;
21import android.os.SystemClock;
22import android.support.annotation.NonNull;
23import android.support.annotation.VisibleForTesting;
24import android.text.TextUtils;
25import com.android.dialer.common.LogUtil;
26import com.android.dialer.logging.ContactSource.Type;
27import com.android.dialer.oem.CequintCallerIdManager;
28import com.android.dialer.phonenumbercache.ContactInfo;
29import com.android.dialer.phonenumbercache.ContactInfoHelper;
30import com.android.dialer.util.ExpirableCache;
31import java.lang.ref.WeakReference;
32import java.util.Objects;
33import java.util.concurrent.BlockingQueue;
34import 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 */
48public 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
67    public InnerHandler(WeakReference<ContactInfoCache> contactInfoCacheWeakReference) {
68      this.contactInfoCacheWeakReference = contactInfoCacheWeakReference;
69    }
70
71    @Override
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
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
100  public void setCequintCallerIdManager(CequintCallerIdManager cequintCallerIdManager) {
101    this.cequintCallerIdManager = cequintCallerIdManager;
102  }
103
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   */
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   */
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   */
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   */
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
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   */
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   */
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. */
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. */
320  public void disableRequestProcessing() {
321    requestProcessingDisabled = true;
322  }
323
324  @VisibleForTesting
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
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
342    public QueryThread() {
343      super("ContactInfoCache.QueryThread");
344    }
345
346    public void stopProcessing() {
347      done = true;
348    }
349
350    @Override
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