blob: 73209742b52aa9d9afdc72d6e798ea5a86970d61 [file] [log] [blame]
/*
* Copyright (C) 2013 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.incallui;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Looper;
import android.provider.ContactsContract;
import android.provider.ContactsContract.Contacts;
import android.provider.ContactsContract.DisplayNameSources;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.telecom.TelecomManager;
import android.text.TextUtils;
import com.android.contacts.common.util.PhoneNumberHelper;
import com.android.dialer.calllog.ContactInfo;
import com.android.dialer.service.CachedNumberLookupService;
import com.android.dialer.service.CachedNumberLookupService.CachedContactInfo;
import com.android.incallui.service.PhoneNumberService;
import com.android.incalluibind.ObjectFactory;
import com.android.services.telephony.common.MoreStrings;
import org.json.JSONException;
import org.json.JSONObject;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.base.Objects;
import com.google.common.base.Preconditions;
import java.util.HashMap;
import java.util.Set;
/**
* Class responsible for querying Contact Information for Call objects. Can perform asynchronous
* requests to the Contact Provider for information as well as respond synchronously for any data
* that it currently has cached from previous queries. This class always gets called from the UI
* thread so it does not need thread protection.
*/
public class ContactInfoCache implements ContactsAsyncHelper.OnImageLoadCompleteListener {
private static final String TAG = ContactInfoCache.class.getSimpleName();
private static final int TOKEN_UPDATE_PHOTO_FOR_CALL_STATE = 0;
private final Context mContext;
private final PhoneNumberService mPhoneNumberService;
private final CachedNumberLookupService mCachedNumberLookupService;
private final HashMap<String, ContactCacheEntry> mInfoMap = Maps.newHashMap();
private final HashMap<String, Set<ContactInfoCacheCallback>> mCallBacks = Maps.newHashMap();
private static ContactInfoCache sCache = null;
private Drawable mDefaultContactPhotoDrawable;
private Drawable mConferencePhotoDrawable;
public static synchronized ContactInfoCache getInstance(Context mContext) {
if (sCache == null) {
sCache = new ContactInfoCache(mContext.getApplicationContext());
}
return sCache;
}
private ContactInfoCache(Context context) {
mContext = context;
mPhoneNumberService = ObjectFactory.newPhoneNumberService(context);
mCachedNumberLookupService =
com.android.dialerbind.ObjectFactory.newCachedNumberLookupService();
}
public ContactCacheEntry getInfo(String callId) {
return mInfoMap.get(callId);
}
public static ContactCacheEntry buildCacheEntryFromCall(Context context, Call call,
boolean isIncoming) {
final ContactCacheEntry entry = new ContactCacheEntry();
// TODO: get rid of caller info.
final CallerInfo info = CallerInfoUtils.buildCallerInfo(context, call);
ContactInfoCache.populateCacheEntry(context, info, entry, call.getNumberPresentation(),
isIncoming);
return entry;
}
public void maybeInsertCnapInformationIntoCache(Context context, final Call call,
final CallerInfo info) {
if (mCachedNumberLookupService == null || TextUtils.isEmpty(info.cnapName)
|| mInfoMap.get(call.getId()) != null) {
return;
}
final Context applicationContext = context.getApplicationContext();
Log.i(TAG, "Found contact with CNAP name - inserting into cache");
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
ContactInfo contactInfo = new ContactInfo();
CachedContactInfo cacheInfo = mCachedNumberLookupService.buildCachedContactInfo(
contactInfo);
cacheInfo.setSource(CachedContactInfo.SOURCE_TYPE_CNAP, "CNAP", 0);
contactInfo.name = info.cnapName;
contactInfo.number = call.getNumber();
contactInfo.type = ContactsContract.CommonDataKinds.Phone.TYPE_MAIN;
try {
final JSONObject contactRows = new JSONObject().put(Phone.CONTENT_ITEM_TYPE,
new JSONObject()
.put(Phone.NUMBER, contactInfo.number)
.put(Phone.TYPE, Phone.TYPE_MAIN));
final String jsonString = new JSONObject()
.put(Contacts.DISPLAY_NAME, contactInfo.name)
.put(Contacts.DISPLAY_NAME_SOURCE, DisplayNameSources.STRUCTURED_NAME)
.put(Contacts.CONTENT_ITEM_TYPE, contactRows).toString();
cacheInfo.setLookupKey(jsonString);
} catch (JSONException e) {
Log.w(TAG, "Creation of lookup key failed when caching CNAP information");
}
mCachedNumberLookupService.addContact(applicationContext, cacheInfo);
return null;
}
}.execute();
}
private class FindInfoCallback implements CallerInfoAsyncQuery.OnQueryCompleteListener {
private final boolean mIsIncoming;
public FindInfoCallback(boolean isIncoming) {
mIsIncoming = isIncoming;
}
@Override
public void onQueryComplete(int token, Object cookie, CallerInfo callerInfo) {
findInfoQueryComplete((Call) cookie, callerInfo, mIsIncoming, true);
}
}
/**
* Requests contact data for the Call object passed in.
* Returns the data through callback. If callback is null, no response is made, however the
* query is still performed and cached.
*
* @param callback The function to call back when the call is found. Can be null.
*/
public void findInfo(final Call call, final boolean isIncoming,
ContactInfoCacheCallback callback) {
Preconditions.checkState(Looper.getMainLooper().getThread() == Thread.currentThread());
Preconditions.checkNotNull(callback);
final String callId = call.getId();
final ContactCacheEntry cacheEntry = mInfoMap.get(callId);
Set<ContactInfoCacheCallback> callBacks = mCallBacks.get(callId);
// If we have a previously obtained intermediate result return that now
if (cacheEntry != null) {
Log.d(TAG, "Contact lookup. In memory cache hit; lookup "
+ (callBacks == null ? "complete" : "still running"));
callback.onContactInfoComplete(callId, cacheEntry);
// If no other callbacks are in flight, we're done.
if (callBacks == null) {
return;
}
}
// If the entry already exists, add callback
if (callBacks != null) {
callBacks.add(callback);
return;
}
Log.d(TAG, "Contact lookup. In memory cache miss; searching provider.");
// New lookup
callBacks = Sets.newHashSet();
callBacks.add(callback);
mCallBacks.put(callId, callBacks);
/**
* Performs a query for caller information.
* Save any immediate data we get from the query. An asynchronous query may also be made
* for any data that we do not already have. Some queries, such as those for voicemail and
* emergency call information, will not perform an additional asynchronous query.
*/
final CallerInfo callerInfo = CallerInfoUtils.getCallerInfoForCall(
mContext, call, new FindInfoCallback(isIncoming));
findInfoQueryComplete(call, callerInfo, isIncoming, false);
}
private void findInfoQueryComplete(Call call, CallerInfo callerInfo, boolean isIncoming,
boolean didLocalLookup) {
final String callId = call.getId();
int presentationMode = call.getNumberPresentation();
if (callerInfo.contactExists || callerInfo.isEmergencyNumber() ||
callerInfo.isVoiceMailNumber()) {
presentationMode = TelecomManager.PRESENTATION_ALLOWED;
}
ContactCacheEntry cacheEntry = mInfoMap.get(callId);
// Ensure we always have a cacheEntry. Replace the existing entry if
// it has no name or if we found a local contact.
if (cacheEntry == null || TextUtils.isEmpty(cacheEntry.name) ||
callerInfo.contactExists) {
cacheEntry = buildEntry(mContext, callId, callerInfo, presentationMode, isIncoming);
mInfoMap.put(callId, cacheEntry);
}
sendInfoNotifications(callId, cacheEntry);
if (didLocalLookup) {
// Before issuing a request for more data from other services, we only check that the
// contact wasn't found in the local DB. We don't check the if the cache entry already
// has a name because we allow overriding cnap data with data from other services.
if (!callerInfo.contactExists && mPhoneNumberService != null) {
Log.d(TAG, "Contact lookup. Local contacts miss, checking remote");
final PhoneNumberServiceListener listener = new PhoneNumberServiceListener(callId);
mPhoneNumberService.getPhoneNumberInfo(cacheEntry.number, listener, listener,
isIncoming);
} else if (cacheEntry.displayPhotoUri != null) {
Log.d(TAG, "Contact lookup. Local contact found, starting image load");
// Load the image with a callback to update the image state.
// When the load is finished, onImageLoadComplete() will be called.
ContactsAsyncHelper.startObtainPhotoAsync(TOKEN_UPDATE_PHOTO_FOR_CALL_STATE,
mContext, cacheEntry.displayPhotoUri, ContactInfoCache.this, callId);
} else {
if (callerInfo.contactExists) {
Log.d(TAG, "Contact lookup done. Local contact found, no image.");
} else {
Log.d(TAG, "Contact lookup done. Local contact not found and"
+ " no remote lookup service available.");
}
clearCallbacks(callId);
}
}
}
class PhoneNumberServiceListener implements PhoneNumberService.NumberLookupListener,
PhoneNumberService.ImageLookupListener {
private final String mCallId;
PhoneNumberServiceListener(String callId) {
mCallId = callId;
}
@Override
public void onPhoneNumberInfoComplete(
final PhoneNumberService.PhoneNumberInfo info) {
// If we got a miss, this is the end of the lookup pipeline,
// so clear the callbacks and return.
if (info == null) {
Log.d(TAG, "Contact lookup done. Remote contact not found.");
clearCallbacks(mCallId);
return;
}
ContactCacheEntry entry = new ContactCacheEntry();
entry.name = info.getDisplayName();
entry.number = info.getNumber();
final int type = info.getPhoneType();
final String label = info.getPhoneLabel();
if (type == Phone.TYPE_CUSTOM) {
entry.label = label;
} else {
final CharSequence typeStr = Phone.getTypeLabel(
mContext.getResources(), type, label);
entry.label = typeStr == null ? null : typeStr.toString();
}
final ContactCacheEntry oldEntry = mInfoMap.get(mCallId);
if (oldEntry != null) {
// Location is only obtained from local lookup so persist
// the value for remote lookups. Once we have a name this
// field is no longer used; it is persisted here in case
// the UI is ever changed to use it.
entry.location = oldEntry.location;
}
// If no image and it's a business, switch to using the default business avatar.
if (info.getImageUrl() == null && info.isBusiness()) {
Log.d(TAG, "Business has no image. Using default.");
entry.photo = mContext.getResources().getDrawable(R.drawable.img_business);
}
// Add the contact info to the cache.
mInfoMap.put(mCallId, entry);
sendInfoNotifications(mCallId, entry);
// If there is no image then we should not expect another callback.
if (info.getImageUrl() == null) {
// We're done, so clear callbacks
clearCallbacks(mCallId);
}
}
@Override
public void onImageFetchComplete(Bitmap bitmap) {
onImageLoadComplete(TOKEN_UPDATE_PHOTO_FOR_CALL_STATE, null, bitmap, mCallId);
}
}
/**
* Implemented for ContactsAsyncHelper.OnImageLoadCompleteListener interface.
* make sure that the call state is reflected after the image is loaded.
*/
@Override
public void onImageLoadComplete(int token, Drawable photo, Bitmap photoIcon, Object cookie) {
Log.d(this, "Image load complete with context: ", mContext);
// TODO: may be nice to update the image view again once the newer one
// is available on contacts database.
final String callId = (String) cookie;
final ContactCacheEntry entry = mInfoMap.get(callId);
if (entry == null) {
Log.e(this, "Image Load received for empty search entry.");
clearCallbacks(callId);
return;
}
Log.d(this, "setting photo for entry: ", entry);
// Conference call icons are being handled in CallCardPresenter.
if (photo != null) {
Log.v(this, "direct drawable: ", photo);
entry.photo = photo;
} else if (photoIcon != null) {
Log.v(this, "photo icon: ", photoIcon);
entry.photo = new BitmapDrawable(mContext.getResources(), photoIcon);
} else {
Log.v(this, "unknown photo");
entry.photo = null;
}
sendImageNotifications(callId, entry);
clearCallbacks(callId);
}
/**
* Blows away the stored cache values.
*/
public void clearCache() {
mInfoMap.clear();
mCallBacks.clear();
}
private ContactCacheEntry buildEntry(Context context, String callId,
CallerInfo info, int presentation, boolean isIncoming) {
// The actual strings we're going to display onscreen:
Drawable photo = null;
final ContactCacheEntry cce = new ContactCacheEntry();
populateCacheEntry(context, info, cce, presentation, isIncoming);
// This will only be true for emergency numbers
if (info.photoResource != 0) {
photo = context.getResources().getDrawable(info.photoResource);
} else if (info.isCachedPhotoCurrent) {
if (info.cachedPhoto != null) {
photo = info.cachedPhoto;
} else {
photo = getDefaultContactPhotoDrawable();
}
} else if (info.contactDisplayPhotoUri == null) {
photo = getDefaultContactPhotoDrawable();
} else {
cce.displayPhotoUri = info.contactDisplayPhotoUri;
}
if (info.lookupKeyOrNull == null || info.contactIdOrZero == 0) {
Log.v(TAG, "lookup key is null or contact ID is 0. Don't create a lookup uri.");
cce.lookupUri = null;
} else {
cce.lookupUri = Contacts.getLookupUri(info.contactIdOrZero, info.lookupKeyOrNull);
}
cce.photo = photo;
cce.lookupKey = info.lookupKeyOrNull;
return cce;
}
/**
* Populate a cache entry from a call (which got converted into a caller info).
*/
public static void populateCacheEntry(Context context, CallerInfo info, ContactCacheEntry cce,
int presentation, boolean isIncoming) {
Preconditions.checkNotNull(info);
String displayName = null;
String displayNumber = null;
String displayLocation = null;
String label = null;
boolean isSipCall = false;
// It appears that there is a small change in behaviour with the
// PhoneUtils' startGetCallerInfo whereby if we query with an
// empty number, we will get a valid CallerInfo object, but with
// fields that are all null, and the isTemporary boolean input
// parameter as true.
// In the past, we would see a NULL callerinfo object, but this
// ends up causing null pointer exceptions elsewhere down the
// line in other cases, so we need to make this fix instead. It
// appears that this was the ONLY call to PhoneUtils
// .getCallerInfo() that relied on a NULL CallerInfo to indicate
// an unknown contact.
// Currently, infi.phoneNumber may actually be a SIP address, and
// if so, it might sometimes include the "sip:" prefix. That
// prefix isn't really useful to the user, though, so strip it off
// if present. (For any other URI scheme, though, leave the
// prefix alone.)
// TODO: It would be cleaner for CallerInfo to explicitly support
// SIP addresses instead of overloading the "phoneNumber" field.
// Then we could remove this hack, and instead ask the CallerInfo
// for a "user visible" form of the SIP address.
String number = info.phoneNumber;
if (!TextUtils.isEmpty(number)) {
isSipCall = PhoneNumberHelper.isUriNumber(number);
if (number.startsWith("sip:")) {
number = number.substring(4);
}
}
if (TextUtils.isEmpty(info.name)) {
// No valid "name" in the CallerInfo, so fall back to
// something else.
// (Typically, we promote the phone number up to the "name" slot
// onscreen, and possibly display a descriptive string in the
// "number" slot.)
if (TextUtils.isEmpty(number)) {
// No name *or* number! Display a generic "unknown" string
// (or potentially some other default based on the presentation.)
displayName = getPresentationString(context, presentation);
Log.d(TAG, " ==> no name *or* number! displayName = " + displayName);
} else if (presentation != TelecomManager.PRESENTATION_ALLOWED) {
// This case should never happen since the network should never send a phone #
// AND a restricted presentation. However we leave it here in case of weird
// network behavior
displayName = getPresentationString(context, presentation);
Log.d(TAG, " ==> presentation not allowed! displayName = " + displayName);
} else if (!TextUtils.isEmpty(info.cnapName)) {
// No name, but we do have a valid CNAP name, so use that.
displayName = info.cnapName;
info.name = info.cnapName;
displayNumber = number;
Log.d(TAG, " ==> cnapName available: displayName '" + displayName +
"', displayNumber '" + displayNumber + "'");
} else {
// No name; all we have is a number. This is the typical
// case when an incoming call doesn't match any contact,
// or if you manually dial an outgoing number using the
// dialpad.
displayNumber = number;
// Display a geographical description string if available
// (but only for incoming calls.)
if (isIncoming) {
// TODO (CallerInfoAsyncQuery cleanup): Fix the CallerInfo
// query to only do the geoDescription lookup in the first
// place for incoming calls.
displayLocation = info.geoDescription; // may be null
Log.d(TAG, "Geodescrption: " + info.geoDescription);
}
Log.d(TAG, " ==> no name; falling back to number:"
+ " displayNumber '" + Log.pii(displayNumber)
+ "', displayLocation '" + displayLocation + "'");
}
} else {
// We do have a valid "name" in the CallerInfo. Display that
// in the "name" slot, and the phone number in the "number" slot.
if (presentation != TelecomManager.PRESENTATION_ALLOWED) {
// This case should never happen since the network should never send a name
// AND a restricted presentation. However we leave it here in case of weird
// network behavior
displayName = getPresentationString(context, presentation);
Log.d(TAG, " ==> valid name, but presentation not allowed!" +
" displayName = " + displayName);
} else {
displayName = info.name;
displayNumber = number;
label = info.phoneLabel;
Log.d(TAG, " ==> name is present in CallerInfo: displayName '" + displayName
+ "', displayNumber '" + displayNumber + "'");
}
}
cce.name = displayName;
cce.number = displayNumber;
cce.location = displayLocation;
cce.label = label;
cce.isSipCall = isSipCall;
}
/**
* Sends the updated information to call the callbacks for the entry.
*/
private void sendInfoNotifications(String callId, ContactCacheEntry entry) {
final Set<ContactInfoCacheCallback> callBacks = mCallBacks.get(callId);
if (callBacks != null) {
for (ContactInfoCacheCallback callBack : callBacks) {
callBack.onContactInfoComplete(callId, entry);
}
}
}
private void sendImageNotifications(String callId, ContactCacheEntry entry) {
final Set<ContactInfoCacheCallback> callBacks = mCallBacks.get(callId);
if (callBacks != null && entry.photo != null) {
for (ContactInfoCacheCallback callBack : callBacks) {
callBack.onImageLoadComplete(callId, entry);
}
}
}
private void clearCallbacks(String callId) {
mCallBacks.remove(callId);
}
/**
* Gets name strings based on some special presentation modes.
*/
private static String getPresentationString(Context context, int presentation) {
String name = context.getString(R.string.unknown);
if (presentation == TelecomManager.PRESENTATION_RESTRICTED) {
name = context.getString(R.string.private_num);
} else if (presentation == TelecomManager.PRESENTATION_PAYPHONE) {
name = context.getString(R.string.payphone);
}
return name;
}
public Drawable getDefaultContactPhotoDrawable() {
if (mDefaultContactPhotoDrawable == null) {
mDefaultContactPhotoDrawable =
mContext.getResources().getDrawable(R.drawable.img_no_image_automirrored);
}
return mDefaultContactPhotoDrawable;
}
public Drawable getConferenceDrawable() {
if (mConferencePhotoDrawable == null) {
mConferencePhotoDrawable =
mContext.getResources().getDrawable(R.drawable.img_conference_automirrored);
}
return mConferencePhotoDrawable;
}
/**
* Callback interface for the contact query.
*/
public interface ContactInfoCacheCallback {
public void onContactInfoComplete(String callId, ContactCacheEntry entry);
public void onImageLoadComplete(String callId, ContactCacheEntry entry);
}
public static class ContactCacheEntry {
public String name;
public String number;
public String location;
public String label;
public Drawable photo;
public boolean isSipCall;
/** This will be used for the "view" notification. */
public Uri contactUri;
/** Either a display photo or a thumbnail URI. */
public Uri displayPhotoUri;
public Uri lookupUri; // Sent to NotificationMananger
public String lookupKey;
@Override
public String toString() {
return Objects.toStringHelper(this)
.add("name", MoreStrings.toSafeString(name))
.add("number", MoreStrings.toSafeString(number))
.add("location", MoreStrings.toSafeString(location))
.add("label", label)
.add("photo", photo)
.add("isSipCall", isSipCall)
.add("contactUri", contactUri)
.add("displayPhotoUri", displayPhotoUri)
.toString();
}
}
}