com.android.incallui.ContactInfoCache.java Source code

Java tutorial

Introduction

Here is the source code for com.android.incallui.ContactInfoCache.java

Source

/*
 * 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();
        }
    }
}