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