1/*
2 * Copyright (C) 2013 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License
15 */
16
17package com.android.incallui;
18
19import android.content.Context;
20import android.graphics.Bitmap;
21import android.graphics.drawable.BitmapDrawable;
22import android.graphics.drawable.Drawable;
23import android.net.Uri;
24import android.os.AsyncTask;
25import android.os.Looper;
26import android.provider.ContactsContract;
27import android.provider.ContactsContract.Contacts;
28import android.provider.ContactsContract.DisplayNameSources;
29import android.provider.ContactsContract.CommonDataKinds.Phone;
30import android.telecom.TelecomManager;
31import android.text.TextUtils;
32
33import com.android.contacts.common.util.PhoneNumberHelper;
34import com.android.dialer.calllog.ContactInfo;
35import com.android.dialer.service.CachedNumberLookupService;
36import com.android.dialer.service.CachedNumberLookupService.CachedContactInfo;
37import com.android.incallui.service.PhoneNumberService;
38import com.android.incalluibind.ObjectFactory;
39import com.android.services.telephony.common.MoreStrings;
40
41import org.json.JSONException;
42import org.json.JSONObject;
43
44import com.google.common.collect.Maps;
45import com.google.common.collect.Sets;
46import com.google.common.base.Objects;
47import com.google.common.base.Preconditions;
48
49import java.util.HashMap;
50import java.util.Set;
51
52/**
53 * Class responsible for querying Contact Information for Call objects. Can perform asynchronous
54 * requests to the Contact Provider for information as well as respond synchronously for any data
55 * that it currently has cached from previous queries. This class always gets called from the UI
56 * thread so it does not need thread protection.
57 */
58public class ContactInfoCache implements ContactsAsyncHelper.OnImageLoadCompleteListener {
59
60    private static final String TAG = ContactInfoCache.class.getSimpleName();
61    private static final int TOKEN_UPDATE_PHOTO_FOR_CALL_STATE = 0;
62
63    private final Context mContext;
64    private final PhoneNumberService mPhoneNumberService;
65    private final CachedNumberLookupService mCachedNumberLookupService;
66    private final HashMap<String, ContactCacheEntry> mInfoMap = Maps.newHashMap();
67    private final HashMap<String, Set<ContactInfoCacheCallback>> mCallBacks = Maps.newHashMap();
68
69    private static ContactInfoCache sCache = null;
70
71    private Drawable mDefaultContactPhotoDrawable;
72    private Drawable mConferencePhotoDrawable;
73
74    public static synchronized ContactInfoCache getInstance(Context mContext) {
75        if (sCache == null) {
76            sCache = new ContactInfoCache(mContext.getApplicationContext());
77        }
78        return sCache;
79    }
80
81    private ContactInfoCache(Context context) {
82        mContext = context;
83        mPhoneNumberService = ObjectFactory.newPhoneNumberService(context);
84        mCachedNumberLookupService =
85                com.android.dialerbind.ObjectFactory.newCachedNumberLookupService();
86    }
87
88    public ContactCacheEntry getInfo(String callId) {
89        return mInfoMap.get(callId);
90    }
91
92    public static ContactCacheEntry buildCacheEntryFromCall(Context context, Call call,
93            boolean isIncoming) {
94        final ContactCacheEntry entry = new ContactCacheEntry();
95
96        // TODO: get rid of caller info.
97        final CallerInfo info = CallerInfoUtils.buildCallerInfo(context, call);
98        ContactInfoCache.populateCacheEntry(context, info, entry, call.getNumberPresentation(),
99                isIncoming);
100        return entry;
101    }
102
103    public void maybeInsertCnapInformationIntoCache(Context context, final Call call,
104            final CallerInfo info) {
105        if (mCachedNumberLookupService == null || TextUtils.isEmpty(info.cnapName)
106                || mInfoMap.get(call.getId()) != null) {
107            return;
108        }
109        final Context applicationContext = context.getApplicationContext();
110        Log.i(TAG, "Found contact with CNAP name - inserting into cache");
111        new AsyncTask<Void, Void, Void>() {
112            @Override
113            protected Void doInBackground(Void... params) {
114                ContactInfo contactInfo = new ContactInfo();
115                CachedContactInfo cacheInfo = mCachedNumberLookupService.buildCachedContactInfo(
116                        contactInfo);
117                cacheInfo.setSource(CachedContactInfo.SOURCE_TYPE_CNAP, "CNAP", 0);
118                contactInfo.name = info.cnapName;
119                contactInfo.number = call.getNumber();
120                contactInfo.type = ContactsContract.CommonDataKinds.Phone.TYPE_MAIN;
121                try {
122                    final JSONObject contactRows = new JSONObject().put(Phone.CONTENT_ITEM_TYPE,
123                            new JSONObject()
124                                    .put(Phone.NUMBER, contactInfo.number)
125                                    .put(Phone.TYPE, Phone.TYPE_MAIN));
126                    final String jsonString = new JSONObject()
127                            .put(Contacts.DISPLAY_NAME, contactInfo.name)
128                            .put(Contacts.DISPLAY_NAME_SOURCE, DisplayNameSources.STRUCTURED_NAME)
129                            .put(Contacts.CONTENT_ITEM_TYPE, contactRows).toString();
130                    cacheInfo.setLookupKey(jsonString);
131                } catch (JSONException e) {
132                    Log.w(TAG, "Creation of lookup key failed when caching CNAP information");
133                }
134                mCachedNumberLookupService.addContact(applicationContext, cacheInfo);
135                return null;
136            }
137        }.execute();
138    }
139
140    private class FindInfoCallback implements CallerInfoAsyncQuery.OnQueryCompleteListener {
141        private final boolean mIsIncoming;
142
143        public FindInfoCallback(boolean isIncoming) {
144            mIsIncoming = isIncoming;
145        }
146
147        @Override
148        public void onQueryComplete(int token, Object cookie, CallerInfo callerInfo) {
149            findInfoQueryComplete((Call) cookie, callerInfo, mIsIncoming, true);
150        }
151    }
152
153    /**
154     * Requests contact data for the Call object passed in.
155     * Returns the data through callback.  If callback is null, no response is made, however the
156     * query is still performed and cached.
157     *
158     * @param callback The function to call back when the call is found. Can be null.
159     */
160    public void findInfo(final Call call, final boolean isIncoming,
161            ContactInfoCacheCallback callback) {
162        Preconditions.checkState(Looper.getMainLooper().getThread() == Thread.currentThread());
163        Preconditions.checkNotNull(callback);
164
165        final String callId = call.getId();
166        final ContactCacheEntry cacheEntry = mInfoMap.get(callId);
167        Set<ContactInfoCacheCallback> callBacks = mCallBacks.get(callId);
168
169        // If we have a previously obtained intermediate result return that now
170        if (cacheEntry != null) {
171            Log.d(TAG, "Contact lookup. In memory cache hit; lookup "
172                    + (callBacks == null ? "complete" : "still running"));
173            callback.onContactInfoComplete(callId, cacheEntry);
174            // If no other callbacks are in flight, we're done.
175            if (callBacks == null) {
176                return;
177            }
178        }
179
180        // If the entry already exists, add callback
181        if (callBacks != null) {
182            callBacks.add(callback);
183            return;
184        }
185        Log.d(TAG, "Contact lookup. In memory cache miss; searching provider.");
186        // New lookup
187        callBacks = Sets.newHashSet();
188        callBacks.add(callback);
189        mCallBacks.put(callId, callBacks);
190
191        /**
192         * Performs a query for caller information.
193         * Save any immediate data we get from the query. An asynchronous query may also be made
194         * for any data that we do not already have. Some queries, such as those for voicemail and
195         * emergency call information, will not perform an additional asynchronous query.
196         */
197        final CallerInfo callerInfo = CallerInfoUtils.getCallerInfoForCall(
198                mContext, call, new FindInfoCallback(isIncoming));
199
200        findInfoQueryComplete(call, callerInfo, isIncoming, false);
201    }
202
203    private void findInfoQueryComplete(Call call, CallerInfo callerInfo, boolean isIncoming,
204            boolean didLocalLookup) {
205        final String callId = call.getId();
206        int presentationMode = call.getNumberPresentation();
207        if (callerInfo.contactExists || callerInfo.isEmergencyNumber() ||
208                callerInfo.isVoiceMailNumber()) {
209            presentationMode = TelecomManager.PRESENTATION_ALLOWED;
210        }
211
212        ContactCacheEntry cacheEntry = mInfoMap.get(callId);
213        // Ensure we always have a cacheEntry. Replace the existing entry if
214        // it has no name or if we found a local contact.
215        if (cacheEntry == null || TextUtils.isEmpty(cacheEntry.name) ||
216                callerInfo.contactExists) {
217            cacheEntry = buildEntry(mContext, callId, callerInfo, presentationMode, isIncoming);
218            mInfoMap.put(callId, cacheEntry);
219        }
220
221        sendInfoNotifications(callId, cacheEntry);
222
223        if (didLocalLookup) {
224            // Before issuing a request for more data from other services, we only check that the
225            // contact wasn't found in the local DB.  We don't check the if the cache entry already
226            // has a name because we allow overriding cnap data with data from other services.
227            if (!callerInfo.contactExists && mPhoneNumberService != null) {
228                Log.d(TAG, "Contact lookup. Local contacts miss, checking remote");
229                final PhoneNumberServiceListener listener = new PhoneNumberServiceListener(callId);
230                mPhoneNumberService.getPhoneNumberInfo(cacheEntry.number, listener, listener,
231                        isIncoming);
232            } else if (cacheEntry.displayPhotoUri != null) {
233                Log.d(TAG, "Contact lookup. Local contact found, starting image load");
234                // Load the image with a callback to update the image state.
235                // When the load is finished, onImageLoadComplete() will be called.
236                ContactsAsyncHelper.startObtainPhotoAsync(TOKEN_UPDATE_PHOTO_FOR_CALL_STATE,
237                        mContext, cacheEntry.displayPhotoUri, ContactInfoCache.this, callId);
238            } else {
239                if (callerInfo.contactExists) {
240                    Log.d(TAG, "Contact lookup done. Local contact found, no image.");
241                } else {
242                    Log.d(TAG, "Contact lookup done. Local contact not found and"
243                            + " no remote lookup service available.");
244                }
245                clearCallbacks(callId);
246            }
247        }
248    }
249
250    class PhoneNumberServiceListener implements PhoneNumberService.NumberLookupListener,
251                                     PhoneNumberService.ImageLookupListener {
252        private final String mCallId;
253
254        PhoneNumberServiceListener(String callId) {
255            mCallId = callId;
256        }
257
258        @Override
259        public void onPhoneNumberInfoComplete(
260                final PhoneNumberService.PhoneNumberInfo info) {
261            // If we got a miss, this is the end of the lookup pipeline,
262            // so clear the callbacks and return.
263            if (info == null) {
264                Log.d(TAG, "Contact lookup done. Remote contact not found.");
265                clearCallbacks(mCallId);
266                return;
267            }
268
269            ContactCacheEntry entry = new ContactCacheEntry();
270            entry.name = info.getDisplayName();
271            entry.number = info.getNumber();
272            final int type = info.getPhoneType();
273            final String label = info.getPhoneLabel();
274            if (type == Phone.TYPE_CUSTOM) {
275                entry.label = label;
276            } else {
277                final CharSequence typeStr = Phone.getTypeLabel(
278                        mContext.getResources(), type, label);
279                entry.label = typeStr == null ? null : typeStr.toString();
280            }
281            final ContactCacheEntry oldEntry = mInfoMap.get(mCallId);
282            if (oldEntry != null) {
283                // Location is only obtained from local lookup so persist
284                // the value for remote lookups. Once we have a name this
285                // field is no longer used; it is persisted here in case
286                // the UI is ever changed to use it.
287                entry.location = oldEntry.location;
288            }
289
290            // If no image and it's a business, switch to using the default business avatar.
291            if (info.getImageUrl() == null && info.isBusiness()) {
292                Log.d(TAG, "Business has no image. Using default.");
293                entry.photo = mContext.getResources().getDrawable(R.drawable.img_business);
294            }
295
296            // Add the contact info to the cache.
297            mInfoMap.put(mCallId, entry);
298            sendInfoNotifications(mCallId, entry);
299
300            // If there is no image then we should not expect another callback.
301            if (info.getImageUrl() == null) {
302                // We're done, so clear callbacks
303                clearCallbacks(mCallId);
304            }
305        }
306
307        @Override
308        public void onImageFetchComplete(Bitmap bitmap) {
309            onImageLoadComplete(TOKEN_UPDATE_PHOTO_FOR_CALL_STATE, null, bitmap, mCallId);
310        }
311    }
312
313    /**
314     * Implemented for ContactsAsyncHelper.OnImageLoadCompleteListener interface.
315     * make sure that the call state is reflected after the image is loaded.
316     */
317    @Override
318    public void onImageLoadComplete(int token, Drawable photo, Bitmap photoIcon, Object cookie) {
319        Log.d(this, "Image load complete with context: ", mContext);
320        // TODO: may be nice to update the image view again once the newer one
321        // is available on contacts database.
322
323        final String callId = (String) cookie;
324        final ContactCacheEntry entry = mInfoMap.get(callId);
325
326        if (entry == null) {
327            Log.e(this, "Image Load received for empty search entry.");
328            clearCallbacks(callId);
329            return;
330        }
331        Log.d(this, "setting photo for entry: ", entry);
332
333        // Conference call icons are being handled in CallCardPresenter.
334        if (photo != null) {
335            Log.v(this, "direct drawable: ", photo);
336            entry.photo = photo;
337        } else if (photoIcon != null) {
338            Log.v(this, "photo icon: ", photoIcon);
339            entry.photo = new BitmapDrawable(mContext.getResources(), photoIcon);
340        } else {
341            Log.v(this, "unknown photo");
342            entry.photo = null;
343        }
344
345        sendImageNotifications(callId, entry);
346        clearCallbacks(callId);
347    }
348
349    /**
350     * Blows away the stored cache values.
351     */
352    public void clearCache() {
353        mInfoMap.clear();
354        mCallBacks.clear();
355    }
356
357    private ContactCacheEntry buildEntry(Context context, String callId,
358            CallerInfo info, int presentation, boolean isIncoming) {
359        // The actual strings we're going to display onscreen:
360        Drawable photo = null;
361
362        final ContactCacheEntry cce = new ContactCacheEntry();
363        populateCacheEntry(context, info, cce, presentation, isIncoming);
364
365        // This will only be true for emergency numbers
366        if (info.photoResource != 0) {
367            photo = context.getResources().getDrawable(info.photoResource);
368        } else if (info.isCachedPhotoCurrent) {
369            if (info.cachedPhoto != null) {
370                photo = info.cachedPhoto;
371            } else {
372                photo = getDefaultContactPhotoDrawable();
373            }
374        } else if (info.contactDisplayPhotoUri == null) {
375            photo = getDefaultContactPhotoDrawable();
376        } else {
377            cce.displayPhotoUri = info.contactDisplayPhotoUri;
378        }
379
380        if (info.lookupKeyOrNull == null || info.contactIdOrZero == 0) {
381            Log.v(TAG, "lookup key is null or contact ID is 0. Don't create a lookup uri.");
382            cce.lookupUri = null;
383        } else {
384            cce.lookupUri = Contacts.getLookupUri(info.contactIdOrZero, info.lookupKeyOrNull);
385        }
386
387        cce.photo = photo;
388        cce.lookupKey = info.lookupKeyOrNull;
389
390        return cce;
391    }
392
393    /**
394     * Populate a cache entry from a call (which got converted into a caller info).
395     */
396    public static void populateCacheEntry(Context context, CallerInfo info, ContactCacheEntry cce,
397            int presentation, boolean isIncoming) {
398        Preconditions.checkNotNull(info);
399        String displayName = null;
400        String displayNumber = null;
401        String displayLocation = null;
402        String label = null;
403        boolean isSipCall = false;
404
405            // It appears that there is a small change in behaviour with the
406            // PhoneUtils' startGetCallerInfo whereby if we query with an
407            // empty number, we will get a valid CallerInfo object, but with
408            // fields that are all null, and the isTemporary boolean input
409            // parameter as true.
410
411            // In the past, we would see a NULL callerinfo object, but this
412            // ends up causing null pointer exceptions elsewhere down the
413            // line in other cases, so we need to make this fix instead. It
414            // appears that this was the ONLY call to PhoneUtils
415            // .getCallerInfo() that relied on a NULL CallerInfo to indicate
416            // an unknown contact.
417
418            // Currently, infi.phoneNumber may actually be a SIP address, and
419            // if so, it might sometimes include the "sip:" prefix. That
420            // prefix isn't really useful to the user, though, so strip it off
421            // if present. (For any other URI scheme, though, leave the
422            // prefix alone.)
423            // TODO: It would be cleaner for CallerInfo to explicitly support
424            // SIP addresses instead of overloading the "phoneNumber" field.
425            // Then we could remove this hack, and instead ask the CallerInfo
426            // for a "user visible" form of the SIP address.
427            String number = info.phoneNumber;
428
429            if (!TextUtils.isEmpty(number)) {
430                isSipCall = PhoneNumberHelper.isUriNumber(number);
431                if (number.startsWith("sip:")) {
432                    number = number.substring(4);
433                }
434            }
435
436            if (TextUtils.isEmpty(info.name)) {
437                // No valid "name" in the CallerInfo, so fall back to
438                // something else.
439                // (Typically, we promote the phone number up to the "name" slot
440                // onscreen, and possibly display a descriptive string in the
441                // "number" slot.)
442                if (TextUtils.isEmpty(number)) {
443                    // No name *or* number! Display a generic "unknown" string
444                    // (or potentially some other default based on the presentation.)
445                    displayName = getPresentationString(context, presentation);
446                    Log.d(TAG, "  ==> no name *or* number! displayName = " + displayName);
447                } else if (presentation != TelecomManager.PRESENTATION_ALLOWED) {
448                    // This case should never happen since the network should never send a phone #
449                    // AND a restricted presentation. However we leave it here in case of weird
450                    // network behavior
451                    displayName = getPresentationString(context, presentation);
452                    Log.d(TAG, "  ==> presentation not allowed! displayName = " + displayName);
453                } else if (!TextUtils.isEmpty(info.cnapName)) {
454                    // No name, but we do have a valid CNAP name, so use that.
455                    displayName = info.cnapName;
456                    info.name = info.cnapName;
457                    displayNumber = number;
458                    Log.d(TAG, "  ==> cnapName available: displayName '" + displayName +
459                            "', displayNumber '" + displayNumber + "'");
460                } else {
461                    // No name; all we have is a number. This is the typical
462                    // case when an incoming call doesn't match any contact,
463                    // or if you manually dial an outgoing number using the
464                    // dialpad.
465                    displayNumber = number;
466
467                    // Display a geographical description string if available
468                    // (but only for incoming calls.)
469                    if (isIncoming) {
470                        // TODO (CallerInfoAsyncQuery cleanup): Fix the CallerInfo
471                        // query to only do the geoDescription lookup in the first
472                        // place for incoming calls.
473                        displayLocation = info.geoDescription; // may be null
474                        Log.d(TAG, "Geodescrption: " + info.geoDescription);
475                    }
476
477                    Log.d(TAG, "  ==>  no name; falling back to number:"
478                            + " displayNumber '" + Log.pii(displayNumber)
479                            + "', displayLocation '" + displayLocation + "'");
480                }
481            } else {
482                // We do have a valid "name" in the CallerInfo. Display that
483                // in the "name" slot, and the phone number in the "number" slot.
484                if (presentation != TelecomManager.PRESENTATION_ALLOWED) {
485                    // This case should never happen since the network should never send a name
486                    // AND a restricted presentation. However we leave it here in case of weird
487                    // network behavior
488                    displayName = getPresentationString(context, presentation);
489                    Log.d(TAG, "  ==> valid name, but presentation not allowed!" +
490                            " displayName = " + displayName);
491                } else {
492                    displayName = info.name;
493                    displayNumber = number;
494                    label = info.phoneLabel;
495                    Log.d(TAG, "  ==>  name is present in CallerInfo: displayName '" + displayName
496                            + "', displayNumber '" + displayNumber + "'");
497                }
498            }
499
500        cce.name = displayName;
501        cce.number = displayNumber;
502        cce.location = displayLocation;
503        cce.label = label;
504        cce.isSipCall = isSipCall;
505    }
506
507    /**
508     * Sends the updated information to call the callbacks for the entry.
509     */
510    private void sendInfoNotifications(String callId, ContactCacheEntry entry) {
511        final Set<ContactInfoCacheCallback> callBacks = mCallBacks.get(callId);
512        if (callBacks != null) {
513            for (ContactInfoCacheCallback callBack : callBacks) {
514                callBack.onContactInfoComplete(callId, entry);
515            }
516        }
517    }
518
519    private void sendImageNotifications(String callId, ContactCacheEntry entry) {
520        final Set<ContactInfoCacheCallback> callBacks = mCallBacks.get(callId);
521        if (callBacks != null && entry.photo != null) {
522            for (ContactInfoCacheCallback callBack : callBacks) {
523                callBack.onImageLoadComplete(callId, entry);
524            }
525        }
526    }
527
528    private void clearCallbacks(String callId) {
529        mCallBacks.remove(callId);
530    }
531
532    /**
533     * Gets name strings based on some special presentation modes.
534     */
535    private static String getPresentationString(Context context, int presentation) {
536        String name = context.getString(R.string.unknown);
537        if (presentation == TelecomManager.PRESENTATION_RESTRICTED) {
538            name = context.getString(R.string.private_num);
539        } else if (presentation == TelecomManager.PRESENTATION_PAYPHONE) {
540            name = context.getString(R.string.payphone);
541        }
542        return name;
543    }
544
545    public Drawable getDefaultContactPhotoDrawable() {
546        if (mDefaultContactPhotoDrawable == null) {
547            mDefaultContactPhotoDrawable =
548                    mContext.getResources().getDrawable(R.drawable.img_no_image_automirrored);
549        }
550        return mDefaultContactPhotoDrawable;
551    }
552
553    public Drawable getConferenceDrawable() {
554        if (mConferencePhotoDrawable == null) {
555            mConferencePhotoDrawable =
556                    mContext.getResources().getDrawable(R.drawable.img_conference_automirrored);
557        }
558        return mConferencePhotoDrawable;
559    }
560
561    /**
562     * Callback interface for the contact query.
563     */
564    public interface ContactInfoCacheCallback {
565        public void onContactInfoComplete(String callId, ContactCacheEntry entry);
566        public void onImageLoadComplete(String callId, ContactCacheEntry entry);
567    }
568
569    public static class ContactCacheEntry {
570        public String name;
571        public String number;
572        public String location;
573        public String label;
574        public Drawable photo;
575        public boolean isSipCall;
576        /** This will be used for the "view" notification. */
577        public Uri contactUri;
578        /** Either a display photo or a thumbnail URI. */
579        public Uri displayPhotoUri;
580        public Uri lookupUri; // Sent to NotificationMananger
581        public String lookupKey;
582
583        @Override
584        public String toString() {
585            return Objects.toStringHelper(this)
586                    .add("name", MoreStrings.toSafeString(name))
587                    .add("number", MoreStrings.toSafeString(number))
588                    .add("location", MoreStrings.toSafeString(location))
589                    .add("label", label)
590                    .add("photo", photo)
591                    .add("isSipCall", isSipCall)
592                    .add("contactUri", contactUri)
593                    .add("displayPhotoUri", displayPhotoUri)
594                    .toString();
595        }
596    }
597}
598