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.phonenumbercache.ContactInfo;
28import com.android.dialer.phonenumbercache.ContactInfoHelper;
29import com.android.dialer.util.ExpirableCache;
30import java.lang.ref.WeakReference;
31import java.util.Objects;
32import java.util.concurrent.BlockingQueue;
33import 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 */
47public 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
65    public InnerHandler(WeakReference<ContactInfoCache> contactInfoCacheWeakReference) {
66      this.contactInfoCacheWeakReference = contactInfoCacheWeakReference;
67    }
68
69    @Override
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
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
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   */
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   */
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   */
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   */
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
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   */
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   */
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. */
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. */
314  public void disableRequestProcessing() {
315    mRequestProcessingDisabled = true;
316  }
317
318  @VisibleForTesting
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
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
336    public QueryThread() {
337      super("ContactInfoCache.QueryThread");
338    }
339
340    public void stopProcessing() {
341      mDone = true;
342    }
343
344    @Override
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