blob: d2ae70939fb6e6de262827acdd93f6adbb7972ae [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.media.RingtoneManager;
import android.net.Uri;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.SystemClock;
import android.os.Trace;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.provider.ContactsContract.Contacts;
import android.provider.ContactsContract.DisplayNameSources;
import android.support.annotation.AnyThread;
import android.support.annotation.MainThread;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.WorkerThread;
import android.support.v4.content.ContextCompat;
import android.support.v4.os.UserManagerCompat;
import android.telecom.TelecomManager;
import android.telephony.PhoneNumberUtils;
import android.text.TextUtils;
import android.util.ArrayMap;
import android.util.ArraySet;
import com.android.contacts.common.ContactsUtils;
import com.android.dialer.common.Assert;
import com.android.dialer.common.concurrent.DialerExecutor;
import com.android.dialer.common.concurrent.DialerExecutor.Worker;
import com.android.dialer.common.concurrent.DialerExecutorComponent;
import com.android.dialer.logging.ContactLookupResult;
import com.android.dialer.logging.ContactSource;
import com.android.dialer.oem.CequintCallerIdManager;
import com.android.dialer.oem.CequintCallerIdManager.CequintCallerIdContact;
import com.android.dialer.phonenumbercache.CachedNumberLookupService;
import com.android.dialer.phonenumbercache.CachedNumberLookupService.CachedContactInfo;
import com.android.dialer.phonenumbercache.ContactInfo;
import com.android.dialer.phonenumbercache.PhoneNumberCache;
import com.android.dialer.phonenumberutil.PhoneNumberHelper;
import com.android.dialer.util.MoreStrings;
import com.android.incallui.CallerInfoAsyncQuery.OnQueryCompleteListener;
import com.android.incallui.ContactsAsyncHelper.OnImageLoadCompleteListener;
import com.android.incallui.bindings.PhoneNumberService;
import com.android.incallui.call.DialerCall;
import com.android.incallui.incall.protocol.ContactPhotoType;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.json.JSONException;
import org.json.JSONObject;
/**
* Class responsible for querying Contact Information for DialerCall 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 OnImageLoadCompleteListener {
private static final String TAG = ContactInfoCache.class.getSimpleName();
private static final int TOKEN_UPDATE_PHOTO_FOR_CALL_STATE = 0;
private static ContactInfoCache cache = null;
private final Context context;
private final PhoneNumberService phoneNumberService;
// Cache info map needs to be thread-safe since it could be modified by both main thread and
// worker thread.
private final ConcurrentHashMap<String, ContactCacheEntry> infoMap = new ConcurrentHashMap<>();
private final Map<String, Set<ContactInfoCacheCallback>> callBacks = new ArrayMap<>();
private int queryId;
private final DialerExecutor<CnapInformationWrapper> cachedNumberLookupExecutor;
private static class CachedNumberLookupWorker implements Worker<CnapInformationWrapper, Void> {
@Nullable
@Override
public Void doInBackground(@Nullable CnapInformationWrapper input) {
if (input == null) {
return null;
}
ContactInfo contactInfo = new ContactInfo();
CachedContactInfo cacheInfo = input.service.buildCachedContactInfo(contactInfo);
cacheInfo.setSource(ContactSource.Type.SOURCE_TYPE_CNAP, "CNAP", 0);
contactInfo.name = input.cnapName;
contactInfo.number = input.number;
try {
final JSONObject contactRows =
new JSONObject()
.put(
Phone.CONTENT_ITEM_TYPE,
new JSONObject().put(Phone.NUMBER, contactInfo.number));
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");
}
input.service.addContact(input.context.getApplicationContext(), cacheInfo);
return null;
}
}
private ContactInfoCache(Context context) {
Trace.beginSection("ContactInfoCache constructor");
this.context = context;
phoneNumberService = Bindings.get(context).newPhoneNumberService(context);
cachedNumberLookupExecutor =
DialerExecutorComponent.get(this.context)
.dialerExecutorFactory()
.createNonUiTaskBuilder(new CachedNumberLookupWorker())
.build();
Trace.endSection();
}
public static synchronized ContactInfoCache getInstance(Context mContext) {
if (cache == null) {
cache = new ContactInfoCache(mContext.getApplicationContext());
}
return cache;
}
static ContactCacheEntry buildCacheEntryFromCall(
Context context, DialerCall 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());
return entry;
}
/** Populate a cache entry from a call (which got converted into a caller info). */
private static void populateCacheEntry(
@NonNull Context context,
@NonNull CallerInfo info,
@NonNull ContactCacheEntry cce,
int presentation) {
Objects.requireNonNull(info);
String displayName = null;
String displayNumber = 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, info.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) && TextUtils.isEmpty(info.cnapName)) {
// No name *or* number! Display a generic "unknown" string
// (or potentially some other default based on the presentation.)
displayName = getPresentationString(context, presentation, info.callSubject);
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, info.callSubject);
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 = PhoneNumberHelper.formatNumber(number, info.countryIso);
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 = PhoneNumberHelper.formatNumber(number, info.countryIso);
Log.d(
TAG,
" ==> no name; falling back to number:"
+ " displayNumber '"
+ Log.pii(displayNumber)
+ "'");
}
} 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, info.callSubject);
Log.d(
TAG,
" ==> valid name, but presentation not allowed!" + " displayName = " + displayName);
} else {
// Causes cce.namePrimary to be set as info.name below. CallCardPresenter will
// later determine whether to use the name or nameAlternative when presenting
displayName = info.name;
cce.nameAlternative = info.nameAlternative;
displayNumber = PhoneNumberHelper.formatNumber(number, info.countryIso);
label = info.phoneLabel;
Log.d(
TAG,
" ==> name is present in CallerInfo: displayName '"
+ displayName
+ "', displayNumber '"
+ displayNumber
+ "'");
}
}
cce.namePrimary = displayName;
cce.number = displayNumber;
cce.location = info.geoDescription;
cce.label = label;
cce.isSipCall = isSipCall;
cce.userType = info.userType;
cce.originalPhoneNumber = info.phoneNumber;
cce.shouldShowLocation = info.shouldShowGeoDescription;
cce.isEmergencyNumber = info.isEmergencyNumber();
cce.isVoicemailNumber = info.isVoiceMailNumber();
if (info.contactExists) {
cce.contactLookupResult = ContactLookupResult.Type.LOCAL_CONTACT;
}
}
/** Gets name strings based on some special presentation modes and the associated custom label. */
private static String getPresentationString(
Context context, int presentation, String customLabel) {
String name = context.getString(R.string.unknown);
if (!TextUtils.isEmpty(customLabel)
&& ((presentation == TelecomManager.PRESENTATION_UNKNOWN)
|| (presentation == TelecomManager.PRESENTATION_RESTRICTED))) {
name = customLabel;
return name;
} else {
if (presentation == TelecomManager.PRESENTATION_RESTRICTED) {
name = PhoneNumberHelper.getDisplayNameForRestrictedNumber(context);
} else if (presentation == TelecomManager.PRESENTATION_PAYPHONE) {
name = context.getString(R.string.payphone);
}
}
return name;
}
ContactCacheEntry getInfo(String callId) {
return infoMap.get(callId);
}
private static final class CnapInformationWrapper {
final String number;
final String cnapName;
final Context context;
final CachedNumberLookupService service;
CnapInformationWrapper(
String number, String cnapName, Context context, CachedNumberLookupService service) {
this.number = number;
this.cnapName = cnapName;
this.context = context;
this.service = service;
}
}
void maybeInsertCnapInformationIntoCache(
Context context, final DialerCall call, final CallerInfo info) {
final CachedNumberLookupService cachedNumberLookupService =
PhoneNumberCache.get(context).getCachedNumberLookupService();
if (!UserManagerCompat.isUserUnlocked(context)) {
Log.i(TAG, "User locked, not inserting cnap info into cache");
return;
}
if (cachedNumberLookupService == null
|| TextUtils.isEmpty(info.cnapName)
|| infoMap.get(call.getId()) != null) {
return;
}
Log.i(TAG, "Found contact with CNAP name - inserting into cache");
cachedNumberLookupExecutor.executeParallel(
new CnapInformationWrapper(
call.getNumber(), info.cnapName, context, cachedNumberLookupService));
}
/**
* Requests contact data for the DialerCall 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.
*/
@MainThread
public void findInfo(
@NonNull final DialerCall call,
final boolean isIncoming,
@NonNull ContactInfoCacheCallback callback) {
Trace.beginSection("ContactInfoCache.findInfo");
Assert.isMainThread();
Objects.requireNonNull(callback);
Trace.beginSection("prepare callback");
final String callId = call.getId();
final ContactCacheEntry cacheEntry = infoMap.get(callId);
Set<ContactInfoCacheCallback> callBacks = this.callBacks.get(callId);
// We need to force a new query if phone number has changed.
boolean forceQuery = needForceQuery(call, cacheEntry);
Trace.endSection();
Log.d(TAG, "findInfo: callId = " + callId + "; forceQuery = " + forceQuery);
// If we have a previously obtained intermediate result return that now except needs
// force query.
if (cacheEntry != null && !forceQuery) {
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) {
Trace.endSection();
return;
}
}
// If the entry already exists, add callback
if (callBacks != null) {
Log.d(TAG, "Another query is in progress, add callback only.");
callBacks.add(callback);
if (!forceQuery) {
Log.d(TAG, "No need to query again, just return and wait for existing query to finish");
Trace.endSection();
return;
}
} else {
Log.d(TAG, "Contact lookup. In memory cache miss; searching provider.");
// New lookup
callBacks = new ArraySet<>();
callBacks.add(callback);
this.callBacks.put(callId, callBacks);
}
Trace.beginSection("prepare query");
/**
* 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 CallerInfoQueryToken queryToken = new CallerInfoQueryToken(queryId, callId);
queryId++;
final CallerInfo callerInfo =
CallerInfoUtils.getCallerInfoForCall(
context,
call,
new DialerCallCookieWrapper(callId, call.getNumberPresentation(), call.getCnapName()),
new FindInfoCallback(isIncoming, queryToken));
Trace.endSection();
if (cacheEntry != null) {
// We should not override the old cache item until the new query is
// back. We should only update the queryId. Otherwise, we may see
// flicker of the name and image (old cache -> new cache before query
// -> new cache after query)
cacheEntry.queryId = queryToken.queryId;
Log.d(TAG, "There is an existing cache. Do not override until new query is back");
} else {
ContactCacheEntry initialCacheEntry =
updateCallerInfoInCacheOnAnyThread(
callId, call.getNumberPresentation(), callerInfo, false, queryToken);
sendInfoNotifications(callId, initialCacheEntry);
}
Trace.endSection();
}
@AnyThread
private ContactCacheEntry updateCallerInfoInCacheOnAnyThread(
String callId,
int numberPresentation,
CallerInfo callerInfo,
boolean didLocalLookup,
CallerInfoQueryToken queryToken) {
Trace.beginSection("ContactInfoCache.updateCallerInfoInCacheOnAnyThread");
Log.d(
TAG,
"updateCallerInfoInCacheOnAnyThread: callId = "
+ callId
+ "; queryId = "
+ queryToken.queryId
+ "; didLocalLookup = "
+ didLocalLookup);
ContactCacheEntry existingCacheEntry = infoMap.get(callId);
Log.d(TAG, "Existing cacheEntry in hashMap " + existingCacheEntry);
// Mark it as emergency/voicemail if the cache exists and was emergency/voicemail before the
// number changed.
if (existingCacheEntry != null) {
if (existingCacheEntry.isEmergencyNumber) {
callerInfo.markAsEmergency(context);
} else if (existingCacheEntry.isVoicemailNumber) {
callerInfo.markAsVoiceMail(context);
}
}
int presentationMode = numberPresentation;
if (callerInfo.contactExists
|| callerInfo.isEmergencyNumber()
|| callerInfo.isVoiceMailNumber()) {
presentationMode = TelecomManager.PRESENTATION_ALLOWED;
}
// We always replace the entry. The only exception is the same photo case.
ContactCacheEntry cacheEntry = buildEntry(context, callerInfo, presentationMode);
cacheEntry.queryId = queryToken.queryId;
if (didLocalLookup) {
if (cacheEntry.displayPhotoUri != null) {
// When the difference between 2 numbers is only the prefix (e.g. + or IDD),
// we will still trigger force query so that the number can be updated on
// the calling screen. We need not query the image again if the previous
// query already has the image to avoid flickering.
if (existingCacheEntry != null
&& existingCacheEntry.displayPhotoUri != null
&& existingCacheEntry.displayPhotoUri.equals(cacheEntry.displayPhotoUri)
&& existingCacheEntry.photo != null) {
Log.d(TAG, "Same picture. Do not need start image load.");
cacheEntry.photo = existingCacheEntry.photo;
cacheEntry.photoType = existingCacheEntry.photoType;
return cacheEntry;
}
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.
cacheEntry.hasPendingQuery = true;
ContactsAsyncHelper.startObtainPhotoAsync(
TOKEN_UPDATE_PHOTO_FOR_CALL_STATE,
context,
cacheEntry.displayPhotoUri,
ContactInfoCache.this,
queryToken);
}
Log.d(TAG, "put entry into map: " + cacheEntry);
infoMap.put(callId, cacheEntry);
} else {
// Don't overwrite if there is existing cache.
Log.d(TAG, "put entry into map if not exists: " + cacheEntry);
infoMap.putIfAbsent(callId, cacheEntry);
}
Trace.endSection();
return cacheEntry;
}
private void maybeUpdateFromCequintCallerId(
CallerInfo callerInfo, String cnapName, boolean isIncoming) {
if (!CequintCallerIdManager.isCequintCallerIdEnabled(context)) {
return;
}
if (callerInfo.phoneNumber == null) {
return;
}
CequintCallerIdContact cequintCallerIdContact =
CequintCallerIdManager.getCequintCallerIdContactForInCall(
context, callerInfo.phoneNumber, cnapName, isIncoming);
if (cequintCallerIdContact == null) {
return;
}
boolean hasUpdate = false;
if (TextUtils.isEmpty(callerInfo.name) && !TextUtils.isEmpty(cequintCallerIdContact.name)) {
callerInfo.name = cequintCallerIdContact.name;
hasUpdate = true;
}
if (!TextUtils.isEmpty(cequintCallerIdContact.geoDescription)) {
callerInfo.geoDescription = cequintCallerIdContact.geoDescription;
callerInfo.shouldShowGeoDescription = true;
hasUpdate = true;
}
// Don't overwrite photo in local contacts.
if (!callerInfo.contactExists
&& callerInfo.contactDisplayPhotoUri == null
&& cequintCallerIdContact.imageUrl != null) {
callerInfo.contactDisplayPhotoUri = Uri.parse(cequintCallerIdContact.imageUrl);
hasUpdate = true;
}
// Set contact to exist to avoid phone number service lookup.
if (hasUpdate) {
callerInfo.contactExists = true;
}
}
/**
* Implemented for ContactsAsyncHelper.OnImageLoadCompleteListener interface. Update contact photo
* when image is loaded in worker thread.
*/
@WorkerThread
@Override
public void onImageLoaded(int token, Drawable photo, Bitmap photoIcon, Object cookie) {
Assert.isWorkerThread();
CallerInfoQueryToken myCookie = (CallerInfoQueryToken) cookie;
final String callId = myCookie.callId;
final int queryId = myCookie.queryId;
if (!isWaitingForThisQuery(callId, queryId)) {
return;
}
loadImage(photo, photoIcon, cookie);
}
private void loadImage(Drawable photo, Bitmap photoIcon, Object cookie) {
Log.d(TAG, "Image load complete with context: ", context);
// TODO: may be nice to update the image view again once the newer one
// is available on contacts database.
CallerInfoQueryToken myCookie = (CallerInfoQueryToken) cookie;
final String callId = myCookie.callId;
ContactCacheEntry entry = infoMap.get(callId);
if (entry == null) {
Log.e(TAG, "Image Load received for empty search entry.");
clearCallbacks(callId);
return;
}
Log.d(TAG, "setting photo for entry: ", entry);
// Conference call icons are being handled in CallCardPresenter.
if (photo != null) {
Log.v(TAG, "direct drawable: ", photo);
entry.photo = photo;
entry.photoType = ContactPhotoType.CONTACT;
} else if (photoIcon != null) {
Log.v(TAG, "photo icon: ", photoIcon);
entry.photo = new BitmapDrawable(context.getResources(), photoIcon);
entry.photoType = ContactPhotoType.CONTACT;
} else {
Log.v(TAG, "unknown photo");
entry.photo = null;
entry.photoType = ContactPhotoType.DEFAULT_PLACEHOLDER;
}
}
/**
* Implemented for ContactsAsyncHelper.OnImageLoadCompleteListener interface. make sure that the
* call state is reflected after the image is loaded.
*/
@MainThread
@Override
public void onImageLoadComplete(int token, Drawable photo, Bitmap photoIcon, Object cookie) {
Assert.isMainThread();
CallerInfoQueryToken myCookie = (CallerInfoQueryToken) cookie;
final String callId = myCookie.callId;
final int queryId = myCookie.queryId;
if (!isWaitingForThisQuery(callId, queryId)) {
return;
}
sendImageNotifications(callId, infoMap.get(callId));
clearCallbacks(callId);
}
/** Blows away the stored cache values. */
public void clearCache() {
infoMap.clear();
callBacks.clear();
queryId = 0;
}
private ContactCacheEntry buildEntry(Context context, CallerInfo info, int presentation) {
final ContactCacheEntry cce = new ContactCacheEntry();
populateCacheEntry(context, info, cce, presentation);
// This will only be true for emergency numbers
if (info.photoResource != 0) {
cce.photo = ContextCompat.getDrawable(context, info.photoResource);
} else if (info.isCachedPhotoCurrent) {
if (info.cachedPhoto != null) {
cce.photo = info.cachedPhoto;
cce.photoType = ContactPhotoType.CONTACT;
} else {
cce.photoType = ContactPhotoType.DEFAULT_PLACEHOLDER;
}
} else {
cce.displayPhotoUri = info.contactDisplayPhotoUri;
cce.photo = null;
}
// Support any contact id in N because QuickContacts in N starts supporting enterprise
// contact id
if (info.lookupKeyOrNull != null
&& (VERSION.SDK_INT >= VERSION_CODES.N || info.contactIdOrZero != 0)) {
cce.lookupUri = Contacts.getLookupUri(info.contactIdOrZero, info.lookupKeyOrNull);
} else {
Log.v(TAG, "lookup key is null or contact ID is 0 on M. Don't create a lookup uri.");
cce.lookupUri = null;
}
cce.lookupKey = info.lookupKeyOrNull;
cce.contactRingtoneUri = info.contactRingtoneUri;
if (cce.contactRingtoneUri == null || Uri.EMPTY.equals(cce.contactRingtoneUri)) {
cce.contactRingtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
}
return cce;
}
/** Sends the updated information to call the callbacks for the entry. */
@MainThread
private void sendInfoNotifications(String callId, ContactCacheEntry entry) {
Trace.beginSection("ContactInfoCache.sendInfoNotifications");
Assert.isMainThread();
final Set<ContactInfoCacheCallback> callBacks = this.callBacks.get(callId);
if (callBacks != null) {
for (ContactInfoCacheCallback callBack : callBacks) {
callBack.onContactInfoComplete(callId, entry);
}
}
Trace.endSection();
}
@MainThread
private void sendImageNotifications(String callId, ContactCacheEntry entry) {
Trace.beginSection("ContactInfoCache.sendImageNotifications");
Assert.isMainThread();
final Set<ContactInfoCacheCallback> callBacks = this.callBacks.get(callId);
if (callBacks != null && entry.photo != null) {
for (ContactInfoCacheCallback callBack : callBacks) {
callBack.onImageLoadComplete(callId, entry);
}
}
Trace.endSection();
}
private void clearCallbacks(String callId) {
callBacks.remove(callId);
}
/** Callback interface for the contact query. */
public interface ContactInfoCacheCallback {
void onContactInfoComplete(String callId, ContactCacheEntry entry);
void onImageLoadComplete(String callId, ContactCacheEntry entry);
}
/** This is cached contact info, which should be the ONLY info used by UI. */
public static class ContactCacheEntry {
public String namePrimary;
public String nameAlternative;
public String number;
public String location;
public String label;
public Drawable photo;
@ContactPhotoType int photoType;
boolean isSipCall;
// Note in cache entry whether this is a pending async loading action to know whether to
// wait for its callback or not.
boolean hasPendingQuery;
/** Either a display photo or a thumbnail URI. */
Uri displayPhotoUri;
public Uri lookupUri; // Sent to NotificationMananger
public String lookupKey;
public ContactLookupResult.Type contactLookupResult = ContactLookupResult.Type.NOT_FOUND;
public long userType = ContactsUtils.USER_TYPE_CURRENT;
Uri contactRingtoneUri;
/** Query id to identify the query session. */
int queryId;
/** The phone number without any changes to display to the user (ex: cnap...) */
String originalPhoneNumber;
boolean shouldShowLocation;
boolean isBusiness;
boolean isEmergencyNumber;
boolean isVoicemailNumber;
public boolean isLocalContact() {
return contactLookupResult == ContactLookupResult.Type.LOCAL_CONTACT;
}
@Override
public String toString() {
return "ContactCacheEntry{"
+ "name='"
+ MoreStrings.toSafeString(namePrimary)
+ '\''
+ ", nameAlternative='"
+ MoreStrings.toSafeString(nameAlternative)
+ '\''
+ ", number='"
+ MoreStrings.toSafeString(number)
+ '\''
+ ", location='"
+ MoreStrings.toSafeString(location)
+ '\''
+ ", label='"
+ label
+ '\''
+ ", photo="
+ photo
+ ", isSipCall="
+ isSipCall
+ ", displayPhotoUri="
+ displayPhotoUri
+ ", contactLookupResult="
+ contactLookupResult
+ ", userType="
+ userType
+ ", contactRingtoneUri="
+ contactRingtoneUri
+ ", queryId="
+ queryId
+ ", originalPhoneNumber="
+ originalPhoneNumber
+ ", shouldShowLocation="
+ shouldShowLocation
+ ", isEmergencyNumber="
+ isEmergencyNumber
+ ", isVoicemailNumber="
+ isVoicemailNumber
+ '}';
}
}
private static final class DialerCallCookieWrapper {
final String callId;
final int numberPresentation;
final String cnapName;
DialerCallCookieWrapper(String callId, int numberPresentation, String cnapName) {
this.callId = callId;
this.numberPresentation = numberPresentation;
this.cnapName = cnapName;
}
}
private class FindInfoCallback implements OnQueryCompleteListener {
private final boolean isIncoming;
private final CallerInfoQueryToken queryToken;
FindInfoCallback(boolean isIncoming, CallerInfoQueryToken queryToken) {
this.isIncoming = isIncoming;
this.queryToken = queryToken;
}
@Override
public void onDataLoaded(int token, Object cookie, CallerInfo ci) {
Assert.isWorkerThread();
DialerCallCookieWrapper cw = (DialerCallCookieWrapper) cookie;
if (!isWaitingForThisQuery(cw.callId, queryToken.queryId)) {
return;
}
long start = SystemClock.uptimeMillis();
maybeUpdateFromCequintCallerId(ci, cw.cnapName, isIncoming);
long time = SystemClock.uptimeMillis() - start;
Log.d(TAG, "Cequint Caller Id look up takes " + time + " ms.");
updateCallerInfoInCacheOnAnyThread(cw.callId, cw.numberPresentation, ci, true, queryToken);
}
@Override
public void onQueryComplete(int token, Object cookie, CallerInfo callerInfo) {
Trace.beginSection("ContactInfoCache.FindInfoCallback.onQueryComplete");
Assert.isMainThread();
DialerCallCookieWrapper cw = (DialerCallCookieWrapper) cookie;
String callId = cw.callId;
if (!isWaitingForThisQuery(cw.callId, queryToken.queryId)) {
Trace.endSection();
return;
}
ContactCacheEntry cacheEntry = infoMap.get(callId);
// This may happen only when InCallPresenter attempt to cleanup.
if (cacheEntry == null) {
Log.w(TAG, "Contact lookup done, but cache entry is not found.");
clearCallbacks(callId);
Trace.endSection();
return;
}
// 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 && phoneNumberService != null) {
Log.d(TAG, "Contact lookup. Local contacts miss, checking remote");
final PhoneNumberServiceListener listener =
new PhoneNumberServiceListener(callId, queryToken.queryId);
cacheEntry.hasPendingQuery = true;
phoneNumberService.getPhoneNumberInfo(cacheEntry.number, listener, listener, isIncoming);
}
sendInfoNotifications(callId, cacheEntry);
if (!cacheEntry.hasPendingQuery) {
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);
}
Trace.endSection();
}
}
class PhoneNumberServiceListener
implements PhoneNumberService.NumberLookupListener, PhoneNumberService.ImageLookupListener {
private final String callId;
private final int queryIdOfRemoteLookup;
PhoneNumberServiceListener(String callId, int queryId) {
this.callId = callId;
queryIdOfRemoteLookup = queryId;
}
@Override
public void onPhoneNumberInfoComplete(final PhoneNumberService.PhoneNumberInfo info) {
Log.d(TAG, "PhoneNumberServiceListener.onPhoneNumberInfoComplete");
if (!isWaitingForThisQuery(callId, queryIdOfRemoteLookup)) {
return;
}
// 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(callId);
return;
}
ContactCacheEntry entry = new ContactCacheEntry();
entry.namePrimary = info.getDisplayName();
entry.number = info.getNumber();
entry.contactLookupResult = info.getLookupSource();
entry.isBusiness = info.isBusiness();
final int type = info.getPhoneType();
final String label = info.getPhoneLabel();
if (type == Phone.TYPE_CUSTOM) {
entry.label = label;
} else {
final CharSequence typeStr = Phone.getTypeLabel(context.getResources(), type, label);
entry.label = typeStr == null ? null : typeStr.toString();
}
final ContactCacheEntry oldEntry = infoMap.get(callId);
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;
entry.shouldShowLocation = oldEntry.shouldShowLocation;
// Contact specific ringtone is obtained from local lookup.
entry.contactRingtoneUri = oldEntry.contactRingtoneUri;
entry.originalPhoneNumber = oldEntry.originalPhoneNumber;
}
// 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.photoType = ContactPhotoType.BUSINESS;
}
Log.d(TAG, "put entry into map: " + entry);
infoMap.put(callId, entry);
sendInfoNotifications(callId, entry);
entry.hasPendingQuery = info.getImageUrl() != null;
// If there is no image then we should not expect another callback.
if (!entry.hasPendingQuery) {
// We're done, so clear callbacks
clearCallbacks(callId);
}
}
@Override
public void onImageFetchComplete(Bitmap bitmap) {
Log.d(TAG, "PhoneNumberServiceListener.onImageFetchComplete");
if (!isWaitingForThisQuery(callId, queryIdOfRemoteLookup)) {
return;
}
CallerInfoQueryToken queryToken = new CallerInfoQueryToken(queryIdOfRemoteLookup, callId);
loadImage(null, bitmap, queryToken);
onImageLoadComplete(TOKEN_UPDATE_PHOTO_FOR_CALL_STATE, null, bitmap, queryToken);
}
}
private boolean needForceQuery(DialerCall call, ContactCacheEntry cacheEntry) {
if (call == null || call.isConferenceCall()) {
return false;
}
String newPhoneNumber = PhoneNumberUtils.stripSeparators(call.getNumber());
if (cacheEntry == null) {
// No info in the map yet so it is the 1st query
Log.d(TAG, "needForceQuery: first query");
return true;
}
String oldPhoneNumber = PhoneNumberUtils.stripSeparators(cacheEntry.originalPhoneNumber);
if (!TextUtils.equals(oldPhoneNumber, newPhoneNumber)) {
Log.d(TAG, "phone number has changed: " + oldPhoneNumber + " -> " + newPhoneNumber);
return true;
}
return false;
}
private static final class CallerInfoQueryToken {
final int queryId;
final String callId;
CallerInfoQueryToken(int queryId, String callId) {
this.queryId = queryId;
this.callId = callId;
}
}
/** Check if the queryId in the cached map is the same as the one from query result. */
private boolean isWaitingForThisQuery(String callId, int queryId) {
final ContactCacheEntry existingCacheEntry = infoMap.get(callId);
if (existingCacheEntry == null) {
// This might happen if lookup on background thread comes back before the initial entry is
// created.
Log.d(TAG, "Cached entry is null.");
return true;
} else {
int waitingQueryId = existingCacheEntry.queryId;
Log.d(TAG, "waitingQueryId = " + waitingQueryId + "; queryId = " + queryId);
return waitingQueryId == queryId;
}
}
}