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