Home | History | Annotate | Download | only in incallui
      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.Context;
     20 import android.graphics.Bitmap;
     21 import android.graphics.drawable.BitmapDrawable;
     22 import android.graphics.drawable.Drawable;
     23 import android.net.Uri;
     24 import android.os.Looper;
     25 import android.provider.ContactsContract.Contacts;
     26 import android.provider.ContactsContract.CommonDataKinds.Phone;
     27 import android.telecom.TelecomManager;
     28 import android.text.TextUtils;
     29 
     30 import com.android.contacts.common.util.PhoneNumberHelper;
     31 import com.android.incallui.service.PhoneNumberService;
     32 import com.android.incalluibind.ServiceFactory;
     33 import com.android.services.telephony.common.MoreStrings;
     34 import com.google.common.collect.Maps;
     35 import com.google.common.collect.Sets;
     36 import com.google.common.base.Objects;
     37 import com.google.common.base.Preconditions;
     38 
     39 import java.util.HashMap;
     40 import 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  */
     48 public 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