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.oem.CequintCallerIdManager; 28 import com.android.dialer.phonenumbercache.ContactInfo; 29 import com.android.dialer.phonenumbercache.ContactInfoHelper; 30 import com.android.dialer.util.ExpirableCache; 31 import java.lang.ref.WeakReference; 32 import java.util.Objects; 33 import java.util.concurrent.BlockingQueue; 34 import 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 */ 48 public 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> mCache; 55 private final ContactInfoHelper mContactInfoHelper; 56 private final OnContactInfoChangedListener mOnContactInfoChangedListener; 57 private final BlockingQueue<ContactInfoRequest> mUpdateRequests; 58 private final Handler mHandler; 59 private CequintCallerIdManager mCequintCallerIdManager; 60 private QueryThread mContactInfoQueryThread; 61 private volatile boolean mRequestProcessingDisabled = 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.mOnContactInfoChangedListener.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 mCache = internalCache; 94 mContactInfoHelper = contactInfoHelper; 95 mOnContactInfoChangedListener = listener; 96 mUpdateRequests = new PriorityBlockingQueue<>(); 97 mHandler = new InnerHandler(new WeakReference<>(this)); 98 } 99 100 public void setCequintCallerIdManager(CequintCallerIdManager cequintCallerIdManager) { 101 mCequintCallerIdManager = 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 = mCache.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 mCache.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 = mContactInfoHelper.lookupNumber(request.number, request.countryIso); 165 if (info != null && !info.contactExists) { 166 // TODO: Maybe skip look up if it's already available in cached number lookup 167 // service. 168 long start = SystemClock.elapsedRealtime(); 169 mContactInfoHelper.updateFromCequintCallerId(mCequintCallerIdManager, 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 (!mContactInfoHelper.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 = mContactInfoHelper.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 = mCache.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 mCache.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 mContactInfoHelper.updateCallLogContactInfo( 218 request.number, request.countryIso, info, request.callLogInfo); 219 if (!request.isLocalRequest()) { 220 mContactInfoHelper.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 (mContactInfoQueryThread == null) { 233 // TODO: Check whether this delay before starting to process is necessary. 234 mHandler.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 (mRequestProcessingDisabled) { 253 return; 254 } 255 256 // If a thread is already started, don't start another. 257 if (mContactInfoQueryThread != null) { 258 return; 259 } 260 261 mContactInfoQueryThread = new QueryThread(); 262 mContactInfoQueryThread.setPriority(Thread.MIN_PRIORITY); 263 mContactInfoQueryThread.start(); 264 } 265 266 public void invalidate() { 267 mCache.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 mHandler.removeMessages(START_THREAD); 278 if (mContactInfoQueryThread != null) { 279 // Stop the thread; we are finished with it. 280 mContactInfoQueryThread.stopProcessing(); 281 mContactInfoQueryThread.interrupt(); 282 mContactInfoQueryThread = 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 (!mUpdateRequests.contains(request)) { 303 mUpdateRequests.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 mRequestProcessingDisabled = true; 322 } 323 324 @VisibleForTesting 325 public void injectContactInfoForTest(String number, String countryIso, ContactInfo contactInfo) { 326 NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); 327 mCache.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 mDone = false; 341 342 public QueryThread() { 343 super("ContactInfoCache.QueryThread"); 344 } 345 346 public void stopProcessing() { 347 mDone = 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 (mDone) { 356 return; 357 } 358 359 try { 360 ContactInfoRequest request = mUpdateRequests.take(); 361 shouldRedraw |= queryContactInfo(request); 362 if (shouldRedraw 363 && (mUpdateRequests.isEmpty() 364 || (request.isLocalRequest() && !mUpdateRequests.peek().isLocalRequest()))) { 365 shouldRedraw = false; 366 mHandler.sendEmptyMessage(REDRAW); 367 } 368 } catch (InterruptedException e) { 369 // Ignore and attempt to continue processing requests 370 } 371 } 372 } 373 } 374 } 375