| /* |
| * Copyright (C) 2013 The Android Open Source Project |
| * Copyright (C) 2023 The LineageOS 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.SystemClock; |
| import android.os.Trace; |
| import android.provider.ContactsContract.CommonDataKinds.Phone; |
| import android.provider.ContactsContract.Contacts; |
| import android.provider.ContactsContract.DisplayNameSources; |
| import android.telecom.TelecomManager; |
| import android.telephony.PhoneNumberUtils; |
| import android.text.TextUtils; |
| import android.util.ArrayMap; |
| import android.util.ArraySet; |
| |
| import androidx.annotation.AnyThread; |
| import androidx.annotation.MainThread; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.WorkerThread; |
| import androidx.core.content.ContextCompat; |
| import androidx.core.os.UserManagerCompat; |
| |
| import com.android.contacts.common.ContactsUtils; |
| import com.android.dialer.R; |
| 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 org.json.JSONException; |
| import org.json.JSONObject; |
| |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.Set; |
| import java.util.concurrent.ConcurrentHashMap; |
| |
| /** |
| * 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) { |
| 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(context, 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(context, 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(context, 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 = info.contactLookupResultType; |
| } |
| } |
| |
| /** 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 = context.getString(R.string.private_num_non_verizon); |
| } 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.getCequintCallerIdContactForCall( |
| 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.geolocation())) { |
| callerInfo.geoDescription = cequintCallerIdContact.geolocation(); |
| callerInfo.shouldShowGeoDescription = true; |
| hasUpdate = true; |
| } |
| // Don't overwrite photo in local contacts. |
| if (!callerInfo.contactExists |
| && callerInfo.contactDisplayPhotoUri == null |
| && cequintCallerIdContact.photoUri() != null) { |
| callerInfo.contactDisplayPhotoUri = Uri.parse(cequintCallerIdContact.photoUri()); |
| hasUpdate = true; |
| } |
| // Set contact to exist to avoid phone number service lookup. |
| if (hasUpdate) { |
| callerInfo.contactExists = true; |
| callerInfo.contactLookupResultType = ContactLookupResult.Type.CEQUINT; |
| } |
| } |
| |
| /** |
| * 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; |
| } |
| |
| if (info.lookupKeyOrNull != null && 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); |
| } |
| 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 { |
| |
| 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); |
| } |
| } |
| } |
| |
| 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; |
| } |
| } |
| } |