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