/* * Copyright (C) 2011 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.dialer.calllog; import android.content.Context; import android.database.Cursor; import android.net.Uri; import android.os.Handler; import android.os.Message; import android.provider.ContactsContract.PhoneLookup; import android.text.TextUtils; import android.view.View; import android.view.ViewTreeObserver; import com.android.dialer.util.ExpirableCache; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Objects; import java.util.LinkedList; /** * Adapter class to fill in data for the Call Log. */ public class CallLogAdapterHelper implements ViewTreeObserver.OnPreDrawListener { public interface Callback { void dataSetChanged(); void updateContactInfo(String number, String countryIso, ContactInfo updatedInfo, ContactInfo callLogInfo); } /** * Stores a phone number of a call with the country code where it originally occurred. *

* Note the country does not necessarily specifies the country of the phone number itself, but * it is the country in which the user was in when the call was placed or received. */ public static final class NumberWithCountryIso { public final String number; public final String countryIso; public NumberWithCountryIso(String number, String countryIso) { this.number = number; this.countryIso = countryIso; } @Override public boolean equals(Object o) { if (o == null) return false; if (!(o instanceof NumberWithCountryIso)) return false; NumberWithCountryIso other = (NumberWithCountryIso) o; return TextUtils.equals(number, other.number) && TextUtils.equals(countryIso, other.countryIso); } @Override public int hashCode() { return (number == null ? 0 : number.hashCode()) ^ (countryIso == null ? 0 : countryIso.hashCode()); } } /** * A request for contact details for the given number. */ private static final class ContactInfoRequest { /** The number to look-up. */ public final String number; /** The country in which a call to or from this number was placed or received. */ public final String countryIso; /** The cached contact information stored in the call log. */ public final ContactInfo callLogInfo; public ContactInfoRequest(String number, String countryIso, ContactInfo callLogInfo) { this.number = number; this.countryIso = countryIso; this.callLogInfo = callLogInfo; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (!(obj instanceof ContactInfoRequest)) return false; ContactInfoRequest other = (ContactInfoRequest) obj; if (!TextUtils.equals(number, other.number)) return false; if (!TextUtils.equals(countryIso, other.countryIso)) return false; if (!Objects.equal(callLogInfo, other.callLogInfo)) return false; return true; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((callLogInfo == null) ? 0 : callLogInfo.hashCode()); result = prime * result + ((countryIso == null) ? 0 : countryIso.hashCode()); result = prime * result + ((number == null) ? 0 : number.hashCode()); return result; } } /* * Handles requests for contact name and number type. */ private class QueryThread extends Thread { private volatile boolean mDone = false; public QueryThread() { super("CallLogAdapter.QueryThread"); } public void stopProcessing() { mDone = true; } @Override public void run() { boolean needRedraw = false; while (true) { // Check if thread is finished, and if so return immediately. if (mDone) return; // Obtain next request, if any is available. // Keep synchronized section small. ContactInfoRequest req = null; synchronized (mRequests) { if (!mRequests.isEmpty()) { req = mRequests.removeFirst(); } } if (req != null) { // Process the request. If the lookup succeeds, schedule a // redraw. needRedraw |= queryContactInfo(req.number, req.countryIso, req.callLogInfo); } else { // Throttle redraw rate by only sending them when there are // more requests. if (needRedraw) { needRedraw = false; mHandler.sendEmptyMessage(REDRAW); } // Wait until another request is available, or until this // thread is no longer needed (as indicated by being // interrupted). try { synchronized (mRequests) { mRequests.wait(1000); } } catch (InterruptedException ie) { // Ignore, and attempt to continue processing requests. } } } } } private static final int REDRAW = 1; private static final int START_THREAD = 2; /** The time in millis to delay starting the thread processing requests. */ private static final int START_PROCESSING_REQUESTS_DELAY_MILLIS = 1000; /** The size of the cache of contact info. */ private static final int CONTACT_INFO_CACHE_SIZE = 100; private Callback mCb; private final Context mContext; private final ContactInfoHelper mContactInfoHelper; private final PhoneNumberDisplayHelper mPhoneNumberHelper; /** * A cache of the contact details for the phone numbers in the call log. *

* The content of the cache is expired (but not purged) whenever the application comes to * the foreground. *

* The key is number with the country in which the call was placed or received. */ private ExpirableCache mContactInfoCache; private QueryThread mCallerIdThread; /** Can be set to true by tests to disable processing of requests. */ private volatile boolean mRequestProcessingDisabled = false; /** * List of requests to update contact details. *

* Each request is made of a phone number to look up, and the contact info currently stored in * the call log for this number. *

* The requests are added when displaying the contacts and are processed by a background * thread. */ private final LinkedList mRequests; private ViewTreeObserver mViewTreeObserver = null; private Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { switch (msg.what) { case REDRAW: mCb.dataSetChanged(); break; case START_THREAD: startRequestProcessing(); break; } } }; /** * Enqueues a request to look up the contact details for the given phone number. *

* It also provides the current contact info stored in the call log for this number. *

* If the {@code immediate} parameter is true, it will start immediately the thread that looks * up the contact information (if it has not been already started). Otherwise, it will be * started with a delay. See {@link #START_PROCESSING_REQUESTS_DELAY_MILLIS}. */ @VisibleForTesting void enqueueRequest(String number, String countryIso, ContactInfo callLogInfo, boolean immediate) { ContactInfoRequest request = new ContactInfoRequest(number, countryIso, callLogInfo); synchronized (mRequests) { if (!mRequests.contains(request)) { mRequests.add(request); mRequests.notifyAll(); } } if (immediate) startRequestProcessing(); } @Override public boolean onPreDraw() { // We only wanted to listen for the first draw (and this is it). unregisterPreDrawListener(); // Only schedule a thread-creation message if the thread hasn't been // created yet. This is purely an optimization, to queue fewer messages. if (mCallerIdThread == null) { mHandler.sendEmptyMessageDelayed(START_THREAD, START_PROCESSING_REQUESTS_DELAY_MILLIS); } return true; } public void registerOnPreDrawListener(View v) { // Listen for the first draw if (mViewTreeObserver == null) { mViewTreeObserver = v.getViewTreeObserver(); mViewTreeObserver.addOnPreDrawListener(this); } } /** * Stop receiving onPreDraw() notifications. */ private void unregisterPreDrawListener() { if (mViewTreeObserver != null && mViewTreeObserver.isAlive()) { mViewTreeObserver.removeOnPreDrawListener(this); } mViewTreeObserver = null; } public void invalidateCache() { mContactInfoCache.expireAll(); // Restart the request-processing thread after the next draw. stopRequestProcessing(); unregisterPreDrawListener(); } /** * Starts a background thread to process contact-lookup requests, unless one * has already been started. */ private synchronized void startRequestProcessing() { // For unit-testing. if (mRequestProcessingDisabled) return; // Idempotence... if a thread is already started, don't start another. if (mCallerIdThread != null) return; mCallerIdThread = new QueryThread(); mCallerIdThread.setPriority(Thread.MIN_PRIORITY); mCallerIdThread.start(); } /** * Stops the background thread that processes updates and cancels any * pending requests to start it. */ public synchronized void stopRequestProcessing() { // Remove any pending requests to start the processing thread. mHandler.removeMessages(START_THREAD); if (mCallerIdThread != null) { // Stop the thread; we are finished with it. mCallerIdThread.stopProcessing(); mCallerIdThread.interrupt(); mCallerIdThread = null; } } /** * Queries the appropriate content provider for the contact associated with the number. *

* Upon completion it also updates the cache in the call log, if it is different from * {@code callLogInfo}. *

* The number might be either a SIP address or a phone number. *

* It returns true if it updated the content of the cache and we should therefore tell the * view to update its content. */ private boolean queryContactInfo(String number, String countryIso, ContactInfo callLogInfo) { final ContactInfo info = mContactInfoHelper.lookupNumber(number, countryIso); if (info == null) { // The lookup failed, just return without requesting to update the view. return false; } // Check the existing entry in the cache: only if it has changed we should update the // view. NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); ContactInfo existingInfo = mContactInfoCache.getPossiblyExpired(numberCountryIso); boolean updated = (existingInfo != ContactInfo.EMPTY) && !info.equals(existingInfo); // Store the data in the cache so that the UI thread can use to display it. Store it // even if it has not changed so that it is marked as not expired. mContactInfoCache.put(numberCountryIso, info); mCb.updateContactInfo(number, countryIso, info, callLogInfo); return updated; } /* * Get the number from the Contacts, if available, since sometimes * the number provided by caller id may not be formatted properly * depending on the carrier (roaming) in use at the time of the * incoming call. * Logic : If the caller-id number starts with a "+", use it * Else if the number in the contacts starts with a "+", use that one * Else if the number in the contacts is longer, use that one */ public String getBetterNumberFromContacts(String number, String countryIso) { String matchingNumber = null; // Look in the cache first. If it's not found then query the Phones db NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); ContactInfo ci = mContactInfoCache.getPossiblyExpired(numberCountryIso); if (ci != null && ci != ContactInfo.EMPTY) { matchingNumber = ci.number; } else { try { Cursor phonesCursor = mContext.getContentResolver().query( Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, number), PhoneQuery._PROJECTION, null, null, null); if (phonesCursor != null) { if (phonesCursor.moveToFirst()) { matchingNumber = phonesCursor.getString(PhoneQuery.MATCHED_NUMBER); } phonesCursor.close(); } } catch (Exception e) { // Use the number from the call log } } if (!TextUtils.isEmpty(matchingNumber) && (matchingNumber.startsWith("+") || matchingNumber.length() > number.length())) { number = matchingNumber; } return number; } /** Checks whether the contact info from the call log matches the one from the contacts db. */ private boolean callLogInfoMatches(ContactInfo callLogInfo, ContactInfo info) { // The call log only contains a subset of the fields in the contacts db. // Only check those. return TextUtils.equals(callLogInfo.name, info.name) && callLogInfo.type == info.type && TextUtils.equals(callLogInfo.label, info.label); } public ContactInfo lookupContact(String number, int numberPresentation, String countryIso, ContactInfo cachedContactInfo) { NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); ExpirableCache.CachedValue cachedInfo = mContactInfoCache.getCachedValue(numberCountryIso); ContactInfo info = cachedInfo == null ? null : cachedInfo.getValue(); if (!PhoneNumberUtilsWrapper.canPlaceCallsTo(number, numberPresentation) || new PhoneNumberUtilsWrapper().isVoicemailNumber(number)) { // If this is a number that cannot be dialed, there is no point in looking up a contact // for it. info = ContactInfo.EMPTY; } else if (cachedInfo == null) { mContactInfoCache.put(numberCountryIso, ContactInfo.EMPTY); // Use the cached contact info from the call log. info = cachedContactInfo; // The db request should happen on a non-UI thread. // Request the contact details immediately since they are currently missing. enqueueRequest(number, countryIso, cachedContactInfo, true); // We will format the phone number when we make the background request. } else { if (cachedInfo.isExpired()) { // The contact info is no longer up to date, we should request it. However, we // do not need to request them immediately. enqueueRequest(number, countryIso, cachedContactInfo, false); } else if (!callLogInfoMatches(cachedContactInfo, info)) { // The call log information does not match the one we have, look it up again. // We could simply update the call log directly, but that needs to be done in a // background thread, so it is easier to simply request a new lookup, which will, as // a side-effect, update the call log. enqueueRequest(number, countryIso, cachedContactInfo, false); } if (info == ContactInfo.EMPTY) { // Use the cached contact info from the call log. info = cachedContactInfo; } } return info; } public CallLogAdapterHelper(Context context, Callback cb, ContactInfoHelper contactInfoHelper, PhoneNumberDisplayHelper phoneNumberHelper) { mContext = context; mCb = cb; mContactInfoHelper = contactInfoHelper; mPhoneNumberHelper = phoneNumberHelper; mContactInfoCache = ExpirableCache.create(CONTACT_INFO_CACHE_SIZE); mRequests = new LinkedList(); } /** * Sets whether processing of requests for contact details should be enabled. *

* This method should be called in tests to disable such processing of requests when not * needed. */ @VisibleForTesting void disableRequestProcessingForTest() { mRequestProcessingDisabled = true; } @VisibleForTesting void injectContactInfoForTest(String number, String countryIso, ContactInfo contactInfo) { NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); mContactInfoCache.put(numberCountryIso, contactInfo); } }