/* * Copyright (C) 2011 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.dialer.calllog; import android.content.ContentValues; import android.content.Context; import android.content.res.Resources; import android.database.Cursor; import android.net.Uri; import android.os.Handler; import android.os.Message; import android.provider.CallLog.Calls; import android.provider.ContactsContract.PhoneLookup; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import com.android.common.widget.GroupingListAdapter; import com.android.contacts.ContactPhotoManager; import com.android.contacts.util.UriUtils; import com.android.dialer.PhoneCallDetails; import com.android.dialer.PhoneCallDetailsHelper; import com.android.dialer.R; import com.android.dialer.util.ExpirableCache; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Objects; import java.util.LinkedList; /** * Adapter class to fill in data for the Call Log. */ /*package*/ class CallLogAdapter extends GroupingListAdapter implements ViewTreeObserver.OnPreDrawListener, CallLogGroupBuilder.GroupCreator { /** Interface used to initiate a refresh of the content. */ public interface CallFetcher { public void fetchCalls(); } /** * Stores a phone number of a call with the country code where it originally occurred. *
* Note the country does not necessarily specifies the country of the phone number itself, but * it is the country in which the user was in when the call was placed or received. */ private static final class NumberWithCountryIso { public final String number; public final String countryIso; public NumberWithCountryIso(String number, String countryIso) { this.number = number; this.countryIso = countryIso; } @Override public boolean equals(Object o) { if (o == null) return false; if (!(o instanceof NumberWithCountryIso)) return false; NumberWithCountryIso other = (NumberWithCountryIso) o; return TextUtils.equals(number, other.number) && TextUtils.equals(countryIso, other.countryIso); } @Override public int hashCode() { return (number == null ? 0 : number.hashCode()) ^ (countryIso == null ? 0 : countryIso.hashCode()); } } /** The time in millis to delay starting the thread processing requests. */ private static final int START_PROCESSING_REQUESTS_DELAY_MILLIS = 1000; /** The size of the cache of contact info. */ private static final int CONTACT_INFO_CACHE_SIZE = 100; private final Context mContext; private final ContactInfoHelper mContactInfoHelper; private final CallFetcher mCallFetcher; private ViewTreeObserver mViewTreeObserver = null; /** * A cache of the contact details for the phone numbers in the call log. *
* The content of the cache is expired (but not purged) whenever the application comes to * the foreground. *
* The key is number with the country in which the call was placed or received.
*/
private ExpirableCache
* Each request is made of a phone number to look up, and the contact info currently stored in
* the call log for this number.
*
* The requests are added when displaying the contacts and are processed by a background
* thread.
*/
private final LinkedList
* It also provides the current contact info stored in the call log for this number.
*
* If the {@code immediate} parameter is true, it will start immediately the thread that looks
* up the contact information (if it has not been already started). Otherwise, it will be
* started with a delay. See {@link #START_PROCESSING_REQUESTS_DELAY_MILLIS}.
*/
@VisibleForTesting
void enqueueRequest(String number, String countryIso, ContactInfo callLogInfo,
boolean immediate) {
ContactInfoRequest request = new ContactInfoRequest(number, countryIso, callLogInfo);
synchronized (mRequests) {
if (!mRequests.contains(request)) {
mRequests.add(request);
mRequests.notifyAll();
}
}
if (immediate) startRequestProcessing();
}
/**
* Queries the appropriate content provider for the contact associated with the number.
*
* Upon completion it also updates the cache in the call log, if it is different from
* {@code callLogInfo}.
*
* The number might be either a SIP address or a phone number.
*
* It returns true if it updated the content of the cache and we should therefore tell the
* view to update its content.
*/
private boolean queryContactInfo(String number, String countryIso, ContactInfo callLogInfo) {
final ContactInfo info = mContactInfoHelper.lookupNumber(number, countryIso);
if (info == null) {
// The lookup failed, just return without requesting to update the view.
return false;
}
// Check the existing entry in the cache: only if it has changed we should update the
// view.
NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso);
ContactInfo existingInfo = mContactInfoCache.getPossiblyExpired(numberCountryIso);
boolean updated = (existingInfo != ContactInfo.EMPTY) && !info.equals(existingInfo);
// Store the data in the cache so that the UI thread can use to display it. Store it
// even if it has not changed so that it is marked as not expired.
mContactInfoCache.put(numberCountryIso, info);
// Update the call log even if the cache it is up-to-date: it is possible that the cache
// contains the value from a different call log entry.
updateCallLogContactInfoCache(number, countryIso, info, callLogInfo);
return updated;
}
/*
* Handles requests for contact name and number type.
*/
private class QueryThread extends Thread {
private volatile boolean mDone = false;
public QueryThread() {
super("CallLogAdapter.QueryThread");
}
public void stopProcessing() {
mDone = true;
}
@Override
public void run() {
boolean needRedraw = false;
while (true) {
// Check if thread is finished, and if so return immediately.
if (mDone) return;
// Obtain next request, if any is available.
// Keep synchronized section small.
ContactInfoRequest req = null;
synchronized (mRequests) {
if (!mRequests.isEmpty()) {
req = mRequests.removeFirst();
}
}
if (req != null) {
// Process the request. If the lookup succeeds, schedule a
// redraw.
needRedraw |= queryContactInfo(req.number, req.countryIso, req.callLogInfo);
} else {
// Throttle redraw rate by only sending them when there are
// more requests.
if (needRedraw) {
needRedraw = false;
mHandler.sendEmptyMessage(REDRAW);
}
// Wait until another request is available, or until this
// thread is no longer needed (as indicated by being
// interrupted).
try {
synchronized (mRequests) {
mRequests.wait(1000);
}
} catch (InterruptedException ie) {
// Ignore, and attempt to continue processing requests.
}
}
}
}
}
@Override
protected void addGroups(Cursor cursor) {
mCallLogGroupBuilder.addGroups(cursor);
}
@Override
protected View newStandAloneView(Context context, ViewGroup parent) {
LayoutInflater inflater =
(LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View view = inflater.inflate(R.layout.call_log_list_item, parent, false);
findAndCacheViews(view);
return view;
}
@Override
protected void bindStandAloneView(View view, Context context, Cursor cursor) {
bindView(view, cursor, 1);
}
@Override
protected View newChildView(Context context, ViewGroup parent) {
LayoutInflater inflater =
(LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View view = inflater.inflate(R.layout.call_log_list_item, parent, false);
findAndCacheViews(view);
return view;
}
@Override
protected void bindChildView(View view, Context context, Cursor cursor) {
bindView(view, cursor, 1);
}
@Override
protected View newGroupView(Context context, ViewGroup parent) {
LayoutInflater inflater =
(LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View view = inflater.inflate(R.layout.call_log_list_item, parent, false);
findAndCacheViews(view);
return view;
}
@Override
protected void bindGroupView(View view, Context context, Cursor cursor, int groupSize,
boolean expanded) {
bindView(view, cursor, groupSize);
}
private void findAndCacheViews(View view) {
// Get the views to bind to.
CallLogListItemViews views = CallLogListItemViews.fromView(view);
views.primaryActionView.setOnClickListener(mPrimaryActionListener);
views.secondaryActionView.setOnClickListener(mSecondaryActionListener);
view.setTag(views);
}
/**
* Binds the views in the entry to the data in the call log.
*
* @param view the view corresponding to this entry
* @param c the cursor pointing to the entry in the call log
* @param count the number of entries in the current item, greater than 1 if it is a group
*/
private void bindView(View view, Cursor c, int count) {
final CallLogListItemViews views = (CallLogListItemViews) view.getTag();
final int section = c.getInt(CallLogQuery.SECTION);
// This might be a header: check the value of the section column in the cursor.
if (section == CallLogQuery.SECTION_NEW_HEADER
|| section == CallLogQuery.SECTION_OLD_HEADER) {
views.primaryActionView.setVisibility(View.GONE);
views.bottomDivider.setVisibility(View.GONE);
views.listHeaderTextView.setVisibility(View.VISIBLE);
views.listHeaderTextView.setText(
section == CallLogQuery.SECTION_NEW_HEADER
? R.string.call_log_new_header
: R.string.call_log_old_header);
// Nothing else to set up for a header.
return;
}
// Default case: an item in the call log.
views.primaryActionView.setVisibility(View.VISIBLE);
views.bottomDivider.setVisibility(isLastOfSection(c) ? View.GONE : View.VISIBLE);
views.listHeaderTextView.setVisibility(View.GONE);
final String number = c.getString(CallLogQuery.NUMBER);
final long date = c.getLong(CallLogQuery.DATE);
final long duration = c.getLong(CallLogQuery.DURATION);
final int callType = c.getInt(CallLogQuery.CALL_TYPE);
final String countryIso = c.getString(CallLogQuery.COUNTRY_ISO);
final ContactInfo cachedContactInfo = getContactInfoFromCallLog(c);
views.primaryActionView.setTag(
IntentProvider.getCallDetailIntentProvider(
this, c.getPosition(), c.getLong(CallLogQuery.ID), count));
// Store away the voicemail information so we can play it directly.
if (callType == Calls.VOICEMAIL_TYPE) {
String voicemailUri = c.getString(CallLogQuery.VOICEMAIL_URI);
final long rowId = c.getLong(CallLogQuery.ID);
views.secondaryActionView.setTag(
IntentProvider.getPlayVoicemailIntentProvider(rowId, voicemailUri));
} else if (!TextUtils.isEmpty(number)) {
// Store away the number so we can call it directly if you click on the call icon.
views.secondaryActionView.setTag(
IntentProvider.getReturnCallIntentProvider(number));
} else {
// No action enabled.
views.secondaryActionView.setTag(null);
}
// Lookup contacts with this number
NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso);
ExpirableCache.CachedValue
* It uses the next {@code count} rows in the cursor to extract the types.
*
* It position in the cursor is unchanged by this function.
*/
private int[] getCallTypes(Cursor cursor, int count) {
int position = cursor.getPosition();
int[] callTypes = new int[count];
for (int index = 0; index < count; ++index) {
callTypes[index] = cursor.getInt(CallLogQuery.CALL_TYPE);
cursor.moveToNext();
}
cursor.moveToPosition(position);
return callTypes;
}
private void setPhoto(CallLogListItemViews views, long photoId, Uri contactUri) {
views.quickContactView.assignContactUri(contactUri);
mContactPhotoManager.loadThumbnail(views.quickContactView, photoId, true);
}
/**
* Sets whether processing of requests for contact details should be enabled.
*
* This method should be called in tests to disable such processing of requests when not
* needed.
*/
@VisibleForTesting
void disableRequestProcessingForTest() {
mRequestProcessingDisabled = true;
}
@VisibleForTesting
void injectContactInfoForTest(String number, String countryIso, ContactInfo contactInfo) {
NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso);
mContactInfoCache.put(numberCountryIso, contactInfo);
}
@Override
public void addGroup(int cursorPosition, int size, boolean expanded) {
super.addGroup(cursorPosition, size, expanded);
}
/*
* Get the number from the Contacts, if available, since sometimes
* the number provided by caller id may not be formatted properly
* depending on the carrier (roaming) in use at the time of the
* incoming call.
* Logic : If the caller-id number starts with a "+", use it
* Else if the number in the contacts starts with a "+", use that one
* Else if the number in the contacts is longer, use that one
*/
public String getBetterNumberFromContacts(String number, String countryIso) {
String matchingNumber = null;
// Look in the cache first. If it's not found then query the Phones db
NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso);
ContactInfo ci = mContactInfoCache.getPossiblyExpired(numberCountryIso);
if (ci != null && ci != ContactInfo.EMPTY) {
matchingNumber = ci.number;
} else {
try {
Cursor phonesCursor = mContext.getContentResolver().query(
Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, number),
PhoneQuery._PROJECTION, null, null, null);
if (phonesCursor != null) {
if (phonesCursor.moveToFirst()) {
matchingNumber = phonesCursor.getString(PhoneQuery.MATCHED_NUMBER);
}
phonesCursor.close();
}
} catch (Exception e) {
// Use the number from the call log
}
}
if (!TextUtils.isEmpty(matchingNumber) &&
(matchingNumber.startsWith("+")
|| matchingNumber.length() > number.length())) {
number = matchingNumber;
}
return number;
}
}