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