From 5acc180074556da9885c8282901f4331afd27348 Mon Sep 17 00:00:00 2001 From: Danny Baumann Date: Wed, 12 Nov 2014 17:01:47 -0800 Subject: Add back call stats feature. Conflicts: src/com/android/dialer/CallDetailActivity.java src/com/android/dialer/calllog/CallLogAdapter.java src/com/android/dialer/calllog/ContactInfoHelper.java Change-Id: Id10bc12cacaee3523b7614bce8493d8b423b3f40 --- AndroidManifest.xml | 11 + res/drawable-hdpi/ic_call_inout_holo_dark.png | Bin 0 -> 550 bytes res/drawable-mdpi/ic_call_inout_holo_dark.png | Bin 0 -> 385 bytes res/drawable-xhdpi/ic_call_inout_holo_dark.png | Bin 0 -> 751 bytes res/layout/call_stats_detail.xml | 225 ++++++++++ res/layout/call_stats_detail_info.xml | 215 ++++++++++ res/layout/call_stats_fragment.xml | 85 ++++ res/layout/call_stats_list_item.xml | 115 +++++ res/layout/call_stats_nav_item.xml | 39 ++ res/layout/double_date_picker_dialog.xml | 67 +++ res/menu/call_stats_details_options.xml | 27 ++ res/menu/call_stats_options.xml | 45 ++ res/values/attrs.xml | 17 + res/values/cm_arrays.xml | 42 ++ res/values/cm_plurals.xml | 35 ++ res/values/cm_strings.xml | 37 ++ res/values/colors.xml | 8 + res/values/dimens.xml | 5 + src/com/android/dialer/CallDetailActivity.java | 158 +------ src/com/android/dialer/CallDetailHeader.java | 426 ++++++++++++++++++ src/com/android/dialer/PhoneCallDetails.java | 31 +- .../android/dialer/calllog/CallLogActivity.java | 30 +- src/com/android/dialer/calllog/CallLogAdapter.java | 434 ++----------------- .../dialer/calllog/CallLogAdapterHelper.java | 474 +++++++++++++++++++++ .../android/dialer/calllog/ContactInfoHelper.java | 32 ++ .../android/dialer/callstats/CallStatsAdapter.java | 249 +++++++++++ .../dialer/callstats/CallStatsDetailActivity.java | 274 ++++++++++++ .../dialer/callstats/CallStatsDetailHelper.java | 174 ++++++++ .../dialer/callstats/CallStatsDetailViews.java | 50 +++ .../android/dialer/callstats/CallStatsDetails.java | 238 +++++++++++ .../dialer/callstats/CallStatsFragment.java | 340 +++++++++++++++ .../dialer/callstats/CallStatsListItemViews.java | 55 +++ .../android/dialer/callstats/CallStatsQuery.java | 59 +++ .../dialer/callstats/CallStatsQueryHandler.java | 247 +++++++++++ .../android/dialer/callstats/IntentProvider.java | 54 +++ .../android/dialer/widget/AnchoredScrollView.java | 101 +++++ .../dialer/widget/DoubleDatePickerDialog.java | 336 +++++++++++++++ src/com/android/dialer/widget/LinearColorBar.java | 210 +++++++++ src/com/android/dialer/widget/PieChartView.java | 261 ++++++++++++ 39 files changed, 4644 insertions(+), 562 deletions(-) create mode 100644 res/drawable-hdpi/ic_call_inout_holo_dark.png create mode 100644 res/drawable-mdpi/ic_call_inout_holo_dark.png create mode 100644 res/drawable-xhdpi/ic_call_inout_holo_dark.png create mode 100644 res/layout/call_stats_detail.xml create mode 100644 res/layout/call_stats_detail_info.xml create mode 100644 res/layout/call_stats_fragment.xml create mode 100644 res/layout/call_stats_list_item.xml create mode 100644 res/layout/call_stats_nav_item.xml create mode 100644 res/layout/double_date_picker_dialog.xml create mode 100644 res/menu/call_stats_details_options.xml create mode 100644 res/menu/call_stats_options.xml create mode 100644 res/values/cm_arrays.xml create mode 100644 res/values/cm_plurals.xml create mode 100644 src/com/android/dialer/CallDetailHeader.java create mode 100644 src/com/android/dialer/calllog/CallLogAdapterHelper.java create mode 100644 src/com/android/dialer/callstats/CallStatsAdapter.java create mode 100644 src/com/android/dialer/callstats/CallStatsDetailActivity.java create mode 100644 src/com/android/dialer/callstats/CallStatsDetailHelper.java create mode 100644 src/com/android/dialer/callstats/CallStatsDetailViews.java create mode 100644 src/com/android/dialer/callstats/CallStatsDetails.java create mode 100644 src/com/android/dialer/callstats/CallStatsFragment.java create mode 100644 src/com/android/dialer/callstats/CallStatsListItemViews.java create mode 100644 src/com/android/dialer/callstats/CallStatsQuery.java create mode 100644 src/com/android/dialer/callstats/CallStatsQueryHandler.java create mode 100644 src/com/android/dialer/callstats/IntentProvider.java create mode 100644 src/com/android/dialer/widget/AnchoredScrollView.java create mode 100644 src/com/android/dialer/widget/DoubleDatePickerDialog.java create mode 100644 src/com/android/dialer/widget/LinearColorBar.java create mode 100644 src/com/android/dialer/widget/PieChartView.java diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 3b6484eb9..d7708b475 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -163,6 +163,17 @@ + + + + + + + diff --git a/res/drawable-hdpi/ic_call_inout_holo_dark.png b/res/drawable-hdpi/ic_call_inout_holo_dark.png new file mode 100644 index 000000000..1dbf4b485 Binary files /dev/null and b/res/drawable-hdpi/ic_call_inout_holo_dark.png differ diff --git a/res/drawable-mdpi/ic_call_inout_holo_dark.png b/res/drawable-mdpi/ic_call_inout_holo_dark.png new file mode 100644 index 000000000..88b92a195 Binary files /dev/null and b/res/drawable-mdpi/ic_call_inout_holo_dark.png differ diff --git a/res/drawable-xhdpi/ic_call_inout_holo_dark.png b/res/drawable-xhdpi/ic_call_inout_holo_dark.png new file mode 100644 index 000000000..f133f164c Binary files /dev/null and b/res/drawable-xhdpi/ic_call_inout_holo_dark.png differ diff --git a/res/layout/call_stats_detail.xml b/res/layout/call_stats_detail.xml new file mode 100644 index 000000000..656218ff2 --- /dev/null +++ b/res/layout/call_stats_detail.xml @@ -0,0 +1,225 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/call_stats_detail_info.xml b/res/layout/call_stats_detail_info.xml new file mode 100644 index 000000000..70716700a --- /dev/null +++ b/res/layout/call_stats_detail_info.xml @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/call_stats_fragment.xml b/res/layout/call_stats_fragment.xml new file mode 100644 index 000000000..df121335b --- /dev/null +++ b/res/layout/call_stats_fragment.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/call_stats_list_item.xml b/res/layout/call_stats_list_item.xml new file mode 100644 index 000000000..29d584daf --- /dev/null +++ b/res/layout/call_stats_list_item.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/call_stats_nav_item.xml b/res/layout/call_stats_nav_item.xml new file mode 100644 index 000000000..c6852b888 --- /dev/null +++ b/res/layout/call_stats_nav_item.xml @@ -0,0 +1,39 @@ + + + + + + + + + diff --git a/res/layout/double_date_picker_dialog.xml b/res/layout/double_date_picker_dialog.xml new file mode 100644 index 000000000..ec4a1f25b --- /dev/null +++ b/res/layout/double_date_picker_dialog.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + diff --git a/res/menu/call_stats_details_options.xml b/res/menu/call_stats_details_options.xml new file mode 100644 index 000000000..c16db3315 --- /dev/null +++ b/res/menu/call_stats_details_options.xml @@ -0,0 +1,27 @@ + + + + + + diff --git a/res/menu/call_stats_options.xml b/res/menu/call_stats_options.xml new file mode 100644 index 000000000..6cc9ec0d0 --- /dev/null +++ b/res/menu/call_stats_options.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + diff --git a/res/values/attrs.xml b/res/values/attrs.xml index 23f639fd2..5ea16f98b 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -33,4 +33,21 @@ + + + + + + + + + + + + + + + + + diff --git a/res/values/cm_arrays.xml b/res/values/cm_arrays.xml new file mode 100644 index 000000000..4a58561dd --- /dev/null +++ b/res/values/cm_arrays.xml @@ -0,0 +1,42 @@ + + + + + @string/call_stats_nav_all + @string/call_stats_nav_incoming + @string/call_stats_nav_outgoing + @string/call_stats_nav_missed + + + + + + %3$s + %2$s + %2$s %3$s + %1$s + %1$s %3$s + %1$s %2$s + %1$s %2$s %3$s + + diff --git a/res/values/cm_plurals.xml b/res/values/cm_plurals.xml new file mode 100644 index 000000000..0c4b4d21a --- /dev/null +++ b/res/values/cm_plurals.xml @@ -0,0 +1,35 @@ + + + + + 1 hr + %d hrs + + + 1 min + %d mins + + + 1 sec + %d secs + + + + 1 call + %d calls + + diff --git a/res/values/cm_strings.xml b/res/values/cm_strings.xml index 6c0a5596a..ef1068ef6 100644 --- a/res/values/cm_strings.xml +++ b/res/values/cm_strings.xml @@ -18,4 +18,41 @@ Nearby places People + Statistics + Call stat details + + Call statistics + Refresh + Call statistics + + All + Incoming + Outgoing + Missed + Incoming: %d%% + Outgoing: %d%% + Missed + Missed: %d%% + Total: %s, %s + Total: %s + Start date + End date + Filter range + + Quick selection + Current month + Current quarter + Current year + Last week + Last month + Last quarter + Last year + + Adjust time range + Reset time range + Sort by call duration + Sort by call count + + Add to blacklist + %s added to blacklist. diff --git a/res/values/colors.xml b/res/values/colors.xml index 863bfe9d4..83b2b8f85 100644 --- a/res/values/colors.xml +++ b/res/values/colors.xml @@ -101,4 +101,12 @@ #80ffffff + + #777777 + + + #33b5e5 + #99cc00 + #eb1313 + #88888888 diff --git a/res/values/dimens.xml b/res/values/dimens.xml index 383a8faa6..3d50ed728 100644 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -31,6 +31,8 @@ 24dip 2dp 56dip + 64dip + 24dip 40dp @@ -141,4 +143,7 @@ 16dp 16dp 4dp + + + 140dip diff --git a/src/com/android/dialer/CallDetailActivity.java b/src/com/android/dialer/CallDetailActivity.java index eb4af634a..2d332851c 100755 --- a/src/com/android/dialer/CallDetailActivity.java +++ b/src/com/android/dialer/CallDetailActivity.java @@ -92,9 +92,6 @@ public class CallDetailActivity extends AnalyticsActivity implements ProximitySe private static final int LOADER_ID = 0; private static final String BUNDLE_CONTACT_URI_EXTRA = "contact_uri_extra"; - private static final char LEFT_TO_RIGHT_EMBEDDING = '\u202A'; - private static final char POP_DIRECTIONAL_FORMATTING = '\u202C'; - /** The time to wait before enabling the blank the screen due to the proximity sensor. */ private static final long PROXIMITY_BLANK_DELAY_MILLIS = 100; /** The time to wait before disabling the blank the screen due to the proximity sensor. */ @@ -119,6 +116,7 @@ public class CallDetailActivity extends AnalyticsActivity implements ProximitySe public static final String VOICEMAIL_FRAGMENT_TAG = "voicemail_fragment"; + private CallDetailHeader mCallDetailHeader; private CallTypeHelper mCallTypeHelper; private PhoneNumberDisplayHelper mPhoneNumberHelper; private QuickContactBadge mQuickContactBadge; @@ -133,8 +131,6 @@ public class CallDetailActivity extends AnalyticsActivity implements ProximitySe /* package */ LayoutInflater mInflater; /* package */ Resources mResources; - /** Helper to load contact photos. */ - private ContactPhotoManager mContactPhotoManager; /** Helper to make async queries to content resolver. */ private CallDetailActivityQueryHandler mAsyncQueryHandler; /** Helper to get voicemail status messages. */ @@ -259,6 +255,7 @@ public class CallDetailActivity extends AnalyticsActivity implements ProximitySe mCallTypeHelper = new CallTypeHelper(getResources()); mPhoneNumberHelper = new PhoneNumberDisplayHelper(mResources); + mCallDetailHeader = new CallDetailHeader(this, mPhoneNumberHelper); mVoicemailStatusHelper = new VoicemailStatusHelperImpl(); mAsyncQueryHandler = new CallDetailActivityQueryHandler(this); @@ -270,7 +267,6 @@ public class CallDetailActivity extends AnalyticsActivity implements ProximitySe mCallerNumber = (TextView) findViewById(R.id.caller_number); mAccountLabel = (TextView) findViewById(R.id.phone_account_label); mDefaultCountryIso = GeoUtil.getCurrentCountryIso(this); - mContactPhotoManager = ContactPhotoManager.getInstance(this); mProximitySensorManager = new ProximitySensorManager(this, mProximitySensorListener); mContactInfoHelper = new ContactInfoHelper(this, GeoUtil.getCurrentCountryIso(this)); getActionBar().setDisplayHomeAsUpEnabled(true); @@ -393,18 +389,8 @@ public class CallDetailActivity extends AnalyticsActivity implements ProximitySe @Override public boolean onKeyDown(int keyCode, KeyEvent event) { - switch (keyCode) { - case KeyEvent.KEYCODE_CALL: { - // Make sure phone isn't already busy before starting direct call - TelephonyManager tm = (TelephonyManager) - getSystemService(Context.TELEPHONY_SERVICE); - if (tm.getCallState() == TelephonyManager.CALL_STATE_IDLE) { - DialerUtils.startActivityWithErrorToast(this, - CallUtil.getCallIntent(Uri.fromParts(PhoneAccount.SCHEME_TEL, mNumber, - null)), R.string.call_not_available); - return true; - } - } + if (mCallDetailHeader.handleKeyDown(keyCode, event)) { + return true; } return super.onKeyDown(keyCode, event); @@ -450,8 +436,8 @@ public class CallDetailActivity extends AnalyticsActivity implements ProximitySe PhoneCallDetails firstDetails = details[0]; mNumber = firstDetails.number.toString(); final int numberPresentation = firstDetails.numberPresentation; - final Uri contactUri = firstDetails.contactUri; - final Uri photoUri = firstDetails.photoUri; + // Set the details header, based on the first phone call. + mCallDetailHeader.updateViews(mNumber, numberPresentation, firstDetails); // Cache the details about the phone number. final boolean canPlaceCallsTo = @@ -460,38 +446,7 @@ public class CallDetailActivity extends AnalyticsActivity implements ProximitySe final boolean isVoicemailNumber = phoneUtils.isVoicemailNumber(mNumber); final boolean isSipNumber = phoneUtils.isSipNumber(mNumber); - final CharSequence callLocationOrType = getNumberTypeOrLocation(firstDetails); - - final CharSequence displayNumber = mPhoneNumberHelper.getDisplayNumber( - firstDetails.number, - firstDetails.numberPresentation, - firstDetails.formattedNumber); - final String displayNumberStr = mBidiFormatter.unicodeWrap( - displayNumber.toString(), TextDirectionHeuristics.LTR); - - - if (!TextUtils.isEmpty(firstDetails.name)) { - mCallerName.setText(firstDetails.name); - mCallerNumber.setText(callLocationOrType + " " + displayNumberStr); - } else { - mCallerName.setText(displayNumberStr); - if (!TextUtils.isEmpty(callLocationOrType)) { - mCallerNumber.setText(callLocationOrType); - mCallerNumber.setVisibility(View.VISIBLE); - } else { - mCallerNumber.setVisibility(View.GONE); - } - } - - if (!TextUtils.isEmpty(firstDetails.accountLabel)) { - mAccountLabel.setText(firstDetails.accountLabel); - mAccountLabel.setVisibility(View.VISIBLE); - } else { - mAccountLabel.setVisibility(View.GONE); - } - - mHasEditNumberBeforeCallOption = - canPlaceCallsTo && !isSipNumber && !isVoicemailNumber; + mHasEditNumberBeforeCallOption = mCallDetailHeader.canEditNumberBeforeCall(); mHasTrashOption = hasVoicemail(); mHasRemoveFromCallLogOption = !hasVoicemail(); invalidateOptionsMenu(); @@ -500,33 +455,7 @@ public class CallDetailActivity extends AnalyticsActivity implements ProximitySe historyList.setAdapter( new CallDetailHistoryAdapter(CallDetailActivity.this, mInflater, mCallTypeHelper, details)); - - String lookupKey = contactUri == null ? null - : ContactInfoHelper.getLookupKeyFromUri(contactUri); - - final boolean isBusiness = mContactInfoHelper.isBusiness(firstDetails.sourceType); - - final int contactType = - isVoicemailNumber? ContactPhotoManager.TYPE_VOICEMAIL : - isBusiness ? ContactPhotoManager.TYPE_BUSINESS : - ContactPhotoManager.TYPE_DEFAULT; - - String nameForDefaultImage; - if (TextUtils.isEmpty(firstDetails.name)) { - nameForDefaultImage = mPhoneNumberHelper.getDisplayNumber(firstDetails.number, - firstDetails.numberPresentation, - firstDetails.formattedNumber).toString(); - } else { - nameForDefaultImage = firstDetails.name.toString(); - } - - if (hasVoicemail() && !TextUtils.isEmpty(firstDetails.transcription)) { - mVoicemailTranscription.setText(firstDetails.transcription); - mVoicemailTranscription.setVisibility(View.VISIBLE); - } - - loadContactPhotos( - contactUri, photoUri, nameForDefaultImage, lookupKey, contactType); + mCallDetailHeader.loadContactPhotos(firstDetails.photoUri); findViewById(R.id.call_detail).setVisibility(View.VISIBLE); } @@ -629,68 +558,6 @@ public class CallDetailActivity extends AnalyticsActivity implements ProximitySe } } - /** Load the contact photos and places them in the corresponding views. */ - private void loadContactPhotos(Uri contactUri, Uri photoUri, String displayName, - String lookupKey, int contactType) { - - Account contactAccount = null; - if (contactUri != null) { - ContentResolver resolver = getContentResolver(); - Uri uri = Contacts.lookupContact(resolver, contactUri); - if (uri != null) { - Cursor cursor = resolver.query( - uri, - new String[] { RawContacts.ACCOUNT_TYPE, RawContacts.ACCOUNT_NAME }, - null, null, null); - if (cursor != null && cursor.moveToFirst()) { - String accountType = cursor.getString(0); - String accountName = cursor.getString(1); - if (!TextUtils.isEmpty(accountName) && !TextUtils.isEmpty(accountType)) { - contactAccount = new Account(accountName, accountType); - } - cursor.close(); - } - } - } - - final DefaultImageRequest request = new DefaultImageRequest(displayName, lookupKey, - contactType, true /* isCircular */); - - mQuickContactBadge.assignContactUri(contactUri); - mQuickContactBadge.setContentDescription( - mResources.getString(R.string.description_contact_details, displayName)); - - mContactPhotoManager.loadDirectoryPhoto(mQuickContactBadge, photoUri, - contactAccount, false /* darkTheme */, true /* isCircular */, request); - } - - static final class ViewEntry { - public final String text; - public final Intent primaryIntent; - /** The description for accessibility of the primary action. */ - public final String primaryDescription; - - public CharSequence label = null; - /** Icon for the secondary action. */ - public int secondaryIcon = 0; - /** Intent for the secondary action. If not null, an icon must be defined. */ - public Intent secondaryIntent = null; - /** The description for accessibility of the secondary action. */ - public String secondaryDescription = null; - - public ViewEntry(String text, Intent intent, String description) { - this.text = text; - primaryIntent = intent; - primaryDescription = description; - } - - public void setSecondaryAction(int icon, Intent intent, String description) { - secondaryIcon = icon; - secondaryIntent = intent; - secondaryDescription = description; - } - } - protected void updateVoicemailStatusMessage(Cursor statusCursor) { if (statusCursor == null) { mStatusMessageView.setVisibility(View.GONE); @@ -856,13 +723,4 @@ public class CallDetailActivity extends AnalyticsActivity implements ProximitySe private void closeSystemDialogs() { sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)); } - - /** Returns the given text, forced to be left-to-right. */ - private static CharSequence forceLeftToRight(CharSequence text) { - StringBuilder sb = new StringBuilder(); - sb.append(LEFT_TO_RIGHT_EMBEDDING); - sb.append(text); - sb.append(POP_DIRECTIONAL_FORMATTING); - return sb.toString(); - } } diff --git a/src/com/android/dialer/CallDetailHeader.java b/src/com/android/dialer/CallDetailHeader.java new file mode 100644 index 000000000..70ef60ae2 --- /dev/null +++ b/src/com/android/dialer/CallDetailHeader.java @@ -0,0 +1,426 @@ +/* + * Copyright (C) 2009 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; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.provider.Contacts.Intents.Insert; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.Contacts; +import android.telecom.PhoneAccount; +import android.telephony.PhoneNumberUtils; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.view.ActionMode; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.contacts.common.CallUtil; +import com.android.contacts.common.ClipboardUtils; +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.format.FormatUtils; +import com.android.contacts.common.util.Constants; +import com.android.dialer.calllog.PhoneNumberDisplayHelper; +import com.android.dialer.calllog.PhoneNumberUtilsWrapper; + +public class CallDetailHeader { + private static final char LEFT_TO_RIGHT_EMBEDDING = '\u202A'; + private static final char POP_DIRECTIONAL_FORMATTING = '\u202C'; + + private Activity mActivity; + private Resources mResources; + private PhoneNumberDisplayHelper mPhoneNumberHelper; + private ContactPhotoManager mContactPhotoManager; + + private String mNumber; + + private TextView mHeaderTextView; + private View mHeaderOverlayView; + private ImageView mMainActionView; + private ImageButton mMainActionPushLayerView; + private ImageView mContactBackgroundView; + + private ActionMode mPhoneNumberActionMode; + private boolean mHasEditNumberBeforeCallOption; + private boolean mCanPlaceCallsTo; + + private CharSequence mPhoneNumberLabelToCopy; + private CharSequence mPhoneNumberToCopy; + + public interface Data { + CharSequence getName(); + CharSequence getNumber(); + int getNumberPresentation(); + int getNumberType(); + CharSequence getNumberLabel(); + CharSequence getFormattedNumber(); + Uri getContactUri(); + } + + private final View.OnClickListener mPrimaryActionListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + if (finishPhoneNumerSelectedActionModeIfShown()) { + return; + } + mActivity.startActivity(((ViewEntry) view.getTag()).primaryIntent); + } + }; + + private final View.OnClickListener mSecondaryActionListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + if (finishPhoneNumerSelectedActionModeIfShown()) { + return; + } + mActivity.startActivity(((ViewEntry) view.getTag()).secondaryIntent); + } + }; + + private final View.OnLongClickListener mPrimaryLongClickListener = + new View.OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + if (finishPhoneNumerSelectedActionModeIfShown()) { + return true; + } + startPhoneNumberSelectedActionMode(v); + return true; + } + }; + + public CallDetailHeader(Activity activity, PhoneNumberDisplayHelper phoneNumberHelper) { + mActivity = activity; + mResources = activity.getResources(); + mPhoneNumberHelper = phoneNumberHelper; + mContactPhotoManager = ContactPhotoManager.getInstance(activity); + + mHeaderTextView = (TextView) activity.findViewById(R.id.header_text); + mHeaderOverlayView = activity.findViewById(R.id.photo_text_bar); + mMainActionView = (ImageView) activity.findViewById(R.id.main_action); + mMainActionPushLayerView = (ImageButton) activity.findViewById(R.id.main_action_push_layer); + mContactBackgroundView = (ImageView) activity.findViewById(R.id.contact_background); + } + + /** + * If the phone number is selected, unselect it and return {@code true}. + * Otherwise, just {@code false}. + */ + private boolean finishPhoneNumerSelectedActionModeIfShown() { + if (mPhoneNumberActionMode == null) return false; + mPhoneNumberActionMode.finish(); + return true; + } + + private void startPhoneNumberSelectedActionMode(View targetView) { + mPhoneNumberActionMode = + mActivity.startActionMode(new PhoneNumberActionModeCallback(targetView)); + } + + private class PhoneNumberActionModeCallback implements ActionMode.Callback { + private final View mTargetView; + private final Drawable mOriginalViewBackground; + + public PhoneNumberActionModeCallback(View targetView) { + mTargetView = targetView; + + // Highlight the phone number view. Remember the old background, and put a new one. + mOriginalViewBackground = mTargetView.getBackground(); + mTargetView.setBackgroundColor(mResources.getColor(R.color.item_selected)); + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + if (TextUtils.isEmpty(mPhoneNumberToCopy)) return false; + + mActivity.getMenuInflater().inflate(R.menu.call_details_cab, menu); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + switch (item.getItemId()) { + case R.id.copy_phone_number: + ClipboardUtils.copyText(mActivity, mPhoneNumberLabelToCopy, + mPhoneNumberToCopy, true); + mode.finish(); // Close the CAB + return true; + } + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + mPhoneNumberActionMode = null; + + // Restore the view background. + mTargetView.setBackground(mOriginalViewBackground); + } + } + + public void updateViews(String number, int numberPresentation, Data data) { + // Cache the details about the phone number. + final PhoneNumberUtilsWrapper phoneUtils = new PhoneNumberUtilsWrapper(); + final boolean isVoicemailNumber = phoneUtils.isVoicemailNumber(number); + final boolean isSipNumber = phoneUtils.isSipNumber(number); + + final CharSequence dataName = data.getName(); + final CharSequence dataNumber = data.getNumber(); + final Uri contactUri = data.getContactUri(); + + mNumber = number; + mCanPlaceCallsTo = PhoneNumberUtilsWrapper.canPlaceCallsTo(number, numberPresentation); + + // Let user view contact details if they exist, otherwise add option to create new + // contact from this number. + final Intent mainActionIntent; + final int mainActionIcon; + final String mainActionDescription; + + final CharSequence nameOrNumber; + if (!TextUtils.isEmpty(dataName)) { + nameOrNumber = dataName; + } else { + nameOrNumber = dataNumber; + } + + if (contactUri != null) { + mainActionIntent = new Intent(Intent.ACTION_VIEW, contactUri); + // This will launch People's detail contact screen, so we probably want to + // treat it as a separate People task. + mainActionIntent.setFlags( + Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + mainActionIcon = R.drawable.ic_contacts_holo_dark; + mainActionDescription = + mResources.getString(R.string.description_view_contact, nameOrNumber); + } else if (isVoicemailNumber) { + mainActionIntent = null; + mainActionIcon = 0; + mainActionDescription = null; + } else if (isSipNumber) { + // TODO: This item is currently disabled for SIP addresses, because + // the Insert.PHONE extra only works correctly for PSTN numbers. + // + // To fix this for SIP addresses, we need to: + // - define ContactsContract.Intents.Insert.SIP_ADDRESS, and use it here if + // the current number is a SIP address + // - update the contacts UI code to handle Insert.SIP_ADDRESS by + // updating the SipAddress field + // and then we can remove the "!isSipNumber" check above. + mainActionIntent = null; + mainActionIcon = 0; + mainActionDescription = null; + } else if (mCanPlaceCallsTo) { + mainActionIntent = new Intent(Intent.ACTION_INSERT_OR_EDIT); + mainActionIntent.setType(Contacts.CONTENT_ITEM_TYPE); + mainActionIntent.putExtra(Insert.PHONE, number); + mainActionIcon = R.drawable.ic_add_contact_holo_dark; + mainActionDescription = mResources.getString(R.string.description_add_contact); + } else { + // If we cannot call the number, when we probably cannot add it as a contact either. + // This is usually the case of private, unknown, or payphone numbers. + mainActionIntent = null; + mainActionIcon = 0; + mainActionDescription = null; + } + + if (mainActionIntent == null) { + mMainActionView.setVisibility(View.INVISIBLE); + mMainActionPushLayerView.setVisibility(View.GONE); + mHeaderTextView.setVisibility(View.INVISIBLE); + mHeaderOverlayView.setVisibility(View.INVISIBLE); + } else { + mMainActionView.setVisibility(View.VISIBLE); + mMainActionView.setImageResource(mainActionIcon); + mMainActionPushLayerView.setVisibility(View.VISIBLE); + mMainActionPushLayerView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + mActivity.startActivity(mainActionIntent); + } + }); + mMainActionPushLayerView.setContentDescription(mainActionDescription); + mHeaderTextView.setVisibility(View.VISIBLE); + mHeaderOverlayView.setVisibility(View.VISIBLE); + } + + // This action allows to call the number that places the call. + if (mCanPlaceCallsTo) { + final CharSequence displayNumber = + mPhoneNumberHelper.getDisplayNumber( + dataNumber, data.getNumberPresentation(), data.getFormattedNumber()); + + ViewEntry entry = new ViewEntry( + mResources.getString(R.string.menu_callNumber, + forceLeftToRight(displayNumber)), + CallUtil.getCallIntent(number), + mResources.getString(R.string.description_call, nameOrNumber)); + + // Only show a label if the number is shown and it is not a SIP address. + if (!TextUtils.isEmpty(dataName) + && !TextUtils.isEmpty(dataNumber) + && !PhoneNumberUtils.isUriNumber(dataNumber.toString())) { + entry.label = Phone.getTypeLabel(mResources, data.getNumberType(), + data.getNumberLabel()); + } + + // The secondary action allows to send an SMS to the number that placed the + // call. + if (phoneUtils.canSendSmsTo(number, numberPresentation)) { + entry.setSecondaryAction( + R.drawable.ic_text_holo_light, + new Intent(Intent.ACTION_SENDTO, + Uri.fromParts("sms", number, null)), + mResources.getString(R.string.description_send_text_message, nameOrNumber)); + } + + configureCallButton(entry); + mPhoneNumberToCopy = displayNumber; + mPhoneNumberLabelToCopy = entry.label; + } else { + disableCallButton(); + mPhoneNumberToCopy = null; + mPhoneNumberLabelToCopy = null; + } + + mHasEditNumberBeforeCallOption = + mCanPlaceCallsTo && !isSipNumber && !isVoicemailNumber; + } + + /** Load the contact photos and places them in the corresponding views. */ + public void loadContactPhotos(Uri photoUri) { + mContactPhotoManager.loadPhoto(mContactBackgroundView, photoUri, + null, mContactBackgroundView.getWidth(), false, true, null); + } + + public boolean canEditNumberBeforeCall() { + return mHasEditNumberBeforeCallOption; + } + + public boolean canPlaceCallsTo() { + return mCanPlaceCallsTo; + } + + static final class ViewEntry { + public final String text; + public final Intent primaryIntent; + /** The description for accessibility of the primary action. */ + public final String primaryDescription; + + public CharSequence label = null; + /** Icon for the secondary action. */ + public int secondaryIcon = 0; + /** Intent for the secondary action. If not null, an icon must be defined. */ + public Intent secondaryIntent = null; + /** The description for accessibility of the secondary action. */ + public String secondaryDescription = null; + + public ViewEntry(String text, Intent intent, String description) { + this.text = text; + primaryIntent = intent; + primaryDescription = description; + } + + public void setSecondaryAction(int icon, Intent intent, String description) { + secondaryIcon = icon; + secondaryIntent = intent; + secondaryDescription = description; + } + } + + /** Disables the call button area, e.g., for private numbers. */ + private void disableCallButton() { + mActivity.findViewById(R.id.call_and_sms).setVisibility(View.GONE); + } + + /** Configures the call button area using the given entry. */ + private void configureCallButton(ViewEntry entry) { + View convertView = mActivity.findViewById(R.id.call_and_sms); + convertView.setVisibility(View.VISIBLE); + + ImageView icon = (ImageView) convertView.findViewById(R.id.call_and_sms_icon); + View divider = convertView.findViewById(R.id.call_and_sms_divider); + TextView text = (TextView) convertView.findViewById(R.id.call_and_sms_text); + + View mainAction = convertView.findViewById(R.id.call_and_sms_main_action); + mainAction.setOnClickListener(mPrimaryActionListener); + mainAction.setTag(entry); + mainAction.setContentDescription(entry.primaryDescription); + mainAction.setOnLongClickListener(mPrimaryLongClickListener); + + if (entry.secondaryIntent != null) { + icon.setOnClickListener(mSecondaryActionListener); + icon.setImageResource(entry.secondaryIcon); + icon.setVisibility(View.VISIBLE); + icon.setTag(entry); + icon.setContentDescription(entry.secondaryDescription); + divider.setVisibility(View.VISIBLE); + } else { + icon.setVisibility(View.GONE); + divider.setVisibility(View.GONE); + } + text.setText(entry.text); + + TextView label = (TextView) convertView.findViewById(R.id.call_and_sms_label); + if (TextUtils.isEmpty(entry.label)) { + label.setVisibility(View.GONE); + } else { + label.setText(entry.label); + label.setVisibility(View.VISIBLE); + } + } + + public boolean handleKeyDown(int keyCode, KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_CALL: { + // Make sure phone isn't already busy before starting direct call + TelephonyManager tm = (TelephonyManager) + mActivity.getSystemService(Context.TELEPHONY_SERVICE); + if (tm.getCallState() == TelephonyManager.CALL_STATE_IDLE) { + mActivity.startActivity(CallUtil.getCallIntent( + Uri.fromParts(PhoneAccount.SCHEME_TEL, mNumber, null))); + return true; + } + } + } + + return false; + } + + /** Returns the given text, forced to be left-to-right. */ + private static CharSequence forceLeftToRight(CharSequence text) { + StringBuilder sb = new StringBuilder(); + sb.append(LEFT_TO_RIGHT_EMBEDDING); + sb.append(text); + sb.append(POP_DIRECTIONAL_FORMATTING); + return sb.toString(); + } +} diff --git a/src/com/android/dialer/PhoneCallDetails.java b/src/com/android/dialer/PhoneCallDetails.java index 2de5f5f32..6bc75a5d7 100755 --- a/src/com/android/dialer/PhoneCallDetails.java +++ b/src/com/android/dialer/PhoneCallDetails.java @@ -26,7 +26,7 @@ import android.provider.ContactsContract.CommonDataKinds.Phone; /** * The details of a phone call to be shown in the UI. */ -public class PhoneCallDetails { +public class PhoneCallDetails implements CallDetailHeader.Data { /** The number of the other party involved in the call. */ public final CharSequence number; /** The number presenting rules set by the network, e.g., {@link Calls#PRESENTATION_ALLOWED} */ @@ -154,4 +154,33 @@ public class PhoneCallDetails { this.transcription = transcription; this.durationType = durationType; } + + @Override + public CharSequence getName() { + return name; + } + @Override + public CharSequence getNumber() { + return number; + } + @Override + public int getNumberPresentation() { + return numberPresentation; + } + @Override + public int getNumberType() { + return numberType; + } + @Override + public CharSequence getNumberLabel() { + return numberLabel; + } + @Override + public CharSequence getFormattedNumber() { + return formattedNumber; + } + @Override + public Uri getContactUri() { + return contactUri; + } } diff --git a/src/com/android/dialer/calllog/CallLogActivity.java b/src/com/android/dialer/calllog/CallLogActivity.java index d6ee030e5..10a77e10d 100755 --- a/src/com/android/dialer/calllog/CallLogActivity.java +++ b/src/com/android/dialer/calllog/CallLogActivity.java @@ -11,7 +11,7 @@ * 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. +m * limitations under the License. */ package com.android.dialer.calllog; @@ -50,14 +50,19 @@ import com.android.dialer.R; import com.android.dialer.voicemail.VoicemailStatusHelper; import com.android.dialer.voicemail.VoicemailStatusHelperImpl; import com.android.dialerbind.analytics.AnalyticsActivity; +import com.android.dialer.calllog.CallLogFragment; +import com.android.dialer.callstats.CallStatsFragment; +import com.android.dialer.widget.DoubleDatePickerDialog; -public class CallLogActivity extends AnalyticsActivity implements CallLogQueryHandler.Listener { +public class CallLogActivity extends AnalyticsActivity implements CallLogQueryHandler.Listener, + DoubleDatePickerDialog.OnDateSetListener { private Handler mHandler; private ViewPager mViewPager; private ViewPagerTabs mViewPagerTabs; private FragmentPagerAdapter mViewPagerAdapter; private CallLogFragment mAllCallsFragment; private CallLogFragment mMissedCallsFragment; + private CallStatsFragment mStatsFragment; private CallLogFragment mVoicemailFragment; private VoicemailStatusHelper mVoicemailStatusHelper; @@ -72,10 +77,11 @@ public class CallLogActivity extends AnalyticsActivity implements CallLogQueryHa private static final int TAB_INDEX_ALL = 0; private static final int TAB_INDEX_MISSED = 1; - private static final int TAB_INDEX_VOICEMAIL = 2; + private static final int TAB_INDEX_STATS = 2; + private static final int TAB_INDEX_VOICEMAIL = 3; - private static final int TAB_INDEX_COUNT_DEFAULT = 2; - private static final int TAB_INDEX_COUNT_WITH_VOICEMAIL = 3; + private static final int TAB_INDEX_COUNT_DEFAULT = 3; + private static final int TAB_INDEX_COUNT_WITH_VOICEMAIL = 4; private boolean mHasActiveVoicemailProvider; @@ -108,6 +114,9 @@ public class CallLogActivity extends AnalyticsActivity implements CallLogQueryHa case TAB_INDEX_VOICEMAIL: mVoicemailFragment = new CallLogFragment(Calls.VOICEMAIL_TYPE); return mVoicemailFragment; + case TAB_INDEX_STATS: + mStatsFragment = new CallStatsFragment(); + return mStatsFragment; } throw new IllegalStateException("No fragment at position " + position); } @@ -188,7 +197,8 @@ public class CallLogActivity extends AnalyticsActivity implements CallLogQueryHa mTabTitles = new String[TAB_INDEX_COUNT_WITH_VOICEMAIL]; mTabTitles[0] = getString(R.string.call_log_all_title); mTabTitles[1] = getString(R.string.call_log_missed_title); - mTabTitles[2] = getString(R.string.call_log_voicemail_title); + mTabTitles[2] = getString(R.string.call_log_stats_title); + mTabTitles[3] = getString(R.string.call_log_voicemail_title); mViewPager = (ViewPager) findViewById(R.id.call_log_pager); @@ -250,7 +260,6 @@ public class CallLogActivity extends AnalyticsActivity implements CallLogQueryHa mViewPagerAdapter = new MSimViewPagerAdapter(getFragmentManager()); mViewPager.setAdapter(mViewPagerAdapter); - mViewPager.setOffscreenPageLimit(1); } @Override @@ -403,6 +412,8 @@ public class CallLogActivity extends AnalyticsActivity implements CallLogQueryHa return mMissedCallsFragment; case TAB_INDEX_VOICEMAIL: return mVoicemailFragment; + case TAB_INDEX_STATS: + return mStatsFragment; default: throw new IllegalStateException("Unknown fragment index: " + position); @@ -550,4 +561,9 @@ public class CallLogActivity extends AnalyticsActivity implements CallLogQueryHa mSearchView.clearFocus(); mInSearchUi = false; } + + @Override + public void onDateSet(long from, long to) { + mStatsFragment.onDateSet(from, to); + } } diff --git a/src/com/android/dialer/calllog/CallLogAdapter.java b/src/com/android/dialer/calllog/CallLogAdapter.java index fcce7736c..5fcb51638 100755 --- a/src/com/android/dialer/calllog/CallLogAdapter.java +++ b/src/com/android/dialer/calllog/CallLogAdapter.java @@ -24,8 +24,6 @@ import android.content.res.Resources; import android.database.Cursor; import android.graphics.drawable.Drawable; import android.net.Uri; -import android.os.Handler; -import android.os.Message; import android.provider.CallLog.Calls; import android.provider.ContactsContract.PhoneLookup; import android.telecom.PhoneAccountHandle; @@ -53,6 +51,7 @@ import com.android.dialer.R; import com.android.dialer.util.DialerUtils; import com.android.dialer.util.ExpirableCache; +import com.android.dialer.calllog.CallLogAdapterHelper.NumberWithCountryIso; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Objects; @@ -63,7 +62,7 @@ import java.util.LinkedList; * Adapter class to fill in data for the Call Log. */ public class CallLogAdapter extends GroupingListAdapter - implements ViewTreeObserver.OnPreDrawListener, CallLogGroupBuilder.GroupCreator { + implements CallLogAdapterHelper.Callback, CallLogGroupBuilder.GroupCreator { private static final int VOICEMAIL_TRANSCRIPTION_MAX_LINES = 10; @@ -100,43 +99,6 @@ public class CallLogAdapter extends GroupingListAdapter 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; @@ -145,20 +107,9 @@ public class CallLogAdapter extends GroupingListAdapter private final CallFetcher mCallFetcher; private final Toast mReportedToast; private final OnReportButtonClickListener mOnReportButtonClickListener; - private ViewTreeObserver mViewTreeObserver = null; private String mFilterString; - /** - * 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 mContactInfoCache; - /** * Tracks the call log row which was previously expanded. Used so that the closure of a * previously expanded call log entry can be animated on rebind. @@ -184,65 +135,7 @@ public class CallLogAdapter extends GroupingListAdapter */ private HashMap mDayGroups = new HashMap(); - /** - * A request for contact details for the given number. - */ - private static final class ContactInfoRequest { - /** The number to look-up. */ - public final String number; - /** The country in which a call to or from this number was placed or received. */ - public final String countryIso; - /** The cached contact information stored in the call log. */ - public final ContactInfo callLogInfo; - - public ContactInfoRequest(String number, String countryIso, ContactInfo callLogInfo) { - this.number = number; - this.countryIso = countryIso; - this.callLogInfo = callLogInfo; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (obj == null) return false; - if (!(obj instanceof ContactInfoRequest)) return false; - - ContactInfoRequest other = (ContactInfoRequest) obj; - - if (!TextUtils.equals(number, other.number)) return false; - if (!TextUtils.equals(countryIso, other.countryIso)) return false; - if (!Objects.equal(callLogInfo, other.callLogInfo)) return false; - - return true; - } - - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + ((callLogInfo == null) ? 0 : callLogInfo.hashCode()); - result = prime * result + ((countryIso == null) ? 0 : countryIso.hashCode()); - result = prime * result + ((number == null) ? 0 : number.hashCode()); - return result; - } - } - - /** - * List of requests to update contact details. - *

- * 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 mRequests; - private boolean mLoading = true; - private static final int REDRAW = 1; - private static final int START_THREAD = 2; - - private QueryThread mCallerIdThread; /** Instance of helper class for managing views. */ private final CallLogListItemHelper mCallLogViewsHelper; @@ -256,8 +149,7 @@ public class CallLogAdapter extends GroupingListAdapter private CallItemExpandedListener mCallItemExpandedListener; - /** Can be set to true by tests to disable processing of requests. */ - private volatile boolean mRequestProcessingDisabled = false; + private final CallLogAdapterHelper mAdapterHelper; private boolean mIsCallLog = true; @@ -315,34 +207,6 @@ public class CallLogAdapter extends GroupingListAdapter } } - @Override - public boolean onPreDraw() { - // We only wanted to listen for the first draw (and this is it). - unregisterPreDrawListener(); - - // Only schedule a thread-creation message if the thread hasn't been - // created yet. This is purely an optimization, to queue fewer messages. - if (mCallerIdThread == null) { - mHandler.sendEmptyMessageDelayed(START_THREAD, START_PROCESSING_REQUESTS_DELAY_MILLIS); - } - - return true; - } - - private Handler mHandler = new Handler() { - @Override - public void handleMessage(Message msg) { - switch (msg.what) { - case REDRAW: - notifyDataSetChanged(); - break; - case START_THREAD: - startRequestProcessing(); - break; - } - } - }; - public CallLogAdapter(Context context, CallFetcher callFetcher, ContactInfoHelper contactInfoHelper, CallItemExpandedListener callItemExpandedListener, OnReportButtonClickListener onReportButtonClickListener, boolean isCallLog) { @@ -358,9 +222,6 @@ public class CallLogAdapter extends GroupingListAdapter mReportedToast = Toast.makeText(mContext, R.string.toast_caller_id_reported, Toast.LENGTH_SHORT); - mContactInfoCache = ExpirableCache.create(CONTACT_INFO_CACHE_SIZE); - mRequests = new LinkedList(); - Resources resources = mContext.getResources(); CallTypeHelper callTypeHelper = new CallTypeHelper(resources); mCallLogBackgroundColor = resources.getColor(R.color.background_dialer_list_items); @@ -369,6 +230,8 @@ public class CallLogAdapter extends GroupingListAdapter mContactPhotoManager = ContactPhotoManager.getInstance(mContext); mPhoneNumberHelper = new PhoneNumberDisplayHelper(resources); + mAdapterHelper = new CallLogAdapterHelper(context, this, + contactInfoHelper, mPhoneNumberHelper); PhoneCallDetailsHelper phoneCallDetailsHelper = new PhoneCallDetailsHelper( resources, callTypeHelper, new PhoneNumberUtilsWrapper()); mCallLogViewsHelper = @@ -399,177 +262,6 @@ public class CallLogAdapter extends GroupingListAdapter } } - /** - * Starts a background thread to process contact-lookup requests, unless one - * has already been started. - */ - private synchronized void startRequestProcessing() { - // For unit-testing. - if (mRequestProcessingDisabled) return; - - // Idempotence... if a thread is already started, don't start another. - if (mCallerIdThread != null) return; - - mCallerIdThread = new QueryThread(); - mCallerIdThread.setPriority(Thread.MIN_PRIORITY); - mCallerIdThread.start(); - } - - /** - * Stops the background thread that processes updates and cancels any - * pending requests to start it. - */ - public synchronized void stopRequestProcessing() { - // Remove any pending requests to start the processing thread. - mHandler.removeMessages(START_THREAD); - if (mCallerIdThread != null) { - // Stop the thread; we are finished with it. - mCallerIdThread.stopProcessing(); - mCallerIdThread.interrupt(); - mCallerIdThread = null; - } - } - - /** - * Stop receiving onPreDraw() notifications. - */ - private void unregisterPreDrawListener() { - if (mViewTreeObserver != null && mViewTreeObserver.isAlive()) { - mViewTreeObserver.removeOnPreDrawListener(this); - } - mViewTreeObserver = null; - } - - public void invalidateCache() { - mContactInfoCache.expireAll(); - - // Restart the request-processing thread after the next draw. - stopRequestProcessing(); - unregisterPreDrawListener(); - } - - /** - * Enqueues a request to look up the contact details for the given phone number. - *

- * 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); @@ -705,42 +397,8 @@ public class CallLogAdapter extends GroupingListAdapter } // Lookup contacts with this number - NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); - ExpirableCache.CachedValue cachedInfo = - mContactInfoCache.getCachedValue(numberCountryIso); - ContactInfo info = cachedInfo == null ? null : cachedInfo.getValue(); - if (!PhoneNumberUtilsWrapper.canPlaceCallsTo(number, numberPresentation) - || isVoicemailNumber) { - // If this is a number that cannot be dialed, there is no point in looking up a contact - // for it. - info = ContactInfo.EMPTY; - } else if (cachedInfo == null) { - mContactInfoCache.put(numberCountryIso, ContactInfo.EMPTY); - // Use the cached contact info from the call log. - info = cachedContactInfo; - // The db request should happen on a non-UI thread. - // Request the contact details immediately since they are currently missing. - enqueueRequest(number, countryIso, cachedContactInfo, true); - // We will format the phone number when we make the background request. - } else { - if (cachedInfo.isExpired()) { - // The contact info is no longer up to date, we should request it. However, we - // do not need to request them immediately. - enqueueRequest(number, countryIso, cachedContactInfo, false); - } else if (!callLogInfoMatches(cachedContactInfo, info)) { - // The call log information does not match the one we have, look it up again. - // We could simply update the call log directly, but that needs to be done in a - // background thread, so it is easier to simply request a new lookup, which will, as - // a side-effect, update the call log. - enqueueRequest(number, countryIso, cachedContactInfo, false); - } - - if (info == ContactInfo.EMPTY) { - // Use the cached contact info from the call log. - info = cachedContactInfo; - } - } - + final ContactInfo info = mAdapterHelper.lookupContact( + number, numberPresentation, countryIso, cachedContactInfo); final Uri lookupUri = info.lookupUri; final String name = info.name; final int ntype = info.type; @@ -818,10 +476,7 @@ public class CallLogAdapter extends GroupingListAdapter } // Listen for the first draw - if (mViewTreeObserver == null) { - mViewTreeObserver = view.getViewTreeObserver(); - mViewTreeObserver.addOnPreDrawListener(this); - } + mAdapterHelper.registerOnPreDrawListener(view); bindBadge(view, info, details, callType); } @@ -1095,17 +750,14 @@ public class CallLogAdapter extends GroupingListAdapter } } - /** Checks whether the contact info from the call log matches the one from the contacts db. */ - private boolean callLogInfoMatches(ContactInfo callLogInfo, ContactInfo info) { - // The call log only contains a subset of the fields in the contacts db. - // Only check those. - return TextUtils.equals(callLogInfo.name, info.name) - && callLogInfo.type == info.type - && TextUtils.equals(callLogInfo.label, info.label); + @Override + public void dataSetChanged() { + notifyDataSetChanged(); } /** Stores the updated contact info in the call log if it is different from the current one. */ - private void updateCallLogContactInfoCache(String number, String countryIso, + @Override + public void updateContactInfo(String number, String countryIso, ContactInfo updatedInfo, ContactInfo callLogInfo) { final ContentValues values = new ContentValues(); boolean needsUpdate = false; @@ -1268,13 +920,18 @@ public class CallLogAdapter extends GroupingListAdapter */ @VisibleForTesting void disableRequestProcessingForTest() { - mRequestProcessingDisabled = true; + mAdapterHelper.disableRequestProcessingForTest(); } @VisibleForTesting void injectContactInfoForTest(String number, String countryIso, ContactInfo contactInfo) { - NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); - mContactInfoCache.put(numberCountryIso, contactInfo); + mAdapterHelper.injectContactInfoForTest(number, countryIso, contactInfo); + } + + @VisibleForTesting + void enqueueRequest(String number, String countryIso, ContactInfo callLogInfo, + boolean immediate) { + mAdapterHelper.enqueueRequest(number, countryIso, callLogInfo, immediate); } @Override @@ -1303,46 +960,16 @@ public class CallLogAdapter extends GroupingListAdapter 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 void stopRequestProcessing() { + mAdapterHelper.stopRequestProcessing(); + } + + public void invalidateCache() { + mAdapterHelper.invalidateCache(); + } + 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; + return mAdapterHelper.getBetterNumberFromContacts(number, countryIso); } /** @@ -1382,7 +1009,6 @@ public class CallLogAdapter extends GroupingListAdapter } public void onBadDataReported(String number) { - mContactInfoCache.expireAll(); mReportedToast.show(); } diff --git a/src/com/android/dialer/calllog/CallLogAdapterHelper.java b/src/com/android/dialer/calllog/CallLogAdapterHelper.java new file mode 100644 index 000000000..a16935cfe --- /dev/null +++ b/src/com/android/dialer/calllog/CallLogAdapterHelper.java @@ -0,0 +1,474 @@ +/* + * 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.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Handler; +import android.os.Message; +import android.provider.ContactsContract.PhoneLookup; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewTreeObserver; + +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. + */ +public class CallLogAdapterHelper implements ViewTreeObserver.OnPreDrawListener { + public interface Callback { + void dataSetChanged(); + void updateContactInfo(String number, String countryIso, + ContactInfo updatedInfo, ContactInfo callLogInfo); + } + + /** + * 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. + */ + public 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()); + } + } + + /** + * A request for contact details for the given number. + */ + private static final class ContactInfoRequest { + /** The number to look-up. */ + public final String number; + /** The country in which a call to or from this number was placed or received. */ + public final String countryIso; + /** The cached contact information stored in the call log. */ + public final ContactInfo callLogInfo; + + public ContactInfoRequest(String number, String countryIso, ContactInfo callLogInfo) { + this.number = number; + this.countryIso = countryIso; + this.callLogInfo = callLogInfo; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (!(obj instanceof ContactInfoRequest)) return false; + + ContactInfoRequest other = (ContactInfoRequest) obj; + + if (!TextUtils.equals(number, other.number)) return false; + if (!TextUtils.equals(countryIso, other.countryIso)) return false; + if (!Objects.equal(callLogInfo, other.callLogInfo)) return false; + + return true; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((callLogInfo == null) ? 0 : callLogInfo.hashCode()); + result = prime * result + ((countryIso == null) ? 0 : countryIso.hashCode()); + result = prime * result + ((number == null) ? 0 : number.hashCode()); + return result; + } + } + + /* + * 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. + } + } + } + } + } + + private static final int REDRAW = 1; + private static final int START_THREAD = 2; + + /** 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 Callback mCb; + private final Context mContext; + private final ContactInfoHelper mContactInfoHelper; + private final PhoneNumberDisplayHelper mPhoneNumberHelper; + + /** + * 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 mContactInfoCache; + + private QueryThread mCallerIdThread; + /** Can be set to true by tests to disable processing of requests. */ + private volatile boolean mRequestProcessingDisabled = false; + + /** + * List of requests to update contact details. + *

+ * 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 mRequests; + + private ViewTreeObserver mViewTreeObserver = null; + + private Handler mHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case REDRAW: + mCb.dataSetChanged(); + break; + case START_THREAD: + startRequestProcessing(); + break; + } + } + }; + + /** + * Enqueues a request to look up the contact details for the given phone number. + *

+ * 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(); + } + + @Override + public boolean onPreDraw() { + // We only wanted to listen for the first draw (and this is it). + unregisterPreDrawListener(); + + // Only schedule a thread-creation message if the thread hasn't been + // created yet. This is purely an optimization, to queue fewer messages. + if (mCallerIdThread == null) { + mHandler.sendEmptyMessageDelayed(START_THREAD, START_PROCESSING_REQUESTS_DELAY_MILLIS); + } + + return true; + } + + public void registerOnPreDrawListener(View v) { + // Listen for the first draw + if (mViewTreeObserver == null) { + mViewTreeObserver = v.getViewTreeObserver(); + mViewTreeObserver.addOnPreDrawListener(this); + } + } + + /** + * Stop receiving onPreDraw() notifications. + */ + private void unregisterPreDrawListener() { + if (mViewTreeObserver != null && mViewTreeObserver.isAlive()) { + mViewTreeObserver.removeOnPreDrawListener(this); + } + mViewTreeObserver = null; + } + + public void invalidateCache() { + mContactInfoCache.expireAll(); + + // Restart the request-processing thread after the next draw. + stopRequestProcessing(); + unregisterPreDrawListener(); + } + + /** + * Starts a background thread to process contact-lookup requests, unless one + * has already been started. + */ + private synchronized void startRequestProcessing() { + // For unit-testing. + if (mRequestProcessingDisabled) return; + + // Idempotence... if a thread is already started, don't start another. + if (mCallerIdThread != null) return; + + mCallerIdThread = new QueryThread(); + mCallerIdThread.setPriority(Thread.MIN_PRIORITY); + mCallerIdThread.start(); + } + + /** + * Stops the background thread that processes updates and cancels any + * pending requests to start it. + */ + public synchronized void stopRequestProcessing() { + // Remove any pending requests to start the processing thread. + mHandler.removeMessages(START_THREAD); + if (mCallerIdThread != null) { + // Stop the thread; we are finished with it. + mCallerIdThread.stopProcessing(); + mCallerIdThread.interrupt(); + mCallerIdThread = null; + } + } + + /** + * 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); + mCb.updateContactInfo(number, countryIso, info, callLogInfo); + return updated; + } + + /* + * 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; + } + + /** Checks whether the contact info from the call log matches the one from the contacts db. */ + private boolean callLogInfoMatches(ContactInfo callLogInfo, ContactInfo info) { + // The call log only contains a subset of the fields in the contacts db. + // Only check those. + return TextUtils.equals(callLogInfo.name, info.name) + && callLogInfo.type == info.type + && TextUtils.equals(callLogInfo.label, info.label); + } + + + public ContactInfo lookupContact(String number, int numberPresentation, + String countryIso, ContactInfo cachedContactInfo) { + NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); + ExpirableCache.CachedValue cachedInfo = + mContactInfoCache.getCachedValue(numberCountryIso); + ContactInfo info = cachedInfo == null ? null : cachedInfo.getValue(); + if (!PhoneNumberUtilsWrapper.canPlaceCallsTo(number, numberPresentation) + || new PhoneNumberUtilsWrapper().isVoicemailNumber(number)) { + // If this is a number that cannot be dialed, there is no point in looking up a contact + // for it. + info = ContactInfo.EMPTY; + } else if (cachedInfo == null) { + mContactInfoCache.put(numberCountryIso, ContactInfo.EMPTY); + // Use the cached contact info from the call log. + info = cachedContactInfo; + // The db request should happen on a non-UI thread. + // Request the contact details immediately since they are currently missing. + enqueueRequest(number, countryIso, cachedContactInfo, true); + // We will format the phone number when we make the background request. + } else { + if (cachedInfo.isExpired()) { + // The contact info is no longer up to date, we should request it. However, we + // do not need to request them immediately. + enqueueRequest(number, countryIso, cachedContactInfo, false); + } else if (!callLogInfoMatches(cachedContactInfo, info)) { + // The call log information does not match the one we have, look it up again. + // We could simply update the call log directly, but that needs to be done in a + // background thread, so it is easier to simply request a new lookup, which will, as + // a side-effect, update the call log. + enqueueRequest(number, countryIso, cachedContactInfo, false); + } + + if (info == ContactInfo.EMPTY) { + // Use the cached contact info from the call log. + info = cachedContactInfo; + } + } + + return info; + } + + public CallLogAdapterHelper(Context context, Callback cb, + ContactInfoHelper contactInfoHelper, + PhoneNumberDisplayHelper phoneNumberHelper) { + mContext = context; + mCb = cb; + mContactInfoHelper = contactInfoHelper; + mPhoneNumberHelper = phoneNumberHelper; + + mContactInfoCache = ExpirableCache.create(CONTACT_INFO_CACHE_SIZE); + mRequests = new LinkedList(); + } + + /** + * 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); + } +} diff --git a/src/com/android/dialer/calllog/ContactInfoHelper.java b/src/com/android/dialer/calllog/ContactInfoHelper.java index 68654f28c..3274a9df5 100644 --- a/src/com/android/dialer/calllog/ContactInfoHelper.java +++ b/src/com/android/dialer/calllog/ContactInfoHelper.java @@ -15,6 +15,7 @@ package com.android.dialer.calllog; import android.content.ContentUris; +import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.net.Uri; @@ -24,13 +25,17 @@ import android.provider.ContactsContract.Contacts; import android.provider.ContactsContract.DisplayNameSources; import android.provider.ContactsContract.PhoneLookup; import android.provider.ContactsContract.RawContacts; +import android.provider.Settings; +import android.provider.Telephony; import android.telephony.PhoneNumberUtils; import android.text.TextUtils; +import android.widget.Toast; import com.android.contacts.common.util.Constants; import com.android.contacts.common.util.PhoneNumberHelper; import com.android.contacts.common.util.UriUtils; import com.android.dialer.lookup.LookupCache; +import com.android.dialer.R; import com.android.dialer.service.CachedNumberLookupService; import com.android.dialer.service.CachedNumberLookupService.CachedContactInfo; import com.android.dialerbind.ObjectFactory; @@ -324,7 +329,34 @@ public class ContactInfoHelper { public boolean canReportAsInvalid(int sourceType, String objectId) { return mCachedNumberLookupService != null && mCachedNumberLookupService.canReportAsInvalid(sourceType, objectId); + } + + /** + * Checks whether calls can be blacklisted; that is, whether the + * phone blacklist is enabled + */ + public boolean canBlacklistCalls() { + return Settings.System.getInt(mContext.getContentResolver(), + Settings.System.PHONE_BLACKLIST_ENABLED, 1) != 0; } + /** + * Requests the given number to be added to the phone blacklist + * + * @param number the number to be blacklisted + */ + public void addNumberToBlacklist(String number) { + ContentValues cv = new ContentValues(); + cv.put(Telephony.Blacklist.PHONE_MODE, 1); + + Uri uri = Uri.withAppendedPath(Telephony.Blacklist.CONTENT_FILTER_BYNUMBER_URI, number); + int count = mContext.getContentResolver().update(uri, cv, null, null); + + if (count != 0) { + // Give the user some feedback + String message = mContext.getString(R.string.toast_added_to_blacklist, number); + Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show(); + } + } } diff --git a/src/com/android/dialer/callstats/CallStatsAdapter.java b/src/com/android/dialer/callstats/CallStatsAdapter.java new file mode 100644 index 000000000..82ddde78d --- /dev/null +++ b/src/com/android/dialer/callstats/CallStatsAdapter.java @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * Copyright (C) 2013 Android Open Kang 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.callstats; + +import android.content.Context; +import android.content.res.Resources; +import android.net.Uri; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.CallUtil; +import com.android.contacts.common.GeoUtil; +import com.android.dialer.R; +import com.android.dialer.calllog.CallLogAdapterHelper; +import com.android.dialer.calllog.ContactInfo; +import com.android.dialer.calllog.ContactInfoHelper; +import com.android.dialer.calllog.PhoneNumberDisplayHelper; +import com.android.dialer.calllog.PhoneNumberUtilsWrapper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Adapter class to hold and handle call stat entries + */ +class CallStatsAdapter extends ArrayAdapter + implements CallLogAdapterHelper.Callback { + /** Interface used to initiate a refresh of the content. */ + public interface CallDataLoader { + public boolean isDataLoaded(); + } + + private final View.OnClickListener mPrimaryActionListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + IntentProvider intentProvider = (IntentProvider) view.getTag(); + if (intentProvider != null) { + mContext.startActivity(intentProvider.getIntent(mContext)); + } + } + }; + + private final Context mContext; + private final CallDataLoader mDataLoader; + private final CallLogAdapterHelper mAdapterHelper; + private final CallStatsDetailHelper mCallStatsDetailHelper; + + private ArrayList mAllItems; + private CallStatsDetails mTotalItem; + private Map mInfoLookup; + + private int mType = CallStatsQueryHandler.CALL_TYPE_ALL; + private long mFilterFrom; + private long mFilterTo; + private boolean mSortByDuration; + + private final ContactPhotoManager mContactPhotoManager; + + private final Comparator mDurationComparator = new Comparator() { + @Override + public int compare(CallStatsDetails o1, CallStatsDetails o2) { + Long duration1 = o1.getRequestedDuration(mType); + Long duration2 = o2.getRequestedDuration(mType); + // sort descending + return duration2.compareTo(duration1); + } + }; + private final Comparator mCountComparator = new Comparator() { + @Override + public int compare(CallStatsDetails o1, CallStatsDetails o2) { + Integer count1 = o1.getRequestedCount(mType); + Integer count2 = o2.getRequestedCount(mType); + // sort descending + return count2.compareTo(count1); + } + }; + + CallStatsAdapter(Context context, CallDataLoader dataLoader) { + super(context, R.layout.call_stats_list_item, R.id.number); + + mContext = context; + mDataLoader = dataLoader; + + setNotifyOnChange(false); + + mAllItems = new ArrayList(); + mTotalItem = new CallStatsDetails(null, 0, null, null, null, 0); + mInfoLookup = new ConcurrentHashMap(); + + Resources resources = mContext.getResources(); + PhoneNumberDisplayHelper phoneNumberHelper = new PhoneNumberDisplayHelper(resources); + + final String currentCountryIso = GeoUtil.getCurrentCountryIso(mContext); + final ContactInfoHelper contactInfoHelper = + new ContactInfoHelper(mContext, currentCountryIso); + + mAdapterHelper = new CallLogAdapterHelper(mContext, this, + contactInfoHelper, phoneNumberHelper); + mContactPhotoManager = ContactPhotoManager.getInstance(mContext); + mCallStatsDetailHelper = new CallStatsDetailHelper(resources, + new PhoneNumberUtilsWrapper()); + } + + public void updateData(Map calls, long from, long to) { + mInfoLookup.clear(); + mInfoLookup.putAll(calls); + mFilterFrom = from; + mFilterTo = to; + + mAllItems.clear(); + mTotalItem.reset(); + + for (Map.Entry entry : calls.entrySet()) { + final CallStatsDetails call = entry.getValue(); + mAllItems.add(call); + mTotalItem.mergeWith(call); + mAdapterHelper.lookupContact(call.number, call.numberPresentation, + call.countryIso, entry.getKey()); + } + } + + public void updateDisplayedData(int type, boolean sortByDuration) { + mType = type; + mSortByDuration = sortByDuration; + Collections.sort(mAllItems, sortByDuration ? mDurationComparator : mCountComparator); + + clear(); + + for (CallStatsDetails call : mAllItems) { + if (sortByDuration && call.getRequestedDuration(type) > 0) { + add(call); + } else if (!sortByDuration && call.getRequestedCount(type) > 0) { + add(call); + } + } + + notifyDataSetChanged(); + } + + public void stopRequestProcessing() { + mAdapterHelper.stopRequestProcessing(); + } + + public String getBetterNumberFromContacts(String number, String countryIso) { + return mAdapterHelper.getBetterNumberFromContacts(number, countryIso); + } + + public void invalidateCache() { + mAdapterHelper.invalidateCache(); + } + + public String getTotalCallCountString() { + return CallStatsDetailHelper.getCallCountString( + mContext.getResources(), mTotalItem.getRequestedCount(mType)); + } + + public String getFullDurationString(boolean withSeconds) { + final long duration = mTotalItem.getRequestedDuration(mType); + return CallStatsDetailHelper.getDurationString( + mContext.getResources(), duration, withSeconds); + } + + @Override + public boolean isEmpty() { + if (!mDataLoader.isDataLoaded()) { + return false; + } + return super.isEmpty(); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View v = convertView; + if (v == null) { + LayoutInflater inflater = (LayoutInflater) + getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + v = inflater.inflate(R.layout.call_stats_list_item, parent, false); + } + + findAndCacheViews(v); + bindView(position, v); + + return v; + } + + private void bindView(int position, View v) { + final CallStatsListItemViews views = (CallStatsListItemViews) v.getTag(); + final CallStatsDetails details = getItem(position); + final CallStatsDetails first = getItem(0); + + views.primaryActionView.setVisibility(View.VISIBLE); + views.primaryActionView.setTag(IntentProvider.getCallStatsDetailIntentProvider( + details, mFilterFrom, mFilterTo, mSortByDuration)); + + mCallStatsDetailHelper.setCallStatsDetails(views.callStatsDetailViews, + details, first, mTotalItem, mType, mSortByDuration); + setPhoto(views, details.photoId, details.contactUri); + + // Listen for the first draw + mAdapterHelper.registerOnPreDrawListener(v); + } + + private void findAndCacheViews(View view) { + CallStatsListItemViews views = CallStatsListItemViews.fromView(view); + views.primaryActionView.setOnClickListener(mPrimaryActionListener); + view.setTag(views); + } + + private void setPhoto(CallStatsListItemViews views, long photoId, Uri contactUri) { + views.quickContactView.assignContactUri(contactUri); + mContactPhotoManager.loadThumbnail(views.quickContactView, photoId, null, + false, true, null, null); + } + + @Override + public void dataSetChanged() { + notifyDataSetChanged(); + } + + @Override + public void updateContactInfo(String number, String countryIso, + ContactInfo updatedInfo, ContactInfo callLogInfo) { + CallStatsDetails details = mInfoLookup.get(callLogInfo); + if (details != null) { + details.updateFromInfo(updatedInfo); + } + } +} diff --git a/src/com/android/dialer/callstats/CallStatsDetailActivity.java b/src/com/android/dialer/callstats/CallStatsDetailActivity.java new file mode 100644 index 000000000..dc64c2e74 --- /dev/null +++ b/src/com/android/dialer/callstats/CallStatsDetailActivity.java @@ -0,0 +1,274 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * Copyright (C) 2013 Android Open Kang 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.callstats; + +import android.app.ActionBar; +import android.app.Activity; +import android.content.Intent; +import android.content.res.Resources; +import android.os.Bundle; +import android.provider.CallLog.Calls; +import android.os.AsyncTask; +import android.text.format.DateUtils; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.TextView; + +import com.android.contacts.common.CallUtil; +import com.android.contacts.common.GeoUtil; +import com.android.dialer.CallDetailHeader; +import com.android.dialer.R; +import com.android.dialer.calllog.ContactInfo; +import com.android.dialer.calllog.ContactInfoHelper; +import com.android.dialer.calllog.PhoneNumberDisplayHelper; +import com.android.dialer.calllog.PhoneNumberUtilsWrapper; +import com.android.dialer.widget.PieChartView; + +/** + * Activity to display detailed information about a callstat item + */ +public class CallStatsDetailActivity extends Activity { + private static final String TAG = "CallStatsDetailActivity"; + + public static final String EXTRA_DETAILS = "details"; + public static final String EXTRA_FROM = "from"; + public static final String EXTRA_TO = "to"; + public static final String EXTRA_BY_DURATION = "by_duration"; + + private CallStatsDetailHelper mCallStatsDetailHelper; + private ContactInfoHelper mContactInfoHelper; + private CallDetailHeader mCallDetailHeader; + private Resources mResources; + + private TextView mHeaderTextView; + private TextView mTotalSummary; + private TextView mTotalDuration; + private TextView mInSummary; + private TextView mInCount; + private TextView mInDuration; + private TextView mOutSummary; + private TextView mOutCount; + private TextView mOutDuration; + private TextView mMissedSummary; + private TextView mMissedCount; + private PieChartView mPieChart; + + private CallStatsDetails mData; + private String mNumber = null; + + private class UpdateContactTask extends AsyncTask { + protected ContactInfo doInBackground(String... strings) { + ContactInfo info = mContactInfoHelper.lookupNumber(strings[0], strings[1]); + return info; + } + + protected void onPostExecute(ContactInfo info) { + mData.updateFromInfo(info); + updateData(); + } + } + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + + setContentView(R.layout.call_stats_detail); + + mResources = getResources(); + + PhoneNumberDisplayHelper phoneNumberHelper = new PhoneNumberDisplayHelper(mResources); + mCallDetailHeader = new CallDetailHeader(this, phoneNumberHelper); + mCallStatsDetailHelper = new CallStatsDetailHelper(mResources, + new PhoneNumberUtilsWrapper()); + mContactInfoHelper = new ContactInfoHelper(this, GeoUtil.getCurrentCountryIso(this)); + + mHeaderTextView = (TextView) findViewById(R.id.header_text); + mTotalSummary = (TextView) findViewById(R.id.total_summary); + mTotalDuration = (TextView) findViewById(R.id.total_duration); + mInSummary = (TextView) findViewById(R.id.in_summary); + mInCount = (TextView) findViewById(R.id.in_count); + mInDuration = (TextView) findViewById(R.id.in_duration); + mOutSummary = (TextView) findViewById(R.id.out_summary); + mOutCount = (TextView) findViewById(R.id.out_count); + mOutDuration = (TextView) findViewById(R.id.out_duration); + mMissedSummary = (TextView) findViewById(R.id.missed_summary); + mMissedCount = (TextView) findViewById(R.id.missed_count); + mPieChart = (PieChartView) findViewById(R.id.pie_chart); + + configureActionBar(); + Intent launchIntent = getIntent(); + mData = (CallStatsDetails) launchIntent.getParcelableExtra(EXTRA_DETAILS); + + TextView dateFilterView = (TextView) findViewById(R.id.date_filter); + long filterFrom = launchIntent.getLongExtra(EXTRA_FROM, -1); + if (filterFrom == -1) { + dateFilterView.setVisibility(View.GONE); + } else { + long filterTo = launchIntent.getLongExtra(EXTRA_TO, -1); + dateFilterView.setText(DateUtils.formatDateRange( + this, filterFrom, filterTo, DateUtils.FORMAT_ABBREV_ALL)); + } + } + + @Override + public void onResume() { + super.onResume(); + new UpdateContactTask().execute(mData.number.toString(), mData.countryIso); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (mCallDetailHeader.handleKeyDown(keyCode, event)) { + return true; + } + + return super.onKeyDown(keyCode, event); + } + + private void updateData() { + mNumber = mData.number.toString(); + + // Set the details header, based on the first phone call. + mCallStatsDetailHelper.setCallStatsDetailHeader(mHeaderTextView, mData); + mCallDetailHeader.updateViews(mNumber, mData.numberPresentation, mData); + mCallDetailHeader.loadContactPhotos(mData.photoUri); + invalidateOptionsMenu(); + + mPieChart.setOriginAngle(240); + mPieChart.removeAllSlices(); + + boolean byDuration = getIntent().getBooleanExtra(EXTRA_BY_DURATION, true); + + mTotalSummary.setText(getString(R.string.call_stats_header_total_callsonly, + CallStatsDetailHelper.getCallCountString(mResources, mData.getTotalCount()))); + mTotalDuration.setText(CallStatsDetailHelper.getDurationString( + mResources, mData.getFullDuration(), true)); + + if (shouldDisplay(Calls.INCOMING_TYPE, byDuration)) { + int percent = byDuration + ? mData.getDurationPercentage(Calls.INCOMING_TYPE) + : mData.getCountPercentage(Calls.INCOMING_TYPE); + + mInSummary.setText(getString(R.string.call_stats_incoming, percent)); + mInCount.setText(CallStatsDetailHelper.getCallCountString( + mResources, mData.incomingCount)); + mInDuration.setText(CallStatsDetailHelper.getDurationString( + mResources, mData.inDuration, true)); + mPieChart.addSlice(byDuration ? mData.inDuration : mData.incomingCount, + mResources.getColor(R.color.call_stats_incoming)); + } else { + findViewById(R.id.in_container).setVisibility(View.GONE); + } + + if (shouldDisplay(Calls.OUTGOING_TYPE, byDuration)) { + int percent = byDuration + ? mData.getDurationPercentage(Calls.OUTGOING_TYPE) + : mData.getCountPercentage(Calls.OUTGOING_TYPE); + + mOutSummary.setText(getString(R.string.call_stats_outgoing, percent)); + mOutCount.setText(CallStatsDetailHelper.getCallCountString( + mResources, mData.outgoingCount)); + mOutDuration.setText(CallStatsDetailHelper.getDurationString( + mResources, mData.outDuration, true)); + mPieChart.addSlice(byDuration ? mData.outDuration : mData.outgoingCount, + mResources.getColor(R.color.call_stats_outgoing)); + } else { + findViewById(R.id.out_container).setVisibility(View.GONE); + } + + if (shouldDisplay(Calls.MISSED_TYPE, false)) { + final String missedCount = + CallStatsDetailHelper.getCallCountString(mResources, mData.missedCount); + + if (byDuration) { + mMissedSummary.setText(getString(R.string.call_stats_missed)); + } else { + mMissedSummary.setText(getString(R.string.call_stats_missed_percent, + mData.getCountPercentage(Calls.MISSED_TYPE))); + mPieChart.addSlice(mData.missedCount, mResources.getColor(R.color.call_stats_missed)); + } + mMissedCount.setText(CallStatsDetailHelper.getCallCountString( + mResources, mData.missedCount)); + } else { + findViewById(R.id.missed_container).setVisibility(View.GONE); + } + + mPieChart.generatePath(); + findViewById(R.id.call_stats_detail).setVisibility(View.VISIBLE); + } + + private boolean shouldDisplay(int type, boolean byDuration) { + if (byDuration) { + return mData.getRequestedDuration(type) != 0; + } else { + return mData.getRequestedCount(type) != 0; + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.call_stats_details_options, menu); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + menu.findItem(R.id.menu_edit_number_before_call).setVisible( + mCallDetailHeader.canEditNumberBeforeCall()); + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onMenuItemSelected(int featureId, MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: { + onHomeSelected(); + return true; + } + // All the options menu items are handled by onMenu... methods. + default: + throw new IllegalArgumentException(); + } + } + + public void onMenuEditNumberBeforeCall(MenuItem menuItem) { + startActivity(new Intent(Intent.ACTION_DIAL, CallUtil.getCallUri(mNumber))); + } + + public void onMenuAddToBlacklist(MenuItem menuItem) { + mContactInfoHelper.addNumberToBlacklist(mNumber); + } + + private void configureActionBar() { + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP + | ActionBar.DISPLAY_SHOW_HOME); + } + } + + private void onHomeSelected() { + Intent intent = new Intent(Intent.ACTION_VIEW, Calls.CONTENT_URI); + // This will open the call log even if the detail view has been opened directly. + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + finish(); + } +} diff --git a/src/com/android/dialer/callstats/CallStatsDetailHelper.java b/src/com/android/dialer/callstats/CallStatsDetailHelper.java new file mode 100644 index 000000000..d9b3ea0f2 --- /dev/null +++ b/src/com/android/dialer/callstats/CallStatsDetailHelper.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * Copyright (C) 2013 Android Open Kang 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.callstats; + +import android.content.res.Resources; +import android.provider.CallLog.Calls; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import android.view.View; +import android.widget.TextView; + +import com.android.dialer.R; +import com.android.dialer.calllog.PhoneNumberDisplayHelper; +import com.android.dialer.calllog.PhoneNumberUtilsWrapper; + +/** + * Class used to populate a detailed view for a callstats item + */ +public class CallStatsDetailHelper { + + private final Resources mResources; + private final PhoneNumberDisplayHelper mPhoneNumberHelper; + private final PhoneNumberUtilsWrapper mPhoneNumberUtilsWrapper; + + public CallStatsDetailHelper(Resources resources, PhoneNumberUtilsWrapper phoneUtils) { + mResources = resources; + mPhoneNumberHelper = new PhoneNumberDisplayHelper(resources); + mPhoneNumberUtilsWrapper = phoneUtils; + } + + public void setCallStatsDetails(CallStatsDetailViews views, + CallStatsDetails details, CallStatsDetails first, CallStatsDetails total, + int type, boolean byDuration) { + + CharSequence numberFormattedLabel = null; + // Only show a label if the number is shown and it is not a SIP address. + if (!TextUtils.isEmpty(details.number) + && !PhoneNumberUtils.isUriNumber(details.number.toString())) { + numberFormattedLabel = Phone.getTypeLabel(mResources, + details.numberType, details.numberLabel); + } + + final CharSequence nameText; + final CharSequence numberText; + final CharSequence labelText; + final CharSequence displayNumber = mPhoneNumberHelper.getDisplayNumber( + details.number, details.numberPresentation, details.formattedNumber); + + if (TextUtils.isEmpty(details.name)) { + nameText = displayNumber; + if (TextUtils.isEmpty(details.geocode) + || mPhoneNumberUtilsWrapper.isVoicemailNumber(details.number)) { + numberText = mResources.getString(R.string.call_log_empty_gecode); + } else { + numberText = details.geocode; + } + labelText = null; + } else { + nameText = details.name; + numberText = displayNumber; + labelText = numberFormattedLabel; + } + + float in = 0, out = 0, missed = 0; + float ratio = getDetailValue(details, type, byDuration) / + getDetailValue(first, type, byDuration); + + if (type == CallStatsQueryHandler.CALL_TYPE_ALL) { + float full = getDetailValue(details, type, byDuration); + in = getDetailValue(details, Calls.INCOMING_TYPE, byDuration) * ratio / full; + out = getDetailValue(details, Calls.OUTGOING_TYPE, byDuration) * ratio / full; + if (!byDuration) { + missed = getDetailValue(details, Calls.MISSED_TYPE, byDuration) * ratio / full; + } + } else if (type == Calls.INCOMING_TYPE) { + in = ratio; + } else if (type == Calls.OUTGOING_TYPE) { + out = ratio; + } else if (type == Calls.MISSED_TYPE) { + missed = ratio; + } + + views.barView.setRatios(in, out, missed); + views.nameView.setText(nameText); + views.numberView.setText(numberText); + views.labelView.setText(labelText); + views.labelView.setVisibility(TextUtils.isEmpty(labelText) ? View.GONE : View.VISIBLE); + + if (byDuration && type == Calls.MISSED_TYPE) { + views.percentView.setText(getCallCountString(mResources, details.missedCount)); + } else { + float percent = getDetailValue(details, type, byDuration) * 100F / + getDetailValue(total, type, byDuration); + views.percentView.setText(String.format("%.1f%%", percent)); + } + } + + private float getDetailValue(CallStatsDetails details, int type, boolean byDuration) { + if (byDuration) { + return (float) details.getRequestedDuration(type); + } else { + return (float) details.getRequestedCount(type); + } + } + + public void setCallStatsDetailHeader(TextView nameView, CallStatsDetails details) { + final CharSequence nameText; + final CharSequence displayNumber = mPhoneNumberHelper.getDisplayNumber( + details.number, details.numberPresentation, + mResources.getString(R.string.recentCalls_addToContact)); + + if (TextUtils.isEmpty(details.name)) { + nameText = displayNumber; + } else { + nameText = details.name; + } + + nameView.setText(nameText); + } + + public static String getCallCountString(Resources res, long count) { + return res.getQuantityString(R.plurals.call, (int) count, (int) count); + } + + public static String getDurationString(Resources res, long duration, boolean includeSeconds) { + int hours, minutes, seconds; + + hours = (int) (duration / 3600); + duration -= (long) hours * 3600; + minutes = (int) (duration / 60); + duration -= (long) minutes * 60; + seconds = (int) duration; + + if (!includeSeconds) { + if (seconds >= 30) { + minutes++; + } + if (minutes >= 60) { + hours++; + } + } + + boolean dispHours = hours > 0; + boolean dispMinutes = minutes > 0 || (!includeSeconds && hours == 0); + boolean dispSeconds = includeSeconds && (seconds > 0 || (hours == 0 && minutes == 0)); + + final String hourString = dispHours ? + res.getQuantityString(R.plurals.hour, hours, hours) : null; + final String minuteString = dispMinutes ? + res.getQuantityString(R.plurals.minute, minutes, minutes) : null; + final String secondString = dispSeconds ? + res.getQuantityString(R.plurals.second, seconds, seconds) : null; + + int index = ((dispHours ? 4 : 0) | (dispMinutes ? 2 : 0) | (dispSeconds ? 1 : 0)) - 1; + String[] formats = res.getStringArray(R.array.call_stats_duration); + return String.format(formats[index], hourString, minuteString, secondString); + } +} diff --git a/src/com/android/dialer/callstats/CallStatsDetailViews.java b/src/com/android/dialer/callstats/CallStatsDetailViews.java new file mode 100644 index 000000000..ea20f7235 --- /dev/null +++ b/src/com/android/dialer/callstats/CallStatsDetailViews.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * Copyright (C) 2013 Android Open Kang 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.callstats; + +import android.view.View; +import android.widget.TextView; + +import com.android.dialer.R; +import com.android.dialer.widget.LinearColorBar; + +public final class CallStatsDetailViews { + public final TextView nameView; + public final TextView numberView; + public final TextView labelView; + public final TextView percentView; + public final LinearColorBar barView; + + private CallStatsDetailViews(TextView nameView, TextView numberView, + TextView labelView, TextView percentView, LinearColorBar barView) { + this.nameView = nameView; + this.numberView = numberView; + this.labelView = labelView; + this.percentView = percentView; + this.barView = barView; + } + + public static CallStatsDetailViews fromView(View view) { + return new CallStatsDetailViews( + (TextView) view.findViewById(R.id.name), + (TextView) view.findViewById(R.id.number), + (TextView) view.findViewById(R.id.label), + (TextView) view.findViewById(R.id.percent), + (LinearColorBar) view.findViewById(R.id.percent_bar)); + } +} diff --git a/src/com/android/dialer/callstats/CallStatsDetails.java b/src/com/android/dialer/callstats/CallStatsDetails.java new file mode 100644 index 000000000..377b5149c --- /dev/null +++ b/src/com/android/dialer/callstats/CallStatsDetails.java @@ -0,0 +1,238 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * Copyright (C) 2013 Android Open Kang 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.callstats; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.CallLog.Calls; +import android.util.Log; + +import com.android.dialer.CallDetailHeader; +import com.android.dialer.calllog.ContactInfo; + +/** + * Class to store statistical details for a given contact/number. + */ +public class CallStatsDetails implements CallDetailHeader.Data, Parcelable { + public final String number; + public final int numberPresentation; + public String formattedNumber; + public final String countryIso; + public final String geocode; + public final long date; + public String name; + public int numberType; + public String numberLabel; + public Uri contactUri; + public Uri photoUri; + public long photoId; + public long inDuration; + public long outDuration; + public int incomingCount; + public int outgoingCount; + public int missedCount; + + public CallStatsDetails(CharSequence number, int numberPresentation, + ContactInfo info, String countryIso, String geocode, long date) { + this.number = number != null ? number.toString() : null; + this.numberPresentation = numberPresentation; + this.countryIso = countryIso; + this.geocode = geocode; + this.date = date; + + reset(); + + if (info != null) { + updateFromInfo(info); + } + } + + @Override + public CharSequence getName() { + return name; + } + @Override + public CharSequence getNumber() { + return number; + } + @Override + public int getNumberPresentation() { + return numberPresentation; + } + @Override + public int getNumberType() { + return numberType; + } + @Override + public CharSequence getNumberLabel() { + return numberLabel; + } + @Override + public CharSequence getFormattedNumber() { + return formattedNumber; + } + @Override + public Uri getContactUri() { + return contactUri; + } + + public void updateFromInfo(ContactInfo info) { + this.name = info.name; + this.numberType = info.type; + this.numberLabel = info.label; + this.photoId = info.photoId; + this.photoUri = info.photoUri; + this.formattedNumber = info.formattedNumber; + this.contactUri = info.lookupUri; + this.photoUri = info.photoUri; + this.photoId = info.photoId; + } + + public long getFullDuration() { + return inDuration + outDuration; + } + + public int getTotalCount() { + return incomingCount + outgoingCount + missedCount; + } + + public void addTimeOrMissed(int type, long time) { + switch (type) { + case Calls.INCOMING_TYPE: + incomingCount++; + inDuration += time; + break; + case Calls.OUTGOING_TYPE: + outgoingCount++; + outDuration += time; + break; + case Calls.MISSED_TYPE: + missedCount++; + break; + } + } + + public int getDurationPercentage(int type) { + long duration = getRequestedDuration(type); + return Math.round((float) duration * 100F / getFullDuration()); + } + + public int getCountPercentage(int type) { + int count = getRequestedCount(type); + return Math.round((float) count * 100F / getTotalCount()); + } + + public long getRequestedDuration(int type) { + switch (type) { + case Calls.INCOMING_TYPE: + return inDuration; + case Calls.OUTGOING_TYPE: + return outDuration; + case Calls.MISSED_TYPE: + return (long) missedCount; + default: + return getFullDuration(); + } + } + + public int getRequestedCount(int type) { + switch (type) { + case Calls.INCOMING_TYPE: + return incomingCount; + case Calls.OUTGOING_TYPE: + return outgoingCount; + case Calls.MISSED_TYPE: + return missedCount; + default: + return getTotalCount(); + } + } + + public void mergeWith(CallStatsDetails other) { + this.inDuration += other.inDuration; + this.outDuration += other.outDuration; + this.incomingCount += other.incomingCount; + this.outgoingCount += other.outgoingCount; + this.missedCount += other.missedCount; + } + + public void reset() { + this.inDuration = this.outDuration = 0; + this.incomingCount = this.outgoingCount = this.missedCount = 0; + } + + /* Parcelable interface */ + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeString(number); + out.writeInt(numberPresentation); + out.writeString(formattedNumber); + out.writeString(countryIso); + out.writeString(geocode); + out.writeLong(date); + out.writeString(name); + out.writeInt(numberType); + out.writeString(numberLabel); + out.writeParcelable(contactUri, flags); + out.writeParcelable(photoUri, flags); + out.writeLong(photoId); + out.writeLong(inDuration); + out.writeLong(outDuration); + out.writeInt(incomingCount); + out.writeInt(outgoingCount); + out.writeInt(missedCount); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + public CallStatsDetails createFromParcel(Parcel in) { + return new CallStatsDetails(in); + } + + public CallStatsDetails[] newArray(int size) { + return new CallStatsDetails[size]; + } + }; + + private CallStatsDetails (Parcel in) { + number = in.readString(); + numberPresentation = in.readInt(); + formattedNumber = in.readString(); + countryIso = in.readString(); + geocode = in.readString(); + date = in.readLong(); + name = in.readString(); + numberType = in.readInt(); + numberLabel = in.readString(); + contactUri = in.readParcelable(null); + photoUri = in.readParcelable(null); + photoId = in.readLong(); + inDuration = in.readLong(); + outDuration = in.readLong(); + incomingCount = in.readInt(); + outgoingCount = in.readInt(); + missedCount = in.readInt(); + } +} diff --git a/src/com/android/dialer/callstats/CallStatsFragment.java b/src/com/android/dialer/callstats/CallStatsFragment.java new file mode 100644 index 000000000..e2790b0b2 --- /dev/null +++ b/src/com/android/dialer/callstats/CallStatsFragment.java @@ -0,0 +1,340 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * Copyright (C) 2013 Android Open Kang 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.callstats; + +import android.app.ActionBar; +import android.app.ListFragment; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.ContentObserver; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.provider.CallLog; +import android.provider.ContactsContract; +import android.telecom.PhoneAccount; +import android.telephony.PhoneNumberUtils; +import android.text.format.DateUtils; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.Spinner; +import android.widget.TextView; + +import com.android.contacts.common.CallUtil; +import com.android.contacts.common.util.Constants; +import com.android.dialer.DialtactsActivity; +import com.android.dialer.R; +import com.android.dialer.calllog.ContactInfo; +import com.android.dialer.calllog.PhoneNumberUtilsWrapper; +import com.android.dialer.widget.DoubleDatePickerDialog; +import com.android.internal.telephony.CallerInfo; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class CallStatsFragment extends ListFragment implements + CallStatsAdapter.CallDataLoader, CallStatsQueryHandler.Listener, + AdapterView.OnItemSelectedListener, DoubleDatePickerDialog.OnDateSetListener { + private static final String TAG = "CallStatsFragment"; + + private static final int[] CALL_DIRECTION_RESOURCES = new int[] { + R.drawable.ic_call_inout_holo_dark, + R.drawable.ic_call_incoming_holo_dark, + R.drawable.ic_call_outgoing_holo_dark, + R.drawable.ic_call_missed_holo_dark + }; + + private String[] mNavItems; + private Spinner mFilterSpinner; + + private int mCallTypeFilter = CallStatsQueryHandler.CALL_TYPE_ALL; + private long mFilterFrom = -1; + private long mFilterTo = -1; + private boolean mSortByDuration = true; + private boolean mDataLoaded = false; + + private CallStatsAdapter mAdapter; + private CallStatsQueryHandler mCallStatsQueryHandler; + + private TextView mSumHeaderView; + private TextView mDateFilterView; + + private boolean mRefreshDataRequired = true; + private final ContentObserver mObserver = new ContentObserver(new Handler()) { + @Override + public void onChange(boolean selfChange) { + mRefreshDataRequired = true; + } + }; + + public class CallStatsNavAdapter extends ArrayAdapter { + public CallStatsNavAdapter(Context context, int textResourceId, Object[] objects) { + super(context, textResourceId, mNavItems); + } + + @Override + public View getDropDownView(int position, View convertView, ViewGroup parent) { + return getCustomView(position, convertView, parent); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + return getCustomView(position, convertView, parent); + } + + public View getCustomView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = getLayoutInflater(null).inflate( + R.layout.call_stats_nav_item, parent, false); + } + + TextView label = (TextView) convertView.findViewById(R.id.call_stats_nav_text); + label.setText(mNavItems[position]); + + ImageView icon = (ImageView) convertView.findViewById(R.id.call_stats_nav_icon); + icon.setImageResource(CALL_DIRECTION_RESOURCES[position]); + + return convertView; + } + } + + @Override + public void onCreate(Bundle state) { + super.onCreate(state); + + final ContentResolver cr = getActivity().getContentResolver(); + mCallStatsQueryHandler = new CallStatsQueryHandler(cr, this); + cr.registerContentObserver(CallLog.CONTENT_URI, true, mObserver); + cr.registerContentObserver(ContactsContract.Contacts.CONTENT_URI, true, mObserver); + + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) { + View view = inflater.inflate(R.layout.call_stats_fragment, container, false); + mSumHeaderView = (TextView) view.findViewById(R.id.sum_header); + mDateFilterView = (TextView) view.findViewById(R.id.date_filter); + return view; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + mAdapter = new CallStatsAdapter(getActivity(), this); + setListAdapter(mAdapter); + getListView().setItemsCanFocus(true); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.call_stats_options, menu); + + final MenuItem resetItem = menu.findItem(R.id.reset_date_filter); + final MenuItem sortDurationItem = menu.findItem(R.id.sort_by_duration); + final MenuItem sortCountItem = menu.findItem(R.id.sort_by_count); + final MenuItem filterItem = menu.findItem(R.id.filter); + + resetItem.setVisible(mFilterFrom != -1); + sortDurationItem.setVisible(!mSortByDuration); + sortCountItem.setVisible(mSortByDuration); + + mFilterSpinner = (Spinner) filterItem.getActionView(); + mNavItems = getResources().getStringArray(R.array.call_stats_nav_items); + CallStatsNavAdapter filterAdapter = new CallStatsNavAdapter(getActivity(), + android.R.layout.simple_list_item_1, mNavItems); + mFilterSpinner.setAdapter(filterAdapter); + mFilterSpinner.setOnItemSelectedListener(this); + + super.onCreateOptionsMenu(menu, inflater); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + final int itemId = item.getItemId(); + switch (itemId) { + case R.id.date_filter: { + final DoubleDatePickerDialog.Fragment fragment = + new DoubleDatePickerDialog.Fragment(); + fragment.setArguments(DoubleDatePickerDialog.Fragment.createArguments( + mFilterFrom, mFilterTo)); + fragment.show(getFragmentManager(), "filter"); + break; + } + case R.id.reset_date_filter: { + mFilterFrom = -1; + mFilterTo = -1; + fetchCalls(); + getActivity().invalidateOptionsMenu(); + break; + } + case R.id.sort_by_duration: + case R.id.sort_by_count: { + mSortByDuration = itemId == R.id.sort_by_duration; + mAdapter.updateDisplayedData(mCallTypeFilter, mSortByDuration); + getActivity().invalidateOptionsMenu(); + break; + } + } + return true; + } + + @Override + public void onItemSelected(AdapterView parent, View view, int pos, long id) { + mCallTypeFilter = pos; + mAdapter.updateDisplayedData(mCallTypeFilter, mSortByDuration); + if (mDataLoaded) { + updateHeader(); + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + + @Override + public void onDateSet(long from, long to) { + mFilterFrom = from; + mFilterTo = to; + getActivity().invalidateOptionsMenu(); + fetchCalls(); + } + + /** + * Called by the CallStatsQueryHandler when the list of calls has been + * fetched or updated. + */ + @Override + public void onCallsFetched(Map calls) { + if (getActivity() == null || getActivity().isFinishing()) { + return; + } + + mDataLoaded = true; + mAdapter.updateData(calls, mFilterFrom, mFilterTo); + mAdapter.updateDisplayedData(mCallTypeFilter, mSortByDuration); + updateHeader(); + } + + @Override + public void onResume() { + super.onResume(); + refreshData(); + } + + @Override + public void onPause() { + super.onPause(); + // Kill the requests thread + mAdapter.stopRequestProcessing(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mAdapter.stopRequestProcessing(); + getActivity().getContentResolver().unregisterContentObserver(mObserver); + } + + @Override + public boolean isDataLoaded() { + return mDataLoaded; + } + + private void fetchCalls() { + mCallStatsQueryHandler.fetchCalls(mFilterFrom, mFilterTo); + } + + private void updateHeader() { + final String callCount = mAdapter.getTotalCallCountString(); + final String duration = mAdapter.getFullDurationString(false); + + if (duration != null) { + mSumHeaderView.setText(getString(R.string.call_stats_header_total, callCount, duration)); + } else { + mSumHeaderView.setText(getString(R.string.call_stats_header_total_callsonly, callCount)); + } + + if (mFilterFrom == -1) { + mDateFilterView.setVisibility(View.GONE); + } else { + mDateFilterView.setText(DateUtils.formatDateRange(getActivity(), + mFilterFrom, mFilterTo, 0)); + mDateFilterView.setVisibility(View.VISIBLE); + } + + getView().findViewById(R.id.call_stats_header).setVisibility(View.VISIBLE); + } + + public void callSelectedEntry() { + int position = getListView().getSelectedItemPosition(); + if (position < 0) { + // In touch mode you may often not have something selected, so + // just call the first entry to make sure that [send] calls + // the most recent entry. + position = 0; + } + final CallStatsDetails item = mAdapter.getItem(position); + String number = (String) item.number; + + if (!PhoneNumberUtilsWrapper.canPlaceCallsTo(number, item.numberPresentation)) { + // This number can't be called, do nothing + return; + } + + Uri callUri; + // If "number" is really a SIP address, construct a sip: URI. + if (PhoneNumberUtils.isUriNumber(number)) { + callUri = Uri.fromParts(PhoneAccount.SCHEME_SIP, number, null); + } else { + if (!number.startsWith("+")) { + // If the caller-id matches a contact with a better qualified + // number, use it + number = mAdapter.getBetterNumberFromContacts(number, item.countryIso); + } + callUri = Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null); + } + + final Intent intent = CallUtil.getCallIntent(callUri); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + startActivity(intent); + } + + /** Requests updates to the data to be shown. */ + private void refreshData() { + // Prevent unnecessary refresh. + if (mRefreshDataRequired) { + // Mark all entries in the contact info cache as out of date, so + // they will be looked up again once being shown. + mAdapter.invalidateCache(); + fetchCalls(); + mRefreshDataRequired = false; + } + } +} diff --git a/src/com/android/dialer/callstats/CallStatsListItemViews.java b/src/com/android/dialer/callstats/CallStatsListItemViews.java new file mode 100644 index 000000000..4ebf247e9 --- /dev/null +++ b/src/com/android/dialer/callstats/CallStatsListItemViews.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * Copyright (C) 2013 Android Open Kang 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.callstats; + +import android.view.View; +import android.widget.QuickContactBadge; + +import com.android.dialer.R; + +/** + * Simple value object containing the various views within a call stat entry. + */ +public final class CallStatsListItemViews { + /** The quick contact badge for the contact. */ + public final QuickContactBadge quickContactView; + /** The primary action view of the entry. */ + public final View primaryActionView; + /** The details of the phone call. */ + public final CallStatsDetailViews callStatsDetailViews; + /** The divider to be shown below items. */ + public final View bottomDivider; + + private CallStatsListItemViews(QuickContactBadge quickContactView, View primaryActionView, + CallStatsDetailViews callStatsDetailViews, + View bottomDivider) { + this.quickContactView = quickContactView; + this.primaryActionView = primaryActionView; + this.callStatsDetailViews = callStatsDetailViews; + this.bottomDivider = bottomDivider; + } + + public static CallStatsListItemViews fromView(View view) { + return new CallStatsListItemViews( + (QuickContactBadge) view.findViewById(R.id.quick_contact_photo), + view.findViewById(R.id.primary_action_view), + CallStatsDetailViews.fromView(view), + view.findViewById(R.id.call_stats_divider)); + } + +} diff --git a/src/com/android/dialer/callstats/CallStatsQuery.java b/src/com/android/dialer/callstats/CallStatsQuery.java new file mode 100644 index 000000000..390bbfcab --- /dev/null +++ b/src/com/android/dialer/callstats/CallStatsQuery.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * Copyright (C) 2013 Android Open Kang 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.callstats; + +import android.provider.CallLog.Calls; + +public class CallStatsQuery { + + public static final String[] _PROJECTION = new String[] { + Calls._ID, // 0 + Calls.NUMBER, // 1 + Calls.DATE, // 2 + Calls.DURATION, // 3 + Calls.TYPE, // 4 + Calls.COUNTRY_ISO, // 5 + Calls.GEOCODED_LOCATION, // 6 + Calls.CACHED_NAME, // 7 + Calls.CACHED_NUMBER_TYPE, // 8 + Calls.CACHED_NUMBER_LABEL, // 9 + Calls.CACHED_LOOKUP_URI, // 10 + Calls.CACHED_MATCHED_NUMBER, // 11 + Calls.CACHED_NORMALIZED_NUMBER, // 12 + Calls.CACHED_PHOTO_ID, // 13 + Calls.CACHED_FORMATTED_NUMBER, // 14 + Calls.NUMBER_PRESENTATION, // 15 + }; + + public static final int ID = 0; + public static final int NUMBER = 1; + public static final int DATE = 2; + public static final int DURATION = 3; + public static final int CALL_TYPE = 4; + public static final int COUNTRY_ISO = 5; + public static final int GEOCODED_LOCATION = 6; + public static final int CACHED_NAME = 7; + public static final int CACHED_NUMBER_TYPE = 8; + public static final int CACHED_NUMBER_LABEL = 9; + public static final int CACHED_LOOKUP_URI = 10; + public static final int CACHED_MATCHED_NUMBER = 11; + public static final int CACHED_NORMALIZED_NUMBER = 12; + public static final int CACHED_PHOTO_ID = 13; + public static final int CACHED_FORMATTED_NUMBER = 14; + public static final int NUMBER_PRESENTATION = 15; +} diff --git a/src/com/android/dialer/callstats/CallStatsQueryHandler.java b/src/com/android/dialer/callstats/CallStatsQueryHandler.java new file mode 100644 index 000000000..f3590554e --- /dev/null +++ b/src/com/android/dialer/callstats/CallStatsQueryHandler.java @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * Copyright (C) 2013 Android Open Kang 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.callstats; + +import android.content.AsyncQueryHandler; +import android.content.ContentResolver; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabaseCorruptException; +import android.database.sqlite.SQLiteDiskIOException; +import android.database.sqlite.SQLiteFullException; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.provider.CallLog.Calls; +import android.util.Log; + +import com.android.contacts.common.CallUtil; +import com.android.contacts.common.util.UriUtils; +import com.android.dialer.calllog.ContactInfo; +import com.google.common.collect.Lists; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Class to handle call-log queries, optionally with a date-range filter + */ +public class CallStatsQueryHandler extends AsyncQueryHandler { + private static final String[] EMPTY_STRING_ARRAY = new String[0]; + + private static final int EVENT_PROCESS_DATA = 10; + + private static final int QUERY_CALLS_TOKEN = 100; + + public static final int CALL_TYPE_ALL = 0; + + private static final String TAG = "CallStatsQueryHandler"; + + private final WeakReference mListener; + private Handler mWorkerThreadHandler; + + /** + * Simple handler that wraps background calls to catch + * {@link SQLiteException}, such as when the disk is full. + */ + protected class CatchingWorkerHandler extends AsyncQueryHandler.WorkerHandler { + public CatchingWorkerHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + if (msg.arg1 == EVENT_PROCESS_DATA) { + Cursor cursor = (Cursor) msg.obj; + Message reply = CallStatsQueryHandler.this.obtainMessage(msg.what); + reply.obj = processData(cursor); + reply.arg1 = msg.arg1; + reply.sendToTarget(); + return; + } + + try { + // Perform same query while catching any exceptions + super.handleMessage(msg); + } catch (SQLiteDiskIOException e) { + Log.w(TAG, "Exception on background worker thread", e); + } catch (SQLiteFullException e) { + Log.w(TAG, "Exception on background worker thread", e); + } catch (SQLiteDatabaseCorruptException e) { + Log.w(TAG, "Exception on background worker thread", e); + } + } + } + + @Override + protected Handler createHandler(Looper looper) { + // Provide our special handler that catches exceptions + mWorkerThreadHandler = new CatchingWorkerHandler(looper); + return mWorkerThreadHandler; + } + + public CallStatsQueryHandler(ContentResolver contentResolver, Listener listener) { + super(contentResolver); + mListener = new WeakReference(listener); + } + + public void fetchCalls(long from, long to) { + cancelOperation(QUERY_CALLS_TOKEN); + + StringBuilder selection = new StringBuilder(); + List selectionArgs = Lists.newArrayList(); + + if (from != -1) { + selection.append(String.format("(%s > ?)", Calls.DATE)); + selectionArgs.add(String.valueOf(from)); + } + if (to != -1) { + if (selection.length() > 0) { + selection.append(" AND "); + } + selection.append(String.format("(%s < ?)", Calls.DATE)); + selectionArgs.add(String.valueOf(to)); + } + + startQuery(QUERY_CALLS_TOKEN, null, Calls.CONTENT_URI, CallStatsQuery._PROJECTION, + selection.toString(), selectionArgs.toArray(EMPTY_STRING_ARRAY), + Calls.NUMBER + " ASC"); + } + + @Override + protected synchronized void onQueryComplete(int token, Object cookie, Cursor cursor) { + if (token == QUERY_CALLS_TOKEN) { + Message msg = mWorkerThreadHandler.obtainMessage(token); + msg.arg1 = EVENT_PROCESS_DATA; + msg.obj = cursor; + + mWorkerThreadHandler.sendMessage(msg); + } + } + + @Override + public void handleMessage(Message msg) { + if (msg.arg1 == EVENT_PROCESS_DATA) { + final Map calls = + (Map) msg.obj; + final Listener listener = mListener.get(); + if (listener != null) { + listener.onCallsFetched(calls); + } + } else { + super.handleMessage(msg); + } + } + + private Map processData(Cursor cursor) { + final Map result = new HashMap(); + final ArrayList infos = new ArrayList(); + final ArrayList calls = new ArrayList(); + CallStatsDetails pending = null; + + cursor.moveToFirst(); + + while (!cursor.isAfterLast()) { + final String number = cursor.getString(CallStatsQuery.NUMBER); + final long duration = cursor.getLong(CallStatsQuery.DURATION); + final int callType = cursor.getInt(CallStatsQuery.CALL_TYPE); + + if (pending == null || !CallUtil.phoneNumbersEqual(pending.number.toString(), number)) { + final long date = cursor.getLong(CallStatsQuery.DATE); + final int numberPresentation = cursor.getInt(CallStatsQuery.NUMBER_PRESENTATION); + final String countryIso = cursor.getString(CallStatsQuery.COUNTRY_ISO); + final String geocode = cursor.getString(CallStatsQuery.GEOCODED_LOCATION); + final ContactInfo info = getContactInfoFromCallStats(cursor); + + pending = new CallStatsDetails(number, numberPresentation, + info, countryIso, geocode, date); + infos.add(info); + calls.add(pending); + } + + pending.addTimeOrMissed(callType, duration); + cursor.moveToNext(); + } + + cursor.close(); + mergeItemsByNumber(calls, infos); + + for (int i = 0; i < calls.size(); i++) { + result.put(infos.get(i), calls.get(i)); + } + + return result; + } + + private void mergeItemsByNumber(List calls, List infos) { + // temporarily store items marked for removal + final ArrayList callsToRemove = new ArrayList(); + final ArrayList infosToRemove = new ArrayList(); + + for (int i = 0; i < calls.size(); i++) { + final CallStatsDetails outerItem = calls.get(i); + final String currentFormattedNumber = outerItem.number.toString(); + + for (int j = calls.size() - 1; j > i; j--) { + final CallStatsDetails innerItem = calls.get(j); + final String innerNumber = innerItem.number.toString(); + + if (CallUtil.phoneNumbersEqual(currentFormattedNumber, innerNumber)) { + outerItem.mergeWith(innerItem); + //make sure we're not counting twice in case we're dealing with + //multiple different formats + innerItem.reset(); + callsToRemove.add(innerItem); + infosToRemove.add(infos.get(j)); + } + } + } + + for (CallStatsDetails call : callsToRemove) { + calls.remove(call); + } + for (ContactInfo info : infosToRemove) { + infos.remove(info); + } + } + + private ContactInfo getContactInfoFromCallStats(Cursor c) { + ContactInfo info = new ContactInfo(); + info.lookupUri = UriUtils.parseUriOrNull(c.getString(CallStatsQuery.CACHED_LOOKUP_URI)); + info.name = c.getString(CallStatsQuery.CACHED_NAME); + info.type = c.getInt(CallStatsQuery.CACHED_NUMBER_TYPE); + info.label = c.getString(CallStatsQuery.CACHED_NUMBER_LABEL); + + final String matchedNumber = c.getString(CallStatsQuery.CACHED_MATCHED_NUMBER); + info.number = matchedNumber == null ? c.getString(CallStatsQuery.NUMBER) : matchedNumber; + info.normalizedNumber = c.getString(CallStatsQuery.CACHED_NORMALIZED_NUMBER); + info.formattedNumber = c.getString(CallStatsQuery.CACHED_FORMATTED_NUMBER); + + info.photoId = c.getLong(CallStatsQuery.CACHED_PHOTO_ID); + info.photoUri = null; // We do not cache the photo URI. + + return info; + } + + public interface Listener { + void onCallsFetched(Map calls); + } +} diff --git a/src/com/android/dialer/callstats/IntentProvider.java b/src/com/android/dialer/callstats/IntentProvider.java new file mode 100644 index 000000000..8b02d0733 --- /dev/null +++ b/src/com/android/dialer/callstats/IntentProvider.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * Copyright (C) 2013 Android Open Kang 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.callstats; + +import android.content.Context; +import android.content.Intent; + +import com.android.contacts.common.CallUtil; + +/** + * Class to get intents for a phone call or for a detailed statistical view + */ +public abstract class IntentProvider { + public abstract Intent getIntent(Context context); + + public static IntentProvider getReturnCallIntentProvider(final String number) { + return new IntentProvider() { + @Override + public Intent getIntent(Context context) { + return CallUtil.getCallIntent(number); + } + }; + } + + public static IntentProvider getCallStatsDetailIntentProvider(final CallStatsDetails item, + final long from, final long to, final boolean byDuration) { + return new IntentProvider() { + @Override + public Intent getIntent(Context context) { + Intent intent = new Intent(context, CallStatsDetailActivity.class); + intent.putExtra(CallStatsDetailActivity.EXTRA_DETAILS, item); + intent.putExtra(CallStatsDetailActivity.EXTRA_FROM, from); + intent.putExtra(CallStatsDetailActivity.EXTRA_TO, to); + intent.putExtra(CallStatsDetailActivity.EXTRA_BY_DURATION, byDuration); + return intent; + } + }; + } +} diff --git a/src/com/android/dialer/widget/AnchoredScrollView.java b/src/com/android/dialer/widget/AnchoredScrollView.java new file mode 100644 index 000000000..a07b8f798 --- /dev/null +++ b/src/com/android/dialer/widget/AnchoredScrollView.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2013 The CyanogenMod 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.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ScrollView; + +import com.android.dialer.R; + +/** + * A ScrollView that makes sure that the part of the 'anchored' view + * that is defined by the anchor is never scrolled out of view. + */ +public class AnchoredScrollView extends ScrollView { + private int mAnchorId; + private int mAnchoredId; + private boolean mAnchorAtBottom; + + private View mAnchorView; + private View mAnchoredView; + private int mOrigAnchoredTop; + + public AnchoredScrollView(Context context) { + this(context, null); + } + + public AnchoredScrollView(Context context, AttributeSet attrs) { + this(context, attrs, com.android.internal.R.attr.scrollViewStyle); + } + + public AnchoredScrollView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + TypedArray a = + context.obtainStyledAttributes(attrs, R.styleable.AnchoredScrollView); + + mAnchorId = a.getResourceId(R.styleable.AnchoredScrollView_anchorView, 0); + mAnchoredId = a.getResourceId(R.styleable.AnchoredScrollView_anchoredView, 0); + mAnchorAtBottom = a.getBoolean(R.styleable.AnchoredScrollView_anchorAtBottom, false); + + a.recycle(); + } + + private boolean ensureViews() { + if (mAnchorView == null) { + mAnchorView = findViewTraversal(mAnchorId); + } + if (mAnchoredView == null) { + mAnchoredView = findViewTraversal(mAnchoredId); + } + + return mAnchorView != null && mAnchoredView != null; + } + + private int getAnchor() { + return mAnchorAtBottom ? mAnchorView.getBottom() : mAnchorView.getTop(); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + if (ensureViews()) { + mOrigAnchoredTop = mAnchoredView.getTop(); + } + } + + @Override + protected void onScrollChanged(int l, int t, int oldl, int oldt) { + super.onScrollChanged(l, t, oldl, oldt); + + if (!ensureViews()) { + return; + } + + int currentOffset = mAnchoredView.getTop() - mOrigAnchoredTop; + int matchDistance = getAnchor() - getScrollY(); + int desiredOffset = Math.max(-matchDistance, 0); + int neededOffset = desiredOffset - currentOffset; + + if (neededOffset != 0) { + mAnchoredView.offsetTopAndBottom(neededOffset); + } + } +} diff --git a/src/com/android/dialer/widget/DoubleDatePickerDialog.java b/src/com/android/dialer/widget/DoubleDatePickerDialog.java new file mode 100644 index 000000000..1d75e1bab --- /dev/null +++ b/src/com/android/dialer/widget/DoubleDatePickerDialog.java @@ -0,0 +1,336 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * Copyright (C) 2013 Android Open Kang 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.widget; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.os.Bundle; +import android.text.format.DateUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemSelectedListener; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.DatePicker; +import android.widget.DatePicker.OnDateChangedListener; +import android.widget.Spinner; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.Calendar; + +import com.android.dialer.R; + +/** + * Alertdialog with two date pickers - one for a start and one for an end date. + * Used to filter the callstats query. + */ +public class DoubleDatePickerDialog extends AlertDialog + implements OnClickListener, OnDateChangedListener, OnItemSelectedListener { + + private static final String TAG = "DoubleDatePickerDialog"; + + public interface OnDateSetListener { + void onDateSet(long from, long to); + } + + public static class Fragment extends DialogFragment implements OnDateSetListener { + private DoubleDatePickerDialog mDialog; + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + mDialog = new DoubleDatePickerDialog(getActivity(), this); + return mDialog; + } + + @Override + public void onStart() { + final Bundle args = getArguments(); + final long from = args.getLong("from", -1); + final long to = args.getLong("to", -1); + + if (from != -1) { + mDialog.setValues(from, to); + } else { + mDialog.resetPickers(); + } + super.onStart(); + } + + @Override + public void onDateSet(long from, long to) { + ((DoubleDatePickerDialog.OnDateSetListener) getActivity()).onDateSet(from, to); + } + + public static Bundle createArguments(long from, long to) { + final Bundle args = new Bundle(); + args.putLong("from", from); + args.putLong("to", to); + return args; + } + } + + private interface QuickSelection { + void adjustStartDate(Calendar date); + } + + private static final int[] QUICKSELECTION_ENTRIES = new int[] { + R.string.date_qs_currentmonth, + R.string.date_qs_currentquarter, + R.string.date_qs_currentyear, + R.string.date_qs_lastweek, + R.string.date_qs_lastmonth, + R.string.date_qs_lastquarter, + R.string.date_qs_lastyear + }; + + private static final QuickSelection[] QUICKSELECTIONS = new QuickSelection[] { + new QuickSelection() { + @Override + public void adjustStartDate(Calendar date) { + date.set(Calendar.DAY_OF_MONTH, 1); + } + }, + new QuickSelection() { + @Override + public void adjustStartDate(Calendar date) { + final int currentMonth = date.get(Calendar.MONTH); + date.set(Calendar.MONTH, currentMonth - (currentMonth % 3)); + date.set(Calendar.DAY_OF_MONTH, 1); + } + }, + new QuickSelection() { + @Override + public void adjustStartDate(Calendar date) { + date.set(Calendar.MONTH, 0); + date.set(Calendar.DAY_OF_MONTH, 1); + } + }, + new QuickSelection() { + @Override + public void adjustStartDate(Calendar date) { + date.add(Calendar.WEEK_OF_YEAR, -1); + } + }, + new QuickSelection() { + @Override + public void adjustStartDate(Calendar date) { + date.add(Calendar.MONTH, -1); + } + }, + new QuickSelection() { + @Override + public void adjustStartDate(Calendar date) { + date.add(Calendar.MONTH, -3); + } + }, + new QuickSelection() { + @Override + public void adjustStartDate(Calendar date) { + date.add(Calendar.YEAR, -1); + } + }, + }; + + private static final String YEAR = "year"; + private static final String MONTH = "month"; + private static final String DAY = "day"; + + private final Spinner mQuickSelSpinner; + private final DatePicker mDatePickerFrom; + private final DatePicker mDatePickerTo; + private final OnDateSetListener mCallBack; + private Button mOkButton; + private int mQuickSelSelection = -1; + + public DoubleDatePickerDialog(final Context context, OnDateSetListener callBack) { + super(context); + + mCallBack = callBack; + + setTitle(R.string.call_stats_filter_picker_title); + setButton(BUTTON_NEGATIVE, context.getString(android.R.string.cancel), this); + setButton(BUTTON_POSITIVE, context.getString(android.R.string.ok), this); + setIcon(0); + + LayoutInflater inflater = + (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(R.layout.double_date_picker_dialog, null); + setView(view); + + mDatePickerFrom = (DatePicker) view.findViewById(R.id.date_picker_from); + mDatePickerTo = (DatePicker) view.findViewById(R.id.date_picker_to); + + ArrayList quickSelEntries = new ArrayList(); + for (int entryId : QUICKSELECTION_ENTRIES) { + quickSelEntries.add(context.getString(entryId)); + } + ArrayAdapter quickSelAdapter = new ArrayAdapter( + context, android.R.layout.simple_spinner_item, + android.R.id.text1, quickSelEntries) { + @Override + public View getView(int position, View convertView, android.view.ViewGroup parent) { + final TextView v = (TextView) super.getView(position, convertView, parent); + v.setText(context.getString(R.string.date_quick_selection)); + return v; + } + }; + quickSelAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + + mQuickSelSpinner = (Spinner) view.findViewById(R.id.date_quick_selection); + mQuickSelSpinner.setOnItemSelectedListener(this); + mQuickSelSpinner.setAdapter(quickSelAdapter); + + resetPickers(); + } + + @Override + protected void onStart() { + super.onStart(); + mOkButton = getButton(DialogInterface.BUTTON_POSITIVE); + updateOkButtonState(); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + switch (which) { + case BUTTON_POSITIVE: + tryNotifyDateSet(); + break; + case BUTTON_NEGATIVE: + break; + } + } + + @Override + public void onItemSelected(AdapterView parent, View view, int pos, long id) { + if (mQuickSelSelection >= 0) { + QuickSelection sel = QUICKSELECTIONS[pos]; + Calendar from = Calendar.getInstance(); + long millisTo = from.getTimeInMillis(); + sel.adjustStartDate(from); + long millisFrom = from.getTimeInMillis(); + + setValues(millisFrom, millisTo); + } + mQuickSelSelection = pos; + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + + public void onDateChanged(DatePicker view, int year, + int month, int day) { + view.init(year, month, day, this); + updateOkButtonState(); + } + + public void setValues(long millisFrom, long millisTo) { + setPicker(mDatePickerFrom, millisFrom); + setPicker(mDatePickerTo, millisTo); + updateOkButtonState(); + } + + public void resetPickers() { + long millis = System.currentTimeMillis(); + setPicker(mDatePickerFrom, millis); + setPicker(mDatePickerTo, millis); + updateOkButtonState(); + } + + private void setPicker(DatePicker picker, long millis) { + Calendar c = Calendar.getInstance(); + c.setTimeInMillis(millis); + + int year = c.get(Calendar.YEAR); + int month = c.get(Calendar.MONTH); + int day = c.get(Calendar.DAY_OF_MONTH); + + picker.init(year, month, day, this); + } + + private long getMillisForPicker(DatePicker picker, boolean endOfDay) { + Calendar c = Calendar.getInstance(); + c.set(Calendar.YEAR, picker.getYear()); + c.set(Calendar.MONTH, picker.getMonth()); + c.set(Calendar.DAY_OF_MONTH, picker.getDayOfMonth()); + c.set(Calendar.HOUR_OF_DAY, 0); + c.set(Calendar.MINUTE, 0); + c.set(Calendar.SECOND, 0); + + long millis = c.getTimeInMillis(); + if (endOfDay) { + millis += 24L * 60L * 60L * 1000L - 1L; + } + + return millis; + } + + private void updateOkButtonState() { + if (mOkButton != null) { + long millisFrom = getMillisForPicker(mDatePickerFrom, false); + long millisTo = getMillisForPicker(mDatePickerTo, true); + mOkButton.setEnabled(millisFrom < millisTo); + } + } + + private void tryNotifyDateSet() { + if (mCallBack != null) { + mDatePickerFrom.clearFocus(); + mDatePickerTo.clearFocus(); + + long millisFrom = getMillisForPicker(mDatePickerFrom, false); + long millisTo = getMillisForPicker(mDatePickerTo, true); + + mCallBack.onDateSet(millisFrom, millisTo); + } + } + + // users like to play with it, so save the state and don't reset each time + @Override + public Bundle onSaveInstanceState() { + Bundle state = super.onSaveInstanceState(); + state.putInt("F_" + YEAR, mDatePickerFrom.getYear()); + state.putInt("F_" + MONTH, mDatePickerFrom.getMonth()); + state.putInt("F_" + DAY, mDatePickerFrom.getDayOfMonth()); + state.putInt("T_" + YEAR, mDatePickerTo.getYear()); + state.putInt("T_" + MONTH, mDatePickerTo.getMonth()); + state.putInt("T_" + DAY, mDatePickerTo.getDayOfMonth()); + return state; + } + + @Override + public void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + int fyear = savedInstanceState.getInt("F_" + YEAR); + int fmonth = savedInstanceState.getInt("F_" + MONTH); + int fday = savedInstanceState.getInt("F_" + DAY); + int tyear = savedInstanceState.getInt("T_" + YEAR); + int tmonth = savedInstanceState.getInt("T_" + MONTH); + int tday = savedInstanceState.getInt("T_" + DAY); + mDatePickerFrom.init(fyear, fmonth, fday, this); + mDatePickerTo.init(tyear, tmonth, tday, this); + } +} diff --git a/src/com/android/dialer/widget/LinearColorBar.java b/src/com/android/dialer/widget/LinearColorBar.java new file mode 100644 index 000000000..0b824af55 --- /dev/null +++ b/src/com/android/dialer/widget/LinearColorBar.java @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * Copyright (C) 2013 Android Open Kang 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.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Rect; +import android.graphics.Shader; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.widget.LinearLayout; + +import com.android.dialer.R; + +public class LinearColorBar extends LinearLayout { + private float mFirstRatio; + private float mSecondRatio; + private float mThirdRatio; + + private int mBackgroundColor; + private int mBlueColor; + private int mGreenColor; + private int mRedColor; + + final Rect mRect = new Rect(); + final Paint mPaint = new Paint(); + + int mLastInterestingLeft, mLastInterestingRight; + int mLineWidth; + + final Path mColorPath = new Path(); + final Path mEdgePath = new Path(); + final Paint mColorGradientPaint = new Paint(); + final Paint mEdgeGradientPaint = new Paint(); + + public LinearColorBar(Context context, AttributeSet attrs) { + super(context, attrs); + setWillNotDraw(false); + + TypedArray a = context.obtainStyledAttributes( + attrs, R.styleable.LinearColorBar, 0, 0); + int n = a.getIndexCount(); + + for (int i = 0; i < n; i++) { + int attr = a.getIndex(i); + + switch (attr) { + case R.styleable.LinearColorBar_backgroundColor: + mBackgroundColor = a.getInt(attr, 0); + break; + case R.styleable.LinearColorBar_redColor: + mRedColor = a.getInt(attr, 0); + break; + case R.styleable.LinearColorBar_greenColor: + mGreenColor = a.getInt(attr, 0); + break; + case R.styleable.LinearColorBar_blueColor: + mBlueColor = a.getInt(attr, 0); + break; + } + } + + a.recycle(); + + mPaint.setStyle(Paint.Style.FILL); + mColorGradientPaint.setStyle(Paint.Style.FILL); + mColorGradientPaint.setAntiAlias(true); + mEdgeGradientPaint.setStyle(Paint.Style.STROKE); + mLineWidth = getResources().getDisplayMetrics().densityDpi >= DisplayMetrics.DENSITY_HIGH + ? 2 : 1; + mEdgeGradientPaint.setStrokeWidth(mLineWidth); + mEdgeGradientPaint.setAntiAlias(true); + } + + public void setRatios(float blue, float green, float red) { + mFirstRatio = blue; + mSecondRatio = green; + mThirdRatio = red; + invalidate(); + } + + private void updateIndicator() { + int off = getPaddingTop() - getPaddingBottom(); + if (off < 0) + off = 0; + mRect.top = off; + mRect.bottom = getHeight(); + + mColorGradientPaint.setShader(new LinearGradient( + 0, 0, 0, off - 2, mBackgroundColor & 0xffffff, + mBackgroundColor, Shader.TileMode.CLAMP)); + mEdgeGradientPaint.setShader(new LinearGradient( + 0, 0, 0, off / 2, 0x00a0a0a0, 0xffa0a0a0, Shader.TileMode.CLAMP)); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + updateIndicator(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + int width = getWidth(); + + int left = 0; + + int right = left + (int) (width * mFirstRatio); + int right2 = right + (int) (width * mSecondRatio); + int right3 = right2 + (int) (width * mThirdRatio); + + int indicatorLeft = right3; + int indicatorRight = width; + + if (mLastInterestingLeft != indicatorLeft || mLastInterestingRight != indicatorRight) { + mColorPath.reset(); + mEdgePath.reset(); + if (indicatorLeft < indicatorRight) { + final int midTopY = mRect.top; + final int midBottomY = 0; + final int xoff = 2; + mColorPath.moveTo(indicatorLeft, mRect.top); + mColorPath.cubicTo(indicatorLeft, midBottomY, + -xoff, midTopY, + -xoff, 0); + mColorPath.lineTo(width + xoff - 1, 0); + mColorPath.cubicTo(width + xoff - 1, midTopY, + indicatorRight, midBottomY, + indicatorRight, mRect.top); + mColorPath.close(); + final float lineOffset = mLineWidth + .5f; + mEdgePath.moveTo(-xoff + lineOffset, 0); + mEdgePath.cubicTo(-xoff + lineOffset, midTopY, + indicatorLeft + lineOffset, midBottomY, + indicatorLeft + lineOffset, mRect.top); + mEdgePath.moveTo(width + xoff - 1 - lineOffset, 0); + mEdgePath.cubicTo(width + xoff - 1 - lineOffset, midTopY, + indicatorRight - lineOffset, midBottomY, + indicatorRight - lineOffset, mRect.top); + } + mLastInterestingLeft = indicatorLeft; + mLastInterestingRight = indicatorRight; + } + + if (!mEdgePath.isEmpty()) { + canvas.drawPath(mEdgePath, mEdgeGradientPaint); + canvas.drawPath(mColorPath, mColorGradientPaint); + } + + if (left < right) { + mRect.left = left; + mRect.right = right; + mPaint.setColor(mBlueColor); + canvas.drawRect(mRect, mPaint); + width -= (right - left); + left = right; + } + + right = right2; + + if (left < right) { + mRect.left = left; + mRect.right = right; + mPaint.setColor(mGreenColor); + canvas.drawRect(mRect, mPaint); + width -= (right - left); + left = right; + } + + right = right3; + + if (left < right) { + mRect.left = left; + mRect.right = right; + mPaint.setColor(mRedColor); + canvas.drawRect(mRect, mPaint); + width -= (right - left); + left = right; + } + + right = left + width; + if (left < right) { + mRect.left = left; + mRect.right = right; + mPaint.setColor(mBackgroundColor); + canvas.drawRect(mRect, mPaint); + } + } +} diff --git a/src/com/android/dialer/widget/PieChartView.java b/src/com/android/dialer/widget/PieChartView.java new file mode 100644 index 000000000..10df10da5 --- /dev/null +++ b/src/com/android/dialer/widget/PieChartView.java @@ -0,0 +1,261 @@ +/* + * 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.widget; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.graphics.Path; +import android.graphics.Path.Direction; +import android.graphics.RadialGradient; +import android.graphics.RectF; +import android.graphics.Shader.TileMode; +import android.util.AttributeSet; +import android.view.View; + +import com.android.dialer.R; +import com.google.common.collect.Lists; + +import java.util.ArrayList; + +/** + * Pie chart with multiple items. + */ +public class PieChartView extends View { + public static final String TAG = "PieChartView"; + public static final boolean LOGD = false; + + private static final boolean FILL_GRADIENT = false; + + private ArrayList mSlices = Lists.newArrayList(); + + private int mOriginAngle; + private Matrix mMatrix = new Matrix(); + + private Paint mPaintOutline = new Paint(); + + private Path mPathSide = new Path(); + private Path mPathSideOutline = new Path(); + + private Path mPathOutline = new Path(); + + private int mSideWidth; + + public class Slice { + public long value; + + public Path path = new Path(); + public Path pathSide = new Path(); + public Path pathOutline = new Path(); + + public Paint paint; + + public Slice(long value, int color) { + this.value = value; + this.paint = buildFillPaint(color, getResources()); + } + } + + public PieChartView(Context context) { + this(context, null); + } + + public PieChartView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public PieChartView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + TypedArray a = context.obtainStyledAttributes(attrs, + R.styleable.PieChartView, 0, 0); + int n = a.getIndexCount(); + int outlineColor = 0; + + for (int i = 0; i < n; i++) { + int attr = a.getIndex(i); + + switch (attr) { + case R.styleable.PieChartView_outlineColor: + outlineColor = a.getInt(attr, 0); + break; + } + } + + a.recycle(); + + mPaintOutline.setColor(outlineColor); + mPaintOutline.setStyle(Style.STROKE); + mPaintOutline.setStrokeWidth(3f * getResources().getDisplayMetrics().density); + mPaintOutline.setAntiAlias(true); + + mSideWidth = (int) (20 * getResources().getDisplayMetrics().density); + + setWillNotDraw(false); + } + + private static Paint buildFillPaint(int color, Resources res) { + final Paint paint = new Paint(); + + paint.setColor(color); + paint.setStyle(Style.FILL_AND_STROKE); + paint.setAntiAlias(true); + + if (FILL_GRADIENT) { + final int width = (int) (280 * res.getDisplayMetrics().density); + paint.setShader(new RadialGradient(0, 0, width, color, darken(color), TileMode.MIRROR)); + } + + return paint; + } + + public void setOriginAngle(int originAngle) { + mOriginAngle = originAngle; + } + + public void addSlice(long value, int color) { + mSlices.add(new Slice(value, color)); + } + + public void removeAllSlices() { + mSlices.clear(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + final float centerX = getWidth() / 2; + final float centerY = getHeight() / 2; + + mMatrix.reset(); + mMatrix.postScale(0.665f, 0.95f, centerX, centerY); + mMatrix.postRotate(-40, centerX, centerY); + + generatePath(); + } + + public void generatePath() { + + long total = 0; + for (Slice slice : mSlices) { + slice.path.reset(); + slice.pathSide.reset(); + slice.pathOutline.reset(); + total += slice.value; + } + + mPathSide.reset(); + mPathSideOutline.reset(); + mPathOutline.reset(); + + // bail when not enough stats to render + if (total == 0) { + invalidate(); + return; + } + + final int width = getWidth(); + final int height = getHeight(); + + final RectF rect = new RectF(0, 0, width, height); + final RectF rectSide = new RectF(); + rectSide.set(rect); + rectSide.offset(-mSideWidth, 0); + + mPathSide.addOval(rectSide, Direction.CW); + mPathSideOutline.addOval(rectSide, Direction.CW); + mPathOutline.addOval(rect, Direction.CW); + + int startAngle = mOriginAngle; + for (Slice slice : mSlices) { + final int sweepAngle = (int) (slice.value * 360 / total); + final int endAngle = startAngle + sweepAngle; + + final float startAngleMod = startAngle % 360; + final float endAngleMod = endAngle % 360; + final boolean startSideVisible = startAngleMod > 90 && startAngleMod < 270; + final boolean endSideVisible = endAngleMod > 90 && endAngleMod < 270; + + // draw slice + slice.path.moveTo(rect.centerX(), rect.centerY()); + slice.path.arcTo(rect, startAngle, sweepAngle); + slice.path.lineTo(rect.centerX(), rect.centerY()); + + if (startSideVisible || endSideVisible) { + + // when start is beyond horizon, push until visible + final float startAngleSide = startSideVisible ? startAngle : 450; + final float endAngleSide = endSideVisible ? endAngle : 270; + final float sweepAngleSide = endAngleSide - startAngleSide; + + // draw slice side + slice.pathSide.moveTo(rect.centerX(), rect.centerY()); + slice.pathSide.arcTo(rect, startAngleSide, 0); + slice.pathSide.rLineTo(-mSideWidth, 0); + slice.pathSide.arcTo(rectSide, startAngleSide, sweepAngleSide); + slice.pathSide.rLineTo(mSideWidth, 0); + slice.pathSide.arcTo(rect, endAngleSide, -sweepAngleSide); + } + + // draw slice outline + slice.pathOutline.moveTo(rect.centerX(), rect.centerY()); + slice.pathOutline.arcTo(rect, startAngle, 0); + if (startSideVisible) { + slice.pathOutline.rLineTo(-mSideWidth, 0); + } + slice.pathOutline.moveTo(rect.centerX(), rect.centerY()); + slice.pathOutline.arcTo(rect, startAngle + sweepAngle, 0); + if (endSideVisible) { + slice.pathOutline.rLineTo(-mSideWidth, 0); + } + + startAngle += sweepAngle; + } + + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + + canvas.concat(mMatrix); + + for (Slice slice : mSlices) { + canvas.drawPath(slice.pathSide, slice.paint); + } + canvas.drawPath(mPathSideOutline, mPaintOutline); + + for (Slice slice : mSlices) { + canvas.drawPath(slice.path, slice.paint); + canvas.drawPath(slice.pathOutline, mPaintOutline); + } + canvas.drawPath(mPathOutline, mPaintOutline); + } + + public static int darken(int color) { + float[] hsv = new float[3]; + Color.colorToHSV(color, hsv); + hsv[2] /= 2; + hsv[1] /= 2; + return Color.HSVToColor(hsv); + } + +} -- cgit v1.2.3