1/* 2 * Copyright (C) 2013 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.incallui; 18 19import android.content.Context; 20import android.graphics.Bitmap; 21import android.graphics.drawable.BitmapDrawable; 22import android.graphics.drawable.Drawable; 23import android.net.Uri; 24import android.os.Looper; 25import android.provider.ContactsContract.Contacts; 26import android.provider.ContactsContract.CommonDataKinds.Phone; 27import android.telecom.TelecomManager; 28import android.text.TextUtils; 29 30import com.android.contacts.common.util.PhoneNumberHelper; 31import com.android.incallui.service.PhoneNumberService; 32import com.android.incalluibind.ServiceFactory; 33import com.android.services.telephony.common.MoreStrings; 34import com.google.common.collect.Maps; 35import com.google.common.collect.Sets; 36import com.google.common.base.Objects; 37import com.google.common.base.Preconditions; 38 39import java.util.HashMap; 40import java.util.Set; 41 42/** 43 * Class responsible for querying Contact Information for Call objects. Can perform asynchronous 44 * requests to the Contact Provider for information as well as respond synchronously for any data 45 * that it currently has cached from previous queries. This class always gets called from the UI 46 * thread so it does not need thread protection. 47 */ 48public class ContactInfoCache implements ContactsAsyncHelper.OnImageLoadCompleteListener { 49 50 private static final String TAG = ContactInfoCache.class.getSimpleName(); 51 private static final int TOKEN_UPDATE_PHOTO_FOR_CALL_STATE = 0; 52 53 private final Context mContext; 54 private final PhoneNumberService mPhoneNumberService; 55 private final HashMap<String, ContactCacheEntry> mInfoMap = Maps.newHashMap(); 56 private final HashMap<String, Set<ContactInfoCacheCallback>> mCallBacks = Maps.newHashMap(); 57 58 private static ContactInfoCache sCache = null; 59 60 public static synchronized ContactInfoCache getInstance(Context mContext) { 61 if (sCache == null) { 62 sCache = new ContactInfoCache(mContext.getApplicationContext()); 63 } 64 return sCache; 65 } 66 67 private ContactInfoCache(Context context) { 68 mContext = context; 69 mPhoneNumberService = ServiceFactory.newPhoneNumberService(context); 70 } 71 72 public ContactCacheEntry getInfo(String callId) { 73 return mInfoMap.get(callId); 74 } 75 76 public static ContactCacheEntry buildCacheEntryFromCall(Context context, Call call, 77 boolean isIncoming) { 78 final ContactCacheEntry entry = new ContactCacheEntry(); 79 80 // TODO: get rid of caller info. 81 final CallerInfo info = CallerInfoUtils.buildCallerInfo(context, call); 82 ContactInfoCache.populateCacheEntry(context, info, entry, call.getNumberPresentation(), 83 isIncoming); 84 return entry; 85 } 86 87 private class FindInfoCallback implements CallerInfoAsyncQuery.OnQueryCompleteListener { 88 private final boolean mIsIncoming; 89 90 public FindInfoCallback(boolean isIncoming) { 91 mIsIncoming = isIncoming; 92 } 93 94 @Override 95 public void onQueryComplete(int token, Object cookie, CallerInfo callerInfo) { 96 findInfoQueryComplete((Call) cookie, callerInfo, mIsIncoming, true); 97 } 98 } 99 100 /** 101 * Requests contact data for the Call object passed in. 102 * Returns the data through callback. If callback is null, no response is made, however the 103 * query is still performed and cached. 104 * 105 * @param callback The function to call back when the call is found. Can be null. 106 */ 107 public void findInfo(final Call call, final boolean isIncoming, 108 ContactInfoCacheCallback callback) { 109 Preconditions.checkState(Looper.getMainLooper().getThread() == Thread.currentThread()); 110 Preconditions.checkNotNull(callback); 111 112 final String callId = call.getId(); 113 final ContactCacheEntry cacheEntry = mInfoMap.get(callId); 114 Set<ContactInfoCacheCallback> callBacks = mCallBacks.get(callId); 115 116 // If we have a previously obtained intermediate result return that now 117 if (cacheEntry != null) { 118 Log.d(TAG, "Contact lookup. In memory cache hit; lookup " 119 + (callBacks == null ? "complete" : "still running")); 120 callback.onContactInfoComplete(callId, cacheEntry); 121 // If no other callbacks are in flight, we're done. 122 if (callBacks == null) { 123 return; 124 } 125 } 126 127 // If the entry already exists, add callback 128 if (callBacks != null) { 129 callBacks.add(callback); 130 return; 131 } 132 Log.d(TAG, "Contact lookup. In memory cache miss; searching provider."); 133 // New lookup 134 callBacks = Sets.newHashSet(); 135 callBacks.add(callback); 136 mCallBacks.put(callId, callBacks); 137 138 /** 139 * Performs a query for caller information. 140 * Save any immediate data we get from the query. An asynchronous query may also be made 141 * for any data that we do not already have. Some queries, such as those for voicemail and 142 * emergency call information, will not perform an additional asynchronous query. 143 */ 144 final CallerInfo callerInfo = CallerInfoUtils.getCallerInfoForCall( 145 mContext, call, new FindInfoCallback(isIncoming)); 146 147 findInfoQueryComplete(call, callerInfo, isIncoming, false); 148 } 149 150 private void findInfoQueryComplete(Call call, CallerInfo callerInfo, boolean isIncoming, 151 boolean didLocalLookup) { 152 final String callId = call.getId(); 153 int presentationMode = call.getNumberPresentation(); 154 if (callerInfo.contactExists || callerInfo.isEmergencyNumber() || 155 callerInfo.isVoiceMailNumber()) { 156 presentationMode = TelecomManager.PRESENTATION_ALLOWED; 157 } 158 159 ContactCacheEntry cacheEntry = mInfoMap.get(callId); 160 // Rebuild the entry from the new data if: 161 // 1) This is NOT the asynchronous local lookup (IOW, this is the first pass) 162 // 2) The local lookup was done and the contact exists 163 // 3) The existing cached entry is empty (no name). 164 if (!didLocalLookup || callerInfo.contactExists || 165 (cacheEntry != null && TextUtils.isEmpty(cacheEntry.name))) { 166 cacheEntry = buildEntry(mContext, callId, callerInfo, presentationMode, isIncoming); 167 mInfoMap.put(callId, cacheEntry); 168 } 169 170 sendInfoNotifications(callId, cacheEntry); 171 172 if (didLocalLookup) { 173 // Before issuing a request for more data from other services, We only check that the 174 // contact wasn't found in the local DB. We don't check the if the cache entry already 175 // has a name because we allow overriding cnap data with data from other services. 176 if (!callerInfo.contactExists && mPhoneNumberService != null) { 177 Log.d(TAG, "Contact lookup. Local contacts miss, checking remote"); 178 final PhoneNumberServiceListener listener = new PhoneNumberServiceListener(callId); 179 mPhoneNumberService.getPhoneNumberInfo(cacheEntry.number, listener, listener, 180 isIncoming); 181 } else if (cacheEntry.displayPhotoUri != null) { 182 Log.d(TAG, "Contact lookup. Local contact found, starting image load"); 183 // Load the image with a callback to update the image state. 184 // When the load is finished, onImageLoadComplete() will be called. 185 ContactsAsyncHelper.startObtainPhotoAsync(TOKEN_UPDATE_PHOTO_FOR_CALL_STATE, 186 mContext, cacheEntry.displayPhotoUri, ContactInfoCache.this, callId); 187 } else { 188 if (callerInfo.contactExists) { 189 Log.d(TAG, "Contact lookup done. Local contact found, no image."); 190 } else { 191 Log.d(TAG, "Contact lookup done. Local contact not found and" 192 + " no remote lookup service available."); 193 } 194 clearCallbacks(callId); 195 } 196 } 197 } 198 199 class PhoneNumberServiceListener implements PhoneNumberService.NumberLookupListener, 200 PhoneNumberService.ImageLookupListener { 201 private final String mCallId; 202 203 PhoneNumberServiceListener(String callId) { 204 mCallId = callId; 205 } 206 207 @Override 208 public void onPhoneNumberInfoComplete( 209 final PhoneNumberService.PhoneNumberInfo info) { 210 // If we got a miss, this is the end of the lookup pipeline, 211 // so clear the callbacks and return. 212 if (info == null) { 213 Log.d(TAG, "Contact lookup done. Remote contact not found."); 214 clearCallbacks(mCallId); 215 return; 216 } 217 218 ContactCacheEntry entry = new ContactCacheEntry(); 219 entry.name = info.getDisplayName(); 220 entry.number = info.getNumber(); 221 final int type = info.getPhoneType(); 222 final String label = info.getPhoneLabel(); 223 if (type == Phone.TYPE_CUSTOM) { 224 entry.label = label; 225 } else { 226 final CharSequence typeStr = Phone.getTypeLabel( 227 mContext.getResources(), type, label); 228 entry.label = typeStr == null ? null : typeStr.toString(); 229 } 230 final ContactCacheEntry oldEntry = mInfoMap.get(mCallId); 231 if (oldEntry != null) { 232 // Location is only obtained from local lookup so persist 233 // the value for remote lookups. Once we have a name this 234 // field is no longer used; it is persisted here in case 235 // the UI is ever changed to use it. 236 entry.location = oldEntry.location; 237 } 238 239 // If no image and it's a business, switch to using the default business avatar. 240 if (info.getImageUrl() == null && info.isBusiness()) { 241 Log.d(TAG, "Business has no image. Using default."); 242 entry.photo = mContext.getResources().getDrawable(R.drawable.img_business); 243 } 244 245 // Add the contact info to the cache. 246 mInfoMap.put(mCallId, entry); 247 sendInfoNotifications(mCallId, entry); 248 249 // If there is no image then we should not expect another callback. 250 if (info.getImageUrl() == null) { 251 // We're done, so clear callbacks 252 clearCallbacks(mCallId); 253 } 254 } 255 256 @Override 257 public void onImageFetchComplete(Bitmap bitmap) { 258 onImageLoadComplete(TOKEN_UPDATE_PHOTO_FOR_CALL_STATE, null, bitmap, mCallId); 259 } 260 } 261 262 /** 263 * Implemented for ContactsAsyncHelper.OnImageLoadCompleteListener interface. 264 * make sure that the call state is reflected after the image is loaded. 265 */ 266 @Override 267 public void onImageLoadComplete(int token, Drawable photo, Bitmap photoIcon, Object cookie) { 268 Log.d(this, "Image load complete with context: ", mContext); 269 // TODO: may be nice to update the image view again once the newer one 270 // is available on contacts database. 271 272 final String callId = (String) cookie; 273 final ContactCacheEntry entry = mInfoMap.get(callId); 274 275 if (entry == null) { 276 Log.e(this, "Image Load received for empty search entry."); 277 clearCallbacks(callId); 278 return; 279 } 280 Log.d(this, "setting photo for entry: ", entry); 281 282 // Conference call icons are being handled in CallCardPresenter. 283 if (photo != null) { 284 Log.v(this, "direct drawable: ", photo); 285 entry.photo = photo; 286 } else if (photoIcon != null) { 287 Log.v(this, "photo icon: ", photoIcon); 288 entry.photo = new BitmapDrawable(mContext.getResources(), photoIcon); 289 } else { 290 Log.v(this, "unknown photo"); 291 entry.photo = null; 292 } 293 294 sendImageNotifications(callId, entry); 295 clearCallbacks(callId); 296 } 297 298 /** 299 * Blows away the stored cache values. 300 */ 301 public void clearCache() { 302 mInfoMap.clear(); 303 mCallBacks.clear(); 304 } 305 306 private ContactCacheEntry buildEntry(Context context, String callId, 307 CallerInfo info, int presentation, boolean isIncoming) { 308 // The actual strings we're going to display onscreen: 309 Drawable photo = null; 310 311 final ContactCacheEntry cce = new ContactCacheEntry(); 312 populateCacheEntry(context, info, cce, presentation, isIncoming); 313 314 // This will only be true for emergency numbers 315 if (info.photoResource != 0) { 316 photo = context.getResources().getDrawable(info.photoResource); 317 } else if (info.isCachedPhotoCurrent) { 318 if (info.cachedPhoto != null) { 319 photo = info.cachedPhoto; 320 } else { 321 photo = context.getResources().getDrawable(R.drawable.img_no_image); 322 photo.setAutoMirrored(true); 323 } 324 } else if (info.contactDisplayPhotoUri == null) { 325 photo = context.getResources().getDrawable(R.drawable.img_no_image); 326 photo.setAutoMirrored(true); 327 } else { 328 cce.displayPhotoUri = info.contactDisplayPhotoUri; 329 } 330 331 if (info.lookupKeyOrNull == null || info.contactIdOrZero == 0) { 332 Log.v(TAG, "lookup key is null or contact ID is 0. Don't create a lookup uri."); 333 cce.lookupUri = null; 334 } else { 335 cce.lookupUri = Contacts.getLookupUri(info.contactIdOrZero, info.lookupKeyOrNull); 336 } 337 338 cce.photo = photo; 339 cce.lookupKey = info.lookupKeyOrNull; 340 341 return cce; 342 } 343 344 /** 345 * Populate a cache entry from a call (which got converted into a caller info). 346 */ 347 public static void populateCacheEntry(Context context, CallerInfo info, ContactCacheEntry cce, 348 int presentation, boolean isIncoming) { 349 Preconditions.checkNotNull(info); 350 String displayName = null; 351 String displayNumber = null; 352 String displayLocation = null; 353 String label = null; 354 boolean isSipCall = false; 355 356 // It appears that there is a small change in behaviour with the 357 // PhoneUtils' startGetCallerInfo whereby if we query with an 358 // empty number, we will get a valid CallerInfo object, but with 359 // fields that are all null, and the isTemporary boolean input 360 // parameter as true. 361 362 // In the past, we would see a NULL callerinfo object, but this 363 // ends up causing null pointer exceptions elsewhere down the 364 // line in other cases, so we need to make this fix instead. It 365 // appears that this was the ONLY call to PhoneUtils 366 // .getCallerInfo() that relied on a NULL CallerInfo to indicate 367 // an unknown contact. 368 369 // Currently, infi.phoneNumber may actually be a SIP address, and 370 // if so, it might sometimes include the "sip:" prefix. That 371 // prefix isn't really useful to the user, though, so strip it off 372 // if present. (For any other URI scheme, though, leave the 373 // prefix alone.) 374 // TODO: It would be cleaner for CallerInfo to explicitly support 375 // SIP addresses instead of overloading the "phoneNumber" field. 376 // Then we could remove this hack, and instead ask the CallerInfo 377 // for a "user visible" form of the SIP address. 378 String number = info.phoneNumber; 379 380 if (!TextUtils.isEmpty(number)) { 381 isSipCall = PhoneNumberHelper.isUriNumber(number); 382 if (number.startsWith("sip:")) { 383 number = number.substring(4); 384 } 385 } 386 387 if (TextUtils.isEmpty(info.name)) { 388 // No valid "name" in the CallerInfo, so fall back to 389 // something else. 390 // (Typically, we promote the phone number up to the "name" slot 391 // onscreen, and possibly display a descriptive string in the 392 // "number" slot.) 393 if (TextUtils.isEmpty(number)) { 394 // No name *or* number! Display a generic "unknown" string 395 // (or potentially some other default based on the presentation.) 396 displayName = getPresentationString(context, presentation); 397 Log.d(TAG, " ==> no name *or* number! displayName = " + displayName); 398 } else if (presentation != TelecomManager.PRESENTATION_ALLOWED) { 399 // This case should never happen since the network should never send a phone # 400 // AND a restricted presentation. However we leave it here in case of weird 401 // network behavior 402 displayName = getPresentationString(context, presentation); 403 Log.d(TAG, " ==> presentation not allowed! displayName = " + displayName); 404 } else if (!TextUtils.isEmpty(info.cnapName)) { 405 // No name, but we do have a valid CNAP name, so use that. 406 displayName = info.cnapName; 407 info.name = info.cnapName; 408 displayNumber = number; 409 Log.d(TAG, " ==> cnapName available: displayName '" + displayName + 410 "', displayNumber '" + displayNumber + "'"); 411 } else { 412 // No name; all we have is a number. This is the typical 413 // case when an incoming call doesn't match any contact, 414 // or if you manually dial an outgoing number using the 415 // dialpad. 416 displayNumber = number; 417 418 // Display a geographical description string if available 419 // (but only for incoming calls.) 420 if (isIncoming) { 421 // TODO (CallerInfoAsyncQuery cleanup): Fix the CallerInfo 422 // query to only do the geoDescription lookup in the first 423 // place for incoming calls. 424 displayLocation = info.geoDescription; // may be null 425 Log.d(TAG, "Geodescrption: " + info.geoDescription); 426 } 427 428 Log.d(TAG, " ==> no name; falling back to number:" 429 + " displayNumber '" + Log.pii(displayNumber) 430 + "', displayLocation '" + displayLocation + "'"); 431 } 432 } else { 433 // We do have a valid "name" in the CallerInfo. Display that 434 // in the "name" slot, and the phone number in the "number" slot. 435 if (presentation != TelecomManager.PRESENTATION_ALLOWED) { 436 // This case should never happen since the network should never send a name 437 // AND a restricted presentation. However we leave it here in case of weird 438 // network behavior 439 displayName = getPresentationString(context, presentation); 440 Log.d(TAG, " ==> valid name, but presentation not allowed!" + 441 " displayName = " + displayName); 442 } else { 443 displayName = info.name; 444 displayNumber = number; 445 label = info.phoneLabel; 446 Log.d(TAG, " ==> name is present in CallerInfo: displayName '" + displayName 447 + "', displayNumber '" + displayNumber + "'"); 448 } 449 } 450 451 cce.name = displayName; 452 cce.number = displayNumber; 453 cce.location = displayLocation; 454 cce.label = label; 455 cce.isSipCall = isSipCall; 456 } 457 458 /** 459 * Sends the updated information to call the callbacks for the entry. 460 */ 461 private void sendInfoNotifications(String callId, ContactCacheEntry entry) { 462 final Set<ContactInfoCacheCallback> callBacks = mCallBacks.get(callId); 463 if (callBacks != null) { 464 for (ContactInfoCacheCallback callBack : callBacks) { 465 callBack.onContactInfoComplete(callId, entry); 466 } 467 } 468 } 469 470 private void sendImageNotifications(String callId, ContactCacheEntry entry) { 471 final Set<ContactInfoCacheCallback> callBacks = mCallBacks.get(callId); 472 if (callBacks != null && entry.photo != null) { 473 for (ContactInfoCacheCallback callBack : callBacks) { 474 callBack.onImageLoadComplete(callId, entry); 475 } 476 } 477 } 478 479 private void clearCallbacks(String callId) { 480 mCallBacks.remove(callId); 481 } 482 483 /** 484 * Gets name strings based on some special presentation modes. 485 */ 486 private static String getPresentationString(Context context, int presentation) { 487 String name = context.getString(R.string.unknown); 488 if (presentation == TelecomManager.PRESENTATION_RESTRICTED) { 489 name = context.getString(R.string.private_num); 490 } else if (presentation == TelecomManager.PRESENTATION_PAYPHONE) { 491 name = context.getString(R.string.payphone); 492 } 493 return name; 494 } 495 496 /** 497 * Callback interface for the contact query. 498 */ 499 public interface ContactInfoCacheCallback { 500 public void onContactInfoComplete(String callId, ContactCacheEntry entry); 501 public void onImageLoadComplete(String callId, ContactCacheEntry entry); 502 } 503 504 public static class ContactCacheEntry { 505 public String name; 506 public String number; 507 public String location; 508 public String label; 509 public Drawable photo; 510 public boolean isSipCall; 511 /** This will be used for the "view" notification. */ 512 public Uri contactUri; 513 /** Either a display photo or a thumbnail URI. */ 514 public Uri displayPhotoUri; 515 public Uri lookupUri; // Sent to NotificationMananger 516 public String lookupKey; 517 518 @Override 519 public String toString() { 520 return Objects.toStringHelper(this) 521 .add("name", MoreStrings.toSafeString(name)) 522 .add("number", MoreStrings.toSafeString(number)) 523 .add("location", MoreStrings.toSafeString(location)) 524 .add("label", label) 525 .add("photo", photo) 526 .add("isSipCall", isSipCall) 527 .add("contactUri", contactUri) 528 .add("displayPhotoUri", displayPhotoUri) 529 .toString(); 530 } 531 } 532} 533