/* * 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.Intent; import android.content.Loader; import android.content.res.Resources; import android.database.Cursor; import android.database.sqlite.SQLiteFullException; import android.net.Uri; import android.os.Handler; import android.os.Message; import android.provider.CallLog.Calls; import android.provider.ContactsContract; import android.provider.ContactsContract.PhoneLookup; import android.telecom.PhoneAccountHandle; import android.telephony.PhoneNumberUtils; import android.text.TextUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.View.AccessibilityDelegate; import android.view.ViewGroup; import android.view.ViewStub; import android.view.ViewTreeObserver; import android.view.accessibility.AccessibilityEvent; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; import com.android.common.widget.GroupingListAdapter; import com.android.contacts.common.CallUtil; import com.android.contacts.common.ContactPhotoManager; import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest; import com.android.contacts.common.util.PhoneNumberHelper; import com.android.contacts.common.model.Contact; import com.android.contacts.common.model.ContactLoader; import com.android.contacts.common.util.UriUtils; import com.android.dialer.DialtactsActivity; import com.android.dialer.PhoneCallDetails; import com.android.dialer.PhoneCallDetailsHelper; import com.android.dialer.R; import com.android.dialer.util.DialerUtils; import com.android.dialer.util.ExpirableCache; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Objects; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; /** * Adapter class to fill in data for the Call Log. */ public class CallLogAdapter extends GroupingListAdapter implements ViewTreeObserver.OnPreDrawListener, CallLogGroupBuilder.GroupCreator { private static final String TAG = CallLogAdapter.class.getSimpleName(); private static final int VOICEMAIL_TRANSCRIPTION_MAX_LINES = 10; /** The enumeration of {@link android.os.AsyncTask} objects used in this class. */ public enum Tasks { REMOVE_CALL_LOG_ENTRIES, } /** Interface used to inform a parent UI element that a list item has been expanded. */ public interface CallItemExpandedListener { /** * @param view The {@link View} that represents the item that was clicked * on. */ public void onItemExpanded(View view); /** * Retrieves the call log view for the specified call Id. If the view is not currently * visible, returns null. * * @param callId The call Id. * @return The call log view. */ public View getViewForCallId(long callId); } /** Interface used to initiate a refresh of the content. */ public interface CallFetcher { public void fetchCalls(); } /** Implements onClickListener for the report button. */ public interface OnReportButtonClickListener { public void onReportButtonClick(String number); } /** * 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; /** Constant used to indicate no row is expanded. */ private static final long NONE_EXPANDED = -1; protected final Context mContext; private final ContactInfoHelper mContactInfoHelper; private final CallFetcher mCallFetcher; private final Toast mReportedToast; private final OnReportButtonClickListener mOnReportButtonClickListener; 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}.
*/
protected 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);
final boolean isRemoteSource = info.sourceType != 0;
// Don't force redraw if existing info in the cache is equal to {@link ContactInfo#EMPTY}
// to avoid updating the data set for every new row that is scrolled into view.
// see (https://googleplex-android-review.git.corp.google.com/#/c/166680/)
// Exception: Photo uris for contacts from remote sources are not cached in the call log
// cache, so we have to force a redraw for these contacts regardless.
boolean updated = (existingInfo != ContactInfo.EMPTY || isRemoteSource) &&
!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) {
return newChildView(context, parent);
}
@Override
protected View newGroupView(Context context, ViewGroup parent) {
return newChildView(context, parent);
}
@Override
protected View newChildView(Context context, ViewGroup parent) {
LayoutInflater inflater = LayoutInflater.from(context);
View view = inflater.inflate(R.layout.call_log_list_item, parent, false);
// Get the views to bind to and cache them.
CallLogListItemViews views = CallLogListItemViews.fromView(view);
view.setTag(views);
// Set text height to false on the TextViews so they don't have extra padding.
views.phoneCallDetailsViews.nameView.setElegantTextHeight(false);
views.phoneCallDetailsViews.callLocationAndDate.setElegantTextHeight(false);
return view;
}
@Override
protected void bindStandAloneView(View view, Context context, Cursor cursor) {
bindView(view, cursor, 1);
}
@Override
protected void bindChildView(View view, Context context, Cursor cursor) {
bindView(view, cursor, 1);
}
@Override
protected void bindGroupView(View view, Context context, Cursor cursor, int groupSize,
boolean expanded) {
bindView(view, cursor, groupSize);
}
private void findAndCacheViews(View view) {
}
/**
* Binds the views in the entry to the data in the call log.
*
* @param callLogItemView 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 callLogItemView, Cursor c, int count) {
callLogItemView.setAccessibilityDelegate(mAccessibilityDelegate);
final CallLogListItemViews views = (CallLogListItemViews) callLogItemView.getTag();
// Default case: an item in the call log.
views.primaryActionView.setVisibility(View.VISIBLE);
final String number = c.getString(CallLogQuery.NUMBER);
final int numberPresentation = c.getInt(CallLogQuery.NUMBER_PRESENTATION);
final long date = c.getLong(CallLogQuery.DATE);
final long duration = c.getLong(CallLogQuery.DURATION);
final int callType = c.getInt(CallLogQuery.CALL_TYPE);
final PhoneAccountHandle accountHandle = PhoneAccountUtils.getAccount(
c.getString(CallLogQuery.ACCOUNT_COMPONENT_NAME),
c.getString(CallLogQuery.ACCOUNT_ID));
final String countryIso = c.getString(CallLogQuery.COUNTRY_ISO);
final long rowId = c.getLong(CallLogQuery.ID);
views.rowId = rowId;
// For entries in the call log, check if the day group has changed and display a header
// if necessary.
if (mIsCallLog) {
int currentGroup = getDayGroupForCall(rowId);
int previousGroup = getPreviousDayGroup(c);
if (currentGroup != previousGroup) {
views.dayGroupHeader.setVisibility(View.VISIBLE);
views.dayGroupHeader.setText(getGroupDescription(currentGroup));
} else {
views.dayGroupHeader.setVisibility(View.GONE);
}
} else {
views.dayGroupHeader.setVisibility(View.GONE);
}
// Store some values used when the actions ViewStub is inflated on expansion of the actions
// section.
views.number = number;
views.numberPresentation = numberPresentation;
views.callType = callType;
views.accountHandle = accountHandle;
views.voicemailUri = c.getString(CallLogQuery.VOICEMAIL_URI);
// Stash away the Ids of the calls so that we can support deleting a row in the call log.
views.callIds = getCallIds(c, count);
final ContactInfo cachedContactInfo = getContactInfoFromCallLog(c);
final boolean isVoicemailNumber =
mPhoneNumberUtilsWrapper.isVoicemailNumber(accountHandle, number);
// Where binding and not in the call log, use default behaviour of invoking a call when
// tapping the primary view.
if (!mIsCallLog) {
views.primaryActionView.setOnClickListener(this.mActionListener);
// Set return call intent, otherwise null.
if (PhoneNumberUtilsWrapper.canPlaceCallsTo(number, numberPresentation)) {
// Sets the primary action to call the number.
if (isVoicemailNumber) {
views.primaryActionView.setTag(
IntentProvider.getReturnVoicemailCallIntentProvider());
} else {
views.primaryActionView.setTag(
IntentProvider.getReturnCallIntentProvider(number));
}
} else {
// Number is not callable, so hide button.
views.primaryActionView.setTag(null);
}
} else {
// In the call log, expand/collapse an actions section for the call log entry when
// the primary view is tapped.
views.primaryActionView.setOnClickListener(this.mExpandCollapseListener);
// Note: Binding of the action buttons is done as required in configureActionViews
// when the user expands the actions ViewStub.
}
// 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;
}
/**
* Determine the features which were enabled for any of the calls that make up a call log
* entry.
*
* @param cursor The cursor.
* @param count The number of calls for the current call log entry.
* @return The features.
*/
private int getCallFeatures(Cursor cursor, int count) {
int features = 0;
int position = cursor.getPosition();
for (int index = 0; index < count; ++index) {
features |= cursor.getInt(CallLogQuery.FEATURES);
cursor.moveToNext();
}
cursor.moveToPosition(position);
return features;
}
private void setPhoto(CallLogListItemViews views, long photoId, Uri contactUri,
String displayName, String identifier, int contactType) {
views.quickContactView.assignContactUri(contactUri);
views.quickContactView.setOverlay(null);
DefaultImageRequest request = new DefaultImageRequest(displayName, identifier,
contactType, true /* isCircular */);
mContactPhotoManager.loadThumbnail(views.quickContactView, photoId, false /* darkTheme */,
true /* isCircular */, request);
}
private void setPhoto(CallLogListItemViews views, Uri photoUri, Uri contactUri,
String displayName, String identifier, int contactType) {
views.quickContactView.assignContactUri(contactUri);
views.quickContactView.setOverlay(null);
DefaultImageRequest request = new DefaultImageRequest(displayName, identifier,
contactType, true /* isCircular */);
mContactPhotoManager.loadPhoto(views.quickContactView, photoUri, mPhotoSize,
false /* darkTheme */, true /* isCircular */, request);
}
/**
* Bind a call log entry view for testing purposes. Also inflates the action view stub so
* unit tests can access the buttons contained within.
*
* @param view The current call log row.
* @param context The current context.
* @param cursor The cursor to bind from.
*/
@VisibleForTesting
void bindViewForTest(View view, Context context, Cursor cursor) {
bindStandAloneView(view, context, cursor);
inflateActionViewStub(view);
}
/**
* 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);
}
/**
* Stores the day group associated with a call in the call log.
*
* @param rowId The row Id of the current call.
* @param dayGroup The day group the call belongs in.
*/
@Override
public void setDayGroup(long rowId, int dayGroup) {
if (!mDayGroups.containsKey(rowId)) {
mDayGroups.put(rowId, dayGroup);
}
}
/**
* Clears the day group associations on re-bind of the call log.
*/
@Override
public void clearDayGroups() {
mDayGroups.clear();
}
/*
* 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) {
try {
if (phonesCursor.moveToFirst()) {
matchingNumber = phonesCursor.getString(PhoneQuery.MATCHED_NUMBER);
}
} finally {
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;
}
/**
* Retrieves the call Ids represented by the current call log row.
*
* @param cursor Call log cursor to retrieve call Ids from.
* @param groupSize Number of calls associated with the current call log row.
* @return Array of call Ids.
*/
private long[] getCallIds(final Cursor cursor, final int groupSize) {
// We want to restore the position in the cursor at the end.
int startingPosition = cursor.getPosition();
long[] ids = new long[groupSize];
// Copy the ids of the rows in the group.
for (int index = 0; index < groupSize; ++index) {
ids[index] = cursor.getLong(CallLogQuery.ID);
cursor.moveToNext();
}
cursor.moveToPosition(startingPosition);
return ids;
}
/**
* Determines the description for a day group.
*
* @param group The day group to retrieve the description for.
* @return The day group description.
*/
private CharSequence getGroupDescription(int group) {
if (group == CallLogGroupBuilder.DAY_GROUP_TODAY) {
return mContext.getResources().getString(R.string.call_log_header_today);
} else if (group == CallLogGroupBuilder.DAY_GROUP_YESTERDAY) {
return mContext.getResources().getString(R.string.call_log_header_yesterday);
} else {
return mContext.getResources().getString(R.string.call_log_header_other);
}
}
public void onBadDataReported(String number) {
mContactInfoCache.expireAll();
mReportedToast.show();
}
/**
* Manages the state changes for the UI interaction where a call log row is expanded.
*
* @param view The view that was tapped
* @param animate Whether or not to animate the expansion/collapse
* @param forceExpand Whether or not to force the call log row into an expanded state regardless
* of its previous state
*/
private void handleRowExpanded(View view, boolean animate, boolean forceExpand) {
final CallLogListItemViews views = (CallLogListItemViews) view.getTag();
if (forceExpand && isExpanded(views.rowId)) {
return;
}
// Hide or show the actions view.
boolean expanded = toggleExpansion(views.rowId);
// Trigger loading of the viewstub and visual expand or collapse.
expandOrCollapseActions(view, expanded);
// Animate the expansion or collapse.
if (mCallItemExpandedListener != null) {
if (animate) {
mCallItemExpandedListener.onItemExpanded(view);
}
// Animate the collapse of the previous item if it is still visible on screen.
if (mPreviouslyExpanded != NONE_EXPANDED) {
View previousItem = mCallItemExpandedListener.getViewForCallId(
mPreviouslyExpanded);
if (previousItem != null) {
expandOrCollapseActions(previousItem, false);
if (animate) {
mCallItemExpandedListener.onItemExpanded(previousItem);
}
}
mPreviouslyExpanded = NONE_EXPANDED;
}
}
}
/**
* Invokes the "add contact" activity given the expanded contact information stored in a
* lookup URI. This can include, for example, address and website information.
*
* @param lookupUri The lookup URI.
*/
private void addContactFromLookupUri(Uri lookupUri) {
Contact contactToSave = ContactLoader.parseEncodedContactEntity(lookupUri);
if (contactToSave == null) {
return;
}
// Note: This code mirrors code in Contacts/QuickContactsActivity.
final Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT);
intent.setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE);
ArrayList