diff options
Diffstat (limited to 'src/com')
108 files changed, 10781 insertions, 2538 deletions
diff --git a/src/com/android/dialer/CallDetailActivity.java b/src/com/android/dialer/CallDetailActivity.java index 56f2cb1dc..6c8d7708a 100644 --- a/src/com/android/dialer/CallDetailActivity.java +++ b/src/com/android/dialer/CallDetailActivity.java @@ -16,55 +16,57 @@ package com.android.dialer; -import android.app.Activity; -import android.content.ContentResolver; import android.content.ContentUris; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.net.Uri; import android.os.Bundle; -import android.os.PowerManager; import android.provider.ContactsContract.CommonDataKinds.Phone; -import android.provider.VoicemailContract.Voicemails; -import android.telecom.PhoneAccount; -import android.telecom.PhoneAccountHandle; -import android.telephony.TelephonyManager; +import android.support.v7.app.AppCompatActivity; import android.text.BidiFormatter; import android.text.TextDirectionHeuristics; import android.text.TextUtils; import android.util.Log; -import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; +import android.view.MotionEvent; import android.view.View; -import android.widget.LinearLayout; import android.widget.ListView; import android.widget.QuickContactBadge; import android.widget.TextView; import android.widget.Toast; +import com.android.contacts.common.CallUtil; +import com.android.contacts.common.ClipboardUtils; import com.android.contacts.common.ContactPhotoManager; import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest; -import com.android.contacts.common.util.PermissionsUtil; import com.android.contacts.common.GeoUtil; -import com.android.contacts.common.CallUtil; +import com.android.contacts.common.compat.CompatUtils; +import com.android.contacts.common.interactions.TouchPointManager; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.contacts.common.testing.NeededForTesting; import com.android.contacts.common.util.UriUtils; import com.android.dialer.calllog.CallDetailHistoryAdapter; -import com.android.dialer.calllog.CallLogAsyncTaskUtil.CallLogAsyncTaskListener; import com.android.dialer.calllog.CallLogAsyncTaskUtil; +import com.android.dialer.calllog.CallLogAsyncTaskUtil.CallLogAsyncTaskListener; import com.android.dialer.calllog.CallTypeHelper; -import com.android.dialer.calllog.ContactInfo; import com.android.dialer.calllog.ContactInfoHelper; import com.android.dialer.calllog.PhoneAccountUtils; -import com.android.dialer.calllog.PhoneNumberDisplayUtil; +import com.android.dialer.compat.FilteredNumberCompat; +import com.android.dialer.database.FilteredNumberAsyncQueryHandler; +import com.android.dialer.database.FilteredNumberAsyncQueryHandler.OnCheckBlockedListener; +import com.android.dialer.filterednumber.BlockNumberDialogFragment; +import com.android.dialer.filterednumber.FilteredNumbersUtil; +import com.android.dialer.filterednumber.MigrateBlockedNumbersDialogFragment; +import com.android.dialer.logging.InteractionEvent; +import com.android.dialer.logging.Logger; import com.android.dialer.util.DialerUtils; -import com.android.dialer.util.IntentUtil; +import com.android.dialer.util.IntentUtil.CallIntentBuilder; import com.android.dialer.util.PhoneNumberUtil; import com.android.dialer.util.TelecomUtil; - -import java.util.List; +import com.android.incallui.Call.LogState; /** * Displays the details of a specific call log entry. @@ -72,9 +74,10 @@ import java.util.List; * This activity can be either started with the URI of a single call log entry, or with the * {@link #EXTRA_CALL_LOG_IDS} extra to specify a group of call log entries. */ -public class CallDetailActivity extends Activity - implements MenuItem.OnMenuItemClickListener { - private static final String TAG = "CallDetail"; +public class CallDetailActivity extends AppCompatActivity + implements MenuItem.OnMenuItemClickListener, View.OnClickListener, + BlockNumberDialogFragment.Callback { + private static final String TAG = CallDetailActivity.class.getSimpleName(); /** A long array extra containing ids of call log entries to display. */ public static final String EXTRA_CALL_LOG_IDS = "EXTRA_CALL_LOG_IDS"; @@ -106,31 +109,29 @@ public class CallDetailActivity extends Activity return; } - // We know that all calls are from the same number and the same contact, so pick the - // first. - PhoneCallDetails firstDetails = details[0]; - mNumber = TextUtils.isEmpty(firstDetails.number) ? - null : firstDetails.number.toString(); - final int numberPresentation = firstDetails.numberPresentation; - final Uri contactUri = firstDetails.contactUri; - final Uri photoUri = firstDetails.photoUri; - final PhoneAccountHandle accountHandle = firstDetails.accountHandle; - - // Cache the details about the phone number. - final boolean canPlaceCallsTo = - PhoneNumberUtil.canPlaceCallsTo(mNumber, numberPresentation); - mIsVoicemailNumber = - PhoneNumberUtil.isVoicemailNumber(mContext, accountHandle, mNumber); - final boolean isSipNumber = PhoneNumberUtil.isSipNumber(mNumber); + // All calls are from the same number and same contact, so pick the first detail. + mDetails = details[0]; + mNumber = TextUtils.isEmpty(mDetails.number) ? null : mDetails.number.toString(); + mPostDialDigits = TextUtils.isEmpty(mDetails.postDialDigits) + ? "" : mDetails.postDialDigits; + mDisplayNumber = mDetails.displayNumber; - final CharSequence callLocationOrType = getNumberTypeOrLocation(firstDetails); + final CharSequence callLocationOrType = getNumberTypeOrLocation(mDetails); + + final CharSequence displayNumber; + if (!TextUtils.isEmpty(mDetails.postDialDigits)) { + displayNumber = mDetails.number + mDetails.postDialDigits; + } else { + displayNumber = mDetails.displayNumber; + } - final CharSequence displayNumber = firstDetails.displayNumber; final String displayNumberStr = mBidiFormatter.unicodeWrap( displayNumber.toString(), TextDirectionHeuristics.LTR); - if (!TextUtils.isEmpty(firstDetails.name)) { - mCallerName.setText(firstDetails.name); + mDetails.nameDisplayOrder = mContactsPreferences.getDisplayOrder(); + + if (!TextUtils.isEmpty(mDetails.getPreferredName())) { + mCallerName.setText(mDetails.getPreferredName()); mCallerNumber.setText(callLocationOrType + " " + displayNumberStr); } else { mCallerName.setText(displayNumberStr); @@ -142,9 +143,8 @@ public class CallDetailActivity extends Activity } } - mCallButton.setVisibility(canPlaceCallsTo ? View.VISIBLE : View.GONE); - - String accountLabel = PhoneAccountUtils.getAccountLabel(mContext, accountHandle); + String accountLabel = + PhoneAccountUtils.getAccountLabel(mContext, mDetails.accountHandle); if (!TextUtils.isEmpty(accountLabel)) { mAccountLabel.setText(accountLabel); mAccountLabel.setVisibility(View.VISIBLE); @@ -152,35 +152,33 @@ public class CallDetailActivity extends Activity mAccountLabel.setVisibility(View.GONE); } - mHasEditNumberBeforeCallOption = - canPlaceCallsTo && !isSipNumber && !mIsVoicemailNumber; - mHasReportMenuOption = mContactInfoHelper.canReportAsInvalid( - firstDetails.sourceType, firstDetails.objectId); - invalidateOptionsMenu(); - - ListView historyList = (ListView) findViewById(R.id.history); - historyList.setAdapter( - new CallDetailHistoryAdapter(mContext, mInflater, mCallTypeHelper, details)); + final boolean canPlaceCallsTo = + PhoneNumberUtil.canPlaceCallsTo(mNumber, mDetails.numberPresentation); + mCallButton.setVisibility(canPlaceCallsTo ? View.VISIBLE : View.GONE); + mCopyNumberActionItem.setVisibility(canPlaceCallsTo ? View.VISIBLE : View.GONE); + mBlockNumberActionItem.setVisibility(canPlaceCallsTo ? View.VISIBLE : View.GONE); - String lookupKey = contactUri == null ? null - : UriUtils.getLookupKeyFromUri(contactUri); + final boolean isSipNumber = PhoneNumberUtil.isSipNumber(mNumber); + final boolean isVoicemailNumber = + PhoneNumberUtil.isVoicemailNumber(mContext, mDetails.accountHandle, mNumber); + final boolean showEditNumberBeforeCallAction = + canPlaceCallsTo && !isSipNumber && !isVoicemailNumber; + mEditBeforeCallActionItem.setVisibility( + showEditNumberBeforeCallAction ? View.VISIBLE : View.GONE); + + final boolean showReportAction = mContactInfoHelper.canReportAsInvalid( + mDetails.sourceType, mDetails.objectId); + mReportActionItem.setVisibility( + showReportAction ? View.VISIBLE : View.GONE); - final boolean isBusiness = mContactInfoHelper.isBusiness(firstDetails.sourceType); + invalidateOptionsMenu(); - final int contactType = - mIsVoicemailNumber ? ContactPhotoManager.TYPE_VOICEMAIL : - isBusiness ? ContactPhotoManager.TYPE_BUSINESS : - ContactPhotoManager.TYPE_DEFAULT; + mHistoryList.setAdapter( + new CallDetailHistoryAdapter(mContext, mInflater, mCallTypeHelper, details)); - String nameForDefaultImage; - if (TextUtils.isEmpty(firstDetails.name)) { - nameForDefaultImage = firstDetails.displayNumber; - } else { - nameForDefaultImage = firstDetails.name.toString(); - } + updateFilteredNumberChanges(); + updateContactPhoto(); - loadContactPhotos( - contactUri, photoUri, nameForDefaultImage, lookupKey, contactType); findViewById(R.id.call_detail).setVisibility(View.VISIBLE); } @@ -192,7 +190,7 @@ public class CallDetailActivity extends Activity * @return The phone number type or location. */ private CharSequence getNumberTypeOrLocation(PhoneCallDetails details) { - if (!TextUtils.isEmpty(details.name)) { + if (!TextUtils.isEmpty(details.namePrimary)) { return Phone.getTypeLabel(mResources, details.numberType, details.numberLabel); } else { @@ -202,64 +200,92 @@ public class CallDetailActivity extends Activity }; private Context mContext; + private ContactInfoHelper mContactInfoHelper; + private ContactsPreferences mContactsPreferences; private CallTypeHelper mCallTypeHelper; + private ContactPhotoManager mContactPhotoManager; + private FilteredNumberAsyncQueryHandler mFilteredNumberAsyncQueryHandler; + private BidiFormatter mBidiFormatter = BidiFormatter.getInstance(); + private LayoutInflater mInflater; + private Resources mResources; + + private PhoneCallDetails mDetails; + protected String mNumber; + private Uri mVoicemailUri; + private String mPostDialDigits = ""; + private String mDisplayNumber; + + private ListView mHistoryList; private QuickContactBadge mQuickContactBadge; private TextView mCallerName; private TextView mCallerNumber; private TextView mAccountLabel; private View mCallButton; - private ContactInfoHelper mContactInfoHelper; - - protected String mNumber; - private boolean mIsVoicemailNumber; - private String mDefaultCountryIso; - - /* package */ LayoutInflater mInflater; - /* package */ Resources mResources; - /** Helper to load contact photos. */ - private ContactPhotoManager mContactPhotoManager; - private Uri mVoicemailUri; - private BidiFormatter mBidiFormatter = BidiFormatter.getInstance(); + private TextView mBlockNumberActionItem; + private View mEditBeforeCallActionItem; + private View mReportActionItem; + private View mCopyNumberActionItem; - /** Whether we should show "edit number before call" in the options menu. */ - private boolean mHasEditNumberBeforeCallOption; - private boolean mHasReportMenuOption; + private Integer mBlockedNumberId; @Override protected void onCreate(Bundle icicle) { super.onCreate(icicle); mContext = this; - - setContentView(R.layout.call_detail); - - mInflater = (LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE); mResources = getResources(); - + mContactInfoHelper = new ContactInfoHelper(this, GeoUtil.getCurrentCountryIso(this)); + mContactsPreferences = new ContactsPreferences(mContext); mCallTypeHelper = new CallTypeHelper(getResources()); + mFilteredNumberAsyncQueryHandler = + new FilteredNumberAsyncQueryHandler(getContentResolver()); mVoicemailUri = getIntent().getParcelableExtra(EXTRA_VOICEMAIL_URI); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + setContentView(R.layout.call_detail); + mInflater = (LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE); + + mHistoryList = (ListView) findViewById(R.id.history); + mHistoryList.addHeaderView(mInflater.inflate(R.layout.call_detail_header, null)); + mHistoryList.addFooterView( + mInflater.inflate(R.layout.call_detail_footer, null), null, false); + mQuickContactBadge = (QuickContactBadge) findViewById(R.id.quick_contact_photo); mQuickContactBadge.setOverlay(null); - mQuickContactBadge.setPrioritizedMimeType(Phone.CONTENT_ITEM_TYPE); + if (CompatUtils.hasPrioritizedMimeType()) { + mQuickContactBadge.setPrioritizedMimeType(Phone.CONTENT_ITEM_TYPE); + } mCallerName = (TextView) findViewById(R.id.caller_name); mCallerNumber = (TextView) findViewById(R.id.caller_number); mAccountLabel = (TextView) findViewById(R.id.phone_account_label); - mDefaultCountryIso = GeoUtil.getCurrentCountryIso(this); mContactPhotoManager = ContactPhotoManager.getInstance(this); - mCallButton = (View) findViewById(R.id.call_back_button); + mCallButton = findViewById(R.id.call_back_button); mCallButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - mContext.startActivity(IntentUtil.getCallIntent(mNumber)); + if (TextUtils.isEmpty(mNumber)) { + return; + } + mContext.startActivity( + new CallIntentBuilder(getDialableNumber()) + .setCallInitiationType(LogState.INITIATION_CALL_DETAILS) + .build()); } }); - mContactInfoHelper = new ContactInfoHelper(this, GeoUtil.getCurrentCountryIso(this)); - getActionBar().setDisplayHomeAsUpEnabled(true); + mBlockNumberActionItem = (TextView) findViewById(R.id.call_detail_action_block); + mBlockNumberActionItem.setOnClickListener(this); + mEditBeforeCallActionItem = findViewById(R.id.call_detail_action_edit_before_call); + mEditBeforeCallActionItem.setOnClickListener(this); + mReportActionItem = findViewById(R.id.call_detail_action_report); + mReportActionItem.setOnClickListener(this); + + mCopyNumberActionItem = findViewById(R.id.call_detail_action_copy); + mCopyNumberActionItem.setOnClickListener(this); if (getIntent().getBooleanExtra(EXTRA_FROM_NOTIFICATION, false)) { closeSystemDialogs(); @@ -269,15 +295,20 @@ public class CallDetailActivity extends Activity @Override public void onResume() { super.onResume(); + mContactsPreferences.refreshValue(ContactsPreferences.DISPLAY_ORDER_KEY); getCallDetails(); } - public void getCallDetails() { - CallLogAsyncTaskUtil.getCallDetails(this, getCallLogEntryUris(), mCallLogAsyncTaskListener); + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + if (ev.getAction() == MotionEvent.ACTION_DOWN) { + TouchPointManager.getInstance().setPoint((int) ev.getRawX(), (int) ev.getRawY()); + } + return super.dispatchTouchEvent(ev); } - private boolean hasVoicemail() { - return mVoicemailUri != null; + public void getCallDetails() { + CallLogAsyncTaskUtil.getCallDetails(this, getCallLogEntryUris(), mCallLogAsyncTaskListener); } /** @@ -304,50 +335,27 @@ public class CallDetailActivity extends Activity return uris; } - /** Load the contact photos and places them in the corresponding views. */ - private void loadContactPhotos(Uri contactUri, Uri photoUri, String displayName, - String lookupKey, int contactType) { - - 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, - false /* darkTheme */, true /* isCircular */, request); - } - @Override public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.call_details_options, menu); - return super.onCreateOptionsMenu(menu); - } + final MenuItem deleteMenuItem = menu.add( + Menu.NONE, + R.id.call_detail_delete_menu_item, + Menu.NONE, + R.string.call_details_delete); + deleteMenuItem.setIcon(R.drawable.ic_delete_24dp); + deleteMenuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); + deleteMenuItem.setOnMenuItemClickListener(this); - @Override - public boolean onPrepareOptionsMenu(Menu menu) { - // This action deletes all elements in the group from the call log. - // We don't have this action for voicemails, because you can just use the trash button. - menu.findItem(R.id.menu_remove_from_call_log) - .setVisible(!hasVoicemail()) - .setOnMenuItemClickListener(this); - menu.findItem(R.id.menu_edit_number_before_call) - .setVisible(mHasEditNumberBeforeCallOption) - .setOnMenuItemClickListener(this); - menu.findItem(R.id.menu_trash) - .setVisible(hasVoicemail()) - .setOnMenuItemClickListener(this); - menu.findItem(R.id.menu_report) - .setVisible(mHasReportMenuOption) - .setOnMenuItemClickListener(this); - return super.onPrepareOptionsMenu(menu); + return super.onCreateOptionsMenu(menu); } @Override public boolean onMenuItemClick(MenuItem item) { - switch (item.getItemId()) { - case R.id.menu_remove_from_call_log: + if (item.getItemId() == R.id.call_detail_delete_menu_item) { + if (hasVoicemail()) { + CallLogAsyncTaskUtil.deleteVoicemail( + this, mVoicemailUri, mCallLogAsyncTaskListener); + } else { final StringBuilder callIds = new StringBuilder(); for (Uri callUri : getCallLogEntryUris()) { if (callIds.length() != 0) { @@ -357,19 +365,123 @@ public class CallDetailActivity extends Activity } CallLogAsyncTaskUtil.deleteCalls( this, callIds.toString(), mCallLogAsyncTaskListener); - break; - case R.id.menu_edit_number_before_call: - startActivity(new Intent(Intent.ACTION_DIAL, CallUtil.getCallUri(mNumber))); - break; - case R.id.menu_trash: - CallLogAsyncTaskUtil.deleteVoicemail( - this, mVoicemailUri, mCallLogAsyncTaskListener); - break; + } } return true; } + @Override + public void onClick(View view) { + int resId = view.getId(); + if (resId == R.id.call_detail_action_block) { + FilteredNumberCompat + .showBlockNumberDialogFlow(mContext.getContentResolver(), mBlockedNumberId, + mNumber, mDetails.countryIso, mDisplayNumber, R.id.call_detail, + getFragmentManager(), this); + } else if (resId == R.id.call_detail_action_copy) { + ClipboardUtils.copyText(mContext, null, mNumber, true); + } else if (resId == R.id.call_detail_action_edit_before_call) { + Intent dialIntent = new Intent(Intent.ACTION_DIAL, + CallUtil.getCallUri(getDialableNumber())); + DialerUtils.startActivityWithErrorToast(mContext, dialIntent); + } else { + Log.wtf(TAG, "Unexpected onClick event from " + view); + } + } + + @Override + public void onFilterNumberSuccess() { + Logger.logInteraction(InteractionEvent.BLOCK_NUMBER_CALL_DETAIL); + updateFilteredNumberChanges(); + } + + @Override + public void onUnfilterNumberSuccess() { + Logger.logInteraction(InteractionEvent.UNBLOCK_NUMBER_CALL_DETAIL); + updateFilteredNumberChanges(); + } + + @Override + public void onChangeFilteredNumberUndo() { + updateFilteredNumberChanges(); + } + + private void updateFilteredNumberChanges() { + if (mDetails == null || + !FilteredNumbersUtil.canBlockNumber(this, mNumber, mDetails.countryIso)) { + return; + } + + final boolean success = mFilteredNumberAsyncQueryHandler.isBlockedNumber( + new OnCheckBlockedListener() { + @Override + public void onCheckComplete(Integer id) { + mBlockedNumberId = id; + updateBlockActionItem(); + } + }, mNumber, mDetails.countryIso); + + if (!success) { + updateBlockActionItem(); + } + } + + // Loads and displays the contact photo. + private void updateContactPhoto() { + if (mDetails == null) { + return; + } + + final boolean isVoicemailNumber = + PhoneNumberUtil.isVoicemailNumber(mContext, mDetails.accountHandle, mNumber); + final boolean isBusiness = mContactInfoHelper.isBusiness(mDetails.sourceType); + int contactType = ContactPhotoManager.TYPE_DEFAULT; + if (isVoicemailNumber) { + contactType = ContactPhotoManager.TYPE_VOICEMAIL; + } else if (isBusiness) { + contactType = ContactPhotoManager.TYPE_BUSINESS; + } + + final String displayName = TextUtils.isEmpty(mDetails.namePrimary) + ? mDetails.displayNumber : mDetails.namePrimary.toString(); + final String lookupKey = mDetails.contactUri == null + ? null : UriUtils.getLookupKeyFromUri(mDetails.contactUri); + + final DefaultImageRequest request = + new DefaultImageRequest(displayName, lookupKey, contactType, true /* isCircular */); + + mQuickContactBadge.assignContactUri(mDetails.contactUri); + mQuickContactBadge.setContentDescription( + mResources.getString(R.string.description_contact_details, displayName)); + + mContactPhotoManager.loadDirectoryPhoto(mQuickContactBadge, mDetails.photoUri, + false /* darkTheme */, true /* isCircular */, request); + } + + private void updateBlockActionItem() { + if (mBlockedNumberId == null) { + mBlockNumberActionItem.setText(R.string.action_block_number); + mBlockNumberActionItem.setCompoundDrawablesRelativeWithIntrinsicBounds( + R.drawable.ic_call_detail_block, 0, 0, 0); + } else { + mBlockNumberActionItem.setText(R.string.action_unblock_number); + mBlockNumberActionItem.setCompoundDrawablesRelativeWithIntrinsicBounds( + R.drawable.ic_call_detail_unblock, 0, 0, 0); + } + + mBlockNumberActionItem.setVisibility(View.VISIBLE); + } + private void closeSystemDialogs() { sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)); } + + private String getDialableNumber() { + return mNumber + mPostDialDigits; + } + + @NeededForTesting + public boolean hasVoicemail() { + return mVoicemailUri != null; + } } diff --git a/src/com/android/dialer/DialerApplication.java b/src/com/android/dialer/DialerApplication.java index b177d8336..189c68221 100644 --- a/src/com/android/dialer/DialerApplication.java +++ b/src/com/android/dialer/DialerApplication.java @@ -17,25 +17,38 @@ package com.android.dialer; import android.app.Application; +import android.content.Context; import android.os.Trace; +import android.support.annotation.Nullable; import com.android.contacts.common.extensions.ExtensionsFactory; -import com.android.contacts.commonbind.analytics.AnalyticsUtil; +import com.android.contacts.common.testing.NeededForTesting; +import com.android.dialer.compat.FilteredNumberCompat; public class DialerApplication extends Application { private static final String TAG = "DialerApplication"; + private static Context sContext; + @Override public void onCreate() { + sContext = this; Trace.beginSection(TAG + " onCreate"); super.onCreate(); Trace.beginSection(TAG + " ExtensionsFactory initialization"); ExtensionsFactory.init(getApplicationContext()); Trace.endSection(); - Trace.beginSection(TAG + " Analytics initialization"); - AnalyticsUtil.initialize(this); - Trace.endSection(); Trace.endSection(); } + + @Nullable + public static Context getContext() { + return sContext; + } + + @NeededForTesting + public static void setContextForTest(Context context) { + sContext = context; + } } diff --git a/src/com/android/dialer/DialtactsActivity.java b/src/com/android/dialer/DialtactsActivity.java index 69cc14673..d063fef5a 100644 --- a/src/com/android/dialer/DialtactsActivity.java +++ b/src/com/android/dialer/DialtactsActivity.java @@ -16,7 +16,6 @@ package com.android.dialer; -import android.app.ActionBar; import android.app.Fragment; import android.app.FragmentTransaction; import android.content.ActivityNotFoundException; @@ -31,9 +30,10 @@ import android.os.Bundle; import android.os.Trace; import android.provider.CallLog.Calls; import android.speech.RecognizerIntent; +import android.support.design.widget.CoordinatorLayout; import android.support.v4.view.ViewPager; +import android.support.v7.app.ActionBar; import android.telecom.PhoneAccount; -import android.telecom.TelecomManager; import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; @@ -46,25 +46,22 @@ import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.View.OnDragListener; -import android.view.View.OnTouchListener; import android.view.ViewTreeObserver; import android.view.animation.Animation; import android.view.animation.AnimationUtils; import android.widget.AbsListView.OnScrollListener; import android.widget.EditText; -import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.PopupMenu; +import android.widget.TextView; import android.widget.Toast; -import com.android.contacts.common.activity.TransactionSafeActivity; import com.android.contacts.common.dialog.ClearFrequentsDialog; import com.android.contacts.common.interactions.ImportExportDialogFragment; import com.android.contacts.common.interactions.TouchPointManager; import com.android.contacts.common.list.OnPhoneNumberPickerActionListener; import com.android.contacts.common.util.PermissionsUtil; import com.android.contacts.common.widget.FloatingActionButtonController; -import com.android.contacts.commonbind.analytics.AnalyticsUtil; import com.android.dialer.calllog.CallLogActivity; import com.android.dialer.calllog.CallLogFragment; import com.android.dialer.database.DialerDatabaseHelper; @@ -81,17 +78,22 @@ import com.android.dialer.list.RegularSearchFragment; import com.android.dialer.list.SearchFragment; import com.android.dialer.list.SmartDialSearchFragment; import com.android.dialer.list.SpeedDialFragment; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.ScreenEvent; import com.android.dialer.settings.DialerSettingsActivity; -import com.android.dialer.util.IntentUtil; +import com.android.dialer.util.Assert; import com.android.dialer.util.DialerUtils; +import com.android.dialer.util.IntentUtil; +import com.android.dialer.util.IntentUtil.CallIntentBuilder; +import com.android.dialer.util.TelecomUtil; +import com.android.dialer.voicemail.VoicemailArchiveActivity; import com.android.dialer.widget.ActionBarController; import com.android.dialer.widget.SearchEditTextLayout; -import com.android.dialer.widget.SearchEditTextLayout.Callback; import com.android.dialerbind.DatabaseHelperManager; +import com.android.dialerbind.ObjectFactory; import com.android.phone.common.animation.AnimUtils; import com.android.phone.common.animation.AnimationListenerAdapter; - -import junit.framework.Assert; +import com.google.common.annotations.VisibleForTesting; import java.util.ArrayList; import java.util.List; @@ -118,17 +120,14 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O public static final String SHARED_PREFS_NAME = "com.android.dialer_preferences"; - /** @see #getCallOrigin() */ - private static final String CALL_ORIGIN_DIALTACTS = - "com.android.dialer.DialtactsActivity"; - private static final String KEY_IN_REGULAR_SEARCH_UI = "in_regular_search_ui"; private static final String KEY_IN_DIALPAD_SEARCH_UI = "in_dialpad_search_ui"; private static final String KEY_SEARCH_QUERY = "search_query"; private static final String KEY_FIRST_LAUNCH = "first_launch"; private static final String KEY_IS_DIALPAD_SHOWN = "is_dialpad_shown"; - private static final String TAG_DIALPAD_FRAGMENT = "dialpad"; + @VisibleForTesting + public static final String TAG_DIALPAD_FRAGMENT = "dialpad"; private static final String TAG_REGULAR_SEARCH_FRAGMENT = "search"; private static final String TAG_SMARTDIAL_SEARCH_FRAGMENT = "smartdial"; private static final String TAG_FAVORITES_FRAGMENT = "favorites"; @@ -143,7 +142,7 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O private static final int FAB_SCALE_IN_DELAY_MS = 300; - private FrameLayout mParentLayout; + private CoordinatorLayout mParentLayout; /** * Fragment containing the dialpad that slides into view @@ -188,7 +187,7 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O }; /** - * Fragment containing the speed dial list, recents list, and all contacts list. + * Fragment containing the speed dial list, call history list, and all contacts list. */ private ListsFragment mListsFragment; @@ -230,6 +229,7 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O private View mVoiceSearchButton; private String mSearchQuery; + private String mDialpadQuery; private DialerDatabaseHelper mDialerDatabaseHelper; private DragDropController mDragDropController; @@ -238,6 +238,7 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O private FloatingActionButtonController mFloatingActionButtonController; private int mActionBarHeight; + private int mPreviouslySelectedTabIndex; /** * The text returned from a voice search query. Set in {@link #onActivityResult} and used in @@ -369,7 +370,6 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O TouchPointManager.getInstance().setPoint((int) ev.getRawX(), (int) ev.getRawY()); } return super.dispatchTouchEvent(ev); - } @Override @@ -388,13 +388,13 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O getWindow().setBackgroundDrawable(null); Trace.beginSection(TAG + " setup Views"); - final ActionBar actionBar = getActionBar(); + final ActionBar actionBar = getSupportActionBar(); actionBar.setCustomView(R.layout.search_edittext); actionBar.setDisplayShowCustomEnabled(true); actionBar.setBackgroundDrawable(null); - SearchEditTextLayout searchEditTextLayout = - (SearchEditTextLayout) actionBar.getCustomView().findViewById(R.id.search_view_container); + SearchEditTextLayout searchEditTextLayout = (SearchEditTextLayout) actionBar + .getCustomView().findViewById(R.id.search_view_container); searchEditTextLayout.setPreImeKeyListener(mSearchEditTextLayoutListener); mActionBarController = new ActionBarController(this, searchEditTextLayout); @@ -422,7 +422,7 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O mIsLandscape = getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; - + mPreviouslySelectedTabIndex = ListsFragment.TAB_INDEX_SPEED_DIAL; final View floatingActionButtonContainer = findViewById( R.id.floating_action_button_container); ImageButton floatingActionButton = (ImageButton) findViewById(R.id.floating_action_button); @@ -468,7 +468,7 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O mSlideIn.setAnimationListener(mSlideInListener); mSlideOut.setAnimationListener(mSlideOutListener); - mParentLayout = (FrameLayout) findViewById(R.id.dialtacts_mainlayout); + mParentLayout = (CoordinatorLayout) findViewById(R.id.dialtacts_mainlayout); mParentLayout.setOnDragListener(new LayoutOnDragListener()); floatingActionButtonContainer.getViewTreeObserver().addOnGlobalLayoutListener( new ViewTreeObserver.OnGlobalLayoutListener() { @@ -527,7 +527,7 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O // This is only called when the activity goes from resumed -> paused -> resumed, so it // will not cause an extra view to be sent out on rotation if (mIsDialpadShown) { - AnalyticsUtil.sendScreenView(mDialpadFragment, this); + Logger.logScreenView(ScreenEvent.DIALPAD, this); } mIsRestarting = false; } @@ -536,15 +536,25 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O mDialerDatabaseHelper.startSmartDialUpdateThread(); mFloatingActionButtonController.align(getFabAlignment(), false /* animate */); - if (getIntent().hasExtra(EXTRA_SHOW_TAB)) { + if (Calls.CONTENT_TYPE.equals(getIntent().getType())) { + // Externally specified extras take precedence to EXTRA_SHOW_TAB, which is only + // used internally. + final Bundle extras = getIntent().getExtras(); + if (extras != null + && extras.getInt(Calls.EXTRA_CALL_TYPE_FILTER) == Calls.VOICEMAIL_TYPE) { + mListsFragment.showTab(ListsFragment.TAB_INDEX_VOICEMAIL); + } else { + mListsFragment.showTab(ListsFragment.TAB_INDEX_HISTORY); + } + } else if (getIntent().hasExtra(EXTRA_SHOW_TAB)) { int index = getIntent().getIntExtra(EXTRA_SHOW_TAB, ListsFragment.TAB_INDEX_SPEED_DIAL); if (index < mListsFragment.getTabCount()) { mListsFragment.showTab(index); } - } else if (Calls.CONTENT_TYPE.equals(getIntent().getType())) { - mListsFragment.showTab(ListsFragment.TAB_INDEX_RECENTS); } + setSearchBoxHint(); + Trace.endSection(); } @@ -556,6 +566,11 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O @Override protected void onPause() { + // Only clear missed calls if the pause was not triggered by an orientation change + // (or any other confirguration change) + if (!isChangingConfigurations()) { + updateMissedCalls(); + } if (mClearSearchOnPause) { hideDialpadAndSearchUi(); mClearSearchOnPause = false; @@ -590,6 +605,9 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O } else if (fragment instanceof SmartDialSearchFragment) { mSmartDialSearchFragment = (SmartDialSearchFragment) fragment; mSmartDialSearchFragment.setOnPhoneNumberPickerActionListener(this); + if (!TextUtils.isEmpty(mDialpadQuery)) { + mSmartDialSearchFragment.setAddToContactNumber(mDialpadQuery); + } } else if (fragment instanceof SearchFragment) { mRegularSearchFragment = (RegularSearchFragment) fragment; mRegularSearchFragment.setOnPhoneNumberPickerActionListener(this); @@ -606,67 +624,74 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O @Override public void onClick(View view) { - switch (view.getId()) { - case R.id.floating_action_button: - if (mListsFragment.getCurrentTabIndex() - == ListsFragment.TAB_INDEX_ALL_CONTACTS && !mInRegularSearch) { - DialerUtils.startActivityWithErrorToast( - this, - IntentUtil.getNewContactIntent(), - R.string.add_contact_not_available); - } else if (!mIsDialpadShown) { - mInCallDialpadUp = false; - showDialpadFragment(true); - } - break; - case R.id.voice_search_button: - try { - startActivityForResult(new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH), - ACTIVITY_REQUEST_CODE_VOICE_SEARCH); - } catch (ActivityNotFoundException e) { - Toast.makeText(DialtactsActivity.this, R.string.voice_search_not_available, - Toast.LENGTH_SHORT).show(); - } - break; - case R.id.dialtacts_options_menu_button: - mOverflowMenu.show(); - break; - default: { - Log.wtf(TAG, "Unexpected onClick event from " + view); - break; + int resId = view.getId(); + if (resId == R.id.floating_action_button) { + if (mListsFragment.getCurrentTabIndex() + == ListsFragment.TAB_INDEX_ALL_CONTACTS && !mInRegularSearch) { + DialerUtils.startActivityWithErrorToast( + this, + IntentUtil.getNewContactIntent(), + R.string.add_contact_not_available); + } else if (!mIsDialpadShown) { + mInCallDialpadUp = false; + showDialpadFragment(true); + } + } else if (resId == R.id.voice_search_button) { + try { + startActivityForResult(new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH), + ACTIVITY_REQUEST_CODE_VOICE_SEARCH); + } catch (ActivityNotFoundException e) { + Toast.makeText(DialtactsActivity.this, R.string.voice_search_not_available, + Toast.LENGTH_SHORT).show(); } + } else if (resId == R.id.dialtacts_options_menu_button) { + mOverflowMenu.show(); + } else { + Log.wtf(TAG, "Unexpected onClick event from " + view); } } @Override public boolean onMenuItemClick(MenuItem item) { - switch (item.getItemId()) { - case R.id.menu_history: - // Use explicit CallLogActivity intent instead of ACTION_VIEW + - // CONTENT_TYPE, so that we always open our call log from our dialer - final Intent intent = new Intent(this, CallLogActivity.class); - startActivity(intent); - break; - case R.id.menu_add_contact: - DialerUtils.startActivityWithErrorToast( - this, - IntentUtil.getNewContactIntent(), - R.string.add_contact_not_available); - break; - case R.id.menu_import_export: - // We hard-code the "contactsAreAvailable" argument because doing it properly would - // involve querying a {@link ProviderStatusLoader}, which we don't want to do right - // now in Dialtacts for (potential) performance reasons. Compare with how it is - // done in {@link PeopleActivity}. + if (!isSafeToCommitTransactions()) { + return true; + } + + int resId = item.getItemId(); + if (resId == R.id.menu_history) {// Use explicit CallLogActivity intent instead of ACTION_VIEW + + // CONTENT_TYPE, so that we always open our call log from our dialer + final Intent intent = new Intent(this, CallLogActivity.class); + startActivity(intent); + } else if (resId == R.id.menu_add_contact) { + DialerUtils.startActivityWithErrorToast( + this, + IntentUtil.getNewContactIntent(), + R.string.add_contact_not_available); + } else if (resId == R.id.menu_import_export) {// We hard-code the "contactsAreAvailable" argument because doing it properly would + // involve querying a {@link ProviderStatusLoader}, which we don't want to do right + // now in Dialtacts for (potential) performance reasons. Compare with how it is + // done in {@link PeopleActivity}. + if (mListsFragment.getCurrentTabIndex() == ListsFragment.TAB_INDEX_SPEED_DIAL) { ImportExportDialogFragment.show(getFragmentManager(), true, - DialtactsActivity.class); - return true; - case R.id.menu_clear_frequents: - ClearFrequentsDialog.show(getFragmentManager()); - return true; - case R.id.menu_call_settings: - handleMenuSettings(); - return true; + DialtactsActivity.class, ImportExportDialogFragment.EXPORT_MODE_FAVORITES); + } else { + ImportExportDialogFragment.show(getFragmentManager(), true, + DialtactsActivity.class, ImportExportDialogFragment.EXPORT_MODE_DEFAULT); + } + Logger.logScreenView(ScreenEvent.IMPORT_EXPORT_CONTACTS, this); + return true; + } else if (resId == R.id.menu_clear_frequents) { + ClearFrequentsDialog.show(getFragmentManager()); + Logger.logScreenView(ScreenEvent.CLEAR_FREQUENTS, this); + return true; + } else if (resId == R.id.menu_call_settings) { + handleMenuSettings(); + Logger.logScreenView(ScreenEvent.SETTINGS, this); + return true; + } else if (resId == R.id.menu_archive) { + final Intent intent = new Intent(this, VoicemailArchiveActivity.class); + startActivity(intent); + return true; } return false; } @@ -691,6 +716,14 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O } /** + * Update the number of unread voicemails (potentially other tabs) displayed next to the tab + * icon. + */ + public void updateTabUnreadCounts() { + mListsFragment.updateTabUnreadCounts(); + } + + /** * Initiates a fragment transaction to show the dialpad fragment. Animations and other visual * updates are handled by a callback which is invoked after the dialpad fragment is shown. * @see #onDialpadShown @@ -712,7 +745,7 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O } mDialpadFragment.setAnimate(animate); - AnalyticsUtil.sendScreenView(mDialpadFragment); + Logger.logScreenView(ScreenEvent.DIALPAD, this); ft.commit(); if (animate) { @@ -724,6 +757,9 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O mActionBarController.onDialpadUp(); mListsFragment.getView().animate().alpha(0).withLayer(); + + //adjust the title, so the user will know where we're at when the activity start/resumes. + setTitle(R.string.launcherDialpadActivityLabel); } /** @@ -750,7 +786,13 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O return; } if (clearDialpad) { + // Temporarily disable accessibility when we clear the dialpad, since it should be + // invisible and should not announce anything. + mDialpadFragment.getDigitsWidget().setImportantForAccessibility( + View.IMPORTANT_FOR_ACCESSIBILITY_NO); mDialpadFragment.clearDialpad(); + mDialpadFragment.getDigitsWidget().setImportantForAccessibility( + View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); } if (!mIsDialpadShown) { return; @@ -776,6 +818,8 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O exitSearchUi(); } } + //reset the title to normal. + setTitle(R.string.launcherActivityLabel); } /** @@ -840,9 +884,30 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O } } + public boolean isNearbyPlacesSearchEnabled() { + return false; + } + + protected int getSearchBoxHint () { + return R.string.dialer_hint_find_contact; + } + + /** + * Sets the hint text for the contacts search box + */ + private void setSearchBoxHint() { + SearchEditTextLayout searchEditTextLayout = (SearchEditTextLayout) getSupportActionBar() + .getCustomView().findViewById(R.id.search_view_container); + ((TextView) searchEditTextLayout.findViewById(R.id.search_box_start_search)) + .setHint(getSearchBoxHint()); + } + protected OptionsPopupMenu buildOptionsMenu(View invoker) { final OptionsPopupMenu popupMenu = new OptionsPopupMenu(this, invoker); popupMenu.inflate(R.menu.dialtacts_options); + if (ObjectFactory.isVoicemailArchiveEnabled(this)) { + popupMenu.getMenu().findItem(R.id.menu_archive).setVisible(true); + } popupMenu.setOnMenuItemClickListener(this); return popupMenu; } @@ -872,7 +937,7 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O final boolean callKey = Intent.ACTION_CALL_BUTTON.equals(intent.getAction()); if (callKey) { - getTelecomManager().showInCallScreen(false); + TelecomUtil.showInCallScreen(this, false); return true; } @@ -891,11 +956,11 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O return; } - final boolean phoneIsInUse = phoneIsInUse(); - if (phoneIsInUse || (intent.getData() != null && isDialIntent(intent))) { + final boolean showDialpadChooser = phoneIsInUse() && !DialpadFragment.isAddCallMode(intent); + if (showDialpadChooser || (intent.getData() != null && isDialIntent(intent))) { showDialpadFragment(false); mDialpadFragment.setStartedFromNewIntent(true); - if (phoneIsInUse && !mDialpadFragment.isVisible()) { + if (showDialpadChooser && !mDialpadFragment.isVisible()) { mInCallDialpadUp = true; } } @@ -927,16 +992,6 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O } /** - * Returns an appropriate call origin for this Activity. May return null when no call origin - * should be used (e.g. when some 3rd party application launched the screen. Call origin is - * for remembering the tab in which the user made a phone call, so the external app's DIAL - * request should not be counted.) - */ - public String getCallOrigin() { - return !isDialIntent(getIntent()) ? CALL_ORIGIN_DIALTACTS : null; - } - - /** * Shows the search fragment */ private void enterSearchUi(boolean smartDialSearch, String query, boolean animate) { @@ -979,12 +1034,13 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O if (smartDialSearch) { fragment = new SmartDialSearchFragment(); } else { - fragment = new RegularSearchFragment(); + fragment = ObjectFactory.newRegularSearchFragment(); fragment.setOnTouchListener(new View.OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { // Show the FAB when the user touches the lists fragment and the soft // keyboard is hidden. + hideDialpadFragment(true, false); showFabInSearchUi(); return false; } @@ -1006,6 +1062,12 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O mListsFragment.getView().animate().alpha(0).withLayer(); } mListsFragment.setUserVisibleHint(false); + + if (smartDialSearch) { + Logger.logScreenView(ScreenEvent.SMART_DIAL_SEARCH, this); + } else { + Logger.logScreenView(ScreenEvent.REGULAR_SEARCH, this); + } } /** @@ -1103,6 +1165,7 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O @Override public void onDialpadQueryChanged(String query) { + mDialpadQuery = query; if (mSmartDialSearchFragment != null) { mSmartDialSearchFragment.setAddToContactNumber(query); } @@ -1162,7 +1225,7 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O } private boolean phoneIsInUse() { - return getTelecomManager().isInCall(); + return TelecomUtil.isInCall(this); } private boolean canIntentBeHandled(Intent intent) { @@ -1224,29 +1287,25 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O } @Override - public void onPickPhoneNumberAction(Uri dataUri) { - // Specify call-origin so that users will see the previous tab instead of - // CallLog screen (search UI will be automatically exited). - PhoneNumberInteraction.startInteractionForPhoneCall( - DialtactsActivity.this, dataUri, getCallOrigin()); + public void onPickDataUri(Uri dataUri, boolean isVideoCall, int callInitiationType) { mClearSearchOnPause = true; + PhoneNumberInteraction.startInteractionForPhoneCall( + DialtactsActivity.this, dataUri, isVideoCall, callInitiationType); } @Override - public void onCallNumberDirectly(String phoneNumber) { - onCallNumberDirectly(phoneNumber, false /* isVideoCall */); - } - - @Override - public void onCallNumberDirectly(String phoneNumber, boolean isVideoCall) { + public void onPickPhoneNumber(String phoneNumber, boolean isVideoCall, int callInitiationType) { if (phoneNumber == null) { // Invalid phone number, but let the call go through so that InCallUI can show // an error message. phoneNumber = ""; } - Intent intent = isVideoCall ? - IntentUtil.getVideoCallIntent(phoneNumber, getCallOrigin()) : - IntentUtil.getCallIntent(phoneNumber, getCallOrigin()); + + final Intent intent = new CallIntentBuilder(phoneNumber) + .setIsVideoCall(isVideoCall) + .setCallInitiationType(callInitiationType) + .build(); + DialerUtils.startActivityWithErrorToast(this, intent); mClearSearchOnPause = true; } @@ -1265,13 +1324,13 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { int tabIndex = mListsFragment.getCurrentTabIndex(); - // Scroll the button from center to end when moving from the Speed Dial to Recents tab. - // In RTL, scroll when the current tab is Recents instead of Speed Dial, because the order - // of the tabs is reversed and the ViewPager returns the left tab position during scroll. + // Scroll the button from center to end when moving from the Speed Dial to Call History tab. + // In RTL, scroll when the current tab is Call History instead, since the order of the tabs + // is reversed and the ViewPager returns the left tab position during scroll. boolean isRtl = DialerUtils.isRtl(); if (!isRtl && tabIndex == ListsFragment.TAB_INDEX_SPEED_DIAL && !mIsLandscape) { mFloatingActionButtonController.onPageScrolled(positionOffset); - } else if (isRtl && tabIndex == ListsFragment.TAB_INDEX_RECENTS && !mIsLandscape) { + } else if (isRtl && tabIndex == ListsFragment.TAB_INDEX_HISTORY && !mIsLandscape) { mFloatingActionButtonController.onPageScrolled(1 - positionOffset); } else if (tabIndex != ListsFragment.TAB_INDEX_SPEED_DIAL) { mFloatingActionButtonController.onPageScrolled(1); @@ -1280,7 +1339,9 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O @Override public void onPageSelected(int position) { + updateMissedCalls(); int tabIndex = mListsFragment.getCurrentTabIndex(); + mPreviouslySelectedTabIndex = tabIndex; if (tabIndex == ListsFragment.TAB_INDEX_ALL_CONTACTS) { mFloatingActionButtonController.changeIcon( getResources().getDrawable(R.drawable.ic_person_add_24dp), @@ -1296,10 +1357,6 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O public void onPageScrollStateChanged(int state) { } - private TelecomManager getTelecomManager() { - return (TelecomManager) getSystemService(Context.TELECOM_SERVICE); - } - @Override public boolean isActionBarShowing() { return mActionBarController.isActionBarShowing(); @@ -1325,12 +1382,12 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O @Override public int getActionBarHideOffset() { - return getActionBar().getHideOffset(); + return getSupportActionBar().getHideOffset(); } @Override public void setActionBarHideOffset(int offset) { - getActionBar().setHideOffset(offset); + getSupportActionBar().setHideOffset(offset); } @Override @@ -1345,4 +1402,10 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O } return FloatingActionButtonController.ALIGN_END; } + + private void updateMissedCalls() { + if (mPreviouslySelectedTabIndex == ListsFragment.TAB_INDEX_HISTORY) { + mListsFragment.markMissedCallsAsReadAndRemoveNotifications(); + } + } } diff --git a/src/com/android/dialer/FloatingActionButtonBehavior.java b/src/com/android/dialer/FloatingActionButtonBehavior.java new file mode 100644 index 000000000..679c9a7c1 --- /dev/null +++ b/src/com/android/dialer/FloatingActionButtonBehavior.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2015 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.content.Context; +import android.support.design.widget.CoordinatorLayout; +import android.support.design.widget.Snackbar.SnackbarLayout; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; + +/** + * Implements custom behavior for the movement of the FAB in response to the Snackbar. + * Because we are not using the design framework FloatingActionButton widget, we need to manually + * implement the Material Design behavior of having the FAB translate upward and downward with + * the appearance and disappearance of a Snackbar. + */ +public class FloatingActionButtonBehavior extends CoordinatorLayout.Behavior<FrameLayout> { + public FloatingActionButtonBehavior(Context context, AttributeSet attrs) { + } + + @Override + public boolean layoutDependsOn(CoordinatorLayout parent, FrameLayout child, View dependency) { + return dependency instanceof SnackbarLayout; + } + + @Override + public boolean onDependentViewChanged(CoordinatorLayout parent, FrameLayout child, + View dependency) { + float translationY = Math.min(0, dependency.getTranslationY() - dependency.getHeight()); + child.setTranslationY(translationY); + return true; + } +} diff --git a/src/com/android/dialer/PhoneCallDetails.java b/src/com/android/dialer/PhoneCallDetails.java index 403c4e86c..b332b43cc 100644 --- a/src/com/android/dialer/PhoneCallDetails.java +++ b/src/com/android/dialer/PhoneCallDetails.java @@ -16,12 +16,15 @@ package com.android.dialer; +import com.android.contacts.common.ContactsUtils.UserType; +import com.android.contacts.common.preference.ContactsPreferences; import com.android.dialer.calllog.PhoneNumberDisplayUtil; import android.content.Context; import android.net.Uri; import android.provider.CallLog.Calls; import android.telecom.PhoneAccountHandle; +import android.text.TextUtils; /** * The details of a phone call to be shown in the UI. @@ -29,6 +32,8 @@ import android.telecom.PhoneAccountHandle; public class PhoneCallDetails { // The number of the other party involved in the call. public CharSequence number; + // Post-dial digits associated with the outgoing call. + public String postDialDigits; // The number presenting rules set by the network, e.g., {@link Calls#PRESENTATION_ALLOWED} public int numberPresentation; // The formatted version of {@link #number}. @@ -50,7 +55,14 @@ public class PhoneCallDetails { // The duration of the call in milliseconds, or 0 for missed calls. public long duration; // The name of the contact, or the empty string. - public CharSequence name; + public CharSequence namePrimary; + // The alternative name of the contact, e.g. last name first, or the empty string + public CharSequence nameAlternative; + /** + * The user's preference on name display order, last name first or first time first. + * {@see ContactsPreferences} + */ + public int nameDisplayOrder; // The type of phone, e.g., {@link Phone#TYPE_HOME}, 0 if not available. public int numberType; // The custom label associated with the phone number in the contact, or the empty string. @@ -90,6 +102,9 @@ public class PhoneCallDetails { // Whether the contact number is a voicemail number. public boolean isVoicemail; + /** The {@link UserType} of the contact */ + public @UserType long contactUserType; + /** * If this is a voicemail, whether the message is read. For other types of calls, this defaults * to {@code true}. @@ -105,16 +120,33 @@ public class PhoneCallDetails { CharSequence number, int numberPresentation, CharSequence formattedNumber, + CharSequence postDialDigits, boolean isVoicemail) { this.number = number; this.numberPresentation = numberPresentation; this.formattedNumber = formattedNumber; this.isVoicemail = isVoicemail; + this.postDialDigits = postDialDigits.toString(); this.displayNumber = PhoneNumberDisplayUtil.getDisplayNumber( context, this.number, this.numberPresentation, this.formattedNumber, + this.postDialDigits, this.isVoicemail).toString(); } + + /** + * Returns the preferred name for the call details as specified by the + * {@link #nameDisplayOrder} + * + * @return the preferred name + */ + public CharSequence getPreferredName() { + if (nameDisplayOrder == ContactsPreferences.DISPLAY_ORDER_PRIMARY + || TextUtils.isEmpty(nameAlternative)) { + return namePrimary; + } + return nameAlternative; + } } diff --git a/src/com/android/dialer/SpecialCharSequenceMgr.java b/src/com/android/dialer/SpecialCharSequenceMgr.java index 31aa5c3c7..4303f3e1f 100644 --- a/src/com/android/dialer/SpecialCharSequenceMgr.java +++ b/src/com/android/dialer/SpecialCharSequenceMgr.java @@ -32,7 +32,6 @@ import android.os.Looper; import android.provider.Settings; import android.telecom.PhoneAccount; import android.telecom.PhoneAccountHandle; -import android.telecom.TelecomManager; import android.telephony.PhoneNumberUtils; import android.telephony.TelephonyManager; import android.text.TextUtils; @@ -42,13 +41,15 @@ import android.widget.EditText; import android.widget.Toast; import com.android.common.io.MoreCloseables; +import com.android.contacts.common.compat.CompatUtils; +import com.android.contacts.common.compat.TelephonyManagerCompat; import com.android.contacts.common.database.NoNullCursorAsyncQueryHandler; +import com.android.contacts.common.util.ContactDisplayUtils; import com.android.contacts.common.widget.SelectPhoneAccountDialogFragment; import com.android.contacts.common.widget.SelectPhoneAccountDialogFragment.SelectPhoneAccountListener; import com.android.dialer.calllog.PhoneAccountUtils; import com.android.dialer.util.TelecomUtil; -import java.util.Arrays; import java.util.ArrayList; import java.util.List; @@ -91,13 +92,13 @@ public class SpecialCharSequenceMgr { private static QueryHandler sPreviousAdnQueryHandler; public static class HandleAdnEntryAccountSelectedCallback extends SelectPhoneAccountListener{ - final private TelecomManager mTelecomManager; + final private Context mContext; final private QueryHandler mQueryHandler; final private SimContactQueryCookie mCookie; - public HandleAdnEntryAccountSelectedCallback(TelecomManager telecomManager, + public HandleAdnEntryAccountSelectedCallback(Context context, QueryHandler queryHandler, SimContactQueryCookie cookie) { - mTelecomManager = telecomManager; + mContext = context; mQueryHandler = queryHandler; mCookie = cookie; } @@ -105,7 +106,7 @@ public class SpecialCharSequenceMgr { @Override public void onPhoneAccountSelected(PhoneAccountHandle selectedAccountHandle, boolean setDefault) { - Uri uri = mTelecomManager.getAdnUriForPhoneAccount(selectedAccountHandle); + Uri uri = TelecomUtil.getAdnUriForPhoneAccount(mContext, selectedAccountHandle); handleAdnQuery(mQueryHandler, mCookie, uri); // TODO: Show error dialog if result isn't valid. } @@ -225,7 +226,7 @@ public class SpecialCharSequenceMgr { // the dialer text field. // create the async query handler - final QueryHandler handler = new QueryHandler (context.getContentResolver()); + final QueryHandler handler = new QueryHandler(context.getContentResolver()); // create the cookie object final SimContactQueryCookie sc = new SimContactQueryCookie(index - 1, handler, @@ -245,27 +246,24 @@ public class SpecialCharSequenceMgr { sc.progressDialog.getWindow().addFlags( WindowManager.LayoutParams.FLAG_BLUR_BEHIND); - final TelecomManager telecomManager = - (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); List<PhoneAccountHandle> subscriptionAccountHandles = PhoneAccountUtils.getSubscriptionPhoneAccounts(context); - + Context applicationContext = context.getApplicationContext(); boolean hasUserSelectedDefault = subscriptionAccountHandles.contains( - telecomManager.getDefaultOutgoingPhoneAccount(PhoneAccount.SCHEME_TEL)); + TelecomUtil.getDefaultOutgoingPhoneAccount(applicationContext, + PhoneAccount.SCHEME_TEL)); - if (subscriptionAccountHandles.size() == 1 || hasUserSelectedDefault) { - Uri uri = telecomManager.getAdnUriForPhoneAccount(null); + if (subscriptionAccountHandles.size() <= 1 || hasUserSelectedDefault) { + Uri uri = TelecomUtil.getAdnUriForPhoneAccount(applicationContext, null); handleAdnQuery(handler, sc, uri); - } else if (subscriptionAccountHandles.size() > 1){ - SelectPhoneAccountListener callback = - new HandleAdnEntryAccountSelectedCallback(telecomManager, handler, sc); + } else { + SelectPhoneAccountListener callback = new HandleAdnEntryAccountSelectedCallback( + applicationContext, handler, sc); DialogFragment dialogFragment = SelectPhoneAccountDialogFragment.newInstance( subscriptionAccountHandles, callback); dialogFragment.show(((Activity) context).getFragmentManager(), TAG_SELECT_ACCT_FRAGMENT); - } else { - return false; } return true; @@ -299,18 +297,16 @@ public class SpecialCharSequenceMgr { static boolean handlePinEntry(final Context context, final String input) { if ((input.startsWith("**04") || input.startsWith("**05")) && input.endsWith("#")) { - final TelecomManager telecomManager = - (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); List<PhoneAccountHandle> subscriptionAccountHandles = PhoneAccountUtils.getSubscriptionPhoneAccounts(context); boolean hasUserSelectedDefault = subscriptionAccountHandles.contains( - telecomManager.getDefaultOutgoingPhoneAccount(PhoneAccount.SCHEME_TEL)); + TelecomUtil.getDefaultOutgoingPhoneAccount(context, PhoneAccount.SCHEME_TEL)); - if (subscriptionAccountHandles.size() == 1 || hasUserSelectedDefault) { + if (subscriptionAccountHandles.size() <= 1 || hasUserSelectedDefault) { // Don't bring up the dialog for single-SIM or if the default outgoing account is // a subscription account. return TelecomUtil.handleMmi(context, input, null); - } else if (subscriptionAccountHandles.size() > 1){ + } else { SelectPhoneAccountListener listener = new HandleMmiAccountSelectedCallback(context, input); @@ -335,11 +331,17 @@ public class SpecialCharSequenceMgr { R.string.imei : R.string.meid; List<String> deviceIds = new ArrayList<String>(); - for (int slot = 0; slot < telephonyManager.getPhoneCount(); slot++) { - String deviceId = telephonyManager.getDeviceId(slot); - if (!TextUtils.isEmpty(deviceId)) { - deviceIds.add(deviceId); + if (TelephonyManagerCompat.getPhoneCount(telephonyManager) > 1 && + CompatUtils.isMethodAvailable(TelephonyManagerCompat.TELEPHONY_MANAGER_CLASS, + "getDeviceId", Integer.TYPE)) { + for (int slot = 0; slot < telephonyManager.getPhoneCount(); slot++) { + String deviceId = telephonyManager.getDeviceId(slot); + if (!TextUtils.isEmpty(deviceId)) { + deviceIds.add(deviceId); + } } + } else { + deviceIds.add(telephonyManager.getDeviceId()); } AlertDialog alert = new AlertDialog.Builder(context) @@ -478,9 +480,9 @@ public class SpecialCharSequenceMgr { // display the name as a toast Context context = sc.progressDialog.getContext(); - name = context.getString(R.string.menu_callNumber, name); - Toast.makeText(context, name, Toast.LENGTH_SHORT) - .show(); + CharSequence msg = ContactDisplayUtils.getTtsSpannedPhoneNumber( + context.getResources(), R.string.menu_callNumber, name); + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show(); } } finally { MoreCloseables.closeQuietly(c); diff --git a/src/com/android/dialer/TransactionSafeActivity.java b/src/com/android/dialer/TransactionSafeActivity.java new file mode 100644 index 000000000..81e50128d --- /dev/null +++ b/src/com/android/dialer/TransactionSafeActivity.java @@ -0,0 +1,65 @@ +/* + * 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; + +import android.app.Activity; +import android.os.Bundle; +import android.support.v7.app.AppCompatActivity; + +/** + * A common superclass that keeps track of whether an {@link Activity} has saved its state yet or + * not. + */ +public abstract class TransactionSafeActivity extends AppCompatActivity { + + private boolean mIsSafeToCommitTransactions; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mIsSafeToCommitTransactions = true; + } + + @Override + protected void onStart() { + super.onStart(); + mIsSafeToCommitTransactions = true; + } + + @Override + protected void onResume() { + super.onResume(); + mIsSafeToCommitTransactions = true; + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + mIsSafeToCommitTransactions = false; + } + + /** + * Returns true if it is safe to commit {@link FragmentTransaction}s at this time, based on + * whether {@link Activity#onSaveInstanceState} has been called or not. + * + * Make sure that the current activity calls into + * {@link super.onSaveInstanceState(Bundle outState)} (if that method is overridden), + * so the flag is properly set. + */ + public boolean isSafeToCommitTransactions() { + return mIsSafeToCommitTransactions; + } +} diff --git a/src/com/android/dialer/calllog/CallDetailHistoryAdapter.java b/src/com/android/dialer/calllog/CallDetailHistoryAdapter.java index 3b488a8ae..ac56332ce 100644 --- a/src/com/android/dialer/calllog/CallDetailHistoryAdapter.java +++ b/src/com/android/dialer/calllog/CallDetailHistoryAdapter.java @@ -38,8 +38,6 @@ import java.util.ArrayList; * Adapter for a ListView containing history items from the details of a call. */ public class CallDetailHistoryAdapter extends BaseAdapter { - /** The top element is a blank header, which is hidden under the rest of the UI. */ - private static final int VIEW_TYPE_HEADER = 0; /** Each history item shows the detail of a call. */ private static final int VIEW_TYPE_HISTORY_ITEM = 1; @@ -69,53 +67,37 @@ public class CallDetailHistoryAdapter extends BaseAdapter { @Override public int getCount() { - return mPhoneCallDetails.length + 1; + return mPhoneCallDetails.length; } @Override public Object getItem(int position) { - if (position == 0) { - return null; - } - return mPhoneCallDetails[position - 1]; + return mPhoneCallDetails[position]; } @Override public long getItemId(int position) { - if (position == 0) { - return -1; - } - return position - 1; + return position; } @Override public int getViewTypeCount() { - return 2; + return 1; } @Override public int getItemViewType(int position) { - if (position == 0) { - return VIEW_TYPE_HEADER; - } return VIEW_TYPE_HISTORY_ITEM; } @Override public View getView(int position, View convertView, ViewGroup parent) { - if (position == 0) { - final View header = convertView == null - ? mLayoutInflater.inflate(R.layout.call_detail_history_header, parent, false) - : convertView; - return header; - } - // Make sure we have a valid convertView to start with final View result = convertView == null ? mLayoutInflater.inflate(R.layout.call_detail_history_item, parent, false) : convertView; - PhoneCallDetails details = mPhoneCallDetails[position - 1]; + PhoneCallDetails details = mPhoneCallDetails[position]; CallTypeIconsView callTypeIconView = (CallTypeIconsView) result.findViewById(R.id.call_type_icon); TextView callTypeTextView = (TextView) result.findViewById(R.id.call_type_text); diff --git a/src/com/android/dialer/calllog/CallLogActivity.java b/src/com/android/dialer/calllog/CallLogActivity.java index 97e601630..1823a5bd3 100644 --- a/src/com/android/dialer/calllog/CallLogActivity.java +++ b/src/com/android/dialer/calllog/CallLogActivity.java @@ -15,7 +15,6 @@ */ package com.android.dialer.calllog; -import android.app.ActionBar; import android.app.Activity; import android.app.Fragment; import android.app.FragmentManager; @@ -27,6 +26,7 @@ import android.provider.CallLog; import android.provider.CallLog.Calls; import android.support.v13.app.FragmentPagerAdapter; import android.support.v4.view.ViewPager; +import android.support.v7.app.ActionBar; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; @@ -39,11 +39,12 @@ import com.android.contacts.common.util.PermissionsUtil; import com.android.contacts.commonbind.analytics.AnalyticsUtil; import com.android.dialer.DialtactsActivity; import com.android.dialer.R; +import com.android.dialer.TransactionSafeActivity; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.ScreenEvent; import com.android.dialer.util.DialerUtils; -import com.android.dialer.voicemail.VoicemailStatusHelper; -import com.android.dialer.voicemail.VoicemailStatusHelperImpl; -public class CallLogActivity extends Activity implements ViewPager.OnPageChangeListener { +public class CallLogActivity extends TransactionSafeActivity implements ViewPager.OnPageChangeListener { private ViewPager mViewPager; private ViewPagerTabs mViewPagerTabs; private ViewPagerAdapter mViewPagerAdapter; @@ -73,9 +74,10 @@ public class CallLogActivity extends Activity implements ViewPager.OnPageChangeL public Fragment getItem(int position) { switch (getRtlPosition(position)) { case TAB_INDEX_ALL: - return new CallLogFragment(CallLogQueryHandler.CALL_TYPE_ALL); + return new CallLogFragment( + CallLogQueryHandler.CALL_TYPE_ALL, true /* isCallLogActivity */); case TAB_INDEX_MISSED: - return new CallLogFragment(Calls.MISSED_TYPE); + return new CallLogFragment(Calls.MISSED_TYPE, true /* isCallLogActivity */); } throw new IllegalStateException("No fragment at position " + position); } @@ -121,7 +123,7 @@ public class CallLogActivity extends Activity implements ViewPager.OnPageChangeL setContentView(R.layout.call_log_activity); getWindow().setBackgroundDrawable(null); - final ActionBar actionBar = getActionBar(); + final ActionBar actionBar = getSupportActionBar(); actionBar.setDisplayShowHomeEnabled(true); actionBar.setDisplayHomeAsUpEnabled(true); actionBar.setDisplayShowTitleEnabled(true); @@ -186,15 +188,18 @@ public class CallLogActivity extends Activity implements ViewPager.OnPageChangeL @Override public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - final Intent intent = new Intent(this, DialtactsActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - startActivity(intent); - return true; - case R.id.delete_all: - ClearCallLogDialog.show(getFragmentManager()); - return true; + if (!isSafeToCommitTransactions()) { + return true; + } + + if (item.getItemId() == android.R.id.home) { + final Intent intent = new Intent(this, DialtactsActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + return true; + } else if (item.getItemId() == R.id.delete_all) { + ClearCallLogDialog.show(getFragmentManager()); + return true; } return super.onOptionsItemSelected(item); } @@ -218,22 +223,7 @@ public class CallLogActivity extends Activity implements ViewPager.OnPageChangeL } private void sendScreenViewForChildFragment(int position) { - AnalyticsUtil.sendScreenView(CallLogFragment.class.getSimpleName(), this, - getFragmentTagForPosition(position)); - } - - /** - * Returns the fragment located at the given position in the {@link ViewPagerAdapter}. May - * be null if the position is invalid. - */ - private String getFragmentTagForPosition(int position) { - switch (position) { - case TAB_INDEX_ALL: - return "All"; - case TAB_INDEX_MISSED: - return "Missed"; - } - return null; + Logger.logScreenView(ScreenEvent.CALL_LOG_FILTER, this); } private int getRtlPosition(int position) { diff --git a/src/com/android/dialer/calllog/CallLogAdapter.java b/src/com/android/dialer/calllog/CallLogAdapter.java index 5a87bc8ce..dfb5190e1 100644 --- a/src/com/android/dialer/calllog/CallLogAdapter.java +++ b/src/com/android/dialer/calllog/CallLogAdapter.java @@ -16,75 +16,81 @@ package com.android.dialer.calllog; +import com.google.common.annotations.VisibleForTesting; + import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.res.Resources; import android.database.Cursor; import android.net.Uri; -import android.provider.ContactsContract; -import android.provider.ContactsContract.CommonDataKinds.Phone; -import android.support.v7.widget.RecyclerView; import android.os.Bundle; import android.os.Trace; -import android.preference.PreferenceActivity; import android.preference.PreferenceManager; import android.provider.CallLog; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView.ViewHolder; import android.telecom.PhoneAccountHandle; import android.telephony.PhoneNumberUtils; import android.telephony.TelephonyManager; import android.text.TextUtils; -import android.util.Log; -import android.view.ContextMenu; +import android.util.ArrayMap; import android.view.LayoutInflater; -import android.view.MenuItem; -import android.view.MenuItem.OnMenuItemClickListener; import android.view.View; import android.view.View.AccessibilityDelegate; import android.view.ViewGroup; -import android.view.ViewTreeObserver; -import android.view.ContextMenu.ContextMenuInfo; import android.view.accessibility.AccessibilityEvent; -import com.android.contacts.common.CallUtil; -import com.android.contacts.common.ClipboardUtils; +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.compat.CompatUtils; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; +import com.android.contacts.common.preference.ContactsPreferences; import com.android.contacts.common.util.PermissionsUtil; import com.android.dialer.DialtactsActivity; import com.android.dialer.PhoneCallDetails; import com.android.dialer.R; +import com.android.dialer.calllog.calllogcache.CallLogCache; import com.android.dialer.contactinfo.ContactInfoCache; import com.android.dialer.contactinfo.ContactInfoCache.OnContactInfoChangedListener; -import com.android.dialer.util.DialerUtils; +import com.android.dialer.database.FilteredNumberAsyncQueryHandler; +import com.android.dialer.database.VoicemailArchiveContract; +import com.android.dialer.filterednumber.BlockNumberDialogFragment.Callback; +import com.android.dialer.logging.InteractionEvent; +import com.android.dialer.logging.Logger; +import com.android.dialer.service.ExtendedBlockingButtonRenderer; import com.android.dialer.util.PhoneNumberUtil; import com.android.dialer.voicemail.VoicemailPlaybackPresenter; -import com.google.common.annotations.VisibleForTesting; - import java.util.HashMap; +import java.util.Map; /** * Adapter class to fill in data for the Call Log. */ public class CallLogAdapter extends GroupingListAdapter implements CallLogGroupBuilder.GroupCreator, - VoicemailPlaybackPresenter.OnVoicemailDeletedListener { + VoicemailPlaybackPresenter.OnVoicemailDeletedListener, + ExtendedBlockingButtonRenderer.Listener { + + // Types of activities the call log adapter is used for + public static final int ACTIVITY_TYPE_CALL_LOG = 1; + public static final int ACTIVITY_TYPE_ARCHIVE = 2; + public static final int ACTIVITY_TYPE_DIALTACTS = 3; /** Interface used to initiate a refresh of the content. */ public interface CallFetcher { public void fetchCalls(); } - private static final int VIEW_TYPE_SHOW_CALL_HISTORY_LIST_ITEM = 10; private static final int NO_EXPANDED_LIST_ITEM = -1; + // ConcurrentHashMap doesn't store null values. Use this value for numbers which aren't blocked. + private static final int NOT_BLOCKED = -1; private static final int VOICEMAIL_PROMO_CARD_POSITION = 0; - /** - * View type for voicemail promo card. Note: Numbering starts at 20 to avoid collision - * with {@link com.android.common.widget.GroupingListAdapter#ITEM_TYPE_IN_GROUP}, and - * {@link CallLogAdapter#VIEW_TYPE_SHOW_CALL_HISTORY_LIST_ITEM}. - */ - private static final int VIEW_TYPE_VOICEMAIL_PROMO_CARD = 20; + + protected static final int VIEW_TYPE_NORMAL = 0; + private static final int VIEW_TYPE_VOICEMAIL_PROMO_CARD = 1; /** * The key for the show voicemail promo card preference which will determine whether the promo @@ -95,12 +101,14 @@ public class CallLogAdapter extends GroupingListAdapter protected final Context mContext; private final ContactInfoHelper mContactInfoHelper; - private final VoicemailPlaybackPresenter mVoicemailPlaybackPresenter; + protected final VoicemailPlaybackPresenter mVoicemailPlaybackPresenter; private final CallFetcher mCallFetcher; + private final FilteredNumberAsyncQueryHandler mFilteredNumberAsyncQueryHandler; + private final Map<String, Boolean> mBlockedNumberCache = new ArrayMap<>(); protected ContactInfoCache mContactInfoCache; - private boolean mIsShowingRecentsTab; + private final int mActivityType; private static final String KEY_EXPANDED_POSITION = "expanded_position"; private static final String KEY_EXPANDED_ROW_ID = "expanded_row_id"; @@ -110,6 +118,9 @@ public class CallLogAdapter extends GroupingListAdapter // Tracks the rowId of the currently expanded list item, so the position can be updated if there // are any changes to the call log entries, such as additions or removals. private long mCurrentlyExpandedRowId = NO_EXPANDED_LIST_ITEM; + private int mHiddenPosition = RecyclerView.NO_POSITION; + private Uri mHiddenItemUri = null; + private boolean mPendingHide = false; /** * Hashmap, keyed by call Id, used to track the day group for a call. As call log entries are @@ -123,19 +134,21 @@ public class CallLogAdapter extends GroupingListAdapter * its day group. This hashmap provides a means of determining the previous day group without * having to reverse the cursor to the start of the previous day call log entry. */ - private HashMap<Long,Integer> mDayGroups = new HashMap<Long, Integer>(); + private HashMap<Long, Integer> mDayGroups = new HashMap<>(); private boolean mLoading = true; private SharedPreferences mPrefs; - private boolean mShowPromoCard = false; + private ContactsPreferences mContactsPreferences; + + protected boolean mShowVoicemailPromoCard = false; /** Instance of helper class for managing views. */ private final CallLogListItemHelper mCallLogListItemHelper; - /** Cache for repeated requests to TelecomManager. */ - protected final TelecomCallLogCache mTelecomCallLogCache; + /** Cache for repeated requests to Telecom/Telephony. */ + protected final CallLogCache mCallLogCache; /** Helper to group call log entries. */ private final CallLogGroupBuilder mCallLogGroupBuilder; @@ -163,6 +176,12 @@ public class CallLogAdapter extends GroupingListAdapter mCurrentlyExpandedPosition = RecyclerView.NO_POSITION; mCurrentlyExpandedRowId = NO_EXPANDED_LIST_ITEM; } else { + if (viewHolder.callType == CallLog.Calls.MISSED_TYPE) { + CallLogAsyncTaskUtil.markCallAsRead(mContext, viewHolder.callIds); + if (mActivityType == ACTIVITY_TYPE_DIALTACTS) { + ((DialtactsActivity) v.getContext()).updateTabUnreadCounts(); + } + } expandViewHolderActions(viewHolder); } @@ -193,68 +212,6 @@ public class CallLogAdapter extends GroupingListAdapter } }; - /** - * Listener that is triggered to populate the context menu with actions to perform on the call's - * number, when the call log entry is long pressed. - */ - private final View.OnCreateContextMenuListener mOnCreateContextMenuListener = - new View.OnCreateContextMenuListener() { - @Override - public void onCreateContextMenu(ContextMenu menu, View v, - ContextMenuInfo menuInfo) { - final CallLogListItemViewHolder vh = - (CallLogListItemViewHolder) v.getTag(); - if (TextUtils.isEmpty(vh.number)) { - return; - } - - menu.setHeaderTitle(vh.number); - - final MenuItem copyItem = menu.add( - ContextMenu.NONE, - R.id.context_menu_copy_to_clipboard, - ContextMenu.NONE, - R.string.copy_text); - - copyItem.setOnMenuItemClickListener(new OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - ClipboardUtils.copyText(CallLogAdapter.this.mContext, null, - vh.number, true); - return true; - } - }); - - // The edit number before call does not show up if any of the conditions apply: - // 1) Number cannot be called - // 2) Number is the voicemail number - // 3) Number is a SIP address - - if (!PhoneNumberUtil.canPlaceCallsTo(vh.number, vh.numberPresentation) - || mTelecomCallLogCache.isVoicemailNumber(vh.accountHandle, vh.number) - || PhoneNumberUtil.isSipNumber(vh.number)) { - return; - } - - final MenuItem editItem = menu.add( - ContextMenu.NONE, - R.id.context_menu_edit_before_call, - ContextMenu.NONE, - R.string.recentCalls_editNumberBeforeCall); - - editItem.setOnMenuItemClickListener(new OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - final Intent intent = new Intent(Intent.ACTION_DIAL, - CallUtil.getCallUri(vh.number)); - intent.setClass(mContext, DialtactsActivity.class); - DialerUtils.startActivityWithErrorToast(mContext, intent); - return true; - } - }); - } - }; - private void expandViewHolderActions(CallLogListItemViewHolder viewHolder) { // If another item is expanded, notify it that it has changed. Its actions will be // hidden when it is re-binded because we change mCurrentlyExpandedPosition below. @@ -279,6 +236,11 @@ public class CallLogAdapter extends GroupingListAdapter // function on clicks causes the action views to lose the focus indicator. CallLogListItemViewHolder viewHolder = (CallLogListItemViewHolder) host.getTag(); if (mCurrentlyExpandedPosition != viewHolder.getAdapterPosition()) { + if (mVoicemailPlaybackPresenter != null) { + // Always reset the voicemail playback state on expand. + mVoicemailPlaybackPresenter.resetAll(); + } + expandViewHolderActions((CallLogListItemViewHolder) host.getTag()); } } @@ -299,7 +261,7 @@ public class CallLogAdapter extends GroupingListAdapter CallFetcher callFetcher, ContactInfoHelper contactInfoHelper, VoicemailPlaybackPresenter voicemailPlaybackPresenter, - boolean isShowingRecentsTab) { + int activityType) { super(context); mContext = context; @@ -309,7 +271,8 @@ public class CallLogAdapter extends GroupingListAdapter if (mVoicemailPlaybackPresenter != null) { mVoicemailPlaybackPresenter.setOnVoicemailDeletedListener(this); } - mIsShowingRecentsTab = isShowingRecentsTab; + + mActivityType = activityType; mContactInfoCache = new ContactInfoCache( mContactInfoHelper, mOnContactInfoChangedListener); @@ -320,13 +283,18 @@ public class CallLogAdapter extends GroupingListAdapter Resources resources = mContext.getResources(); CallTypeHelper callTypeHelper = new CallTypeHelper(resources); - mTelecomCallLogCache = new TelecomCallLogCache(mContext); + mCallLogCache = CallLogCache.getCallLogCache(mContext); + PhoneCallDetailsHelper phoneCallDetailsHelper = - new PhoneCallDetailsHelper(mContext, resources, mTelecomCallLogCache); + new PhoneCallDetailsHelper(mContext, resources, mCallLogCache); mCallLogListItemHelper = - new CallLogListItemHelper(phoneCallDetailsHelper, resources, mTelecomCallLogCache); + new CallLogListItemHelper(phoneCallDetailsHelper, resources, mCallLogCache); mCallLogGroupBuilder = new CallLogGroupBuilder(this); + mFilteredNumberAsyncQueryHandler = + new FilteredNumberAsyncQueryHandler(mContext.getContentResolver()); + mPrefs = PreferenceManager.getDefaultSharedPreferences(context); + mContactsPreferences = new ContactsPreferences(mContext); maybeShowVoicemailPromoCard(); } @@ -344,6 +312,24 @@ public class CallLogAdapter extends GroupingListAdapter } } + @Override + public void onBlockedNumber(String number,String countryIso) { + String cacheKey = PhoneNumberUtils.formatNumberToE164(number, countryIso); + if (!TextUtils.isEmpty(cacheKey)) { + mBlockedNumberCache.put(cacheKey, true); + notifyDataSetChanged(); + } + } + + @Override + public void onUnblockedNumber( String number, String countryIso) { + String cacheKey = PhoneNumberUtils.formatNumberToE164(number, countryIso); + if (!TextUtils.isEmpty(cacheKey)) { + mBlockedNumberCache.put(cacheKey, false); + notifyDataSetChanged(); + } + } + /** * Requery on background thread when {@link Cursor} changes. */ @@ -369,15 +355,25 @@ public class CallLogAdapter extends GroupingListAdapter mContactInfoCache.invalidate(); } - public void startCache() { + public void onResume() { if (PermissionsUtil.hasPermission(mContext, android.Manifest.permission.READ_CONTACTS)) { mContactInfoCache.start(); } + mContactsPreferences.refreshValue(ContactsPreferences.DISPLAY_ORDER_KEY); + } + + public void onPause() { + pauseCache(); + + if (mHiddenItemUri != null) { + CallLogAsyncTaskUtil.deleteVoicemail(mContext, mHiddenItemUri, null); + } } - public void pauseCache() { + @VisibleForTesting + /* package */ void pauseCache() { mContactInfoCache.stop(); - mTelecomCallLogCache.reset(); + mCallLogCache.reset(); } @Override @@ -386,10 +382,13 @@ public class CallLogAdapter extends GroupingListAdapter } @Override + public void addVoicemailGroups(Cursor cursor) { + mCallLogGroupBuilder.addVoicemailGroups(cursor); + } + + @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - if (viewType == VIEW_TYPE_SHOW_CALL_HISTORY_LIST_ITEM) { - return ShowCallHistoryViewHolder.create(mContext, parent); - } else if (viewType == VIEW_TYPE_VOICEMAIL_PROMO_CARD) { + if (viewType == VIEW_TYPE_VOICEMAIL_PROMO_CARD) { return createVoicemailPromoCardViewHolder(parent); } return createCallLogEntryViewHolder(parent); @@ -407,15 +406,32 @@ public class CallLogAdapter extends GroupingListAdapter CallLogListItemViewHolder viewHolder = CallLogListItemViewHolder.create( view, mContext, + this, mExpandCollapseListener, - mTelecomCallLogCache, + mCallLogCache, mCallLogListItemHelper, - mVoicemailPlaybackPresenter); + mVoicemailPlaybackPresenter, + mFilteredNumberAsyncQueryHandler, + new Callback() { + @Override + public void onFilterNumberSuccess() { + Logger.logInteraction( + InteractionEvent.BLOCK_NUMBER_CALL_LOG); + } + + @Override + public void onUnfilterNumberSuccess() { + Logger.logInteraction( + InteractionEvent.UNBLOCK_NUMBER_CALL_LOG); + } + + @Override + public void onChangeFilteredNumberUndo() {} + }, mActivityType == ACTIVITY_TYPE_ARCHIVE); viewHolder.callLogEntryView.setTag(viewHolder); viewHolder.callLogEntryView.setAccessibilityDelegate(mAccessibilityDelegate); - viewHolder.primaryActionView.setOnCreateContextMenuListener(mOnCreateContextMenuListener); viewHolder.primaryActionView.setTag(viewHolder); return viewHolder; @@ -426,15 +442,14 @@ public class CallLogAdapter extends GroupingListAdapter * TODO: This gets called 20-30 times when Dialer starts up for a single call log entry and * should not. It invokes cross-process methods and the repeat execution can get costly. * - * @param ViewHolder The view corresponding to this entry. + * @param viewHolder The view corresponding to this entry. * @param position The position of the entry. */ + @Override public void onBindViewHolder(ViewHolder viewHolder, int position) { Trace.beginSection("onBindViewHolder: " + position); switch (getItemViewType(position)) { - case VIEW_TYPE_SHOW_CALL_HISTORY_LIST_ITEM: - break; case VIEW_TYPE_VOICEMAIL_PROMO_CARD: bindVoicemailPromoCardViewHolder(viewHolder); break; @@ -454,9 +469,9 @@ public class CallLogAdapter extends GroupingListAdapter protected void bindVoicemailPromoCardViewHolder(ViewHolder viewHolder) { PromoCardViewHolder promoCardViewHolder = (PromoCardViewHolder) viewHolder; - promoCardViewHolder.getSettingsTextView().setOnClickListener( - mVoicemailSettingsActionListener); - promoCardViewHolder.getOkTextView().setOnClickListener(mOkActionListener); + promoCardViewHolder.getSecondaryActionView() + .setOnClickListener(mVoicemailSettingsActionListener); + promoCardViewHolder.getPrimaryActionView().setOnClickListener(mOkActionListener); } /** @@ -475,14 +490,18 @@ public class CallLogAdapter extends GroupingListAdapter int count = getGroupSize(position); final String number = c.getString(CallLogQuery.NUMBER); + final String postDialDigits = CompatUtils.isNCompatible() + && mActivityType != ACTIVITY_TYPE_ARCHIVE ? + c.getString(CallLogQuery.POST_DIAL_DIGITS) : ""; + final int numberPresentation = c.getInt(CallLogQuery.NUMBER_PRESENTATION); final PhoneAccountHandle accountHandle = PhoneAccountUtils.getAccount( c.getString(CallLogQuery.ACCOUNT_COMPONENT_NAME), c.getString(CallLogQuery.ACCOUNT_ID)); final String countryIso = c.getString(CallLogQuery.COUNTRY_ISO); - final ContactInfo cachedContactInfo = mContactInfoHelper.getContactInfo(c); + final ContactInfo cachedContactInfo = ContactInfoHelper.getContactInfo(c); final boolean isVoicemailNumber = - mTelecomCallLogCache.isVoicemailNumber(accountHandle, number); + mCallLogCache.isVoicemailNumber(accountHandle, number); // Note: Binding of the action buttons is done as required in configureActionViews when the // user expands the actions ViewStub. @@ -490,49 +509,51 @@ public class CallLogAdapter extends GroupingListAdapter ContactInfo info = ContactInfo.EMPTY; if (PhoneNumberUtil.canPlaceCallsTo(number, numberPresentation) && !isVoicemailNumber) { // Lookup contacts with this number - info = mContactInfoCache.getValue(number, countryIso, cachedContactInfo); + info = mContactInfoCache.getValue(number + postDialDigits, + countryIso, cachedContactInfo); } CharSequence formattedNumber = info.formattedNumber == null - ? null : PhoneNumberUtils.createTtsSpannable(info.formattedNumber); + ? null : PhoneNumberUtilsCompat.createTtsSpannable(info.formattedNumber); final PhoneCallDetails details = new PhoneCallDetails( - mContext, number, numberPresentation, formattedNumber, isVoicemailNumber); + mContext, number, numberPresentation, formattedNumber, + postDialDigits, isVoicemailNumber); details.accountHandle = accountHandle; - details.callTypes = getCallTypes(c, count); details.countryIso = countryIso; details.date = c.getLong(CallLogQuery.DATE); details.duration = c.getLong(CallLogQuery.DURATION); details.features = getCallFeatures(c, count); details.geocode = c.getString(CallLogQuery.GEOCODED_LOCATION); details.transcription = c.getString(CallLogQuery.TRANSCRIPTION); - if (details.callTypes[0] == CallLog.Calls.VOICEMAIL_TYPE) { - details.isRead = c.getInt(CallLogQuery.IS_READ) == 1; - } + details.callTypes = getCallTypes(c, count); if (!c.isNull(CallLogQuery.DATA_USAGE)) { details.dataUsage = c.getLong(CallLogQuery.DATA_USAGE); } - if (!TextUtils.isEmpty(info.name)) { + if (!TextUtils.isEmpty(info.name) || !TextUtils.isEmpty(info.nameAlternative)) { details.contactUri = info.lookupUri; - details.name = info.name; + details.namePrimary = info.name; + details.nameAlternative = info.nameAlternative; + details.nameDisplayOrder = mContactsPreferences.getDisplayOrder(); details.numberType = info.type; details.numberLabel = info.label; details.photoUri = info.photoUri; details.sourceType = info.sourceType; details.objectId = info.objectId; + details.contactUserType = info.userType; } - CallLogListItemViewHolder views = (CallLogListItemViewHolder) viewHolder; + final CallLogListItemViewHolder views = (CallLogListItemViewHolder) viewHolder; views.info = info; views.rowId = c.getLong(CallLogQuery.ID); // Store values used when the actions ViewStub is inflated on expansion. views.number = number; + views.postDialDigits = details.postDialDigits; views.displayNumber = details.displayNumber; views.numberPresentation = numberPresentation; - views.callType = c.getInt(CallLogQuery.CALL_TYPE); + views.accountHandle = accountHandle; - views.voicemailUri = c.getString(CallLogQuery.VOICEMAIL_URI); // Stash away the Ids of the calls so that we can support deleting a row in the call log. views.callIds = getCallIds(c, count); views.isBusiness = mContactInfoHelper.isBusiness(info.sourceType); @@ -540,6 +561,8 @@ public class CallLogAdapter extends GroupingListAdapter details.numberLabel); // Default case: an item in the call log. views.primaryActionView.setVisibility(View.VISIBLE); + views.workIconView.setVisibility( + details.contactUserType == ContactsUtils.USER_TYPE_WORK ? View.VISIBLE : View.GONE); // Check if the day group has changed and display a header if necessary. int currentGroup = getDayGroupForCall(views.rowId); @@ -551,6 +574,21 @@ public class CallLogAdapter extends GroupingListAdapter views.dayGroupHeader.setVisibility(View.GONE); } + if (mActivityType == ACTIVITY_TYPE_ARCHIVE) { + views.callType = CallLog.Calls.VOICEMAIL_TYPE; + views.voicemailUri = VoicemailArchiveContract.VoicemailArchive.buildWithId(c.getInt( + c.getColumnIndex(VoicemailArchiveContract.VoicemailArchive._ID))) + .toString(); + + } else { + if (details.callTypes[0] == CallLog.Calls.VOICEMAIL_TYPE || + details.callTypes[0] == CallLog.Calls.MISSED_TYPE) { + details.isRead = c.getInt(CallLogQuery.IS_READ) == 1; + } + views.callType = c.getInt(CallLogQuery.CALL_TYPE); + views.voicemailUri = c.getString(CallLogQuery.VOICEMAIL_URI); + } + mCallLogListItemHelper.setPhoneCallDetails(views, details); if (mCurrentlyExpandedRowId == views.rowId) { @@ -560,29 +598,28 @@ public class CallLogAdapter extends GroupingListAdapter } views.showActions(mCurrentlyExpandedPosition == position); - - String nameForDefaultImage = null; - if (TextUtils.isEmpty(info.name)) { - nameForDefaultImage = details.displayNumber; - } else { - nameForDefaultImage = info.name; - } - views.setPhoto(info.photoId, info.photoUri, info.lookupUri, nameForDefaultImage, - isVoicemailNumber, views.isBusiness); + views.updatePhoto(); mCallLogListItemHelper.setPhoneCallDetails(views, details); } + private String getPreferredDisplayName(ContactInfo contactInfo) { + if (mContactsPreferences.getDisplayOrder() == ContactsPreferences.DISPLAY_ORDER_PRIMARY || + TextUtils.isEmpty(contactInfo.nameAlternative)) { + return contactInfo.name; + } + return contactInfo.nameAlternative; + } + @Override public int getItemCount() { - return super.getItemCount() + ((isShowingRecentsTab() || mShowPromoCard) ? 1 : 0); + return super.getItemCount() + (mShowVoicemailPromoCard ? 1 : 0) + - (mHiddenPosition != RecyclerView.NO_POSITION ? 1 : 0); } @Override public int getItemViewType(int position) { - if (position == getItemCount() - 1 && isShowingRecentsTab()) { - return VIEW_TYPE_SHOW_CALL_HISTORY_LIST_ITEM; - } else if (position == VOICEMAIL_PROMO_CARD_POSITION && mShowPromoCard) { + if (position == VOICEMAIL_PROMO_CARD_POSITION && mShowVoicemailPromoCard) { return VIEW_TYPE_VOICEMAIL_PROMO_CARD; } return super.getItemViewType(position); @@ -597,20 +634,87 @@ public class CallLogAdapter extends GroupingListAdapter */ @Override public Object getItem(int position) { - return super.getItem(position - (mShowPromoCard ? 1 : 0)); + return super.getItem(position - (mShowVoicemailPromoCard ? 1 : 0) + + ((mHiddenPosition != RecyclerView.NO_POSITION && position >= mHiddenPosition) + ? 1 : 0)); + } + + @Override + public int getGroupSize(int position) { + return super.getGroupSize(position - (mShowVoicemailPromoCard ? 1 : 0)); } - protected boolean isShowingRecentsTab() { - return mIsShowingRecentsTab; + protected boolean isCallLogActivity() { + return mActivityType == ACTIVITY_TYPE_CALL_LOG; } + /** + * In order to implement the "undo" function, when a voicemail is "deleted" i.e. when the user + * clicks the delete button, the deleted item is temporarily hidden from the list. If a user + * clicks delete on a second item before the first item's undo option has expired, the first + * item is immediately deleted so that only one item can be "undoed" at a time. + */ @Override public void onVoicemailDeleted(Uri uri) { + if (mHiddenItemUri == null) { + // Immediately hide the currently expanded card. + mHiddenPosition = mCurrentlyExpandedPosition; + notifyDataSetChanged(); + } else { + // This means that there was a previous item that was hidden in the UI but not + // yet deleted from the database (call it a "pending delete"). Delete this previous item + // now since it is only possible to do one "undo" at a time. + CallLogAsyncTaskUtil.deleteVoicemail(mContext, mHiddenItemUri, null); + + // Set pending hide action so that the current item is hidden only after the previous + // item is permanently deleted. + mPendingHide = true; + } + + collapseExpandedCard(); + + // Save the new hidden item uri in case it needs to be deleted from the database when + // a user attempts to delete another item. + mHiddenItemUri = uri; + } + + private void collapseExpandedCard() { mCurrentlyExpandedRowId = NO_EXPANDED_LIST_ITEM; mCurrentlyExpandedPosition = RecyclerView.NO_POSITION; } /** + * When the user clicks "undo", the hidden item is unhidden. + */ + @Override + public void onVoicemailDeleteUndo() { + mHiddenPosition = RecyclerView.NO_POSITION; + mHiddenItemUri = null; + + mPendingHide = false; + notifyDataSetChanged(); + } + + /** + * This callback signifies that a database deletion has completed. This means that if there is + * an item pending deletion, it will be hidden because the previous item that was in "undo" mode + * has been removed from the database. Otherwise it simply resets the hidden state because there + * are no pending deletes and thus no hidden items. + */ + @Override + public void onVoicemailDeletedInDatabase() { + if (mPendingHide) { + mHiddenPosition = mCurrentlyExpandedPosition; + mPendingHide = false; + } else { + // There should no longer be any hidden item because it has been deleted from the + // database. + mHiddenPosition = RecyclerView.NO_POSITION; + mHiddenItemUri = null; + } + } + + /** * Retrieves the day group of the previous call in the call log. Used to determine if the day * group has changed and to trigger display of the day group text. * @@ -622,8 +726,16 @@ public class CallLogAdapter extends GroupingListAdapter int startingPosition = cursor.getPosition(); int dayGroup = CallLogGroupBuilder.DAY_GROUP_NONE; if (cursor.moveToPrevious()) { - long previousRowId = cursor.getLong(CallLogQuery.ID); - dayGroup = getDayGroupForCall(previousRowId); + // If the previous entry is hidden (deleted in the UI but not in the database), skip it + // and check the card above it. A list with the voicemail promo card at the top will be + // 1-indexed because the 0th index is the promo card iteself. + int previousViewPosition = mShowVoicemailPromoCard ? startingPosition : + startingPosition - 1; + if (previousViewPosition != mHiddenPosition || + (previousViewPosition == mHiddenPosition && cursor.moveToPrevious())) { + long previousRowId = cursor.getLong(CallLogQuery.ID); + dayGroup = getDayGroupForCall(previousRowId); + } } cursor.moveToPosition(startingPosition); return dayGroup; @@ -651,6 +763,9 @@ public class CallLogAdapter extends GroupingListAdapter * It position in the cursor is unchanged by this function. */ private int[] getCallTypes(Cursor cursor, int count) { + if (mActivityType == ACTIVITY_TYPE_ARCHIVE) { + return new int[] {CallLog.Calls.VOICEMAIL_TYPE}; + } int position = cursor.getPosition(); int[] callTypes = new int[count]; for (int index = 0; index < count; ++index) { @@ -698,11 +813,6 @@ public class CallLogAdapter extends GroupingListAdapter mContactInfoCache.injectContactInfoForTest(number, countryIso, contactInfo); } - @Override - public void addGroup(int cursorPosition, int size, boolean expanded) { - super.addGroup(cursorPosition, size, expanded); - } - /** * Stores the day group associated with a call in the call log. * @@ -767,7 +877,8 @@ public class CallLogAdapter extends GroupingListAdapter private void maybeShowVoicemailPromoCard() { boolean showPromoCard = mPrefs.getBoolean(SHOW_VOICEMAIL_PROMO_CARD, SHOW_VOICEMAIL_PROMO_CARD_DEFAULT); - mShowPromoCard = (mVoicemailPlaybackPresenter != null) && showPromoCard; + mShowVoicemailPromoCard = mActivityType != ACTIVITY_TYPE_ARCHIVE && + (mVoicemailPlaybackPresenter != null) && showPromoCard; } /** @@ -775,7 +886,7 @@ public class CallLogAdapter extends GroupingListAdapter */ private void dismissVoicemailPromoCard() { mPrefs.edit().putBoolean(SHOW_VOICEMAIL_PROMO_CARD, false).apply(); - mShowPromoCard = false; + mShowVoicemailPromoCard = false; notifyItemRemoved(VOICEMAIL_PROMO_CARD_POSITION); } diff --git a/src/com/android/dialer/calllog/CallLogAsyncTaskUtil.java b/src/com/android/dialer/calllog/CallLogAsyncTaskUtil.java index 22dece57c..7cb35f514 100644 --- a/src/com/android/dialer/calllog/CallLogAsyncTaskUtil.java +++ b/src/com/android/dialer/calllog/CallLogAsyncTaskUtil.java @@ -16,6 +16,9 @@ package com.android.dialer.calllog; +import com.google.common.annotations.VisibleForTesting; + +import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.Intent; @@ -29,13 +32,18 @@ import android.text.TextUtils; import android.util.Log; import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.compat.CompatUtils; +import com.android.contacts.common.util.PermissionsUtil; import com.android.dialer.PhoneCallDetails; +import com.android.dialer.compat.CallsSdkCompat; +import com.android.dialer.database.VoicemailArchiveContract; import com.android.dialer.util.AsyncTaskExecutor; import com.android.dialer.util.AsyncTaskExecutors; import com.android.dialer.util.PhoneNumberUtil; import com.android.dialer.util.TelecomUtil; -import com.google.common.annotations.VisibleForTesting; +import java.util.ArrayList; +import java.util.Arrays; public class CallLogAsyncTaskUtil { private static String TAG = CallLogAsyncTaskUtil.class.getSimpleName(); @@ -44,12 +52,16 @@ public class CallLogAsyncTaskUtil { public enum Tasks { DELETE_VOICEMAIL, DELETE_CALL, + DELETE_BLOCKED_CALL, MARK_VOICEMAIL_READ, + MARK_CALL_READ, GET_CALL_DETAILS, + UPDATE_DURATION } - private static class CallDetailQuery { - static final String[] CALL_LOG_PROJECTION = new String[] { + private static final class CallDetailQuery { + + private static final String[] CALL_LOG_PROJECTION_INTERNAL = new String[] { CallLog.Calls.DATE, CallLog.Calls.DURATION, CallLog.Calls.NUMBER, @@ -63,6 +75,7 @@ public class CallLogAsyncTaskUtil { CallLog.Calls.DATA_USAGE, CallLog.Calls.TRANSCRIPTION }; + public static final String[] CALL_LOG_PROJECTION; static final int DATE_COLUMN_INDEX = 0; static final int DURATION_COLUMN_INDEX = 1; @@ -76,14 +89,44 @@ public class CallLogAsyncTaskUtil { static final int FEATURES = 9; static final int DATA_USAGE = 10; static final int TRANSCRIPTION_COLUMN_INDEX = 11; + static final int POST_DIAL_DIGITS = 12; + + static { + ArrayList<String> projectionList = new ArrayList<>(); + projectionList.addAll(Arrays.asList(CALL_LOG_PROJECTION_INTERNAL)); + if (CompatUtils.isNCompatible()) { + projectionList.add(CallsSdkCompat.POST_DIAL_DIGITS); + } + projectionList.trimToSize(); + CALL_LOG_PROJECTION = projectionList.toArray(new String[projectionList.size()]); + } + } + + private static class CallLogDeleteBlockedCallQuery { + static final String[] PROJECTION = new String[] { + CallLog.Calls._ID, + CallLog.Calls.DATE + }; + + static final int ID_COLUMN_INDEX = 0; + static final int DATE_COLUMN_INDEX = 1; } public interface CallLogAsyncTaskListener { - public void onDeleteCall(); - public void onDeleteVoicemail(); - public void onGetCallDetails(PhoneCallDetails[] details); + void onDeleteCall(); + void onDeleteVoicemail(); + void onGetCallDetails(PhoneCallDetails[] details); + } + + public interface OnCallLogQueryFinishedListener { + void onQueryFinished(boolean hasEntry); } + // Try to identify if a call log entry corresponds to a number which was blocked. We match by + // by comparing its creation time to the time it was added in the InCallUi and seeing if they + // fall within a certain threshold. + private static final int MATCH_BLOCKED_CALL_THRESHOLD_MS = 3000; + private static AsyncTaskExecutor sAsyncTaskExecutor; private static void initTaskExecutor() { @@ -142,6 +185,8 @@ public class CallLogAsyncTaskUtil { // Read call log. final String countryIso = cursor.getString(CallDetailQuery.COUNTRY_ISO_COLUMN_INDEX); final String number = cursor.getString(CallDetailQuery.NUMBER_COLUMN_INDEX); + final String postDialDigits = CompatUtils.isNCompatible() + ? cursor.getString(CallDetailQuery.POST_DIAL_DIGITS) : ""; final int numberPresentation = cursor.getInt(CallDetailQuery.NUMBER_PRESENTATION_COLUMN_INDEX); @@ -155,19 +200,21 @@ public class CallLogAsyncTaskUtil { boolean isVoicemail = PhoneNumberUtil.isVoicemailNumber(context, accountHandle, number); boolean shouldLookupNumber = PhoneNumberUtil.canPlaceCallsTo(number, numberPresentation) && !isVoicemail; - ContactInfo info = ContactInfo.EMPTY; + if (shouldLookupNumber) { ContactInfo lookupInfo = contactInfoHelper.lookupNumber(number, countryIso); info = lookupInfo != null ? lookupInfo : ContactInfo.EMPTY; } PhoneCallDetails details = new PhoneCallDetails( - context, number, numberPresentation, info.formattedNumber, isVoicemail); + context, number, numberPresentation, info.formattedNumber, + postDialDigits, isVoicemail); details.accountHandle = accountHandle; details.contactUri = info.lookupUri; - details.name = info.name; + details.namePrimary = info.name; + details.nameAlternative = info.nameAlternative; details.numberType = info.type; details.numberLabel = info.label; details.photoUri = info.photoUri; @@ -204,7 +251,7 @@ public class CallLogAsyncTaskUtil { * * @param context The context. * @param callIds String of the callIds to delete from the call log, delimited by commas (","). - * @param callLogAsyncTaskListenerg The listener to invoke after the entries have been deleted. + * @param callLogAsyncTaskListener The listener to invoke after the entries have been deleted. */ public static void deleteCalls( final Context context, @@ -214,26 +261,82 @@ public class CallLogAsyncTaskUtil { initTaskExecutor(); } - sAsyncTaskExecutor.submit(Tasks.DELETE_CALL, - new AsyncTask<Void, Void, Void>() { - @Override - public Void doInBackground(Void... params) { + sAsyncTaskExecutor.submit(Tasks.DELETE_CALL, new AsyncTask<Void, Void, Void>() { + @Override + public Void doInBackground(Void... params) { + context.getContentResolver().delete( + TelecomUtil.getCallLogUri(context), + CallLog.Calls._ID + " IN (" + callIds + ")", null); + return null; + } + + @Override + public void onPostExecute(Void result) { + if (callLogAsyncTaskListener != null) { + callLogAsyncTaskListener.onDeleteCall(); + } + } + }); + } + + /** + * Deletes the last call made by the number within a threshold of the call time added in the + * call log, assuming it is a blocked call for which no entry should be shown. + * + * @param context The context. + * @param number Number of blocked call, for which to delete the call log entry. + * @param timeAddedMs The time the number was added to InCall, in milliseconds. + * @param listener The listener to invoke after looking up for a call log entry matching the + * number and time added. + */ + public static void deleteBlockedCall( + final Context context, + final String number, + final long timeAddedMs, + final OnCallLogQueryFinishedListener listener) { + if (sAsyncTaskExecutor == null) { + initTaskExecutor(); + } + + sAsyncTaskExecutor.submit(Tasks.DELETE_BLOCKED_CALL, new AsyncTask<Void, Void, Long>() { + @Override + public Long doInBackground(Void... params) { + // First, lookup the call log entry of the most recent call with this number. + Cursor cursor = context.getContentResolver().query( + TelecomUtil.getCallLogUri(context), + CallLogDeleteBlockedCallQuery.PROJECTION, + CallLog.Calls.NUMBER + "= ?", + new String[] { number }, + CallLog.Calls.DATE + " DESC LIMIT 1"); + + // If match is found, delete this call log entry and return the call log entry id. + if (cursor.moveToFirst()) { + long creationTime = + cursor.getLong(CallLogDeleteBlockedCallQuery.DATE_COLUMN_INDEX); + if (timeAddedMs > creationTime + && timeAddedMs - creationTime < MATCH_BLOCKED_CALL_THRESHOLD_MS) { + long callLogEntryId = + cursor.getLong(CallLogDeleteBlockedCallQuery.ID_COLUMN_INDEX); context.getContentResolver().delete( TelecomUtil.getCallLogUri(context), - CallLog.Calls._ID + " IN (" + callIds + ")", null); - return null; - } - - @Override - public void onPostExecute(Void result) { - if (callLogAsyncTaskListener != null) { - callLogAsyncTaskListener.onDeleteCall(); - } + CallLog.Calls._ID + " IN (" + callLogEntryId + ")", + null); + return callLogEntryId; } - }); + } + return (long) -1; + } + @Override + public void onPostExecute(Long callLogEntryId) { + if (listener != null) { + listener.onQueryFinished(callLogEntryId >= 0); + } + } + }); } + public static void markVoicemailAsRead(final Context context, final Uri voicemailUri) { if (sAsyncTaskExecutor == null) { initTaskExecutor(); @@ -263,21 +366,87 @@ public class CallLogAsyncTaskUtil { initTaskExecutor(); } - sAsyncTaskExecutor.submit(Tasks.DELETE_VOICEMAIL, - new AsyncTask<Void, Void, Void>() { - @Override - public Void doInBackground(Void... params) { - context.getContentResolver().delete(voicemailUri, null, null); - return null; - } + sAsyncTaskExecutor.submit(Tasks.DELETE_VOICEMAIL, new AsyncTask<Void, Void, Void>() { + @Override + public Void doInBackground(Void... params) { + context.getContentResolver().delete(voicemailUri, null, null); + return null; + } - @Override - public void onPostExecute(Void result) { - if (callLogAsyncTaskListener != null) { - callLogAsyncTaskListener.onDeleteVoicemail(); - } - } - }); + @Override + public void onPostExecute(Void result) { + if (callLogAsyncTaskListener != null) { + callLogAsyncTaskListener.onDeleteVoicemail(); + } + } + }); + } + + public static void markCallAsRead(final Context context, final long[] callIds) { + if (!PermissionsUtil.hasPhonePermissions(context)) { + return; + } + if (sAsyncTaskExecutor == null) { + initTaskExecutor(); + } + + sAsyncTaskExecutor.submit(Tasks.MARK_CALL_READ, new AsyncTask<Void, Void, Void>() { + @Override + public Void doInBackground(Void... params) { + + StringBuilder where = new StringBuilder(); + where.append(CallLog.Calls.TYPE).append(" = ").append(CallLog.Calls.MISSED_TYPE); + where.append(" AND "); + + Long[] callIdLongs = new Long[callIds.length]; + for (int i = 0; i < callIds.length; i++) { + callIdLongs[i] = callIds[i]; + } + where.append(CallLog.Calls._ID).append( + " IN (" + TextUtils.join(",", callIdLongs) + ")"); + + ContentValues values = new ContentValues(1); + values.put(CallLog.Calls.IS_READ, "1"); + context.getContentResolver().update( + CallLog.Calls.CONTENT_URI, values, where.toString(), null); + return null; + } + }); + } + + /** + * Updates the duration of a voicemail call log entry if the duration given is greater than 0, + * and if if the duration currently in the database is less than or equal to 0 (non-existent). + */ + public static void updateVoicemailDuration( + final Context context, + final Uri voicemailUri, + final long duration) { + if (duration <= 0 || !PermissionsUtil.hasPhonePermissions(context)) { + return; + } + if (sAsyncTaskExecutor == null) { + initTaskExecutor(); + } + + sAsyncTaskExecutor.submit(Tasks.UPDATE_DURATION, new AsyncTask<Void, Void, Void>() { + @Override + public Void doInBackground(Void... params) { + ContentResolver contentResolver = context.getContentResolver(); + Cursor cursor = contentResolver.query( + voicemailUri, + new String[] { VoicemailArchiveContract.VoicemailArchive.DURATION }, + null, null, null); + if (cursor != null && cursor.moveToFirst() && cursor.getInt( + cursor.getColumnIndex( + VoicemailArchiveContract.VoicemailArchive.DURATION)) <= 0) { + ContentValues values = new ContentValues(1); + values.put(CallLog.Calls.DURATION, duration); + context.getContentResolver().update(voicemailUri, values, null, null); + } + return null; + } + }); } @VisibleForTesting diff --git a/src/com/android/dialer/calllog/CallLogFragment.java b/src/com/android/dialer/calllog/CallLogFragment.java index e7b77646d..07299a2fb 100644 --- a/src/com/android/dialer/calllog/CallLogFragment.java +++ b/src/com/android/dialer/calllog/CallLogFragment.java @@ -16,61 +16,47 @@ package com.android.dialer.calllog; -import static android.Manifest.permission.READ_CALL_LOG; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.ValueAnimator; import android.app.Activity; -import android.app.DialogFragment; import android.app.Fragment; import android.app.KeyguardManager; import android.content.ContentResolver; import android.content.Context; -import android.content.Intent; import android.content.pm.PackageManager; import android.database.ContentObserver; import android.database.Cursor; -import android.graphics.Rect; import android.os.Bundle; import android.os.Handler; +import android.os.Message; import android.provider.CallLog; import android.provider.CallLog.Calls; import android.provider.ContactsContract; -import android.provider.VoicemailContract.Status; -import android.support.v7.widget.RecyclerView; +import android.support.annotation.Nullable; +import android.support.v13.app.FragmentCompat; import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.view.View.OnClickListener; -import android.view.ViewGroup.LayoutParams; -import android.widget.ListView; -import android.widget.TextView; import com.android.contacts.common.GeoUtil; import com.android.contacts.common.util.PermissionsUtil; -import com.android.contacts.common.util.ViewUtil; import com.android.dialer.R; -import com.android.dialer.list.ListsFragment.HostInterface; -import com.android.dialer.util.DialerUtils; +import com.android.dialer.list.ListsFragment; import com.android.dialer.util.EmptyLoader; import com.android.dialer.voicemail.VoicemailPlaybackPresenter; -import com.android.dialer.voicemail.VoicemailStatusHelper; -import com.android.dialer.voicemail.VoicemailStatusHelper.StatusMessage; -import com.android.dialer.voicemail.VoicemailStatusHelperImpl; import com.android.dialer.widget.EmptyContentView; import com.android.dialer.widget.EmptyContentView.OnEmptyViewActionButtonClickedListener; import com.android.dialerbind.ObjectFactory; -import java.util.List; +import static android.Manifest.permission.READ_CALL_LOG; /** * Displays a list of call log entries. To filter for a particular kind of call * (all, missed or voicemails), specify it in the constructor. */ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Listener, - CallLogAdapter.CallFetcher, OnEmptyViewActionButtonClickedListener { + CallLogAdapter.CallFetcher, OnEmptyViewActionButtonClickedListener, + FragmentCompat.OnRequestPermissionsResultCallback { private static final String TAG = "CallLogFragment"; /** @@ -81,6 +67,7 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis private static final String KEY_FILTER_TYPE = "filter_type"; private static final String KEY_LOG_LIMIT = "log_limit"; private static final String KEY_DATE_LIMIT = "date_limit"; + private static final String KEY_IS_CALL_LOG_ACTIVITY = "is_call_log_activity"; // No limit specified for the number of logs to show; use the CallLogQueryHandler's default. private static final int NO_LOG_LIMIT = -1; @@ -89,15 +76,16 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis private static final int READ_CALL_LOG_PERMISSION_REQUEST_CODE = 1; + private static final int EVENT_UPDATE_DISPLAY = 1; + + private static final long MILLIS_IN_MINUTE = 60 * 1000; + private RecyclerView mRecyclerView; private LinearLayoutManager mLayoutManager; private CallLogAdapter mAdapter; private CallLogQueryHandler mCallLogQueryHandler; - private VoicemailPlaybackPresenter mVoicemailPlaybackPresenter; private boolean mScrollToTop; - /** Whether there is at least one voicemail source installed. */ - private boolean mVoicemailSourcesAvailable = false; private EmptyContentView mEmptyListView; private KeyguardManager mKeyguardManager; @@ -106,9 +94,21 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis private boolean mCallLogFetched; private boolean mVoicemailStatusFetched; + private final Handler mDisplayUpdateHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case EVENT_UPDATE_DISPLAY: + refreshData(); + rescheduleDisplayUpdate(); + break; + } + } + }; + private final Handler mHandler = new Handler(); - private class CustomContentObserver extends ContentObserver { + protected class CustomContentObserver extends ContentObserver { public CustomContentObserver() { super(mHandler); } @@ -121,7 +121,6 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis // See issue 6363009 private final ContentObserver mCallLogObserver = new CustomContentObserver(); private final ContentObserver mContactsObserver = new CustomContentObserver(); - private final ContentObserver mVoicemailStatusObserver = new CustomContentObserver(); private boolean mRefreshDataRequired = true; private boolean mHasReadCallLogPermission = false; @@ -141,10 +140,9 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis private long mDateLimit = NO_DATE_LIMIT; /* - * True if this instance of the CallLogFragment is the Recents screen shown in - * DialtactsActivity. + * True if this instance of the CallLogFragment shown in the CallLogActivity. */ - private boolean mIsRecentsFragment; + private boolean mIsCallLogActivity = false; public interface HostInterface { public void showDialpad(); @@ -158,6 +156,11 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis this(filterType, NO_LOG_LIMIT); } + public CallLogFragment(int filterType, boolean isCallLogActivity) { + this(filterType, NO_LOG_LIMIT); + mIsCallLogActivity = isCallLogActivity; + } + public CallLogFragment(int filterType, int logLimit) { this(filterType, logLimit, NO_DATE_LIMIT); } @@ -192,10 +195,9 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis mCallTypeFilter = state.getInt(KEY_FILTER_TYPE, mCallTypeFilter); mLogLimit = state.getInt(KEY_LOG_LIMIT, mLogLimit); mDateLimit = state.getLong(KEY_DATE_LIMIT, mDateLimit); + mIsCallLogActivity = state.getBoolean(KEY_IS_CALL_LOG_ACTIVITY, mIsCallLogActivity); } - mIsRecentsFragment = mLogLimit != NO_LOG_LIMIT; - final Activity activity = getActivity(); final ContentResolver resolver = activity.getContentResolver(); String currentCountryIso = GeoUtil.getCurrentCountryIso(activity); @@ -205,13 +207,7 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis resolver.registerContentObserver(CallLog.CONTENT_URI, true, mCallLogObserver); resolver.registerContentObserver(ContactsContract.Contacts.CONTENT_URI, true, mContactsObserver); - resolver.registerContentObserver(Status.CONTENT_URI, true, mVoicemailStatusObserver); setHasOptionsMenu(true); - - if (mCallTypeFilter == Calls.VOICEMAIL_TYPE) { - mVoicemailPlaybackPresenter = VoicemailPlaybackPresenter - .getInstance(activity, state); - } } /** Called by the CallLogQueryHandler when the list of calls has been fetched or updated. */ @@ -282,9 +278,20 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis } @Override + public void onVoicemailUnreadCountFetched(Cursor cursor) {} + + @Override + public void onMissedCallsUnreadCountFetched(Cursor cursor) {} + + @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) { View view = inflater.inflate(R.layout.call_log_fragment, container, false); + setupView(view, null); + return view; + } + protected void setupView( + View view, @Nullable VoicemailPlaybackPresenter voicemailPlaybackPresenter) { mRecyclerView = (RecyclerView) view.findViewById(R.id.recycler_view); mRecyclerView.setHasFixedSize(true); mLayoutManager = new LinearLayoutManager(getActivity()); @@ -293,18 +300,17 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis mEmptyListView.setImage(R.drawable.empty_call_log); mEmptyListView.setActionClickedListener(this); + int activityType = mIsCallLogActivity ? CallLogAdapter.ACTIVITY_TYPE_CALL_LOG : + CallLogAdapter.ACTIVITY_TYPE_DIALTACTS; String currentCountryIso = GeoUtil.getCurrentCountryIso(getActivity()); - boolean isShowingRecentsTab = mLogLimit != NO_LOG_LIMIT || mDateLimit != NO_DATE_LIMIT; mAdapter = ObjectFactory.newCallLogAdapter( - getActivity(), - this, - new ContactInfoHelper(getActivity(), currentCountryIso), - mVoicemailPlaybackPresenter, - isShowingRecentsTab); + getActivity(), + this, + new ContactInfoHelper(getActivity(), currentCountryIso), + voicemailPlaybackPresenter, + activityType); mRecyclerView.setAdapter(mAdapter); - fetchCalls(); - return view; } @Override @@ -336,39 +342,34 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis mRefreshDataRequired = true; updateEmptyMessage(mCallTypeFilter); } + mHasReadCallLogPermission = hasReadCallLogPermission; refreshData(); - mAdapter.startCache(); + mAdapter.onResume(); + + rescheduleDisplayUpdate(); } @Override public void onPause() { - if (mVoicemailPlaybackPresenter != null) { - mVoicemailPlaybackPresenter.onPause(); - } - mAdapter.pauseCache(); + cancelDisplayUpdate(); + mAdapter.onPause(); super.onPause(); } @Override public void onStop() { - updateOnTransition(false /* onEntry */); + updateOnTransition(); super.onStop(); } @Override public void onDestroy() { - mAdapter.pauseCache(); mAdapter.changeCursor(null); - if (mVoicemailPlaybackPresenter != null) { - mVoicemailPlaybackPresenter.onDestroy(); - } - getActivity().getContentResolver().unregisterContentObserver(mCallLogObserver); getActivity().getContentResolver().unregisterContentObserver(mContactsObserver); - getActivity().getContentResolver().unregisterContentObserver(mVoicemailStatusObserver); super.onDestroy(); } @@ -378,17 +379,17 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis outState.putInt(KEY_FILTER_TYPE, mCallTypeFilter); outState.putInt(KEY_LOG_LIMIT, mLogLimit); outState.putLong(KEY_DATE_LIMIT, mDateLimit); + outState.putBoolean(KEY_IS_CALL_LOG_ACTIVITY, mIsCallLogActivity); mAdapter.onSaveInstanceState(outState); - - if (mVoicemailPlaybackPresenter != null) { - mVoicemailPlaybackPresenter.onSaveInstanceState(outState); - } } @Override public void fetchCalls() { mCallLogQueryHandler.fetchCalls(mCallTypeFilter, mDateLimit); + if (!mIsCallLogActivity) { + ((ListsFragment) getParentFragment()).updateTabUnreadCounts(); + } } private void updateEmptyMessage(int filterType) { @@ -406,23 +407,23 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis final int messageId; switch (filterType) { case Calls.MISSED_TYPE: - messageId = R.string.recentMissed_empty; + messageId = R.string.call_log_missed_empty; break; case Calls.VOICEMAIL_TYPE: - messageId = R.string.recentVoicemails_empty; + messageId = R.string.call_log_voicemail_empty; break; case CallLogQueryHandler.CALL_TYPE_ALL: - messageId = R.string.recentCalls_empty; + messageId = R.string.call_log_all_empty; break; default: throw new IllegalArgumentException("Unexpected filter type in CallLogFragment: " + filterType); } mEmptyListView.setDescription(messageId); - if (mIsRecentsFragment) { - mEmptyListView.setActionLabel(R.string.recentCalls_empty_action); - } else { + if (mIsCallLogActivity) { mEmptyListView.setActionLabel(EmptyContentView.NO_LABEL); + } else if (filterType == CallLogQueryHandler.CALL_TYPE_ALL) { + mEmptyListView.setActionLabel(R.string.call_log_all_empty_action); } } @@ -436,7 +437,7 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis if (mMenuVisible != menuVisible) { mMenuVisible = menuVisible; if (!menuVisible) { - updateOnTransition(false /* onEntry */); + updateOnTransition(); } else if (isResumed()) { refreshData(); } @@ -454,8 +455,8 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis fetchCalls(); mCallLogQueryHandler.fetchVoicemailStatus(); - - updateOnTransition(true /* onEntry */); + mCallLogQueryHandler.fetchMissedCallsUnreadCount(); + updateOnTransition(); mRefreshDataRequired = false; } else { // Refresh the display of the existing data to update the timestamp text descriptions. @@ -464,24 +465,16 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis } /** - * Updates the call data and notification state on entering or leaving the call log tab. - * - * If we are leaving the call log tab, mark all the missed calls as read. + * Updates the voicemail notification state. * * TODO: Move to CallLogActivity */ - private void updateOnTransition(boolean onEntry) { + private void updateOnTransition() { // We don't want to update any call data when keyguard is on because the user has likely not // seen the new calls yet. // This might be called before onCreate() and thus we need to check null explicitly. - if (mKeyguardManager != null && !mKeyguardManager.inKeyguardRestrictedInputMode()) { - // On either of the transitions we update the missed call and voicemail notifications. - // While exiting we additionally consume all missed calls (by marking them as read). - mCallLogQueryHandler.markNewCallsAsOld(); - if (!onEntry) { - mCallLogQueryHandler.markMissedCallsAsRead(); - } - CallLogNotificationsHelper.removeMissedCallNotifications(getActivity()); + if (mKeyguardManager != null && !mKeyguardManager.inKeyguardRestrictedInputMode() + && mCallTypeFilter == Calls.VOICEMAIL_TYPE) { CallLogNotificationsHelper.updateVoicemailNotifications(getActivity()); } } @@ -494,9 +487,10 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis } if (!PermissionsUtil.hasPermission(activity, READ_CALL_LOG)) { - requestPermissions(new String[] {READ_CALL_LOG}, READ_CALL_LOG_PERMISSION_REQUEST_CODE); - } else if (mIsRecentsFragment) { - // Show dialpad if we are the recents fragment. + FragmentCompat.requestPermissions(this, new String[] {READ_CALL_LOG}, + READ_CALL_LOG_PERMISSION_REQUEST_CODE); + } else if (!mIsCallLogActivity) { + // Show dialpad if we are not in the call log activity. ((HostInterface) activity).showDialpad(); } } @@ -511,4 +505,25 @@ public class CallLogFragment extends Fragment implements CallLogQueryHandler.Lis } } } + + /** + * Schedules an update to the relative call times (X mins ago). + */ + private void rescheduleDisplayUpdate() { + if (!mDisplayUpdateHandler.hasMessages(EVENT_UPDATE_DISPLAY)) { + long time = System.currentTimeMillis(); + // This value allows us to change the display relatively close to when the time changes + // from one minute to the next. + long millisUtilNextMinute = MILLIS_IN_MINUTE - (time % MILLIS_IN_MINUTE); + mDisplayUpdateHandler.sendEmptyMessageDelayed( + EVENT_UPDATE_DISPLAY, millisUtilNextMinute); + } + } + + /** + * Cancels any pending update requests to update the relative call times (X mins ago). + */ + private void cancelDisplayUpdate() { + mDisplayUpdateHandler.removeMessages(EVENT_UPDATE_DISPLAY); + } } diff --git a/src/com/android/dialer/calllog/CallLogGroupBuilder.java b/src/com/android/dialer/calllog/CallLogGroupBuilder.java index 0826aeb4a..0931e0644 100644 --- a/src/com/android/dialer/calllog/CallLogGroupBuilder.java +++ b/src/com/android/dialer/calllog/CallLogGroupBuilder.java @@ -16,17 +16,17 @@ package com.android.dialer.calllog; +import com.google.common.annotations.VisibleForTesting; + import android.database.Cursor; -import android.provider.CallLog.Calls; import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; import android.text.format.Time; +import com.android.contacts.common.compat.CompatUtils; import com.android.contacts.common.util.DateUtils; import com.android.contacts.common.util.PhoneNumberHelper; - -import com.google.common.annotations.VisibleForTesting; - -import java.util.Objects; +import com.android.dialer.util.AppCompatConstants; /** * Groups together calls in the call log. The primary grouping attempts to group together calls @@ -46,9 +46,8 @@ public class CallLogGroupBuilder { * dialed. * @param cursorPosition The starting position of the group in the cursor. * @param size The size of the group. - * @param expanded Whether the group is expanded; always false for the call log. */ - public void addGroup(int cursorPosition, int size, boolean expanded); + public void addGroup(int cursorPosition, int size); /** * Defines the interface for tracking the day group each call belongs to. Calls in a call @@ -94,7 +93,7 @@ public class CallLogGroupBuilder { /** * Finds all groups of adjacent entries in the call log which should be grouped together and - * calls {@link GroupCreator#addGroup(int, int, boolean)} on {@link #mGroupCreator} for each of + * calls {@link GroupCreator#addGroup(int, int)} on {@link #mGroupCreator} for each of * them. * <p> * For entries that are not grouped with others, we do not need to create a group of size one. @@ -114,98 +113,106 @@ public class CallLogGroupBuilder { // Get current system time, used for calculating which day group calls belong to. long currentTime = System.currentTimeMillis(); - - int currentGroupSize = 1; cursor.moveToFirst(); - // The number of the first entry in the group. - String firstNumber = cursor.getString(CallLogQuery.NUMBER); - // This is the type of the first call in the group. - int firstCallType = cursor.getInt(CallLogQuery.CALL_TYPE); - - // The account information of the first entry in the group. - String firstAccountComponentName = cursor.getString(CallLogQuery.ACCOUNT_COMPONENT_NAME); - String firstAccountId = cursor.getString(CallLogQuery.ACCOUNT_ID); // Determine the day group for the first call in the cursor. final long firstDate = cursor.getLong(CallLogQuery.DATE); final long firstRowId = cursor.getLong(CallLogQuery.ID); - int currentGroupDayGroup = getDayGroup(firstDate, currentTime); - mGroupCreator.setDayGroup(firstRowId, currentGroupDayGroup); + int groupDayGroup = getDayGroup(firstDate, currentTime); + mGroupCreator.setDayGroup(firstRowId, groupDayGroup); + + // Instantiate the group values to those of the first call in the cursor. + String groupNumber = cursor.getString(CallLogQuery.NUMBER); + String groupPostDialDigits = CompatUtils.isNCompatible() + ? cursor.getString(CallLogQuery.POST_DIAL_DIGITS) : ""; + int groupCallType = cursor.getInt(CallLogQuery.CALL_TYPE); + String groupAccountComponentName = cursor.getString(CallLogQuery.ACCOUNT_COMPONENT_NAME); + String groupAccountId = cursor.getString(CallLogQuery.ACCOUNT_ID); + int groupSize = 1; + + String number; + String numberPostDialDigits; + int callType; + String accountComponentName; + String accountId; while (cursor.moveToNext()) { - // The number of the current row in the cursor. - final String currentNumber = cursor.getString(CallLogQuery.NUMBER); - final int callType = cursor.getInt(CallLogQuery.CALL_TYPE); - final String currentAccountComponentName = cursor.getString( - CallLogQuery.ACCOUNT_COMPONENT_NAME); - final String currentAccountId = cursor.getString(CallLogQuery.ACCOUNT_ID); - - final boolean sameNumber = equalNumbers(firstNumber, currentNumber); - final boolean sameAccountComponentName = Objects.equals( - firstAccountComponentName, - currentAccountComponentName); - final boolean sameAccountId = Objects.equals( - firstAccountId, - currentAccountId); - final boolean sameAccount = sameAccountComponentName && sameAccountId; - - final boolean shouldGroup; - final long currentCallId = cursor.getLong(CallLogQuery.ID); - final long date = cursor.getLong(CallLogQuery.DATE); - - if (!sameNumber || !sameAccount) { - // Should only group with calls from the same number. - shouldGroup = false; - } else if (firstCallType == Calls.VOICEMAIL_TYPE) { - // never group voicemail. - shouldGroup = false; - } else { - // Incoming, outgoing, and missed calls group together. - shouldGroup = callType != Calls.VOICEMAIL_TYPE; - } - - if (shouldGroup) { + // Obtain the values for the current call to group. + number = cursor.getString(CallLogQuery.NUMBER); + numberPostDialDigits = CompatUtils.isNCompatible() + ? cursor.getString(CallLogQuery.POST_DIAL_DIGITS) : ""; + callType = cursor.getInt(CallLogQuery.CALL_TYPE); + accountComponentName = cursor.getString(CallLogQuery.ACCOUNT_COMPONENT_NAME); + accountId = cursor.getString(CallLogQuery.ACCOUNT_ID); + + final boolean isSameNumber = equalNumbers(groupNumber, number); + final boolean isSamePostDialDigits = groupPostDialDigits.equals(numberPostDialDigits); + final boolean isSameAccount = isSameAccount( + groupAccountComponentName, accountComponentName, groupAccountId, accountId); + + // Group with the same number and account. Never group voicemails. Only group blocked + // calls with other blocked calls. + if (isSameNumber && isSameAccount && isSamePostDialDigits + && areBothNotVoicemail(callType, groupCallType) + && (areBothNotBlocked(callType, groupCallType) + || areBothBlocked(callType, groupCallType))) { // Increment the size of the group to include the current call, but do not create - // the group until we find a call that does not match. - currentGroupSize++; + // the group until finding a call that does not match. + groupSize++; } else { - // The call group has changed, so determine the day group for the new call group. - // This ensures all calls grouped together in the call log are assigned the same - // day group. - currentGroupDayGroup = getDayGroup(date, currentTime); - - // Create a group for the previous set of calls, excluding the current one, but do - // not create a group for a single call. - if (currentGroupSize > 1) { - addGroup(cursor.getPosition() - currentGroupSize, currentGroupSize); - } + // The call group has changed. Determine the day group for the new call group. + final long date = cursor.getLong(CallLogQuery.DATE); + groupDayGroup = getDayGroup(date, currentTime); + + // Create a group for the previous group of calls, which does not include the + // current call. + mGroupCreator.addGroup(cursor.getPosition() - groupSize, groupSize); + // Start a new group; it will include at least the current call. - currentGroupSize = 1; - // The current entry is now the first in the group. - firstNumber = currentNumber; - firstCallType = callType; - firstAccountComponentName = currentAccountComponentName; - firstAccountId = currentAccountId; + groupSize = 1; + + // Update the group values to those of the current call. + groupNumber = number; + groupPostDialDigits = numberPostDialDigits; + groupCallType = callType; + groupAccountComponentName = accountComponentName; + groupAccountId = accountId; } // Save the day group associated with the current call. - mGroupCreator.setDayGroup(currentCallId, currentGroupDayGroup); - } - // If the last set of calls at the end of the call log was itself a group, create it now. - if (currentGroupSize > 1) { - addGroup(count - currentGroupSize, currentGroupSize); + final long currentCallId = cursor.getLong(CallLogQuery.ID); + mGroupCreator.setDayGroup(currentCallId, groupDayGroup); } + + // Create a group for the last set of calls. + mGroupCreator.addGroup(count - groupSize, groupSize); } /** - * Creates a group of items in the cursor. - * <p> - * The group is always unexpanded. - * - * @see CallLogAdapter#addGroup(int, int, boolean) + * Group cursor entries by date, with only one entry per group. This is used for listing + * voicemails in the archive tab. */ - private void addGroup(int cursorPosition, int size) { - mGroupCreator.addGroup(cursorPosition, size, false); + public void addVoicemailGroups(Cursor cursor) { + if (cursor.getCount() == 0) { + return; + } + + // Clear any previous day grouping information. + mGroupCreator.clearDayGroups(); + + // Get current system time, used for calculating which day group calls belong to. + long currentTime = System.currentTimeMillis(); + + // Reset cursor to start before the first row + cursor.moveToPosition(-1); + + // Create an individual group for each voicemail + while (cursor.moveToNext()) { + mGroupCreator.addGroup(cursor.getPosition(), 1); + mGroupCreator.setDayGroup(cursor.getLong(CallLogQuery.ID), + getDayGroup(cursor.getLong(CallLogQuery.DATE), currentTime)); + + } } @VisibleForTesting @@ -217,6 +224,10 @@ public class CallLogGroupBuilder { } } + private boolean isSameAccount(String name1, String name2, String id1, String id2) { + return TextUtils.equals(name1, name2) && TextUtils.equals(id1, id2); + } + @VisibleForTesting boolean compareSipAddresses(String number1, String number2) { if (number1 == null || number2 == null) return number1 == number2; @@ -264,4 +275,19 @@ public class CallLogGroupBuilder { return DAY_GROUP_OTHER; } } + + private boolean areBothNotVoicemail(int callType, int groupCallType) { + return callType != AppCompatConstants.CALLS_VOICEMAIL_TYPE + && groupCallType != AppCompatConstants.CALLS_VOICEMAIL_TYPE; + } + + private boolean areBothNotBlocked(int callType, int groupCallType) { + return callType != AppCompatConstants.CALLS_BLOCKED_TYPE + && groupCallType != AppCompatConstants.CALLS_BLOCKED_TYPE; + } + + private boolean areBothBlocked(int callType, int groupCallType) { + return callType == AppCompatConstants.CALLS_BLOCKED_TYPE + && groupCallType == AppCompatConstants.CALLS_BLOCKED_TYPE; + } } diff --git a/src/com/android/dialer/calllog/CallLogListItemHelper.java b/src/com/android/dialer/calllog/CallLogListItemHelper.java index 1c8e397e4..5d2bc8591 100644 --- a/src/com/android/dialer/calllog/CallLogListItemHelper.java +++ b/src/com/android/dialer/calllog/CallLogListItemHelper.java @@ -16,7 +16,6 @@ package com.android.dialer.calllog; -import android.content.Context; import android.content.res.Resources; import android.provider.CallLog.Calls; import android.text.SpannableStringBuilder; @@ -24,7 +23,9 @@ import android.text.TextUtils; import android.util.Log; import com.android.dialer.PhoneCallDetails; +import com.android.dialer.util.AppCompatConstants; import com.android.dialer.R; +import com.android.dialer.calllog.calllogcache.CallLogCache; /** * Helper class to fill in the views of a call log entry. @@ -36,27 +37,27 @@ import com.android.dialer.R; private final PhoneCallDetailsHelper mPhoneCallDetailsHelper; /** Resources to look up strings. */ private final Resources mResources; - private final TelecomCallLogCache mTelecomCallLogCache; + private final CallLogCache mCallLogCache; /** * Creates a new helper instance. * * @param phoneCallDetailsHelper used to set the details of a phone call - * @param phoneNumberHelper used to process phone number + * @param resources The object from which resources can be retrieved + * @param callLogCache A cache for values retrieved from telecom/telephony */ public CallLogListItemHelper( PhoneCallDetailsHelper phoneCallDetailsHelper, Resources resources, - TelecomCallLogCache telecomCallLogCache) { + CallLogCache callLogCache) { mPhoneCallDetailsHelper = phoneCallDetailsHelper; mResources = resources; - mTelecomCallLogCache = telecomCallLogCache; + mCallLogCache = callLogCache; } /** * Sets the name, label, and number for a contact. * - * @param context The application context. * @param views the views to populate * @param details the details of a phone call needed to fill in the data */ @@ -74,6 +75,13 @@ import com.android.dialer.R; // Cache name or number of caller. Used when setting the content descriptions of buttons // when the actions ViewStub is inflated. views.nameOrNumber = getNameOrNumber(details); + + // The call type or Location associated with the call. Use when setting text for a + // voicemail log's call button + views.callTypeOrLocation = mPhoneCallDetailsHelper.getCallTypeOrLocation(details); + + // Cache country iso. Used for number filtering. + views.countryIso = details.countryIso; } /** @@ -157,7 +165,6 @@ import com.android.dialer.R; */ public CharSequence getCallDescription(PhoneCallDetails details) { int lastCallType = getLastCallType(details.callTypes); - boolean isVoiceMail = lastCallType == Calls.VOICEMAIL_TYPE; // Get the name or number of the caller. final CharSequence nameOrNumber = getNameOrNumber(details); @@ -170,11 +177,6 @@ import com.android.dialer.R; SpannableStringBuilder callDescription = new SpannableStringBuilder(); - // Prepend the voicemail indication. - if (isVoiceMail) { - callDescription.append(mResources.getString(R.string.description_new_voicemail)); - } - // Add number of calls if more than one. if (details.callTypes.length > 1) { callDescription.append(mResources.getString(R.string.description_num_calls, @@ -186,8 +188,8 @@ import com.android.dialer.R; callDescription.append(mResources.getString(R.string.description_video_call)); } - int stringID = getCallDescriptionStringID(details.callTypes); - String accountLabel = mTelecomCallLogCache.getAccountLabel(details.accountHandle); + int stringID = getCallDescriptionStringID(details.callTypes, details.isRead); + String accountLabel = mCallLogCache.getAccountLabel(details.accountHandle); // Use chosen string resource to build up the message. CharSequence onAccountLabel = accountLabel == null @@ -210,21 +212,28 @@ import com.android.dialer.R; /** * Determine the appropriate string ID to describe a call for accessibility purposes. * - * @param details Call details. + * @param callTypes The type of call corresponding to this entry or multiple if this entry + * represents multiple calls grouped together. + * @param isRead If the entry is a voicemail, {@code true} if the voicemail is read. * @return String resource ID to use. */ - public int getCallDescriptionStringID(int[] callTypes) { + public int getCallDescriptionStringID(int[] callTypes, boolean isRead) { int lastCallType = getLastCallType(callTypes); int stringID; - if (lastCallType == Calls.VOICEMAIL_TYPE || lastCallType == Calls.MISSED_TYPE) { + if (lastCallType == AppCompatConstants.CALLS_MISSED_TYPE) { //Message: Missed call from <NameOrNumber>, <TypeOrLocation>, <TimeOfCall>, //<PhoneAccount>. stringID = R.string.description_incoming_missed_call; - } else if (lastCallType == Calls.INCOMING_TYPE) { + } else if (lastCallType == AppCompatConstants.CALLS_INCOMING_TYPE) { //Message: Answered call from <NameOrNumber>, <TypeOrLocation>, <TimeOfCall>, //<PhoneAccount>. stringID = R.string.description_incoming_answered_call; + } else if (lastCallType == AppCompatConstants.CALLS_VOICEMAIL_TYPE) { + //Message: (Unread) [V/v]oicemail from <NameOrNumber>, <TypeOrLocation>, <TimeOfCall>, + //<PhoneAccount>. + stringID = isRead ? R.string.description_read_voicemail + : R.string.description_unread_voicemail; } else { //Message: Call to <NameOrNumber>, <TypeOrLocation>, <TimeOfCall>, <PhoneAccount>. stringID = R.string.description_outgoing_call; @@ -252,10 +261,10 @@ import com.android.dialer.R; */ private CharSequence getNameOrNumber(PhoneCallDetails details) { final CharSequence recipient; - if (!TextUtils.isEmpty(details.name)) { - recipient = details.name; + if (!TextUtils.isEmpty(details.getPreferredName())) { + recipient = details.getPreferredName(); } else { - recipient = details.displayNumber; + recipient = details.displayNumber + details.postDialDigits; } return recipient; } diff --git a/src/com/android/dialer/calllog/CallLogListItemViewHolder.java b/src/com/android/dialer/calllog/CallLogListItemViewHolder.java index 0fa5e6d33..750914bdf 100644 --- a/src/com/android/dialer/calllog/CallLogListItemViewHolder.java +++ b/src/com/android/dialer/calllog/CallLogListItemViewHolder.java @@ -18,32 +18,55 @@ package com.android.dialer.calllog; import android.app.Activity; import android.content.Context; -import android.content.res.Resources; import android.content.Intent; +import android.content.res.Resources; import android.net.Uri; +import android.provider.CallLog; import android.provider.CallLog.Calls; import android.provider.ContactsContract.CommonDataKinds.Phone; import android.support.v7.widget.CardView; import android.support.v7.widget.RecyclerView; import android.telecom.PhoneAccountHandle; +import android.text.BidiFormatter; +import android.text.TextDirectionHeuristics; import android.text.TextUtils; +import android.view.ContextMenu; +import android.view.MenuItem; import android.view.View; -import android.view.ViewGroup; import android.view.ViewStub; -import android.widget.QuickContactBadge; +import android.widget.ImageButton; import android.widget.ImageView; +import android.widget.QuickContactBadge; 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.ContactPhotoManager.DefaultImageRequest; +import com.android.contacts.common.compat.CompatUtils; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; import com.android.contacts.common.dialog.CallSubjectDialog; import com.android.contacts.common.testing.NeededForTesting; import com.android.contacts.common.util.UriUtils; +import com.android.dialer.DialtactsActivity; import com.android.dialer.R; +import com.android.dialer.calllog.calllogcache.CallLogCache; +import com.android.dialer.compat.FilteredNumberCompat; +import com.android.dialer.database.FilteredNumberAsyncQueryHandler; +import com.android.dialer.filterednumber.BlockNumberDialogFragment; +import com.android.dialer.filterednumber.FilteredNumbersUtil; +import com.android.dialer.filterednumber.MigrateBlockedNumbersDialogFragment; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.ScreenEvent; +import com.android.dialer.service.ExtendedBlockingButtonRenderer; import com.android.dialer.util.DialerUtils; import com.android.dialer.util.PhoneNumberUtil; -import com.android.dialer.voicemail.VoicemailPlaybackPresenter; import com.android.dialer.voicemail.VoicemailPlaybackLayout; +import com.android.dialer.voicemail.VoicemailPlaybackPresenter; +import com.android.dialerbind.ObjectFactory; +import com.google.common.collect.Lists; + +import java.util.List; /** * This is an object containing references to views contained by the call log list item. This @@ -52,7 +75,8 @@ import com.android.dialer.voicemail.VoicemailPlaybackLayout; * This object also contains UI logic pertaining to the view, to isolate it from the CallLogAdapter. */ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder - implements View.OnClickListener { + implements View.OnClickListener, MenuItem.OnMenuItemClickListener, + View.OnCreateContextMenuListener { /** The root view of the call log list item */ public final View rootView; @@ -80,6 +104,7 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder public View sendMessageView; public View detailsButtonView; public View callWithNoteButtonView; + public ImageView workIconView; /** * The row Id for the first call associated with the call log entry. Used as a key for the @@ -100,6 +125,11 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder public String number; /** + * The post-dial numbers that are dialed following the phone number. + */ + public String postDialDigits; + + /** * The formatted phone number to display. */ public String displayNumber; @@ -116,12 +146,24 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder public String numberType; /** + * The country iso for the call. Cached here as the call back + * intent is set only when the actions ViewStub is inflated. + */ + public String countryIso; + + /** * The type of call for the current call log entry. Cached here as the call back * intent is set only when the actions ViewStub is inflated. */ public int callType; /** + * ID for blocked numbers database. + * Set when context menu is created, if the number is blocked. + */ + public Integer blockId; + + /** * The account for the current call log entry. Cached here as the call back * intent is set only when the actions ViewStub is inflated. */ @@ -141,6 +183,12 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder public CharSequence nameOrNumber; /** + * The call type or Location associated with the call. Cached here for use when setting text + * for a voicemail log's call button + */ + public CharSequence callTypeOrLocation; + + /** * Whether this row is for a business or not. */ public boolean isBusiness; @@ -150,38 +198,57 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder */ public ContactInfo info; - private static final int VOICEMAIL_TRANSCRIPTION_MAX_LINES = 10; + /** + * Whether the current log entry is a blocked number or not. Used in updatePhoto() + */ + public boolean isBlocked; + + /** + * Whether this is the archive tab or not. + */ + public final boolean isArchiveTab; private final Context mContext; - private final TelecomCallLogCache mTelecomCallLogCache; + private final CallLogCache mCallLogCache; private final CallLogListItemHelper mCallLogListItemHelper; private final VoicemailPlaybackPresenter mVoicemailPlaybackPresenter; + private final FilteredNumberAsyncQueryHandler mFilteredNumberAsyncQueryHandler; + + private final BlockNumberDialogFragment.Callback mFilteredNumberDialogCallback; private final int mPhotoSize; + private ViewStub mExtendedBlockingViewStub; + private final ExtendedBlockingButtonRenderer mExtendedBlockingButtonRenderer; private View.OnClickListener mExpandCollapseListener; private boolean mVoicemailPrimaryActionButtonClicked; private CallLogListItemViewHolder( Context context, + ExtendedBlockingButtonRenderer.Listener eventListener, View.OnClickListener expandCollapseListener, - TelecomCallLogCache telecomCallLogCache, + CallLogCache callLogCache, CallLogListItemHelper callLogListItemHelper, VoicemailPlaybackPresenter voicemailPlaybackPresenter, + FilteredNumberAsyncQueryHandler filteredNumberAsyncQueryHandler, + BlockNumberDialogFragment.Callback filteredNumberDialogCallback, View rootView, QuickContactBadge quickContactView, View primaryActionView, PhoneCallDetailsViews phoneCallDetailsViews, CardView callLogEntryView, TextView dayGroupHeader, - ImageView primaryActionButtonView) { + ImageView primaryActionButtonView, + boolean isArchiveTab) { super(rootView); mContext = context; mExpandCollapseListener = expandCollapseListener; - mTelecomCallLogCache = telecomCallLogCache; + mCallLogCache = callLogCache; mCallLogListItemHelper = callLogListItemHelper; mVoicemailPlaybackPresenter = voicemailPlaybackPresenter; + mFilteredNumberAsyncQueryHandler = filteredNumberAsyncQueryHandler; + mFilteredNumberDialogCallback = filteredNumberDialogCallback; this.rootView = rootView; this.quickContactView = quickContactView; @@ -190,7 +257,8 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder this.callLogEntryView = callLogEntryView; this.dayGroupHeader = dayGroupHeader; this.primaryActionButtonView = primaryActionButtonView; - + this.workIconView = (ImageView) rootView.findViewById(R.id.work_profile_icon); + this.isArchiveTab = isArchiveTab; Resources resources = mContext.getResources(); mPhotoSize = mContext.getResources().getDimensionPixelSize(R.dimen.contact_photo_size); @@ -198,49 +266,151 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder phoneCallDetailsViews.nameView.setElegantTextHeight(false); phoneCallDetailsViews.callLocationAndDate.setElegantTextHeight(false); - quickContactView.setPrioritizedMimeType(Phone.CONTENT_ITEM_TYPE); - + quickContactView.setOverlay(null); + if (CompatUtils.hasPrioritizedMimeType()) { + quickContactView.setPrioritizedMimeType(Phone.CONTENT_ITEM_TYPE); + } primaryActionButtonView.setOnClickListener(this); primaryActionView.setOnClickListener(mExpandCollapseListener); + primaryActionView.setOnCreateContextMenuListener(this); + mExtendedBlockingButtonRenderer = + ObjectFactory.newExtendedBlockingButtonRenderer(mContext, eventListener); } public static CallLogListItemViewHolder create( View view, Context context, + ExtendedBlockingButtonRenderer.Listener eventListener, View.OnClickListener expandCollapseListener, - TelecomCallLogCache telecomCallLogCache, + CallLogCache callLogCache, CallLogListItemHelper callLogListItemHelper, - VoicemailPlaybackPresenter voicemailPlaybackPresenter) { + VoicemailPlaybackPresenter voicemailPlaybackPresenter, + FilteredNumberAsyncQueryHandler filteredNumberAsyncQueryHandler, + BlockNumberDialogFragment.Callback filteredNumberDialogCallback, + boolean isArchiveTab) { return new CallLogListItemViewHolder( context, + eventListener, expandCollapseListener, - telecomCallLogCache, + callLogCache, callLogListItemHelper, voicemailPlaybackPresenter, + filteredNumberAsyncQueryHandler, + filteredNumberDialogCallback, view, (QuickContactBadge) view.findViewById(R.id.quick_contact_photo), view.findViewById(R.id.primary_action_view), PhoneCallDetailsViews.fromView(view), (CardView) view.findViewById(R.id.call_log_row), (TextView) view.findViewById(R.id.call_log_day_group_label), - (ImageView) view.findViewById(R.id.primary_action_button)); + (ImageView) view.findViewById(R.id.primary_action_button), + isArchiveTab); + } + + @Override + public void onCreateContextMenu( + final ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { + if (TextUtils.isEmpty(number)) { + return; + } + + if (callType == CallLog.Calls.VOICEMAIL_TYPE) { + menu.setHeaderTitle(mContext.getResources().getText(R.string.voicemail)); + } else { + menu.setHeaderTitle(PhoneNumberUtilsCompat.createTtsSpannable( + BidiFormatter.getInstance().unicodeWrap(number, TextDirectionHeuristics.LTR))); + } + + menu.add(ContextMenu.NONE, R.id.context_menu_copy_to_clipboard, ContextMenu.NONE, + R.string.action_copy_number_text) + .setOnMenuItemClickListener(this); + + // The edit number before call does not show up if any of the conditions apply: + // 1) Number cannot be called + // 2) Number is the voicemail number + // 3) Number is a SIP address + + if (PhoneNumberUtil.canPlaceCallsTo(number, numberPresentation) + && !mCallLogCache.isVoicemailNumber(accountHandle, number) + && !PhoneNumberUtil.isSipNumber(number)) { + menu.add(ContextMenu.NONE, R.id.context_menu_edit_before_call, ContextMenu.NONE, + R.string.action_edit_number_before_call) + .setOnMenuItemClickListener(this); + } + + if (callType == CallLog.Calls.VOICEMAIL_TYPE + && phoneCallDetailsViews.voicemailTranscriptionView.length() > 0) { + menu.add(ContextMenu.NONE, R.id.context_menu_copy_transcript_to_clipboard, + ContextMenu.NONE, R.string.copy_transcript_text) + .setOnMenuItemClickListener(this); + } + + if (FilteredNumbersUtil.canBlockNumber(mContext, number, countryIso)) { + mFilteredNumberAsyncQueryHandler.isBlockedNumber( + new FilteredNumberAsyncQueryHandler.OnCheckBlockedListener() { + @Override + public void onCheckComplete(Integer id) { + blockId = id; + int blockTitleId = blockId == null ? R.string.action_block_number + : R.string.action_unblock_number; + final MenuItem blockItem = menu.add( + ContextMenu.NONE, + R.id.context_menu_block_number, + ContextMenu.NONE, + blockTitleId); + blockItem.setOnMenuItemClickListener( + CallLogListItemViewHolder.this); + } + }, number, countryIso); + } + + Logger.logScreenView(ScreenEvent.CALL_LOG_CONTEXT_MENU, (Activity) mContext); + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + int resId = item.getItemId(); + if (resId == R.id.context_menu_block_number) { + FilteredNumberCompat + .showBlockNumberDialogFlow(mContext.getContentResolver(), blockId, number, + countryIso, displayNumber, R.id.floating_action_button_container, + ((Activity) mContext).getFragmentManager(), + mFilteredNumberDialogCallback); + return true; + } else if (resId == R.id.context_menu_copy_to_clipboard) { + ClipboardUtils.copyText(mContext, null, number, true); + return true; + } else if (resId == R.id.context_menu_copy_transcript_to_clipboard) { + ClipboardUtils.copyText(mContext, null, + phoneCallDetailsViews.voicemailTranscriptionView.getText(), true); + return true; + } else if (resId == R.id.context_menu_edit_before_call) { + final Intent intent = new Intent( + Intent.ACTION_DIAL, CallUtil.getCallUri(number)); + intent.setClass(mContext, DialtactsActivity.class); + DialerUtils.startActivityWithErrorToast(mContext, intent); + return true; + } + return false; } /** * Configures the action buttons in the expandable actions ViewStub. The ViewStub is not * inflated during initial binding, so click handlers, tags and accessibility text must be set * here, if necessary. - * - * @param callLogItem The call log list item view. */ public void inflateActionViewStub() { ViewStub stub = (ViewStub) rootView.findViewById(R.id.call_log_entry_actions_stub); if (stub != null) { - actionsView = (ViewGroup) stub.inflate(); + actionsView = stub.inflate(); voicemailPlaybackView = (VoicemailPlaybackLayout) actionsView .findViewById(R.id.voicemail_playback_layout); + if (isArchiveTab) { + voicemailPlaybackView.hideArchiveButton(); + } + callButtonView = actionsView.findViewById(R.id.call_action); callButtonView.setOnClickListener(this); @@ -263,6 +433,9 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder callWithNoteButtonView = actionsView.findViewById(R.id.call_with_note_action); callWithNoteButtonView.setOnClickListener(this); + + mExtendedBlockingViewStub = + (ViewStub) actionsView.findViewById(R.id.extended_blocking_actions_container); } bindActionButtons(); @@ -273,25 +446,25 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder // Treat as voicemail list item; show play button if not expanded. if (!isExpanded) { primaryActionButtonView.setImageResource(R.drawable.ic_play_arrow_24dp); + primaryActionButtonView.setContentDescription(TextUtils.expandTemplate( + mContext.getString(R.string.description_voicemail_action), + nameOrNumber)); primaryActionButtonView.setVisibility(View.VISIBLE); } else { primaryActionButtonView.setVisibility(View.GONE); } } else { // Treat as normal list item; show call button, if possible. - boolean canPlaceCallToNumber = - PhoneNumberUtil.canPlaceCallsTo(number, numberPresentation); - - if (canPlaceCallToNumber) { + if (PhoneNumberUtil.canPlaceCallsTo(number, numberPresentation)) { boolean isVoicemailNumber = - mTelecomCallLogCache.isVoicemailNumber(accountHandle, number); + mCallLogCache.isVoicemailNumber(accountHandle, number); if (isVoicemailNumber) { // Call to generic voicemail number, in case there are multiple accounts. primaryActionButtonView.setTag( IntentProvider.getReturnVoicemailCallIntentProvider()); } else { primaryActionButtonView.setTag( - IntentProvider.getReturnCallIntentProvider(number)); + IntentProvider.getReturnCallIntentProvider(number + postDialDigits)); } primaryActionButtonView.setContentDescription(TextUtils.expandTemplate( @@ -319,13 +492,21 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder .setText(TextUtils.expandTemplate( mContext.getString(R.string.call_log_action_call), nameOrNumber)); + TextView callTypeOrLocationView = ((TextView) callButtonView.findViewById( + R.id.call_type_or_location_text)); + if (callType == Calls.VOICEMAIL_TYPE && !TextUtils.isEmpty(callTypeOrLocation)) { + callTypeOrLocationView.setText(callTypeOrLocation); + callTypeOrLocationView.setVisibility(View.VISIBLE); + } else { + callTypeOrLocationView.setVisibility(View.GONE); + } callButtonView.setVisibility(View.VISIBLE); } else { callButtonView.setVisibility(View.GONE); } // If one of the calls had video capabilities, show the video call button. - if (mTelecomCallLogCache.isVideoEnabled() && canPlaceCallToNumber && + if (mCallLogCache.isVideoEnabled() && canPlaceCallToNumber && phoneCallDetailsViews.callTypeIcons.isVideoShown()) { videoCallButtonView.setTag(IntentProvider.getReturnVideoCallIntentProvider(number)); videoCallButtonView.setVisibility(View.VISIBLE); @@ -334,22 +515,29 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder } // For voicemail calls, show the voicemail playback layout; hide otherwise. - if (callType == Calls.VOICEMAIL_TYPE && mVoicemailPlaybackPresenter != null) { + if (callType == Calls.VOICEMAIL_TYPE && mVoicemailPlaybackPresenter != null + && !TextUtils.isEmpty(voicemailUri)) { voicemailPlaybackView.setVisibility(View.VISIBLE); Uri uri = Uri.parse(voicemailUri); mVoicemailPlaybackPresenter.setPlaybackView( voicemailPlaybackView, uri, mVoicemailPrimaryActionButtonClicked); mVoicemailPrimaryActionButtonClicked = false; - - CallLogAsyncTaskUtil.markVoicemailAsRead(mContext, uri); + // Only mark voicemail as read when not in archive tab + if (!isArchiveTab) { + CallLogAsyncTaskUtil.markVoicemailAsRead(mContext, uri); + } } else { voicemailPlaybackView.setVisibility(View.GONE); } - detailsButtonView.setVisibility(View.VISIBLE); - detailsButtonView.setTag( - IntentProvider.getCallDetailIntentProvider(rowId, callIds, null)); + if (callType == Calls.VOICEMAIL_TYPE) { + detailsButtonView.setVisibility(View.GONE); + } else { + detailsButtonView.setVisibility(View.VISIBLE); + detailsButtonView.setTag( + IntentProvider.getCallDetailIntentProvider(rowId, callIds, null)); + } if (info != null && UriUtils.isEncodedContactUri(info.lookupUri)) { createNewContactButtonView.setTag(IntentProvider.getAddContactIntentProvider( @@ -364,16 +552,48 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder addToExistingContactButtonView.setVisibility(View.GONE); } - sendMessageView.setTag(IntentProvider.getSendSmsIntentProvider(number)); + if (canPlaceCallToNumber) { + sendMessageView.setTag(IntentProvider.getSendSmsIntentProvider(number)); + sendMessageView.setVisibility(View.VISIBLE); + } else { + sendMessageView.setVisibility(View.GONE); + } mCallLogListItemHelper.setActionContentDescriptions(this); boolean supportsCallSubject = - mTelecomCallLogCache.doesAccountSupportCallSubject(accountHandle); + mCallLogCache.doesAccountSupportCallSubject(accountHandle); boolean isVoicemailNumber = - mTelecomCallLogCache.isVoicemailNumber(accountHandle, number); + mCallLogCache.isVoicemailNumber(accountHandle, number); callWithNoteButtonView.setVisibility( supportsCallSubject && !isVoicemailNumber ? View.VISIBLE : View.GONE); + + if(mExtendedBlockingButtonRenderer != null){ + List<View> completeLogListItems = Lists.newArrayList( + createNewContactButtonView, + addToExistingContactButtonView, + sendMessageView, + callButtonView, + callWithNoteButtonView, + detailsButtonView, + voicemailPlaybackView); + + List<View> blockedNumberVisibleViews = Lists.newArrayList(detailsButtonView); + List<View> extendedBlockingVisibleViews = Lists.newArrayList(detailsButtonView); + + ExtendedBlockingButtonRenderer.ViewHolderInfo viewHolderInfo = + new ExtendedBlockingButtonRenderer.ViewHolderInfo( + completeLogListItems, + extendedBlockingVisibleViews, + blockedNumberVisibleViews, + number, + countryIso, + nameOrNumber.toString(), + displayNumber); + mExtendedBlockingButtonRenderer.setViewHolderInfo(viewHolderInfo); + + mExtendedBlockingButtonRenderer.render(mExtendedBlockingViewStub); + } } /** @@ -382,7 +602,7 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder * If the action views have never been shown yet for this view, inflate the view stub. */ public void showActions(boolean show) { - expandVoicemailTranscriptionView(show); + showOrHideVoicemailTranscriptionView(show); if (show) { // Inflate the view stub if necessary, and wire up the event handlers. @@ -401,24 +621,23 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder updatePrimaryActionButton(show); } - public void expandVoicemailTranscriptionView(boolean isExpanded) { + public void showOrHideVoicemailTranscriptionView(boolean isExpanded) { if (callType != Calls.VOICEMAIL_TYPE) { return; } final TextView view = phoneCallDetailsViews.voicemailTranscriptionView; - if (TextUtils.isEmpty(view.getText())) { + if (!isExpanded || TextUtils.isEmpty(view.getText())) { + view.setVisibility(View.GONE); return; } - view.setMaxLines(isExpanded ? VOICEMAIL_TRANSCRIPTION_MAX_LINES : 1); - view.setSingleLine(!isExpanded); + view.setVisibility(View.VISIBLE); } - public void setPhoto(long photoId, Uri photoUri, Uri contactUri, String displayName, - boolean isVoicemail, boolean isBusiness) { - quickContactView.assignContactUri(contactUri); - quickContactView.setOverlay(null); + public void updatePhoto() { + quickContactView.assignContactUri(info.lookupUri); + final boolean isVoicemail = mCallLogCache.isVoicemailNumber(accountHandle, number); int contactType = ContactPhotoManager.TYPE_DEFAULT; if (isVoicemail) { contactType = ContactPhotoManager.TYPE_VOICEMAIL; @@ -426,21 +645,27 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder contactType = ContactPhotoManager.TYPE_BUSINESS; } - String lookupKey = null; - if (contactUri != null) { - lookupKey = UriUtils.getLookupKeyFromUri(contactUri); - } - - DefaultImageRequest request = new DefaultImageRequest( + final String lookupKey = info.lookupUri != null + ? UriUtils.getLookupKeyFromUri(info.lookupUri) : null; + final String displayName = TextUtils.isEmpty(info.name) ? displayNumber : info.name; + final DefaultImageRequest request = new DefaultImageRequest( displayName, lookupKey, contactType, true /* isCircular */); - if (photoId == 0 && photoUri != null) { - ContactPhotoManager.getInstance(mContext).loadPhoto(quickContactView, photoUri, + if (info.photoId == 0 && info.photoUri != null) { + ContactPhotoManager.getInstance(mContext).loadPhoto(quickContactView, info.photoUri, mPhotoSize, false /* darkTheme */, true /* isCircular */, request); } else { - ContactPhotoManager.getInstance(mContext).loadThumbnail(quickContactView, photoId, + ContactPhotoManager.getInstance(mContext).loadThumbnail(quickContactView, info.photoId, false /* darkTheme */, true /* isCircular */, request); } + + if (mExtendedBlockingButtonRenderer != null) { + mExtendedBlockingButtonRenderer.updatePhotoAndLabelIfNecessary( + number, + countryIso, + quickContactView, + phoneCallDetailsViews.callLocationAndDate); + } } @Override @@ -456,7 +681,7 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder info.lookupUri, (String) nameOrNumber /* top line of contact view in call subject dialog */, isBusiness, - number, /* callable number used for ACTION_CALL intent */ + number, TextUtils.isEmpty(info.name) ? null : displayNumber, /* second line of contact view in dialog. */ numberType, /* phone number type (e.g. mobile) in second line of contact view */ @@ -476,27 +701,32 @@ public final class CallLogListItemViewHolder extends RecyclerView.ViewHolder @NeededForTesting public static CallLogListItemViewHolder createForTest(Context context) { Resources resources = context.getResources(); - TelecomCallLogCache telecomCallLogCache = new TelecomCallLogCache(context); + CallLogCache callLogCache = + CallLogCache.getCallLogCache(context); PhoneCallDetailsHelper phoneCallDetailsHelper = new PhoneCallDetailsHelper( - context, resources, telecomCallLogCache); + context, resources, callLogCache); CallLogListItemViewHolder viewHolder = new CallLogListItemViewHolder( context, + null, null /* expandCollapseListener */, - telecomCallLogCache, - new CallLogListItemHelper(phoneCallDetailsHelper, resources, telecomCallLogCache), + callLogCache, + new CallLogListItemHelper(phoneCallDetailsHelper, resources, callLogCache), null /* voicemailPlaybackPresenter */, + null /* filteredNumberAsyncQueryHandler */, + null /* filteredNumberDialogCallback */, new View(context), new QuickContactBadge(context), new View(context), PhoneCallDetailsViews.createForTest(context), new CardView(context), new TextView(context), - new ImageView(context)); + new ImageView(context), + false); viewHolder.detailsButtonView = new TextView(context); viewHolder.actionsView = new View(context); viewHolder.voicemailPlaybackView = new VoicemailPlaybackLayout(context); - + viewHolder.workIconView = new ImageButton(context); return viewHolder; } -} +}
\ No newline at end of file diff --git a/src/com/android/dialer/calllog/CallLogNotificationsHelper.java b/src/com/android/dialer/calllog/CallLogNotificationsHelper.java index 367cb78c3..189263199 100644 --- a/src/com/android/dialer/calllog/CallLogNotificationsHelper.java +++ b/src/com/android/dialer/calllog/CallLogNotificationsHelper.java @@ -16,14 +16,144 @@ package com.android.dialer.calllog; +import android.Manifest; +import android.content.ContentResolver; +import android.content.ContentUris; import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.CallLog.Calls; +import android.provider.ContactsContract.PhoneLookup; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import android.util.Log; +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.util.PermissionsUtil; +import com.android.dialer.R; import com.android.dialer.util.TelecomUtil; +import java.util.ArrayList; +import java.util.List; + /** * Helper class operating on call log notifications. */ public class CallLogNotificationsHelper { + private static final String TAG = "CallLogNotifHelper"; + private static CallLogNotificationsHelper sInstance; + + /** Returns the singleton instance of the {@link CallLogNotificationsHelper}. */ + public static CallLogNotificationsHelper getInstance(Context context) { + if (sInstance == null) { + ContentResolver contentResolver = context.getContentResolver(); + String countryIso = GeoUtil.getCurrentCountryIso(context); + sInstance = new CallLogNotificationsHelper(context, + createNewCallsQuery(context, contentResolver), + createNameLookupQuery(context, contentResolver), + new ContactInfoHelper(context, countryIso), + countryIso); + } + return sInstance; + } + + private final Context mContext; + private final NewCallsQuery mNewCallsQuery; + private final NameLookupQuery mNameLookupQuery; + private final ContactInfoHelper mContactInfoHelper; + private final String mCurrentCountryIso; + + CallLogNotificationsHelper(Context context, NewCallsQuery newCallsQuery, + NameLookupQuery nameLookupQuery, ContactInfoHelper contactInfoHelper, + String countryIso) { + mContext = context; + mNewCallsQuery = newCallsQuery; + mNameLookupQuery = nameLookupQuery; + mContactInfoHelper = contactInfoHelper; + mCurrentCountryIso = countryIso; + } + + /** + * Get all voicemails with the "new" flag set to 1. + * + * @return A list of NewCall objects where each object represents a new voicemail. + */ + @Nullable + public List<NewCall> getNewVoicemails() { + return mNewCallsQuery.query(Calls.VOICEMAIL_TYPE); + } + + /** + * Get all missed calls with the "new" flag set to 1. + * + * @return A list of NewCall objects where each object represents a new missed call. + */ + @Nullable + public List<NewCall> getNewMissedCalls() { + return mNewCallsQuery.query(Calls.MISSED_TYPE); + } + + /** + * Given a number and number information (presentation and country ISO), get the best name + * for display. If the name is empty but we have a special presentation, display that. + * Otherwise attempt to look it up in the database or the cache. + * If that fails, fall back to displaying the number. + */ + public String getName(@Nullable String number, int numberPresentation, + @Nullable String countryIso) { + return getContactInfo(number, numberPresentation, countryIso).name; + } + + /** + * Given a number and number information (presentation and country ISO), get + * {@link ContactInfo}. If the name is empty but we have a special presentation, display that. + * Otherwise attempt to look it up in the cache. + * If that fails, fall back to displaying the number. + */ + public @NonNull ContactInfo getContactInfo(@Nullable String number, int numberPresentation, + @Nullable String countryIso) { + if (countryIso == null) { + countryIso = mCurrentCountryIso; + } + + ContactInfo contactInfo = new ContactInfo(); + contactInfo.number = number; + contactInfo.formattedNumber = PhoneNumberUtils.formatNumber(number, countryIso); + // contactInfo.normalizedNumber is not PhoneNumberUtils.normalizeNumber. Read ContactInfo. + contactInfo.normalizedNumber = PhoneNumberUtils.formatNumberToE164(number, countryIso); + + // 1. Special number representation. + contactInfo.name = PhoneNumberDisplayUtil.getDisplayName( + mContext, + number, + numberPresentation, + false).toString(); + if (!TextUtils.isEmpty(contactInfo.name)) { + return contactInfo; + } + + // 2. Look it up in the cache. + ContactInfo cachedContactInfo = mContactInfoHelper.lookupNumber(number, countryIso); + + if (cachedContactInfo != null && !TextUtils.isEmpty(cachedContactInfo.name)) { + return cachedContactInfo; + } + + if (!TextUtils.isEmpty(contactInfo.formattedNumber)) { + // 3. If we cannot lookup the contact, use the formatted number instead. + contactInfo.name = contactInfo.formattedNumber; + } else if (!TextUtils.isEmpty(number)) { + // 4. If number can't be formatted, use number. + contactInfo.name = number; + } else { + // 5. Otherwise, it's unknown number. + contactInfo.name = mContext.getResources().getString(R.string.unknown); + } + return contactInfo; + } + /** Removes the missed call notifications. */ public static void removeMissedCallNotifications(Context context) { TelecomUtil.cancelMissedCallsNotification(context); @@ -33,4 +163,188 @@ public class CallLogNotificationsHelper { public static void updateVoicemailNotifications(Context context) { CallLogNotificationsService.updateVoicemailNotifications(context, null); } + + /** Information about a new voicemail. */ + public static final class NewCall { + public final Uri callsUri; + public final Uri voicemailUri; + public final String number; + public final int numberPresentation; + public final String accountComponentName; + public final String accountId; + public final String transcription; + public final String countryIso; + public final long dateMs; + + public NewCall( + Uri callsUri, + Uri voicemailUri, + String number, + int numberPresentation, + String accountComponentName, + String accountId, + String transcription, + String countryIso, + long dateMs) { + this.callsUri = callsUri; + this.voicemailUri = voicemailUri; + this.number = number; + this.numberPresentation = numberPresentation; + this.accountComponentName = accountComponentName; + this.accountId = accountId; + this.transcription = transcription; + this.countryIso = countryIso; + this.dateMs = dateMs; + } + } + + /** Allows determining the new calls for which a notification should be generated. */ + public interface NewCallsQuery { + /** + * Returns the new calls of a certain type for which a notification should be generated. + */ + @Nullable + public List<NewCall> query(int type); + } + + /** Create a new instance of {@link NewCallsQuery}. */ + public static NewCallsQuery createNewCallsQuery(Context context, + ContentResolver contentResolver) { + + return new DefaultNewCallsQuery(context.getApplicationContext(), contentResolver); + } + + /** + * Default implementation of {@link NewCallsQuery} that looks up the list of new calls to + * notify about in the call log. + */ + private static final class DefaultNewCallsQuery implements NewCallsQuery { + private static final String[] PROJECTION = { + Calls._ID, + Calls.NUMBER, + Calls.VOICEMAIL_URI, + Calls.NUMBER_PRESENTATION, + Calls.PHONE_ACCOUNT_COMPONENT_NAME, + Calls.PHONE_ACCOUNT_ID, + Calls.TRANSCRIPTION, + Calls.COUNTRY_ISO, + Calls.DATE + }; + private static final int ID_COLUMN_INDEX = 0; + private static final int NUMBER_COLUMN_INDEX = 1; + private static final int VOICEMAIL_URI_COLUMN_INDEX = 2; + private static final int NUMBER_PRESENTATION_COLUMN_INDEX = 3; + private static final int PHONE_ACCOUNT_COMPONENT_NAME_COLUMN_INDEX = 4; + private static final int PHONE_ACCOUNT_ID_COLUMN_INDEX = 5; + private static final int TRANSCRIPTION_COLUMN_INDEX = 6; + private static final int COUNTRY_ISO_COLUMN_INDEX = 7; + private static final int DATE_COLUMN_INDEX = 8; + + private final ContentResolver mContentResolver; + private final Context mContext; + + private DefaultNewCallsQuery(Context context, ContentResolver contentResolver) { + mContext = context; + mContentResolver = contentResolver; + } + + @Override + @Nullable + public List<NewCall> query(int type) { + if (!PermissionsUtil.hasPermission(mContext, Manifest.permission.READ_CALL_LOG)) { + Log.w(TAG, "No READ_CALL_LOG permission, returning null for calls lookup."); + return null; + } + final String selection = String.format("%s = 1 AND %s = ?", Calls.NEW, Calls.TYPE); + final String[] selectionArgs = new String[]{ Integer.toString(type) }; + try (Cursor cursor = mContentResolver.query(Calls.CONTENT_URI_WITH_VOICEMAIL, + PROJECTION, selection, selectionArgs, Calls.DEFAULT_SORT_ORDER)) { + if (cursor == null) { + return null; + } + List<NewCall> newCalls = new ArrayList<>(); + while (cursor.moveToNext()) { + newCalls.add(createNewCallsFromCursor(cursor)); + } + return newCalls; + } catch (RuntimeException e) { + Log.w(TAG, "Exception when querying Contacts Provider for calls lookup"); + return null; + } + } + + /** Returns an instance of {@link NewCall} created by using the values of the cursor. */ + private NewCall createNewCallsFromCursor(Cursor cursor) { + String voicemailUriString = cursor.getString(VOICEMAIL_URI_COLUMN_INDEX); + Uri callsUri = ContentUris.withAppendedId( + Calls.CONTENT_URI_WITH_VOICEMAIL, cursor.getLong(ID_COLUMN_INDEX)); + Uri voicemailUri = voicemailUriString == null ? null : Uri.parse(voicemailUriString); + return new NewCall( + callsUri, + voicemailUri, + cursor.getString(NUMBER_COLUMN_INDEX), + cursor.getInt(NUMBER_PRESENTATION_COLUMN_INDEX), + cursor.getString(PHONE_ACCOUNT_COMPONENT_NAME_COLUMN_INDEX), + cursor.getString(PHONE_ACCOUNT_ID_COLUMN_INDEX), + cursor.getString(TRANSCRIPTION_COLUMN_INDEX), + cursor.getString(COUNTRY_ISO_COLUMN_INDEX), + cursor.getLong(DATE_COLUMN_INDEX)); + } + } + + /** Allows determining the name associated with a given phone number. */ + public interface NameLookupQuery { + /** + * Returns the name associated with the given number in the contacts database, or null if + * the number does not correspond to any of the contacts. + * <p> + * If there are multiple contacts with the same phone number, it will return the name of one + * of the matching contacts. + */ + @Nullable + public String query(@Nullable String number); + } + + /** Create a new instance of {@link NameLookupQuery}. */ + public static NameLookupQuery createNameLookupQuery(Context context, + ContentResolver contentResolver) { + return new DefaultNameLookupQuery(context.getApplicationContext(), contentResolver); + } + + /** + * Default implementation of {@link NameLookupQuery} that looks up the name of a contact in the + * contacts database. + */ + private static final class DefaultNameLookupQuery implements NameLookupQuery { + private static final String[] PROJECTION = { PhoneLookup.DISPLAY_NAME }; + private static final int DISPLAY_NAME_COLUMN_INDEX = 0; + + private final ContentResolver mContentResolver; + private final Context mContext; + + private DefaultNameLookupQuery(Context context, ContentResolver contentResolver) { + mContext = context; + mContentResolver = contentResolver; + } + + @Override + @Nullable + public String query(@Nullable String number) { + if (!PermissionsUtil.hasPermission(mContext, Manifest.permission.READ_CONTACTS)) { + Log.w(TAG, "No READ_CONTACTS permission, returning null for name lookup."); + return null; + } + try (Cursor cursor = mContentResolver.query( + Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)), + PROJECTION, null, null, null)) { + if (cursor == null || !cursor.moveToFirst()) { + return null; + } + return cursor.getString(DISPLAY_NAME_COLUMN_INDEX); + } catch (RuntimeException e) { + Log.w(TAG, "Exception when querying Contacts Provider for name lookup"); + return null; + } + } + } } diff --git a/src/com/android/dialer/calllog/CallLogNotificationsService.java b/src/com/android/dialer/calllog/CallLogNotificationsService.java index 9a67b61b6..4ff9576ca 100644 --- a/src/com/android/dialer/calllog/CallLogNotificationsService.java +++ b/src/com/android/dialer/calllog/CallLogNotificationsService.java @@ -26,15 +26,16 @@ import com.android.contacts.common.util.PermissionsUtil; import com.android.dialer.util.TelecomUtil; /** - * Provides operations for managing notifications. + * Provides operations for managing call-related notifications. * <p> * It handles the following actions: * <ul> - * <li>{@link #ACTION_MARK_NEW_VOICEMAILS_AS_OLD}: marks all the new voicemails in the call log as - * old; this is called when a notification is dismissed.</li> - * <li>{@link #ACTION_UPDATE_NOTIFICATIONS}: updates the content of the new items notification; it - * may include an optional extra {@link #EXTRA_NEW_VOICEMAIL_URI}, containing the URI of the new - * voicemail that has triggered this update (if any).</li> + * <li>Updating voicemail notifications</li> + * <li>Marking new voicemails as old</li> + * <li>Updating missed call notifications</li> + * <li>Marking new missed calls as old</li> + * <li>Calling back from a missed call</li> + * <li>Sending an SMS from a missed call</li> * </ul> */ public class CallLogNotificationsService extends IntentService { @@ -45,21 +46,62 @@ public class CallLogNotificationsService extends IntentService { "com.android.dialer.calllog.ACTION_MARK_NEW_VOICEMAILS_AS_OLD"; /** - * Action to update the notifications. + * Action to update voicemail notifications. * <p> * May include an optional extra {@link #EXTRA_NEW_VOICEMAIL_URI}. */ - public static final String ACTION_UPDATE_NOTIFICATIONS = - "com.android.dialer.calllog.UPDATE_NOTIFICATIONS"; + public static final String ACTION_UPDATE_VOICEMAIL_NOTIFICATIONS = + "com.android.dialer.calllog.UPDATE_VOICEMAIL_NOTIFICATIONS"; /** - * Extra to included with {@link #ACTION_UPDATE_NOTIFICATIONS} to identify the new voicemail - * that triggered an update. + * Extra to included with {@link #ACTION_UPDATE_VOICEMAIL_NOTIFICATIONS} to identify the new + * voicemail that triggered an update. * <p> * It must be a {@link Uri}. */ public static final String EXTRA_NEW_VOICEMAIL_URI = "NEW_VOICEMAIL_URI"; + /** + * Action to update the missed call notifications. + * <p> + * Includes optional extras {@link #EXTRA_MISSED_CALL_NUMBER} and + * {@link #EXTRA_MISSED_CALL_COUNT}. + */ + public static final String ACTION_UPDATE_MISSED_CALL_NOTIFICATIONS = + "com.android.dialer.calllog.UPDATE_MISSED_CALL_NOTIFICATIONS"; + + /** Action to mark all the new missed calls as old. */ + public static final String ACTION_MARK_NEW_MISSED_CALLS_AS_OLD = + "com.android.dialer.calllog.ACTION_MARK_NEW_MISSED_CALLS_AS_OLD"; + + /** Action to call back a missed call. */ + public static final String ACTION_CALL_BACK_FROM_MISSED_CALL_NOTIFICATION = + "com.android.dialer.calllog.CALL_BACK_FROM_MISSED_CALL_NOTIFICATION"; + + public static final String ACTION_SEND_SMS_FROM_MISSED_CALL_NOTIFICATION = + "com.android.dialer.calllog.SEND_SMS_FROM_MISSED_CALL_NOTIFICATION"; + + /** + * Extra to be included with {@link #ACTION_UPDATE_MISSED_CALL_NOTIFICATIONS}, + * {@link #ACTION_SEND_SMS_FROM_MISSED_CALL_NOTIFICATION} and + * {@link #ACTION_CALL_BACK_FROM_MISSED_CALL_NOTIFICATION} to identify the number to display, + * call or text back. + * <p> + * It must be a {@link String}. + */ + public static final String EXTRA_MISSED_CALL_NUMBER = "MISSED_CALL_NUMBER"; + + /** + * Extra to be included with {@link #ACTION_UPDATE_MISSED_CALL_NOTIFICATIONS} to represent the + * number of missed calls. + * <p> + * It must be a {@link Integer} + */ + public static final String EXTRA_MISSED_CALL_COUNT = + "MISSED_CALL_COUNT"; + + public static final int UNKNOWN_MISSED_CALL_COUNT = -1; + private VoicemailQueryHandler mVoicemailQueryHandler; public CallLogNotificationsService() { @@ -67,12 +109,6 @@ public class CallLogNotificationsService extends IntentService { } @Override - public void onCreate() { - super.onCreate(); - mVoicemailQueryHandler = new VoicemailQueryHandler(this, getContentResolver()); - } - - @Override protected void onHandleIntent(Intent intent) { if (intent == null) { Log.d(TAG, "onHandleIntent: could not handle null intent"); @@ -83,13 +119,38 @@ public class CallLogNotificationsService extends IntentService { return; } - if (ACTION_MARK_NEW_VOICEMAILS_AS_OLD.equals(intent.getAction())) { - mVoicemailQueryHandler.markNewVoicemailsAsOld(); - } else if (ACTION_UPDATE_NOTIFICATIONS.equals(intent.getAction())) { - Uri voicemailUri = (Uri) intent.getParcelableExtra(EXTRA_NEW_VOICEMAIL_URI); - DefaultVoicemailNotifier.getInstance(this).updateNotification(voicemailUri); - } else { - Log.d(TAG, "onHandleIntent: could not handle: " + intent); + String action = intent.getAction(); + switch (action) { + case ACTION_MARK_NEW_VOICEMAILS_AS_OLD: + if (mVoicemailQueryHandler == null) { + mVoicemailQueryHandler = new VoicemailQueryHandler(this, getContentResolver()); + } + mVoicemailQueryHandler.markNewVoicemailsAsOld(); + break; + case ACTION_UPDATE_VOICEMAIL_NOTIFICATIONS: + Uri voicemailUri = (Uri) intent.getParcelableExtra(EXTRA_NEW_VOICEMAIL_URI); + DefaultVoicemailNotifier.getInstance(this).updateNotification(voicemailUri); + break; + case ACTION_UPDATE_MISSED_CALL_NOTIFICATIONS: + int count = intent.getIntExtra(EXTRA_MISSED_CALL_COUNT, + UNKNOWN_MISSED_CALL_COUNT); + String number = intent.getStringExtra(EXTRA_MISSED_CALL_NUMBER); + MissedCallNotifier.getInstance(this).updateMissedCallNotification(count, number); + break; + case ACTION_MARK_NEW_MISSED_CALLS_AS_OLD: + CallLogNotificationsHelper.removeMissedCallNotifications(this); + break; + case ACTION_CALL_BACK_FROM_MISSED_CALL_NOTIFICATION: + MissedCallNotifier.getInstance(this).callBackFromMissedCall( + intent.getStringExtra(EXTRA_MISSED_CALL_NUMBER)); + break; + case ACTION_SEND_SMS_FROM_MISSED_CALL_NOTIFICATION: + MissedCallNotifier.getInstance(this).sendSmsFromMissedCall( + intent.getStringExtra(EXTRA_MISSED_CALL_NUMBER)); + break; + default: + Log.d(TAG, "onHandleIntent: could not handle: " + intent); + break; } } @@ -103,7 +164,8 @@ public class CallLogNotificationsService extends IntentService { public static void updateVoicemailNotifications(Context context, Uri voicemailUri) { if (TelecomUtil.hasReadWriteVoicemailPermissions(context)) { Intent serviceIntent = new Intent(context, CallLogNotificationsService.class); - serviceIntent.setAction(CallLogNotificationsService.ACTION_UPDATE_NOTIFICATIONS); + serviceIntent.setAction( + CallLogNotificationsService.ACTION_UPDATE_VOICEMAIL_NOTIFICATIONS); // If voicemailUri is null, then notifications for all voicemails will be updated. if (voicemailUri != null) { serviceIntent.putExtra( @@ -112,4 +174,21 @@ public class CallLogNotificationsService extends IntentService { context.startService(serviceIntent); } } + + /** + * Updates notifications for any new missed calls. + * + * @param context A valid context. + * @param count The number of new missed calls. + * @param number The phone number of the newest missed call. + */ + public static void updateMissedCallNotifications(Context context, int count, + String number) { + Intent serviceIntent = new Intent(context, CallLogNotificationsService.class); + serviceIntent.setAction( + CallLogNotificationsService.ACTION_UPDATE_MISSED_CALL_NOTIFICATIONS); + serviceIntent.putExtra(EXTRA_MISSED_CALL_COUNT, count); + serviceIntent.putExtra(EXTRA_MISSED_CALL_NUMBER, number); + context.startService(serviceIntent); + } } diff --git a/src/com/android/dialer/calllog/CallLogQuery.java b/src/com/android/dialer/calllog/CallLogQuery.java index 2b43c2857..4900354bf 100644 --- a/src/com/android/dialer/calllog/CallLogQuery.java +++ b/src/com/android/dialer/calllog/CallLogQuery.java @@ -16,13 +16,22 @@ package com.android.dialer.calllog; +import com.google.common.collect.Lists; + import android.provider.CallLog.Calls; +import com.android.contacts.common.compat.CompatUtils; +import com.android.dialer.compat.CallsSdkCompat; +import com.android.dialer.compat.DialerCompatUtils; + +import java.util.List; + /** * The query for the call log table. */ public final class CallLogQuery { - public static final String[] _PROJECTION = new String[] { + + private static final String[] _PROJECTION_INTERNAL = new String[] { Calls._ID, // 0 Calls.NUMBER, // 1 Calls.DATE, // 2 @@ -46,7 +55,6 @@ public final class CallLogQuery { Calls.FEATURES, // 20 Calls.DATA_USAGE, // 21 Calls.TRANSCRIPTION, // 22 - Calls.CACHED_PHOTO_URI // 23 }; public static final int ID = 0; @@ -72,5 +80,33 @@ public final class CallLogQuery { public static final int FEATURES = 20; public static final int DATA_USAGE = 21; public static final int TRANSCRIPTION = 22; - public static final int CACHED_PHOTO_URI = 23; + + // Indices for columns that may not be available, depending on the Sdk Version + /** + * Only available in versions >= M + * Call {@link DialerCompatUtils#isCallsCachedPhotoUriCompatible()} prior to use + */ + public static int CACHED_PHOTO_URI = -1; + + /** + * Only available in versions > M + * Call {@link CompatUtils#isNCompatible()} prior to use + */ + public static int POST_DIAL_DIGITS = -1; + + public static final String[] _PROJECTION; + + static { + List<String> projectionList = Lists.newArrayList(_PROJECTION_INTERNAL); + if (DialerCompatUtils.isCallsCachedPhotoUriCompatible()) { + projectionList.add(Calls.CACHED_PHOTO_URI); + CACHED_PHOTO_URI = projectionList.size() - 1; + } + if (CompatUtils.isNCompatible()) { + projectionList.add(CallsSdkCompat.POST_DIAL_DIGITS); + POST_DIAL_DIGITS = projectionList.size() - 1; + } + _PROJECTION = projectionList.toArray(new String[projectionList.size()]); + } + } diff --git a/src/com/android/dialer/calllog/CallLogQueryHandler.java b/src/com/android/dialer/calllog/CallLogQueryHandler.java index 60bdcff46..cf86bad7f 100644 --- a/src/com/android/dialer/calllog/CallLogQueryHandler.java +++ b/src/com/android/dialer/calllog/CallLogQueryHandler.java @@ -26,6 +26,7 @@ import android.database.sqlite.SQLiteDiskIOException; import android.database.sqlite.SQLiteException; import android.database.sqlite.SQLiteFullException; import android.net.Uri; +import android.os.Build; import android.os.Handler; import android.os.Looper; import android.os.Message; @@ -34,8 +35,11 @@ import android.provider.VoicemailContract.Status; import android.provider.VoicemailContract.Voicemails; import android.util.Log; +import com.android.contacts.common.compat.SdkVersionOverride; import com.android.contacts.common.database.NoNullCursorAsyncQueryHandler; import com.android.contacts.common.util.PermissionsUtil; +import com.android.dialer.database.VoicemailArchiveContract; +import com.android.dialer.util.AppCompatConstants; import com.android.dialer.util.TelecomUtil; import com.android.dialer.voicemail.VoicemailStatusHelperImpl; @@ -46,8 +50,6 @@ import java.util.List; /** Handles asynchronous queries to the call log. */ public class CallLogQueryHandler extends NoNullCursorAsyncQueryHandler { - private static final String[] EMPTY_STRING_ARRAY = new String[0]; - private static final String TAG = "CallLogQueryHandler"; private static final int NUM_LOGS_TO_DISPLAY = 1000; @@ -59,6 +61,12 @@ public class CallLogQueryHandler extends NoNullCursorAsyncQueryHandler { private static final int UPDATE_MARK_MISSED_CALL_AS_READ_TOKEN = 56; /** The token for the query to fetch voicemail status messages. */ private static final int QUERY_VOICEMAIL_STATUS_TOKEN = 57; + /** The token for the query to fetch the number of unread voicemails. */ + private static final int QUERY_VOICEMAIL_UNREAD_COUNT_TOKEN = 58; + /** The token for the query to fetch the number of missed calls. */ + private static final int QUERY_MISSED_CALLS_UNREAD_COUNT_TOKEN = 59; + /** The oken for the query to fetch the archived voicemails. */ + private static final int QUERY_VOICEMAIL_ARCHIVE = 60; private final int mLogLimit; @@ -122,6 +130,17 @@ public class CallLogQueryHandler extends NoNullCursorAsyncQueryHandler { } /** + * Fetch all the voicemails in the voicemail archive. + */ + public void fetchVoicemailArchive() { + startQuery(QUERY_VOICEMAIL_ARCHIVE, null, + VoicemailArchiveContract.VoicemailArchive.CONTENT_URI, + null, VoicemailArchiveContract.VoicemailArchive.ARCHIVED + " = 1", null, + VoicemailArchiveContract.VoicemailArchive.DATE + " DESC"); + } + + + /** * Fetches the list of calls from the call log for a given type. * This call ignores the new or old state. * <p> @@ -147,36 +166,44 @@ public class CallLogQueryHandler extends NoNullCursorAsyncQueryHandler { } } + public void fetchVoicemailUnreadCount() { + if (TelecomUtil.hasReadWriteVoicemailPermissions(mContext)) { + // Only count voicemails that have not been read and have not been deleted. + startQuery(QUERY_VOICEMAIL_UNREAD_COUNT_TOKEN, null, Voicemails.CONTENT_URI, + new String[] { Voicemails._ID }, + Voicemails.IS_READ + "=0" + " AND " + Voicemails.DELETED + "=0", null, null); + } + } + /** Fetches the list of calls in the call log. */ private void fetchCalls(int token, int callType, boolean newOnly, long newerThan) { - // We need to check for NULL explicitly otherwise entries with where READ is NULL - // may not match either the query or its negation. - // We consider the calls that are not yet consumed (i.e. IS_READ = 0) as "new". StringBuilder where = new StringBuilder(); List<String> selectionArgs = Lists.newArrayList(); + // Always hide blocked calls. + where.append("(").append(Calls.TYPE).append(" != ?)"); + selectionArgs.add(Integer.toString(AppCompatConstants.CALLS_BLOCKED_TYPE)); + // Ignore voicemails marked as deleted - where.append(Voicemails.DELETED); - where.append(" = 0"); + if (SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.M) + >= Build.VERSION_CODES.M) { + where.append(" AND (").append(Voicemails.DELETED).append(" = 0)"); + } if (newOnly) { - where.append(" AND "); - where.append(Calls.NEW); - where.append(" = 1"); + where.append(" AND (").append(Calls.NEW).append(" = 1)"); } if (callType > CALL_TYPE_ALL) { - where.append(" AND "); - where.append(String.format("(%s = ?)", Calls.TYPE)); + where.append(" AND (").append(Calls.TYPE).append(" = ?)"); selectionArgs.add(Integer.toString(callType)); } else { where.append(" AND NOT "); - where.append("(" + Calls.TYPE + " = " + Calls.VOICEMAIL_TYPE + ")"); + where.append("(" + Calls.TYPE + " = " + AppCompatConstants.CALLS_VOICEMAIL_TYPE + ")"); } if (newerThan > 0) { - where.append(" AND "); - where.append(String.format("(%s > ?)", Calls.DATE)); + where.append(" AND (").append(Calls.DATE).append(" > ?)"); selectionArgs.add(Long.toString(newerThan)); } @@ -185,9 +212,8 @@ public class CallLogQueryHandler extends NoNullCursorAsyncQueryHandler { Uri uri = TelecomUtil.getCallLogUri(mContext).buildUpon() .appendQueryParameter(Calls.LIMIT_PARAM_KEY, Integer.toString(limit)) .build(); - startQuery(token, null, uri, - CallLogQuery._PROJECTION, selection, selectionArgs.toArray(EMPTY_STRING_ARRAY), - Calls.DEFAULT_SORT_ORDER); + startQuery(token, null, uri, CallLogQuery._PROJECTION, selection, selectionArgs.toArray( + new String[selectionArgs.size()]), Calls.DEFAULT_SORT_ORDER); } /** Cancel any pending fetch request. */ @@ -217,19 +243,25 @@ public class CallLogQueryHandler extends NoNullCursorAsyncQueryHandler { if (!PermissionsUtil.hasPhonePermissions(mContext)) { return; } - // Mark all "new" calls as not new anymore. - StringBuilder where = new StringBuilder(); - where.append(Calls.IS_READ).append(" = 0"); - where.append(" AND "); - where.append(Calls.TYPE).append(" = ").append(Calls.MISSED_TYPE); ContentValues values = new ContentValues(1); values.put(Calls.IS_READ, "1"); startUpdate(UPDATE_MARK_MISSED_CALL_AS_READ_TOKEN, null, Calls.CONTENT_URI, values, - where.toString(), null); + getUnreadMissedCallsQuery(), null); + } + + /** Fetch all missed calls received since last time the tab was opened. */ + public void fetchMissedCallsUnreadCount() { + if (!PermissionsUtil.hasPhonePermissions(mContext)) { + return; + } + + startQuery(QUERY_MISSED_CALLS_UNREAD_COUNT_TOKEN, null, Calls.CONTENT_URI, + new String[]{Calls._ID}, getUnreadMissedCallsQuery(), null, null); } + @Override protected synchronized void onNotNullableQueryComplete(int token, Object cookie, Cursor cursor) { @@ -237,12 +269,16 @@ public class CallLogQueryHandler extends NoNullCursorAsyncQueryHandler { return; } try { - if (token == QUERY_CALLLOG_TOKEN) { + if (token == QUERY_CALLLOG_TOKEN || token == QUERY_VOICEMAIL_ARCHIVE) { if (updateAdapterData(cursor)) { cursor = null; } } else if (token == QUERY_VOICEMAIL_STATUS_TOKEN) { updateVoicemailStatus(cursor); + } else if (token == QUERY_VOICEMAIL_UNREAD_COUNT_TOKEN) { + updateVoicemailUnreadCount(cursor); + } else if (token == QUERY_MISSED_CALLS_UNREAD_COUNT_TOKEN) { + updateMissedCallsUnreadCount(cursor); } else { Log.w(TAG, "Unknown query completed: ignoring: " + token); } @@ -266,6 +302,17 @@ public class CallLogQueryHandler extends NoNullCursorAsyncQueryHandler { } + /** + * @return Query string to get all unread missed calls. + */ + private String getUnreadMissedCallsQuery() { + StringBuilder where = new StringBuilder(); + where.append(Calls.IS_READ).append(" = 0 OR ").append(Calls.IS_READ).append(" IS NULL"); + where.append(" AND "); + where.append(Calls.TYPE).append(" = ").append(Calls.MISSED_TYPE); + return where.toString(); + } + private void updateVoicemailStatus(Cursor statusCursor) { final Listener listener = mListener.get(); if (listener != null) { @@ -273,11 +320,31 @@ public class CallLogQueryHandler extends NoNullCursorAsyncQueryHandler { } } + private void updateVoicemailUnreadCount(Cursor statusCursor) { + final Listener listener = mListener.get(); + if (listener != null) { + listener.onVoicemailUnreadCountFetched(statusCursor); + } + } + + private void updateMissedCallsUnreadCount(Cursor statusCursor) { + final Listener listener = mListener.get(); + if (listener != null) { + listener.onMissedCallsUnreadCountFetched(statusCursor); + } + } + /** Listener to completion of various queries. */ public interface Listener { /** Called when {@link CallLogQueryHandler#fetchVoicemailStatus()} completes. */ void onVoicemailStatusFetched(Cursor statusCursor); + /** Called when {@link CallLogQueryHandler#fetchVoicemailUnreadCount()} completes. */ + void onVoicemailUnreadCountFetched(Cursor cursor); + + /** Called when {@link CallLogQueryHandler#fetchMissedCallsUnreadCount()} completes. */ + void onMissedCallsUnreadCountFetched(Cursor cursor); + /** * Called when {@link CallLogQueryHandler#fetchCalls(int)} complete. * Returns true if takes ownership of cursor. diff --git a/src/com/android/dialer/calllog/CallTypeHelper.java b/src/com/android/dialer/calllog/CallTypeHelper.java index 36c0975bd..acc114c5c 100644 --- a/src/com/android/dialer/calllog/CallTypeHelper.java +++ b/src/com/android/dialer/calllog/CallTypeHelper.java @@ -17,9 +17,9 @@ package com.android.dialer.calllog; import android.content.res.Resources; -import android.provider.CallLog.Calls; import com.android.dialer.R; +import com.android.dialer.util.AppCompatConstants; /** * Helper class to perform operations related to call types. @@ -39,6 +39,10 @@ public class CallTypeHelper { private final CharSequence mMissedVideoName; /** Name used to identify voicemail calls. */ private final CharSequence mVoicemailName; + /** Name used to identify rejected calls. */ + private final CharSequence mRejectedName; + /** Name used to identify blocked calls. */ + private final CharSequence mBlockedName; /** Color used to identify new missed calls. */ private final int mNewMissedColor; /** Color used to identify new voicemail calls. */ @@ -53,6 +57,8 @@ public class CallTypeHelper { mOutgoingVideoName = resources.getString(R.string.type_outgoing_video); mMissedVideoName = resources.getString(R.string.type_missed_video); mVoicemailName = resources.getString(R.string.type_voicemail); + mRejectedName = resources.getString(R.string.type_rejected); + mBlockedName = resources.getString(R.string.type_blocked); mNewMissedColor = resources.getColor(R.color.call_log_missed_call_highlight_color); mNewVoicemailColor = resources.getColor(R.color.call_log_voicemail_highlight_color); } @@ -60,30 +66,36 @@ public class CallTypeHelper { /** Returns the text used to represent the given call type. */ public CharSequence getCallTypeText(int callType, boolean isVideoCall) { switch (callType) { - case Calls.INCOMING_TYPE: + case AppCompatConstants.CALLS_INCOMING_TYPE: if (isVideoCall) { return mIncomingVideoName; } else { return mIncomingName; } - case Calls.OUTGOING_TYPE: + case AppCompatConstants.CALLS_OUTGOING_TYPE: if (isVideoCall) { return mOutgoingVideoName; } else { return mOutgoingName; } - case Calls.MISSED_TYPE: + case AppCompatConstants.CALLS_MISSED_TYPE: if (isVideoCall) { return mMissedVideoName; } else { return mMissedName; } - case Calls.VOICEMAIL_TYPE: + case AppCompatConstants.CALLS_VOICEMAIL_TYPE: return mVoicemailName; + case AppCompatConstants.CALLS_REJECTED_TYPE: + return mRejectedName; + + case AppCompatConstants.CALLS_BLOCKED_TYPE: + return mBlockedName; + default: return mMissedName; } @@ -92,18 +104,18 @@ public class CallTypeHelper { /** Returns the color used to highlight the given call type, null if not highlight is needed. */ public Integer getHighlightedColor(int callType) { switch (callType) { - case Calls.INCOMING_TYPE: + case AppCompatConstants.CALLS_INCOMING_TYPE: // New incoming calls are not highlighted. return null; - case Calls.OUTGOING_TYPE: + case AppCompatConstants.CALLS_OUTGOING_TYPE: // New outgoing calls are not highlighted. return null; - case Calls.MISSED_TYPE: + case AppCompatConstants.CALLS_MISSED_TYPE: return mNewMissedColor; - case Calls.VOICEMAIL_TYPE: + case AppCompatConstants.CALLS_VOICEMAIL_TYPE: return mNewVoicemailColor; default: @@ -115,7 +127,8 @@ public class CallTypeHelper { } public static boolean isMissedCallType(int callType) { - return (callType != Calls.INCOMING_TYPE && callType != Calls.OUTGOING_TYPE && - callType != Calls.VOICEMAIL_TYPE); + return (callType != AppCompatConstants.CALLS_INCOMING_TYPE + && callType != AppCompatConstants.CALLS_OUTGOING_TYPE + && callType != AppCompatConstants.CALLS_VOICEMAIL_TYPE); } } diff --git a/src/com/android/dialer/calllog/CallTypeIconsView.java b/src/com/android/dialer/calllog/CallTypeIconsView.java index 31d4f4b0e..cfd8f9748 100644 --- a/src/com/android/dialer/calllog/CallTypeIconsView.java +++ b/src/com/android/dialer/calllog/CallTypeIconsView.java @@ -23,13 +23,13 @@ import android.graphics.Canvas; import android.graphics.PorterDuff; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; -import android.provider.CallLog.Calls; import android.util.AttributeSet; import android.view.View; import com.android.contacts.common.testing.NeededForTesting; import com.android.contacts.common.util.BitmapUtil; import com.android.dialer.R; +import com.android.dialer.util.AppCompatConstants; import com.google.common.collect.Lists; import java.util.List; @@ -106,14 +106,16 @@ public class CallTypeIconsView extends View { private Drawable getCallTypeDrawable(int callType) { switch (callType) { - case Calls.INCOMING_TYPE: + case AppCompatConstants.CALLS_INCOMING_TYPE: return mResources.incoming; - case Calls.OUTGOING_TYPE: + case AppCompatConstants.CALLS_OUTGOING_TYPE: return mResources.outgoing; - case Calls.MISSED_TYPE: + case AppCompatConstants.CALLS_MISSED_TYPE: return mResources.missed; - case Calls.VOICEMAIL_TYPE: + case AppCompatConstants.CALLS_VOICEMAIL_TYPE: return mResources.voicemail; + case AppCompatConstants.CALLS_BLOCKED_TYPE: + return mResources.blocked; default: // It is possible for users to end up with calls with unknown call types in their // call history, possibly due to 3rd party call log implementations (e.g. to @@ -150,29 +152,22 @@ public class CallTypeIconsView extends View { private static class Resources { - /** - * Drawable representing an incoming answered call. - */ + // Drawable representing an incoming answered call. public final Drawable incoming; - /** - * Drawable respresenting an outgoing call. - */ + // Drawable respresenting an outgoing call. public final Drawable outgoing; - /** - * Drawable representing an incoming missed call. - */ + // Drawable representing an incoming missed call. public final Drawable missed; - /** - * Drawable representing a voicemail. - */ + // Drawable representing a voicemail. public final Drawable voicemail; - /** - * Drawable repesenting a video call. - */ + // Drawable representing a blocked call. + public final Drawable blocked; + + // Drawable repesenting a video call. public final Drawable videoCall; /** @@ -204,21 +199,26 @@ public class CallTypeIconsView extends View { voicemail = r.getDrawable(R.drawable.ic_call_voicemail_holo_dark); - // Get the video call icon, scaled to match the height of the call arrows. - // We want the video call icon to be the same height as the call arrows, while keeping - // the same width aspect ratio. - Bitmap videoIcon = BitmapFactory.decodeResource(context.getResources(), - R.drawable.ic_videocam_24dp); - int scaledHeight = missed.getIntrinsicHeight(); - int scaledWidth = (int) ((float) videoIcon.getWidth() * - ((float) missed.getIntrinsicHeight() / - (float) videoIcon.getHeight())); - Bitmap scaled = Bitmap.createScaledBitmap(videoIcon, scaledWidth, scaledHeight, false); - videoCall = new BitmapDrawable(context.getResources(), scaled); + blocked = getScaledBitmap(context, R.drawable.ic_block_24dp); + blocked.setColorFilter(r.getColor(R.color.blocked_call), PorterDuff.Mode.MULTIPLY); + + videoCall = getScaledBitmap(context, R.drawable.ic_videocam_24dp); videoCall.setColorFilter(r.getColor(R.color.dialtacts_secondary_text_color), PorterDuff.Mode.MULTIPLY); iconMargin = r.getDimensionPixelSize(R.dimen.call_log_icon_margin); } + + // Gets the icon, scaled to the height of the call type icons. This helps display all the + // icons to be the same height, while preserving their width aspect ratio. + private Drawable getScaledBitmap(Context context, int resourceId) { + Bitmap icon = BitmapFactory.decodeResource(context.getResources(), resourceId); + int scaledHeight = + context.getResources().getDimensionPixelSize(R.dimen.call_type_icon_size); + int scaledWidth = (int) ((float) icon.getWidth() + * ((float) scaledHeight / (float) icon.getHeight())); + Bitmap scaledIcon = Bitmap.createScaledBitmap(icon, scaledWidth, scaledHeight, false); + return new BitmapDrawable(context.getResources(), scaledIcon); + } } } diff --git a/src/com/android/dialer/calllog/ContactInfo.java b/src/com/android/dialer/calllog/ContactInfo.java index 357c832cf..8fe4964bc 100644 --- a/src/com/android/dialer/calllog/ContactInfo.java +++ b/src/com/android/dialer/calllog/ContactInfo.java @@ -19,6 +19,7 @@ package com.android.dialer.calllog; import android.net.Uri; import android.text.TextUtils; +import com.android.contacts.common.ContactsUtils.UserType; import com.android.contacts.common.util.UriUtils; import com.google.common.base.Objects; @@ -34,10 +35,20 @@ public class ContactInfo { */ public String lookupKey; public String name; + public String nameAlternative; public int type; public String label; public String number; public String formattedNumber; + /* + * ContactInfo.normalizedNumber is a column value returned by PhoneLookup query. By definition, + * it's E164 representation. + * http://developer.android.com/reference/android/provider/ContactsContract.PhoneLookupColumns. + * html#NORMALIZED_NUMBER. + * + * The fallback value, when PhoneLookup fails or else, should be either null or + * PhoneNumberUtils.formatNumberToE164. + */ public String normalizedNumber; /** The photo for the contact, if available. */ public long photoId; @@ -45,6 +56,7 @@ public class ContactInfo { public Uri photoUri; public boolean isBadData; public String objectId; + public @UserType long userType; public static ContactInfo EMPTY = new ContactInfo(); @@ -70,6 +82,7 @@ public class ContactInfo { ContactInfo other = (ContactInfo) obj; if (!UriUtils.areEqual(lookupUri, other.lookupUri)) return false; if (!TextUtils.equals(name, other.name)) return false; + if (!TextUtils.equals(nameAlternative, other.nameAlternative)) return false; if (type != other.type) return false; if (!TextUtils.equals(label, other.label)) return false; if (!TextUtils.equals(number, other.number)) return false; @@ -78,14 +91,18 @@ public class ContactInfo { if (photoId != other.photoId) return false; if (!UriUtils.areEqual(photoUri, other.photoUri)) return false; if (!TextUtils.equals(objectId, other.objectId)) return false; + if (userType != other.userType) return false; return true; } @Override public String toString() { - return Objects.toStringHelper(this).add("lookupUri", lookupUri).add("name", name).add( - "type", type).add("label", label).add("number", number).add("formattedNumber", - formattedNumber).add("normalizedNumber", normalizedNumber).add("photoId", photoId) - .add("photoUri", photoUri).add("objectId", objectId).toString(); + return Objects.toStringHelper(this).add("lookupUri", lookupUri).add("name", name) + .add("nameAlternative", nameAlternative) + .add("type", type).add("label", label) + .add("number", number).add("formattedNumber",formattedNumber) + .add("normalizedNumber", normalizedNumber).add("photoId", photoId) + .add("photoUri", photoUri).add("objectId", objectId) + .add("userType",userType).toString(); } } diff --git a/src/com/android/dialer/calllog/ContactInfoHelper.java b/src/com/android/dialer/calllog/ContactInfoHelper.java index 2e07a03b1..6e84a92f9 100644 --- a/src/com/android/dialer/calllog/ContactInfoHelper.java +++ b/src/com/android/dialer/calllog/ContactInfoHelper.java @@ -25,14 +25,19 @@ import android.provider.ContactsContract.CommonDataKinds.Phone; import android.provider.ContactsContract.Contacts; import android.provider.ContactsContract.DisplayNameSources; import android.provider.ContactsContract.PhoneLookup; +import android.support.annotation.Nullable; import android.telephony.PhoneNumberUtils; import android.text.TextUtils; import android.util.Log; +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.ContactsUtils.UserType; +import com.android.contacts.common.compat.CompatUtils; import com.android.contacts.common.util.Constants; import com.android.contacts.common.util.PermissionsUtil; import com.android.contacts.common.util.PhoneNumberHelper; import com.android.contacts.common.util.UriUtils; +import com.android.dialer.compat.DialerCompatUtils; import com.android.dialer.service.CachedNumberLookupService; import com.android.dialer.service.CachedNumberLookupService.CachedContactInfo; import com.android.dialer.util.TelecomUtil; @@ -41,8 +46,6 @@ import com.android.dialerbind.ObjectFactory; import org.json.JSONException; import org.json.JSONObject; -import java.util.List; - /** * Utility class to look up the contact information for a given number. */ @@ -71,34 +74,27 @@ public class ContactInfoHelper { * @param number the number to look up * @param countryIso the country associated with this number */ + @Nullable public ContactInfo lookupNumber(String number, String countryIso) { if (TextUtils.isEmpty(number)) { return null; } - final ContactInfo info; - // Determine the contact info. + ContactInfo info; + if (PhoneNumberHelper.isUriNumber(number)) { - // This "number" is really a SIP address. - ContactInfo sipInfo = queryContactInfoForSipAddress(number); - if (sipInfo == null || sipInfo == ContactInfo.EMPTY) { - // Check whether the "username" part of the SIP address is - // actually the phone number of a contact. + // The number is a SIP address.. + info = lookupContactFromUri(getContactInfoLookupUri(number), true); + if (info == null || info == ContactInfo.EMPTY) { + // If lookup failed, check if the "username" of the SIP address is a phone number. String username = PhoneNumberHelper.getUsernameFromUriNumber(number); if (PhoneNumberUtils.isGlobalPhoneNumber(username)) { - sipInfo = queryContactInfoForPhoneNumber(username, countryIso); + info = queryContactInfoForPhoneNumber(username, countryIso, true); } } - info = sipInfo; } else { // Look for a contact that has the given phone number. - ContactInfo phoneInfo = queryContactInfoForPhoneNumber(number, countryIso); - - if (phoneInfo == null || phoneInfo == ContactInfo.EMPTY) { - // Check whether the phone number has been saved as an "Internet call" number. - phoneInfo = queryContactInfoForSipAddress(number); - } - info = phoneInfo; + info = queryContactInfoForPhoneNumber(number, countryIso, false); } final ContactInfo updatedInfo; @@ -159,67 +155,82 @@ public class ContactInfoHelper { * The {@link ContactInfo#formattedNumber} field is always set to {@code null} in the returned * value. */ - private ContactInfo lookupContactFromUri(Uri uri) { + ContactInfo lookupContactFromUri(Uri uri, boolean isSip) { if (uri == null) { return null; } if (!PermissionsUtil.hasContactsPermissions(mContext)) { return ContactInfo.EMPTY; } - final ContactInfo info; - Cursor phonesCursor = - mContext.getContentResolver().query(uri, PhoneQuery._PROJECTION, null, null, null); - - if (phonesCursor != null) { - try { - if (phonesCursor.moveToFirst()) { - info = new ContactInfo(); - long contactId = phonesCursor.getLong(PhoneQuery.PERSON_ID); - String lookupKey = phonesCursor.getString(PhoneQuery.LOOKUP_KEY); - info.lookupKey = lookupKey; - info.lookupUri = Contacts.getLookupUri(contactId, lookupKey); - info.name = phonesCursor.getString(PhoneQuery.NAME); - info.type = phonesCursor.getInt(PhoneQuery.PHONE_TYPE); - info.label = phonesCursor.getString(PhoneQuery.LABEL); - info.number = phonesCursor.getString(PhoneQuery.MATCHED_NUMBER); - info.normalizedNumber = phonesCursor.getString(PhoneQuery.NORMALIZED_NUMBER); - info.photoId = phonesCursor.getLong(PhoneQuery.PHOTO_ID); - info.photoUri = - UriUtils.parseUriOrNull(phonesCursor.getString(PhoneQuery.PHOTO_URI)); - info.formattedNumber = null; - } else { - info = ContactInfo.EMPTY; - } - } finally { - phonesCursor.close(); + + Cursor phoneLookupCursor = null; + try { + String[] projection = PhoneQuery.getPhoneLookupProjection(uri); + phoneLookupCursor = mContext.getContentResolver().query(uri, projection, null, null, + null); + } catch (NullPointerException e) { + // Trap NPE from pre-N CP2 + return null; + } + if (phoneLookupCursor == null) { + return null; + } + + try { + if (!phoneLookupCursor.moveToFirst()) { + return ContactInfo.EMPTY; } - } else { - // Failed to fetch the data, ignore this request. - info = null; + String lookupKey = phoneLookupCursor.getString(PhoneQuery.LOOKUP_KEY); + ContactInfo contactInfo = createPhoneLookupContactInfo(phoneLookupCursor, lookupKey); + contactInfo.nameAlternative = lookUpDisplayNameAlternative(mContext, lookupKey, + contactInfo.userType); + return contactInfo; + } finally { + phoneLookupCursor.close(); } + } + + private ContactInfo createPhoneLookupContactInfo(Cursor phoneLookupCursor, String lookupKey) { + ContactInfo info = new ContactInfo(); + info.lookupKey = lookupKey; + info.lookupUri = Contacts.getLookupUri(phoneLookupCursor.getLong(PhoneQuery.PERSON_ID), + lookupKey); + info.name = phoneLookupCursor.getString(PhoneQuery.NAME); + info.type = phoneLookupCursor.getInt(PhoneQuery.PHONE_TYPE); + info.label = phoneLookupCursor.getString(PhoneQuery.LABEL); + info.number = phoneLookupCursor.getString(PhoneQuery.MATCHED_NUMBER); + info.normalizedNumber = phoneLookupCursor.getString(PhoneQuery.NORMALIZED_NUMBER); + info.photoId = phoneLookupCursor.getLong(PhoneQuery.PHOTO_ID); + info.photoUri = UriUtils.parseUriOrNull(phoneLookupCursor.getString(PhoneQuery.PHOTO_URI)); + info.formattedNumber = null; + info.userType = ContactsUtils.determineUserType(null, + phoneLookupCursor.getLong(PhoneQuery.PERSON_ID)); + return info; } - /** - * Determines the contact information for the given SIP address. - * <p> - * It returns the contact info if found. - * <p> - * If no contact corresponds to the given SIP address, returns {@link ContactInfo#EMPTY}. - * <p> - * If the lookup fails for some other reason, it returns null. - */ - private ContactInfo queryContactInfoForSipAddress(String sipAddress) { - if (TextUtils.isEmpty(sipAddress)) { + public static String lookUpDisplayNameAlternative(Context context, String lookupKey, + @UserType long userType) { + // Query {@link Contacts#CONTENT_LOOKUP_URI} directly with work lookup key is not allowed. + if (lookupKey == null || userType == ContactsUtils.USER_TYPE_WORK) { return null; } - final ContactInfo info; + final Uri uri = Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, lookupKey); + Cursor cursor = null; + try { + cursor = context.getContentResolver().query(uri, + PhoneQuery.DISPLAY_NAME_ALTERNATIVE_PROJECTION, null, null, null); - // "contactNumber" is a SIP address, so use the PhoneLookup table with the SIP parameter. - Uri.Builder uriBuilder = PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI.buildUpon(); - uriBuilder.appendPath(Uri.encode(sipAddress)); - uriBuilder.appendQueryParameter(PhoneLookup.QUERY_PARAMETER_SIP_ADDRESS, "1"); - return lookupContactFromUri(uriBuilder.build()); + if (cursor != null && cursor.moveToFirst()) { + return cursor.getString(PhoneQuery.NAME_ALTERNATIVE); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return null; } /** @@ -231,25 +242,13 @@ public class ContactInfoHelper { * <p> * If the lookup fails for some other reason, it returns null. */ - private ContactInfo queryContactInfoForPhoneNumber(String number, String countryIso) { + private ContactInfo queryContactInfoForPhoneNumber(String number, String countryIso, + boolean isSip) { if (TextUtils.isEmpty(number)) { return null; } - String contactNumber = number; - if (!TextUtils.isEmpty(countryIso)) { - // Normalize the number: this is needed because the PhoneLookup query below does not - // accept a country code as an input. - String numberE164 = PhoneNumberUtils.formatNumberToE164(number, countryIso); - if (!TextUtils.isEmpty(numberE164)) { - // Only use it if the number could be formatted to E164. - contactNumber = numberE164; - } - } - // The "contactNumber" is a regular phone number, so use the PhoneLookup table. - Uri uri = Uri.withAppendedPath(PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI, - Uri.encode(contactNumber)); - ContactInfo info = lookupContactFromUri(uri); + ContactInfo info = lookupContactFromUri(getContactInfoLookupUri(number), isSip); if (info != null && info != ContactInfo.EMPTY) { info.formattedNumber = formatPhoneNumber(number, null, countryIso); } else if (mCachedNumberLookupService != null) { @@ -345,7 +344,8 @@ public class ContactInfoHelper { final Uri updatedPhotoUriContactsOnly = UriUtils.nullForNonContactsUri(updatedInfo.photoUri); - if (!UriUtils.areEqual(updatedPhotoUriContactsOnly, callLogInfo.photoUri)) { + if (DialerCompatUtils.isCallsCachedPhotoUriCompatible() && + !UriUtils.areEqual(updatedPhotoUriContactsOnly, callLogInfo.photoUri)) { values.put(Calls.CACHED_PHOTO_URI, UriUtils.uriToString(updatedPhotoUriContactsOnly)); needsUpdate = true; @@ -364,8 +364,10 @@ public class ContactInfoHelper { values.put(Calls.CACHED_MATCHED_NUMBER, updatedInfo.number); values.put(Calls.CACHED_NORMALIZED_NUMBER, updatedInfo.normalizedNumber); values.put(Calls.CACHED_PHOTO_ID, updatedInfo.photoId); - values.put(Calls.CACHED_PHOTO_URI, UriUtils.uriToString( - UriUtils.nullForNonContactsUri(updatedInfo.photoUri))); + if (DialerCompatUtils.isCallsCachedPhotoUriCompatible()) { + values.put(Calls.CACHED_PHOTO_URI, UriUtils.uriToString( + UriUtils.nullForNonContactsUri(updatedInfo.photoUri))); + } values.put(Calls.CACHED_FORMATTED_NUMBER, updatedInfo.formattedNumber); needsUpdate = true; } @@ -393,6 +395,34 @@ public class ContactInfoHelper { } } + public static Uri getContactInfoLookupUri(String number) { + return getContactInfoLookupUri(number, -1); + } + + public static Uri getContactInfoLookupUri(String number, long directoryId) { + // Get URI for the number in the PhoneLookup table, with a parameter to indicate whether + // the number is a SIP number. + Uri uri = PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI; + if (!ContactsUtils.FLAG_N_FEATURE) { + if (directoryId != -1) { + // ENTERPRISE_CONTENT_FILTER_URI in M doesn't support directory lookup + uri = PhoneLookup.CONTENT_FILTER_URI; + } else { + // b/25900607 in M. PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI, encodes twice. + number = Uri.encode(number); + } + } + Uri.Builder builder = uri.buildUpon() + .appendPath(number) + .appendQueryParameter(PhoneLookup.QUERY_PARAMETER_SIP_ADDRESS, + String.valueOf(PhoneNumberHelper.isUriNumber(number))); + if (directoryId != -1) { + builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, + String.valueOf(directoryId)); + } + return builder.build(); + } + /** * Returns the contact information stored in an entry of the call log. * @@ -400,17 +430,22 @@ public class ContactInfoHelper { */ public static ContactInfo getContactInfo(Cursor c) { ContactInfo info = new ContactInfo(); - info.lookupUri = UriUtils.parseUriOrNull(c.getString(CallLogQuery.CACHED_LOOKUP_URI)); info.name = c.getString(CallLogQuery.CACHED_NAME); info.type = c.getInt(CallLogQuery.CACHED_NUMBER_TYPE); info.label = c.getString(CallLogQuery.CACHED_NUMBER_LABEL); String matchedNumber = c.getString(CallLogQuery.CACHED_MATCHED_NUMBER); - info.number = matchedNumber == null ? c.getString(CallLogQuery.NUMBER) : matchedNumber; + String postDialDigits = CompatUtils.isNCompatible() + ? c.getString(CallLogQuery.POST_DIAL_DIGITS) : ""; + info.number = (matchedNumber == null) ? + c.getString(CallLogQuery.NUMBER) + postDialDigits : matchedNumber; + info.normalizedNumber = c.getString(CallLogQuery.CACHED_NORMALIZED_NUMBER); info.photoId = c.getLong(CallLogQuery.CACHED_PHOTO_ID); - info.photoUri = UriUtils.nullForNonContactsUri( - UriUtils.parseUriOrNull(c.getString(CallLogQuery.CACHED_PHOTO_URI))); + info.photoUri = DialerCompatUtils.isCallsCachedPhotoUriCompatible() ? + UriUtils.nullForNonContactsUri( + UriUtils.parseUriOrNull(c.getString(CallLogQuery.CACHED_PHOTO_URI))) + : null; info.formattedNumber = c.getString(CallLogQuery.CACHED_FORMATTED_NUMBER); return info; @@ -439,6 +474,4 @@ public class ContactInfoHelper { return mCachedNumberLookupService != null && mCachedNumberLookupService.canReportAsInvalid(sourceType, objectId); } - - } diff --git a/src/com/android/dialer/calllog/DefaultVoicemailNotifier.java b/src/com/android/dialer/calllog/DefaultVoicemailNotifier.java index a6d165e3a..de6fc6a3d 100644 --- a/src/com/android/dialer/calllog/DefaultVoicemailNotifier.java +++ b/src/com/android/dialer/calllog/DefaultVoicemailNotifier.java @@ -16,39 +16,45 @@ package com.android.dialer.calllog; -import static android.Manifest.permission.READ_CALL_LOG; -import static android.Manifest.permission.READ_CONTACTS; +import com.google.common.collect.Maps; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; +import android.content.ComponentName; import android.content.ContentResolver; import android.content.ContentUris; import android.content.Context; import android.content.Intent; import android.content.res.Resources; -import android.database.Cursor; import android.net.Uri; -import android.provider.CallLog.Calls; -import android.provider.ContactsContract.PhoneLookup; +import android.support.annotation.Nullable; +import android.support.v4.util.Pair; +import android.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.telephony.TelephonyManager; import android.text.TextUtils; import android.util.Log; -import com.android.common.io.MoreCloseables; -import com.android.contacts.common.util.PermissionsUtil; +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.compat.TelephonyManagerCompat; +import com.android.contacts.common.util.ContactDisplayUtils; import com.android.dialer.DialtactsActivity; import com.android.dialer.R; -import com.android.dialer.calllog.PhoneAccountUtils; +import com.android.dialer.calllog.CallLogNotificationsHelper.NewCall; +import com.android.dialer.filterednumber.FilteredNumbersUtil; import com.android.dialer.list.ListsFragment; -import com.google.common.collect.Maps; +import com.android.dialer.util.TelecomUtil; +import java.util.Iterator; +import java.util.List; import java.util.Map; /** - * VoicemailNotifier that shows a notification in the status bar. + * Shows a voicemail notification in the status bar. */ public class DefaultVoicemailNotifier { - public static final String TAG = "DefaultVoicemailNotifier"; + public static final String TAG = "VoicemailNotifier"; /** The tag used to identify notifications from this class. */ private static final String NOTIFICATION_TAG = "DefaultVoicemailNotifier"; @@ -59,30 +65,18 @@ public class DefaultVoicemailNotifier { private static DefaultVoicemailNotifier sInstance; private final Context mContext; - private final NotificationManager mNotificationManager; - private final NewCallsQuery mNewCallsQuery; - private final NameLookupQuery mNameLookupQuery; /** Returns the singleton instance of the {@link DefaultVoicemailNotifier}. */ - public static synchronized DefaultVoicemailNotifier getInstance(Context context) { + public static DefaultVoicemailNotifier getInstance(Context context) { if (sInstance == null) { - NotificationManager notificationManager = - (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); ContentResolver contentResolver = context.getContentResolver(); - sInstance = new DefaultVoicemailNotifier(context, notificationManager, - createNewCallsQuery(context, contentResolver), - createNameLookupQuery(context, contentResolver)); + sInstance = new DefaultVoicemailNotifier(context); } return sInstance; } - private DefaultVoicemailNotifier(Context context, - NotificationManager notificationManager, NewCallsQuery newCallsQuery, - NameLookupQuery nameLookupQuery) { + private DefaultVoicemailNotifier(Context context) { mContext = context; - mNotificationManager = notificationManager; - mNewCallsQuery = newCallsQuery; - mNameLookupQuery = nameLookupQuery; } /** @@ -96,16 +90,17 @@ public class DefaultVoicemailNotifier { public void updateNotification(Uri newCallUri) { // Lookup the list of new voicemails to include in the notification. // TODO: Move this into a service, to avoid holding the receiver up. - final NewCall[] newCalls = mNewCallsQuery.query(); + final List<NewCall> newCalls = + CallLogNotificationsHelper.getInstance(mContext).getNewVoicemails(); if (newCalls == null) { // Query failed, just return. return; } - if (newCalls.length == 0) { + if (newCalls.isEmpty()) { // No voicemails to notify about: clear the notification. - clearNotification(); + getNotificationManager().cancel(NOTIFICATION_TAG, NOTIFICATION_ID); return; } @@ -122,23 +117,25 @@ public class DefaultVoicemailNotifier { NewCall callToNotify = null; // Iterate over the new voicemails to determine all the information above. - for (NewCall newCall : newCalls) { + Iterator<NewCall> itr = newCalls.iterator(); + while (itr.hasNext()) { + NewCall newCall = itr.next(); + + // Skip notifying for numbers which are blocked. + if (FilteredNumbersUtil.shouldBlockVoicemail( + mContext, newCall.number, newCall.countryIso, newCall.dateMs)) { + itr.remove(); + + // Delete the voicemail. + mContext.getContentResolver().delete(newCall.voicemailUri, null, null); + continue; + } + // Check if we already know the name associated with this number. String name = names.get(newCall.number); if (name == null) { - name = PhoneNumberDisplayUtil.getDisplayName( - mContext, - newCall.number, - newCall.numberPresentation, - /* isVoicemail */ false).toString(); - // If we cannot lookup the contact, use the number instead. - if (TextUtils.isEmpty(name)) { - // Look it up in the database. - name = mNameLookupQuery.query(newCall.number); - if (TextUtils.isEmpty(name)) { - name = newCall.number; - } - } + name = CallLogNotificationsHelper.getInstance(mContext).getName(newCall.number, + newCall.numberPresentation, newCall.countryIso); names.put(newCall.number, name); // This is a new caller. Add it to the back of the list of callers. if (TextUtils.isEmpty(callers)) { @@ -155,10 +152,15 @@ public class DefaultVoicemailNotifier { } } + // All the potential new voicemails have been removed, e.g. if they were spam. + if (newCalls.isEmpty()) { + return; + } + // If there is only one voicemail, set its transcription as the "long text". String transcription = null; - if (newCalls.length == 1) { - transcription = newCalls[0].transcription; + if (newCalls.size() == 1) { + transcription = newCalls.get(0).transcription; } if (newCallUri != null && callToNotify == null) { @@ -167,24 +169,26 @@ public class DefaultVoicemailNotifier { // Determine the title of the notification and the icon for it. final String title = resources.getQuantityString( - R.plurals.notification_voicemail_title, newCalls.length, newCalls.length); + R.plurals.notification_voicemail_title, newCalls.size(), newCalls.size()); // TODO: Use the photo of contact if all calls are from the same person. final int icon = android.R.drawable.stat_notify_voicemail; + Pair<Uri, Integer> info = getNotificationInfo(callToNotify); + Notification.Builder notificationBuilder = new Notification.Builder(mContext) .setSmallIcon(icon) .setContentTitle(title) .setContentText(callers) .setStyle(new Notification.BigTextStyle().bigText(transcription)) .setColor(resources.getColor(R.color.dialer_theme_color)) - .setDefaults(callToNotify != null ? Notification.DEFAULT_ALL : 0) + .setSound(info.first) + .setDefaults(info.second) .setDeleteIntent(createMarkNewVoicemailsAsOldIntent()) .setAutoCancel(true); // Determine the intent to fire when the notification is clicked on. final Intent contentIntent; // Open the call log. - // TODO: Send to recents tab in Dialer instead. contentIntent = new Intent(mContext, DialtactsActivity.class); contentIntent.putExtra(DialtactsActivity.EXTRA_SHOW_TAB, ListsFragment.TAB_INDEX_VOICEMAIL); notificationBuilder.setContentIntent(PendingIntent.getActivity( @@ -192,196 +196,74 @@ public class DefaultVoicemailNotifier { // The text to show in the ticker, describing the new event. if (callToNotify != null) { - notificationBuilder.setTicker(resources.getString( - R.string.notification_new_voicemail_ticker, names.get(callToNotify.number))); + CharSequence msg = ContactDisplayUtils.getTtsSpannedPhoneNumber( + resources, + R.string.notification_new_voicemail_ticker, + names.get(callToNotify.number)); + notificationBuilder.setTicker(msg); } - - mNotificationManager.notify(NOTIFICATION_TAG, NOTIFICATION_ID, notificationBuilder.build()); - } - - /** Creates a pending intent that marks all new voicemails as old. */ - private PendingIntent createMarkNewVoicemailsAsOldIntent() { - Intent intent = new Intent(mContext, CallLogNotificationsService.class); - intent.setAction(CallLogNotificationsService.ACTION_MARK_NEW_VOICEMAILS_AS_OLD); - return PendingIntent.getService(mContext, 0, intent, 0); - } - - public void clearNotification() { - mNotificationManager.cancel(NOTIFICATION_TAG, NOTIFICATION_ID); - } - - /** Information about a new voicemail. */ - private static final class NewCall { - public final Uri callsUri; - public final Uri voicemailUri; - public final String number; - public final int numberPresentation; - public final String accountComponentName; - public final String accountId; - public final String transcription; - - public NewCall( - Uri callsUri, - Uri voicemailUri, - String number, - int numberPresentation, - String accountComponentName, - String accountId, - String transcription) { - this.callsUri = callsUri; - this.voicemailUri = voicemailUri; - this.number = number; - this.numberPresentation = numberPresentation; - this.accountComponentName = accountComponentName; - this.accountId = accountId; - this.transcription = transcription; - } - } - - /** Allows determining the new calls for which a notification should be generated. */ - public interface NewCallsQuery { - /** - * Returns the new calls for which a notification should be generated. - */ - public NewCall[] query(); - } - - /** Create a new instance of {@link NewCallsQuery}. */ - public static NewCallsQuery createNewCallsQuery(Context context, - ContentResolver contentResolver) { - return new DefaultNewCallsQuery(context.getApplicationContext(), contentResolver); + Log.i(TAG, "Creating voicemail notification"); + getNotificationManager().notify(NOTIFICATION_TAG, NOTIFICATION_ID, + notificationBuilder.build()); } /** - * Default implementation of {@link NewCallsQuery} that looks up the list of new calls to - * notify about in the call log. + * Determines which ringtone Uri and Notification defaults to use when updating the notification + * for the given call. */ - private static final class DefaultNewCallsQuery implements NewCallsQuery { - private static final String[] PROJECTION = { - Calls._ID, - Calls.NUMBER, - Calls.VOICEMAIL_URI, - Calls.NUMBER_PRESENTATION, - Calls.PHONE_ACCOUNT_COMPONENT_NAME, - Calls.PHONE_ACCOUNT_ID, - Calls.TRANSCRIPTION - }; - private static final int ID_COLUMN_INDEX = 0; - private static final int NUMBER_COLUMN_INDEX = 1; - private static final int VOICEMAIL_URI_COLUMN_INDEX = 2; - private static final int NUMBER_PRESENTATION_COLUMN_INDEX = 3; - private static final int PHONE_ACCOUNT_COMPONENT_NAME_COLUMN_INDEX = 4; - private static final int PHONE_ACCOUNT_ID_COLUMN_INDEX = 5; - private static final int TRANSCRIPTION_COLUMN_INDEX = 6; - - private final ContentResolver mContentResolver; - private final Context mContext; - - private DefaultNewCallsQuery(Context context, ContentResolver contentResolver) { - mContext = context; - mContentResolver = contentResolver; + private Pair<Uri, Integer> getNotificationInfo(@Nullable NewCall callToNotify) { + Log.v(TAG, "getNotificationInfo"); + if (callToNotify == null) { + Log.i(TAG, "callToNotify == null"); + return new Pair<>(null, 0); } - - @Override - public NewCall[] query() { - if (!PermissionsUtil.hasPermission(mContext, READ_CALL_LOG)) { - Log.w(TAG, "No READ_CALL_LOG permission, returning null for calls lookup."); - return null; - } - final String selection = String.format("%s = 1 AND %s = ?", Calls.NEW, Calls.TYPE); - final String[] selectionArgs = new String[]{ Integer.toString(Calls.VOICEMAIL_TYPE) }; - Cursor cursor = null; - try { - cursor = mContentResolver.query(Calls.CONTENT_URI_WITH_VOICEMAIL, PROJECTION, - selection, selectionArgs, Calls.DEFAULT_SORT_ORDER); - if (cursor == null) { - return null; - } - NewCall[] newCalls = new NewCall[cursor.getCount()]; - while (cursor.moveToNext()) { - newCalls[cursor.getPosition()] = createNewCallsFromCursor(cursor); - } - return newCalls; - } catch (RuntimeException e) { - Log.w(TAG, "Exception when querying Contacts Provider for calls lookup"); - return null; - } finally { - MoreCloseables.closeQuietly(cursor); + PhoneAccountHandle accountHandle = null; + if (callToNotify.accountComponentName == null || callToNotify.accountId == null) { + Log.v(TAG, "accountComponentName == null || callToNotify.accountId == null"); + accountHandle = TelecomUtil + .getDefaultOutgoingPhoneAccount(mContext, PhoneAccount.SCHEME_TEL); + if (accountHandle == null) { + Log.i(TAG, "No default phone account found, using default notification ringtone"); + return new Pair<>(null, Notification.DEFAULT_ALL); } - } - /** Returns an instance of {@link NewCall} created by using the values of the cursor. */ - private NewCall createNewCallsFromCursor(Cursor cursor) { - String voicemailUriString = cursor.getString(VOICEMAIL_URI_COLUMN_INDEX); - Uri callsUri = ContentUris.withAppendedId( - Calls.CONTENT_URI_WITH_VOICEMAIL, cursor.getLong(ID_COLUMN_INDEX)); - Uri voicemailUri = voicemailUriString == null ? null : Uri.parse(voicemailUriString); - return new NewCall( - callsUri, - voicemailUri, - cursor.getString(NUMBER_COLUMN_INDEX), - cursor.getInt(NUMBER_PRESENTATION_COLUMN_INDEX), - cursor.getString(PHONE_ACCOUNT_COMPONENT_NAME_COLUMN_INDEX), - cursor.getString(PHONE_ACCOUNT_ID_COLUMN_INDEX), - cursor.getString(TRANSCRIPTION_COLUMN_INDEX)); + } else { + accountHandle = new PhoneAccountHandle( + ComponentName.unflattenFromString(callToNotify.accountComponentName), + callToNotify.accountId); } + if (accountHandle.getComponentName() != null) { + Log.v(TAG, "PhoneAccountHandle.ComponentInfo:" + accountHandle.getComponentName()); + } else { + Log.i(TAG, "PhoneAccountHandle.ComponentInfo: null"); + } + return new Pair<>( + TelephonyManagerCompat.getVoicemailRingtoneUri( + getTelephonyManager(), accountHandle), + getNotificationDefaults(accountHandle)); } - /** Allows determining the name associated with a given phone number. */ - public interface NameLookupQuery { - /** - * Returns the name associated with the given number in the contacts database, or null if - * the number does not correspond to any of the contacts. - * <p> - * If there are multiple contacts with the same phone number, it will return the name of one - * of the matching contacts. - */ - public String query(String number); + private int getNotificationDefaults(PhoneAccountHandle accountHandle) { + if (ContactsUtils.FLAG_N_FEATURE) { + return TelephonyManagerCompat.isVoicemailVibrationEnabled(getTelephonyManager(), + accountHandle) ? Notification.DEFAULT_VIBRATE : 0; + } + return Notification.DEFAULT_ALL; } - /** Create a new instance of {@link NameLookupQuery}. */ - public static NameLookupQuery createNameLookupQuery(Context context, - ContentResolver contentResolver) { - return new DefaultNameLookupQuery(context.getApplicationContext(), contentResolver); + /** Creates a pending intent that marks all new voicemails as old. */ + private PendingIntent createMarkNewVoicemailsAsOldIntent() { + Intent intent = new Intent(mContext, CallLogNotificationsService.class); + intent.setAction(CallLogNotificationsService.ACTION_MARK_NEW_VOICEMAILS_AS_OLD); + return PendingIntent.getService(mContext, 0, intent, 0); } - /** - * Default implementation of {@link NameLookupQuery} that looks up the name of a contact in the - * contacts database. - */ - private static final class DefaultNameLookupQuery implements NameLookupQuery { - private static final String[] PROJECTION = { PhoneLookup.DISPLAY_NAME }; - private static final int DISPLAY_NAME_COLUMN_INDEX = 0; - - private final ContentResolver mContentResolver; - private final Context mContext; - - private DefaultNameLookupQuery(Context context, ContentResolver contentResolver) { - mContext = context; - mContentResolver = contentResolver; - } + private NotificationManager getNotificationManager() { + return (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); + } - @Override - public String query(String number) { - if (!PermissionsUtil.hasPermission(mContext, READ_CONTACTS)) { - Log.w(TAG, "No READ_CONTACTS permission, returning null for name lookup."); - return null; - } - Cursor cursor = null; - try { - cursor = mContentResolver.query( - Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)), - PROJECTION, null, null, null); - if (cursor == null || !cursor.moveToFirst()) return null; - return cursor.getString(DISPLAY_NAME_COLUMN_INDEX); - } catch (RuntimeException e) { - Log.w(TAG, "Exception when querying Contacts Provider for name lookup"); - return null; - } finally { - if (cursor != null) { - cursor.close(); - } - } - } + private TelephonyManager getTelephonyManager() { + return (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE); } + } diff --git a/src/com/android/dialer/calllog/GroupingListAdapter.java b/src/com/android/dialer/calllog/GroupingListAdapter.java index 8d3ab4545..0d06298e7 100644 --- a/src/com/android/dialer/calllog/GroupingListAdapter.java +++ b/src/com/android/dialer/calllog/GroupingListAdapter.java @@ -22,78 +22,28 @@ import android.database.Cursor; import android.database.DataSetObserver; import android.os.Handler; import android.support.v7.widget.RecyclerView; -import android.util.Log; import android.util.SparseIntArray; -import android.view.View; -import android.view.ViewGroup; -import android.widget.BaseAdapter; - -import com.android.contacts.common.testing.NeededForTesting; /** - * Maintains a list that groups adjacent items sharing the same value of a "group-by" field. + * Maintains a list that groups items into groups of consecutive elements which are disjoint, + * that is, an item can only belong to one group. This is leveraged for grouping calls in the + * call log received from or made to the same phone number. * - * The list has three types of elements: stand-alone, group header and group child. Groups are - * collapsible and collapsed by default. This is used by the call log to group related entries. + * There are two integers stored as metadata for every list item in the adapter. */ abstract class GroupingListAdapter extends RecyclerView.Adapter { - private static final int GROUP_METADATA_ARRAY_INITIAL_SIZE = 16; - private static final int GROUP_METADATA_ARRAY_INCREMENT = 128; - private static final long GROUP_OFFSET_MASK = 0x00000000FFFFFFFFL; - private static final long GROUP_SIZE_MASK = 0x7FFFFFFF00000000L; - private static final long EXPANDED_GROUP_MASK = 0x8000000000000000L; - - public static final int ITEM_TYPE_STANDALONE = 0; - public static final int ITEM_TYPE_GROUP_HEADER = 1; - public static final int ITEM_TYPE_IN_GROUP = 2; - - /** - * Information about a specific list item: is it a group, if so is it expanded. - * Otherwise, is it a stand-alone item or a group member. - */ - protected static class PositionMetadata { - int itemType; - boolean isExpanded; - int cursorPosition; - int childCount; - private int groupPosition; - private int listPosition = -1; - } - private Context mContext; private Cursor mCursor; /** - * Count of list items. - */ - private int mCount; - - private int mRowIdColumnIndex; - - /** - * Count of groups in the list. - */ - private int mGroupCount; - - /** - * Information about where these groups are located in the list, how large they are - * and whether they are expanded. + * SparseIntArray, which maps the cursor position of the first element of a group to the size + * of the group. The index of a key in this map corresponds to the list position of that group. */ - private long[] mGroupMetadata; - - private SparseIntArray mPositionCache = new SparseIntArray(); - private int mLastCachedListPosition; - private int mLastCachedCursorPosition; - private int mLastCachedGroup; - - /** - * A reusable temporary instance of PositionMetadata - */ - private PositionMetadata mPositionMetadata = new PositionMetadata(); + private SparseIntArray mGroupMetadata; + private int mItemCount; protected ContentObserver mChangeObserver = new ContentObserver(new Handler()) { - @Override public boolean deliverSelfNotifications() { return true; @@ -106,7 +56,6 @@ abstract class GroupingListAdapter extends RecyclerView.Adapter { }; protected DataSetObserver mDataSetObserver = new DataSetObserver() { - @Override public void onChanged() { notifyDataSetChanged(); @@ -115,7 +64,7 @@ abstract class GroupingListAdapter extends RecyclerView.Adapter { public GroupingListAdapter(Context context) { mContext = context; - resetCache(); + reset(); } /** @@ -124,21 +73,19 @@ abstract class GroupingListAdapter extends RecyclerView.Adapter { */ protected abstract void addGroups(Cursor cursor); + protected abstract void addVoicemailGroups(Cursor cursor); + protected abstract void onContentChanged(); - /** - * Cache should be reset whenever the cursor changes or groups are expanded or collapsed. - */ - private void resetCache() { - mCount = -1; - mLastCachedListPosition = -1; - mLastCachedCursorPosition = -1; - mLastCachedGroup = -1; - mPositionMetadata.listPosition = -1; - mPositionCache.clear(); + public void changeCursor(Cursor cursor) { + changeCursor(cursor, false); } - public void changeCursor(Cursor cursor) { + public void changeCursorVoicemail(Cursor cursor) { + changeCursor(cursor, true); + } + + public void changeCursor(Cursor cursor, boolean voicemail) { if (cursor == mCursor) { return; } @@ -148,288 +95,77 @@ abstract class GroupingListAdapter extends RecyclerView.Adapter { mCursor.unregisterDataSetObserver(mDataSetObserver); mCursor.close(); } + + // Reset whenever the cursor is changed. + reset(); mCursor = cursor; - resetCache(); - findGroups(); if (cursor != null) { + if (voicemail) { + addVoicemailGroups(mCursor); + } else { + addGroups(mCursor); + } + + // Calculate the item count by subtracting group child counts from the cursor count. + mItemCount = mGroupMetadata.size(); + cursor.registerContentObserver(mChangeObserver); cursor.registerDataSetObserver(mDataSetObserver); - mRowIdColumnIndex = cursor.getColumnIndexOrThrow("_id"); notifyDataSetChanged(); } } - @NeededForTesting - public Cursor getCursor() { - return mCursor; - } - - /** - * Scans over the entire cursor looking for duplicate phone numbers that need - * to be collapsed. - */ - private void findGroups() { - mGroupCount = 0; - mGroupMetadata = new long[GROUP_METADATA_ARRAY_INITIAL_SIZE]; - - if (mCursor == null) { - return; - } - - addGroups(mCursor); - } - /** - * Records information about grouping in the list. Should be called by the overridden - * {@link #addGroups} method. + * Records information about grouping in the list. + * Should be called by the overridden {@link #addGroups} method. */ - protected void addGroup(int cursorPosition, int size, boolean expanded) { - if (mGroupCount >= mGroupMetadata.length) { - int newSize = idealLongArraySize( - mGroupMetadata.length + GROUP_METADATA_ARRAY_INCREMENT); - long[] array = new long[newSize]; - System.arraycopy(mGroupMetadata, 0, array, 0, mGroupCount); - mGroupMetadata = array; - } - - long metadata = ((long)size << 32) | cursorPosition; - if (expanded) { - metadata |= EXPANDED_GROUP_MASK; + public void addGroup(int cursorPosition, int groupSize) { + int lastIndex = mGroupMetadata.size() - 1; + if (lastIndex < 0 || cursorPosition <= mGroupMetadata.keyAt(lastIndex)) { + mGroupMetadata.put(cursorPosition, groupSize); + } else { + // Optimization to avoid binary search if adding groups in ascending cursor position. + mGroupMetadata.append(cursorPosition, groupSize); } - mGroupMetadata[mGroupCount++] = metadata; - } - - // Copy/paste from ArrayUtils - private int idealLongArraySize(int need) { - return idealByteArraySize(need * 8) / 8; - } - - // Copy/paste from ArrayUtils - private int idealByteArraySize(int need) { - for (int i = 4; i < 32; i++) - if (need <= (1 << i) - 12) - return (1 << i) - 12; - - return need; } @Override public int getItemCount() { - if (mCursor == null) { - return 0; - } - - if (mCount != -1) { - return mCount; - } - - int cursorPosition = 0; - int count = 0; - for (int i = 0; i < mGroupCount; i++) { - long metadata = mGroupMetadata[i]; - int offset = (int)(metadata & GROUP_OFFSET_MASK); - boolean expanded = (metadata & EXPANDED_GROUP_MASK) != 0; - int size = (int)((metadata & GROUP_SIZE_MASK) >> 32); - - count += (offset - cursorPosition); - - if (expanded) { - count += size + 1; - } else { - count++; - } - - cursorPosition = offset + size; - } - - mCount = count + mCursor.getCount() - cursorPosition; - return mCount; + return mItemCount; } /** - * Figures out whether the item at the specified position represents a - * stand-alone element, a group or a group child. Also computes the - * corresponding cursor position. + * Given the position of a list item, returns the size of the group of items corresponding to + * that position. */ - public void obtainPositionMetadata(PositionMetadata metadata, int position) { - // If the description object already contains requested information, just return - if (metadata.listPosition == position) { - return; - } - - int listPosition = 0; - int cursorPosition = 0; - int firstGroupToCheck = 0; - - // Check cache for the supplied position. What we are looking for is - // the group descriptor immediately preceding the supplied position. - // Once we have that, we will be able to tell whether the position - // is the header of the group, a member of the group or a standalone item. - if (mLastCachedListPosition != -1) { - if (position <= mLastCachedListPosition) { - - // Have SparceIntArray do a binary search for us. - int index = mPositionCache.indexOfKey(position); - - // If we get back a positive number, the position corresponds to - // a group header. - if (index < 0) { - - // We had a cache miss, but we did obtain valuable information anyway. - // The negative number will allow us to compute the location of - // the group header immediately preceding the supplied position. - index = ~index - 1; - - if (index >= mPositionCache.size()) { - index--; - } - } - - // A non-negative index gives us the position of the group header - // corresponding or preceding the position, so we can - // search for the group information at the supplied position - // starting with the cached group we just found - if (index >= 0) { - listPosition = mPositionCache.keyAt(index); - firstGroupToCheck = mPositionCache.valueAt(index); - long descriptor = mGroupMetadata[firstGroupToCheck]; - cursorPosition = (int)(descriptor & GROUP_OFFSET_MASK); - } - } else { - - // If we haven't examined groups beyond the supplied position, - // we will start where we left off previously - firstGroupToCheck = mLastCachedGroup; - listPosition = mLastCachedListPosition; - cursorPosition = mLastCachedCursorPosition; - } - } - - for (int i = firstGroupToCheck; i < mGroupCount; i++) { - long group = mGroupMetadata[i]; - int offset = (int)(group & GROUP_OFFSET_MASK); - - // Move pointers to the beginning of the group - listPosition += (offset - cursorPosition); - cursorPosition = offset; - - if (i > mLastCachedGroup) { - mPositionCache.append(listPosition, i); - mLastCachedListPosition = listPosition; - mLastCachedCursorPosition = cursorPosition; - mLastCachedGroup = i; - } - - // Now we have several possibilities: - // A) The requested position precedes the group - if (position < listPosition) { - metadata.itemType = ITEM_TYPE_STANDALONE; - metadata.cursorPosition = cursorPosition - (listPosition - position); - metadata.childCount = 1; - return; - } - - boolean expanded = (group & EXPANDED_GROUP_MASK) != 0; - int size = (int) ((group & GROUP_SIZE_MASK) >> 32); - - // B) The requested position is a group header - if (position == listPosition) { - metadata.itemType = ITEM_TYPE_GROUP_HEADER; - metadata.groupPosition = i; - metadata.isExpanded = expanded; - metadata.childCount = size; - metadata.cursorPosition = offset; - return; - } - - if (expanded) { - // C) The requested position is an element in the expanded group - if (position < listPosition + size + 1) { - metadata.itemType = ITEM_TYPE_IN_GROUP; - metadata.cursorPosition = cursorPosition + (position - listPosition) - 1; - return; - } - - // D) The element is past the expanded group - listPosition += size + 1; - } else { - - // E) The element is past the collapsed group - listPosition++; - } - - // Move cursor past the group - cursorPosition += size; + public int getGroupSize(int listPosition) { + if (listPosition < 0 || listPosition >= mGroupMetadata.size()) { + return 0; } - // The required item is past the last group - metadata.itemType = ITEM_TYPE_STANDALONE; - metadata.cursorPosition = cursorPosition + (position - listPosition); - metadata.childCount = 1; + return mGroupMetadata.valueAt(listPosition); } /** - * Returns true if the specified position in the list corresponds to a - * group header. + * Given the position of a list item, returns the the first item in the group of items + * corresponding to that position. */ - public boolean isGroupHeader(int position) { - obtainPositionMetadata(mPositionMetadata, position); - return mPositionMetadata.itemType == ITEM_TYPE_GROUP_HEADER; - } - - /** - * Given a position of a groups header in the list, returns the size of - * the corresponding group. - */ - public int getGroupSize(int position) { - obtainPositionMetadata(mPositionMetadata, position); - return mPositionMetadata.childCount; - } - - /** - * Mark group as expanded if it is collapsed and vice versa. - */ - @NeededForTesting - public void toggleGroup(int position) { - obtainPositionMetadata(mPositionMetadata, position); - if (mPositionMetadata.itemType != ITEM_TYPE_GROUP_HEADER) { - throw new IllegalArgumentException("Not a group at position " + position); - } - - if (mPositionMetadata.isExpanded) { - mGroupMetadata[mPositionMetadata.groupPosition] &= ~EXPANDED_GROUP_MASK; - } else { - mGroupMetadata[mPositionMetadata.groupPosition] |= EXPANDED_GROUP_MASK; - } - resetCache(); - notifyDataSetChanged(); - } - - public int getItemViewType(int position) { - obtainPositionMetadata(mPositionMetadata, position); - return mPositionMetadata.itemType; - } - - public Object getItem(int position) { - if (mCursor == null) { + public Object getItem(int listPosition) { + if (mCursor == null || listPosition < 0 || listPosition >= mGroupMetadata.size()) { return null; } - obtainPositionMetadata(mPositionMetadata, position); - if (mCursor.moveToPosition(mPositionMetadata.cursorPosition)) { + int cursorPosition = mGroupMetadata.keyAt(listPosition); + if (mCursor.moveToPosition(cursorPosition)) { return mCursor; } else { return null; } } - public long getItemId(int position) { - Object item = getItem(position); - if (item != null) { - return mCursor.getLong(mRowIdColumnIndex); - } else { - return -1; - } + private void reset() { + mItemCount = 0; + mGroupMetadata = new SparseIntArray(); } } diff --git a/src/com/android/dialer/calllog/IntentProvider.java b/src/com/android/dialer/calllog/IntentProvider.java index a11d00bc2..773436be4 100644 --- a/src/com/android/dialer/calllog/IntentProvider.java +++ b/src/com/android/dialer/calllog/IntentProvider.java @@ -21,17 +21,17 @@ import android.content.ContentUris; import android.content.Context; import android.content.Intent; import android.net.Uri; -import android.provider.CallLog.Calls; import android.provider.ContactsContract; import android.telecom.PhoneAccountHandle; +import com.android.contacts.common.CallUtil; import com.android.contacts.common.model.Contact; import com.android.contacts.common.model.ContactLoader; import com.android.dialer.CallDetailActivity; -import com.android.dialer.DialtactsActivity; -import com.android.dialer.PhoneCallDetails; import com.android.dialer.util.IntentUtil; +import com.android.dialer.util.IntentUtil.CallIntentBuilder; import com.android.dialer.util.TelecomUtil; +import com.android.incallui.Call.LogState; import java.util.ArrayList; @@ -55,7 +55,10 @@ public abstract class IntentProvider { return new IntentProvider() { @Override public Intent getIntent(Context context) { - return IntentUtil.getCallIntent(number, accountHandle); + return new CallIntentBuilder(number) + .setPhoneAccountHandle(accountHandle) + .setCallInitiationType(LogState.INITIATION_CALL_LOG) + .build(); } }; } @@ -69,7 +72,11 @@ public abstract class IntentProvider { return new IntentProvider() { @Override public Intent getIntent(Context context) { - return IntentUtil.getVideoCallIntent(number, accountHandle); + return new CallIntentBuilder(number) + .setPhoneAccountHandle(accountHandle) + .setCallInitiationType(LogState.INITIATION_CALL_LOG) + .setIsVideoCall(true) + .build(); } }; } @@ -78,7 +85,9 @@ public abstract class IntentProvider { return new IntentProvider() { @Override public Intent getIntent(Context context) { - return IntentUtil.getVoicemailIntent(); + return new CallIntentBuilder(CallUtil.getVoicemailUri()) + .setCallInitiationType(LogState.INITIATION_CALL_LOG) + .build(); } }; } diff --git a/src/com/android/dialer/calllog/MissedCallNotificationReceiver.java b/src/com/android/dialer/calllog/MissedCallNotificationReceiver.java new file mode 100644 index 000000000..86d6cb9fb --- /dev/null +++ b/src/com/android/dialer/calllog/MissedCallNotificationReceiver.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2016 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.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.telecom.TelecomManager; +import android.util.Log; + +import com.android.dialer.calllog.CallLogNotificationsService; + +/** + * Receives broadcasts that should trigger a refresh of the missed call notification. This includes + * both an explicit broadcast from Telecom and a reboot. + */ +public class MissedCallNotificationReceiver extends BroadcastReceiver { + //TODO: Use compat class for these methods. + public static final String ACTION_SHOW_MISSED_CALLS_NOTIFICATION = + "android.telecom.action.SHOW_MISSED_CALLS_NOTIFICATION"; + + public static final String EXTRA_NOTIFICATION_COUNT = + "android.telecom.extra.NOTIFICATION_COUNT"; + + public static final String EXTRA_NOTIFICATION_PHONE_NUMBER = + "android.telecom.extra.NOTIFICATION_PHONE_NUMBER"; + + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (!ACTION_SHOW_MISSED_CALLS_NOTIFICATION.equals(action)) { + return; + } + + int count = intent.getIntExtra(EXTRA_NOTIFICATION_COUNT, + CallLogNotificationsService.UNKNOWN_MISSED_CALL_COUNT); + String number = intent.getStringExtra(EXTRA_NOTIFICATION_PHONE_NUMBER); + CallLogNotificationsService.updateMissedCallNotifications(context, count, number); + } +} diff --git a/src/com/android/dialer/calllog/MissedCallNotifier.java b/src/com/android/dialer/calllog/MissedCallNotifier.java new file mode 100644 index 000000000..98d02d095 --- /dev/null +++ b/src/com/android/dialer/calllog/MissedCallNotifier.java @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2016 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.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.os.AsyncTask; +import android.provider.CallLog.Calls; +import android.text.TextUtils; +import android.util.Log; + +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.util.PhoneNumberHelper; +import com.android.dialer.DialtactsActivity; +import com.android.dialer.R; +import com.android.dialer.calllog.CallLogNotificationsHelper.NewCall; +import com.android.dialer.contactinfo.ContactPhotoLoader; +import com.android.dialer.compat.UserManagerCompat; +import com.android.dialer.list.ListsFragment; +import com.android.dialer.util.DialerUtils; +import com.android.dialer.util.IntentUtil; +import com.android.dialer.util.IntentUtil.CallIntentBuilder; + +import java.util.List; + +/** + * Creates a notification for calls that the user missed (neither answered nor rejected). + * + */ +public class MissedCallNotifier { + public static final String TAG = "MissedCallNotifier"; + + /** The tag used to identify notifications from this class. */ + private static final String NOTIFICATION_TAG = "MissedCallNotifier"; + /** The identifier of the notification of new missed calls. */ + private static final int NOTIFICATION_ID = 1; + /** Preference file key for number of missed calls. */ + private static final String MISSED_CALL_COUNT = "missed_call_count"; + + private static MissedCallNotifier sInstance; + private Context mContext; + + /** Returns the singleton instance of the {@link MissedCallNotifier}. */ + public static MissedCallNotifier getInstance(Context context) { + if (sInstance == null) { + sInstance = new MissedCallNotifier(context); + } + return sInstance; + } + + private MissedCallNotifier(Context context) { + mContext = context; + } + + public void updateMissedCallNotification(int count, String number) { + final int titleResId; + final String expandedText; // The text in the notification's line 1 and 2. + + final List<NewCall> newCalls = + CallLogNotificationsHelper.getInstance(mContext).getNewMissedCalls(); + + if (count == CallLogNotificationsService.UNKNOWN_MISSED_CALL_COUNT) { + if (newCalls == null) { + // If the intent did not contain a count, and we are unable to get a count from the + // call log, then no notification can be shown. + return; + } + count = newCalls.size(); + } + + if (count == 0) { + // No voicemails to notify about: clear the notification. + clearMissedCalls(); + return; + } + + // The call log has been updated, use that information preferentially. + boolean useCallLog = newCalls != null && newCalls.size() == count; + NewCall newestCall = useCallLog ? newCalls.get(0) : null; + long timeMs = useCallLog ? newestCall.dateMs : System.currentTimeMillis(); + + Notification.Builder builder = new Notification.Builder(mContext); + // Display the first line of the notification: + // 1 missed call: <caller name || handle> + // More than 1 missed call: <number of calls> + "missed calls" + if (count == 1) { + //TODO: look up caller ID that is not in contacts. + ContactInfo contactInfo = CallLogNotificationsHelper.getInstance(mContext) + .getContactInfo(useCallLog ? newestCall.number : number, + useCallLog ? newestCall.numberPresentation + : Calls.PRESENTATION_ALLOWED, + useCallLog ? newestCall.countryIso : null); + + titleResId = contactInfo.userType == ContactsUtils.USER_TYPE_WORK + ? R.string.notification_missedWorkCallTitle + : R.string.notification_missedCallTitle; + + expandedText = contactInfo.name; + ContactPhotoLoader loader = new ContactPhotoLoader(mContext, contactInfo); + Bitmap photoIcon = loader.loadPhotoIcon(); + if (photoIcon != null) { + builder.setLargeIcon(photoIcon); + } + } else { + titleResId = R.string.notification_missedCallsTitle; + expandedText = + mContext.getString(R.string.notification_missedCallsMsg, count); + } + + // Create a public viewable version of the notification, suitable for display when sensitive + // notification content is hidden. + Notification.Builder publicBuilder = new Notification.Builder(mContext); + publicBuilder.setSmallIcon(android.R.drawable.stat_notify_missed_call) + .setColor(mContext.getResources().getColor(R.color.dialer_theme_color)) + // Show "Phone" for notification title. + .setContentTitle(mContext.getText(R.string.userCallActivityLabel)) + // Notification details shows that there are missed call(s), but does not reveal + // the missed caller information. + .setContentText(mContext.getText(titleResId)) + .setContentIntent(createCallLogPendingIntent()) + .setAutoCancel(true) + .setWhen(timeMs) + .setDeleteIntent(createClearMissedCallsPendingIntent()); + + // Create the notification suitable for display when sensitive information is showing. + builder.setSmallIcon(android.R.drawable.stat_notify_missed_call) + .setColor(mContext.getResources().getColor(R.color.dialer_theme_color)) + .setContentTitle(mContext.getText(titleResId)) + .setContentText(expandedText) + .setContentIntent(createCallLogPendingIntent()) + .setAutoCancel(true) + .setWhen(timeMs) + .setDeleteIntent(createClearMissedCallsPendingIntent()) + // Include a public version of the notification to be shown when the missed call + // notification is shown on the user's lock screen and they have chosen to hide + // sensitive notification information. + .setPublicVersion(publicBuilder.build()); + + // Add additional actions when there is only 1 missed call and the user isn't locked + if (UserManagerCompat.isUserUnlocked(mContext) && count == 1) { + if (!TextUtils.isEmpty(number) + && !TextUtils.equals( + number, mContext.getString(R.string.handle_restricted))) { + builder.addAction(R.drawable.ic_phone_24dp, + mContext.getString(R.string.notification_missedCall_call_back), + createCallBackPendingIntent(number)); + + if (!PhoneNumberHelper.isUriNumber(number)) { + builder.addAction(R.drawable.ic_message_24dp, + mContext.getString(R.string.notification_missedCall_message), + createSendSmsFromNotificationPendingIntent(number)); + } + } + } + + Notification notification = builder.build(); + configureLedOnNotification(notification); + + Log.i(TAG, "Adding missed call notification."); + getNotificationMgr().notify(NOTIFICATION_TAG, NOTIFICATION_ID, notification); + } + + private void clearMissedCalls() { + AsyncTask.execute(new Runnable() { + @Override + public void run() { + // Call log is only accessible when unlocked. If that's the case, clear the list of + // new missed calls from the call log. + if (UserManagerCompat.isUserUnlocked(mContext)) { + ContentValues values = new ContentValues(); + values.put(Calls.NEW, 0); + values.put(Calls.IS_READ, 1); + StringBuilder where = new StringBuilder(); + where.append(Calls.NEW); + where.append(" = 1 AND "); + where.append(Calls.TYPE); + where.append(" = ?"); + try { + mContext.getContentResolver().update(Calls.CONTENT_URI, values, + where.toString(), new String[]{Integer.toString(Calls. + MISSED_TYPE)}); + } catch (IllegalArgumentException e) { + Log.w(TAG, "ContactsProvider update command failed", e); + } + } + getNotificationMgr().cancel(NOTIFICATION_TAG, NOTIFICATION_ID); + } + }); + } + + /** + * Trigger an intent to make a call from a missed call number. + */ + public void callBackFromMissedCall(String number) { + closeSystemDialogs(mContext); + CallLogNotificationsHelper.removeMissedCallNotifications(mContext); + DialerUtils.startActivityWithErrorToast( + mContext, + new CallIntentBuilder(number) + .build() + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + } + + /** + * Trigger an intent to send an sms from a missed call number. + */ + public void sendSmsFromMissedCall(String number) { + closeSystemDialogs(mContext); + CallLogNotificationsHelper.removeMissedCallNotifications(mContext); + DialerUtils.startActivityWithErrorToast( + mContext, + IntentUtil.getSendSmsIntent(number).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + } + + /** + * Creates a new pending intent that sends the user to the call log. + * + * @return The pending intent. + */ + private PendingIntent createCallLogPendingIntent() { + Intent contentIntent = new Intent(mContext, DialtactsActivity.class); + contentIntent.putExtra(DialtactsActivity.EXTRA_SHOW_TAB, ListsFragment.TAB_INDEX_HISTORY); + return PendingIntent.getActivity( + mContext, 0, contentIntent,PendingIntent.FLAG_UPDATE_CURRENT); + } + + /** Creates a pending intent that marks all new missed calls as old. */ + private PendingIntent createClearMissedCallsPendingIntent() { + Intent intent = new Intent(mContext, CallLogNotificationsService.class); + intent.setAction(CallLogNotificationsService.ACTION_MARK_NEW_MISSED_CALLS_AS_OLD); + return PendingIntent.getService(mContext, 0, intent, 0); + } + + private PendingIntent createCallBackPendingIntent(String number) { + Intent intent = new Intent(mContext, CallLogNotificationsService.class); + intent.setAction( + CallLogNotificationsService.ACTION_CALL_BACK_FROM_MISSED_CALL_NOTIFICATION); + intent.putExtra(CallLogNotificationsService.EXTRA_MISSED_CALL_NUMBER, number); + return PendingIntent.getService(mContext, 0, intent, 0); + } + + private PendingIntent createSendSmsFromNotificationPendingIntent(String number) { + Intent intent = new Intent(mContext, CallLogNotificationsService.class); + intent.setAction( + CallLogNotificationsService.ACTION_SEND_SMS_FROM_MISSED_CALL_NOTIFICATION); + intent.putExtra(CallLogNotificationsService.EXTRA_MISSED_CALL_NUMBER, number); + return PendingIntent.getService(mContext, 0, intent, 0); + } + + /** + * Configures a notification to emit the blinky notification light. + */ + private void configureLedOnNotification(Notification notification) { + notification.flags |= Notification.FLAG_SHOW_LIGHTS; + notification.defaults |= Notification.DEFAULT_LIGHTS; + } + + /** + * Closes open system dialogs and the notification shade. + */ + private void closeSystemDialogs(Context context) { + context.sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)); + } + + private NotificationManager getNotificationMgr() { + return (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); + } +} diff --git a/src/com/android/dialer/calllog/PhoneAccountUtils.java b/src/com/android/dialer/calllog/PhoneAccountUtils.java index 143d13e86..8c3985b3f 100644 --- a/src/com/android/dialer/calllog/PhoneAccountUtils.java +++ b/src/com/android/dialer/calllog/PhoneAccountUtils.java @@ -18,11 +18,14 @@ package com.android.dialer.calllog; import android.content.ComponentName; import android.content.Context; +import android.support.annotation.Nullable; import android.telecom.PhoneAccount; import android.telecom.PhoneAccountHandle; -import android.telecom.TelecomManager; import android.text.TextUtils; +import com.android.contacts.common.compat.CompatUtils; +import com.android.dialer.util.TelecomUtil; + import java.util.ArrayList; import java.util.List; @@ -34,13 +37,11 @@ public class PhoneAccountUtils { * Return a list of phone accounts that are subscription/SIM accounts. */ public static List<PhoneAccountHandle> getSubscriptionPhoneAccounts(Context context) { - final TelecomManager telecomManager = - (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); - List<PhoneAccountHandle> subscriptionAccountHandles = new ArrayList<PhoneAccountHandle>(); - List<PhoneAccountHandle> accountHandles = telecomManager.getCallCapablePhoneAccounts(); + final List<PhoneAccountHandle> accountHandles = + TelecomUtil.getCallCapablePhoneAccounts(context); for (PhoneAccountHandle accountHandle : accountHandles) { - PhoneAccount account = telecomManager.getPhoneAccount(accountHandle); + PhoneAccount account = TelecomUtil.getPhoneAccount(context, accountHandle); if (account.hasCapabilities(PhoneAccount.CAPABILITY_SIM_SUBSCRIPTION)) { subscriptionAccountHandles.add(accountHandle); } @@ -51,7 +52,9 @@ public class PhoneAccountUtils { /** * Compose PhoneAccount object from component name and account id. */ - public static PhoneAccountHandle getAccount(String componentString, String accountId) { + @Nullable + public static PhoneAccountHandle getAccount(@Nullable String componentString, + @Nullable String accountId) { if (TextUtils.isEmpty(componentString) || TextUtils.isEmpty(accountId)) { return null; } @@ -62,7 +65,9 @@ public class PhoneAccountUtils { /** * Extract account label from PhoneAccount object. */ - public static String getAccountLabel(Context context, PhoneAccountHandle accountHandle) { + @Nullable + public static String getAccountLabel(Context context, + @Nullable PhoneAccountHandle accountHandle) { PhoneAccount account = getAccountOrNull(context, accountHandle); if (account != null && account.getLabel() != null) { return account.getLabel().toString(); @@ -73,10 +78,8 @@ public class PhoneAccountUtils { /** * Extract account color from PhoneAccount object. */ - public static int getAccountColor(Context context, PhoneAccountHandle accountHandle) { - TelecomManager telecomManager = - (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); - final PhoneAccount account = telecomManager.getPhoneAccount(accountHandle); + public static int getAccountColor(Context context, @Nullable PhoneAccountHandle accountHandle) { + final PhoneAccount account = TelecomUtil.getPhoneAccount(context, accountHandle); // For single-sim devices the PhoneAccount will be NO_HIGHLIGHT_COLOR by default, so it is // safe to always use the account highlight color. @@ -89,10 +92,8 @@ public class PhoneAccountUtils { * @return {@code true} if call subjects are supported, {@code false} otherwise. */ public static boolean getAccountSupportsCallSubject(Context context, - PhoneAccountHandle accountHandle) { - TelecomManager telecomManager = - (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); - final PhoneAccount account = telecomManager.getPhoneAccount(accountHandle); + @Nullable PhoneAccountHandle accountHandle) { + final PhoneAccount account = TelecomUtil.getPhoneAccount(context, accountHandle); return account == null ? false : account.hasCapabilities(PhoneAccount.CAPABILITY_CALL_SUBJECT); @@ -102,14 +103,12 @@ public class PhoneAccountUtils { * Retrieve the account metadata, but if the account does not exist or the device has only a * single registered and enabled account, return null. */ - static PhoneAccount getAccountOrNull(Context context, - PhoneAccountHandle accountHandle) { - TelecomManager telecomManager = - (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); - final PhoneAccount account = telecomManager.getPhoneAccount(accountHandle); - if (telecomManager.getCallCapablePhoneAccounts().size() <= 1) { + @Nullable + private static PhoneAccount getAccountOrNull(Context context, + @Nullable PhoneAccountHandle accountHandle) { + if (TelecomUtil.getCallCapablePhoneAccounts(context).size() <= 1) { return null; } - return account; + return TelecomUtil.getPhoneAccount(context, accountHandle); } } diff --git a/src/com/android/dialer/calllog/PhoneCallDetailsHelper.java b/src/com/android/dialer/calllog/PhoneCallDetailsHelper.java index df5fe0606..7b149e24e 100644 --- a/src/com/android/dialer/calllog/PhoneCallDetailsHelper.java +++ b/src/com/android/dialer/calllog/PhoneCallDetailsHelper.java @@ -16,13 +16,15 @@ package com.android.dialer.calllog; +import com.google.common.base.MoreObjects; +import com.google.common.collect.Lists; + import android.content.Context; import android.content.res.Resources; import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.provider.CallLog; import android.provider.CallLog.Calls; import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.support.v4.content.ContextCompat; import android.telecom.PhoneAccount; import android.text.TextUtils; import android.text.format.DateUtils; @@ -33,17 +35,18 @@ import com.android.contacts.common.testing.NeededForTesting; import com.android.contacts.common.util.PhoneNumberHelper; import com.android.dialer.PhoneCallDetails; import com.android.dialer.R; +import com.android.dialer.calllog.calllogcache.CallLogCache; import com.android.dialer.util.DialerUtils; -import com.android.dialer.util.PhoneNumberUtil; - -import com.google.common.collect.Lists; import java.util.ArrayList; +import java.util.Calendar; +import java.util.concurrent.TimeUnit; /** * Helper class to fill in the views in {@link PhoneCallDetailsViews}. */ public class PhoneCallDetailsHelper { + /** The maximum number of icons will be shown to represent the call types in a group. */ private static final int MAX_CALL_TYPE_ICONS = 3; @@ -51,7 +54,13 @@ public class PhoneCallDetailsHelper { private final Resources mResources; /** The injected current time in milliseconds since the epoch. Used only by tests. */ private Long mCurrentTimeMillisForTest; - private final TelecomCallLogCache mTelecomCallLogCache; + + private CharSequence mPhoneTypeLabelForTest; + + private final CallLogCache mCallLogCache; + + /** Calendar used to construct dates */ + private final Calendar mCalendar; /** * List of items to be concatenated together for accessibility descriptions @@ -68,10 +77,11 @@ public class PhoneCallDetailsHelper { public PhoneCallDetailsHelper( Context context, Resources resources, - TelecomCallLogCache telecomCallLogCache) { + CallLogCache callLogCache) { mContext = context; mResources = resources; - mTelecomCallLogCache = telecomCallLogCache; + mCallLogCache = callLogCache; + mCalendar = Calendar.getInstance(); } /** Fills the call details views with content. */ @@ -101,18 +111,16 @@ public class PhoneCallDetailsHelper { callCount = null; } - CharSequence callLocationAndDate = getCallLocationAndDate(details); - - // Set the call count, location and date. - setCallCountAndDate(views, callCount, callLocationAndDate); + // Set the call count, location, date and if voicemail, set the duration. + setDetailText(views, callCount, details); // Set the account label if it exists. - String accountLabel = mTelecomCallLogCache.getAccountLabel(details.accountHandle); + String accountLabel = mCallLogCache.getAccountLabel(details.accountHandle); if (accountLabel != null) { views.callAccountLabel.setVisibility(View.VISIBLE); views.callAccountLabel.setText(accountLabel); - int color = PhoneAccountUtils.getAccountColor(mContext, details.accountHandle); + int color = mCallLogCache.getAccountColor(details.accountHandle); if (color == PhoneAccount.NO_HIGHLIGHT_COLOR) { int defaultColor = R.color.dialtacts_secondary_text_color; views.callAccountLabel.setTextColor(mContext.getResources().getColor(defaultColor)); @@ -125,22 +133,19 @@ public class PhoneCallDetailsHelper { final CharSequence nameText; final CharSequence displayNumber = details.displayNumber; - if (TextUtils.isEmpty(details.name)) { + if (TextUtils.isEmpty(details.getPreferredName())) { nameText = displayNumber; // We have a real phone number as "nameView" so make it always LTR views.nameView.setTextDirection(View.TEXT_DIRECTION_LTR); } else { - nameText = details.name; + nameText = details.getPreferredName(); } views.nameView.setText(nameText); - if (isVoicemail && !TextUtils.isEmpty(details.transcription)) { - views.voicemailTranscriptionView.setText(details.transcription); - views.voicemailTranscriptionView.setVisibility(View.VISIBLE); - } else { - views.voicemailTranscriptionView.setText(null); - views.voicemailTranscriptionView.setVisibility(View.GONE); + if (isVoicemail) { + views.voicemailTranscriptionView.setText(TextUtils.isEmpty(details.transcription) ? null + : details.transcription); } // Bold if not read @@ -148,10 +153,13 @@ public class PhoneCallDetailsHelper { views.nameView.setTypeface(typeface); views.voicemailTranscriptionView.setTypeface(typeface); views.callLocationAndDate.setTypeface(typeface); + views.callLocationAndDate.setTextColor(ContextCompat.getColor(mContext, details.isRead ? + R.color.call_log_detail_color : R.color.call_log_unread_text_color)); } /** - * Builds a string containing the call location and date. + * Builds a string containing the call location and date. For voicemail logs only the call date + * is returned because location information is displayed in the call action button * * @param details The call details. * @return The call location and date string. @@ -159,15 +167,18 @@ public class PhoneCallDetailsHelper { private CharSequence getCallLocationAndDate(PhoneCallDetails details) { mDescriptionItems.clear(); - // Get type of call (ie mobile, home, etc) if known, or the caller's location. - CharSequence callTypeOrLocation = getCallTypeOrLocation(details); + if (details.callTypes[0] != Calls.VOICEMAIL_TYPE) { + // Get type of call (ie mobile, home, etc) if known, or the caller's location. + CharSequence callTypeOrLocation = getCallTypeOrLocation(details); - // Only add the call type or location if its not empty. It will be empty for unknown - // callers. - if (!TextUtils.isEmpty(callTypeOrLocation)) { - mDescriptionItems.add(callTypeOrLocation); + // Only add the call type or location if its not empty. It will be empty for unknown + // callers. + if (!TextUtils.isEmpty(callTypeOrLocation)) { + mDescriptionItems.add(callTypeOrLocation); + } } - // The date of this call, relative to the current time. + + // The date of this call mDescriptionItems.add(getCallDate(details)); // Create a comma separated list from the call type or location, and call date. @@ -178,6 +189,7 @@ public class PhoneCallDetailsHelper { * For a call, if there is an associated contact for the caller, return the known call type * (e.g. mobile, home, work). If there is no associated contact, attempt to use the caller's * location if known. + * * @param details Call details to use. * @return Type of call (mobile/home) if known, or the location of the caller (if known). */ @@ -186,43 +198,94 @@ public class PhoneCallDetailsHelper { // Only show a label if the number is shown and it is not a SIP address. if (!TextUtils.isEmpty(details.number) && !PhoneNumberHelper.isUriNumber(details.number.toString()) - && !mTelecomCallLogCache.isVoicemailNumber(details.accountHandle, details.number)) { + && !mCallLogCache.isVoicemailNumber(details.accountHandle, details.number)) { - if (TextUtils.isEmpty(details.name) && !TextUtils.isEmpty(details.geocode)) { + if (TextUtils.isEmpty(details.namePrimary) && !TextUtils.isEmpty(details.geocode)) { numberFormattedLabel = details.geocode; } else if (!(details.numberType == Phone.TYPE_CUSTOM && TextUtils.isEmpty(details.numberLabel))) { // Get type label only if it will not be "Custom" because of an empty number label. - numberFormattedLabel = Phone.getTypeLabel( - mResources, details.numberType, details.numberLabel); + numberFormattedLabel = MoreObjects.firstNonNull(mPhoneTypeLabelForTest, + Phone.getTypeLabel(mResources, details.numberType, details.numberLabel)); } } - if (!TextUtils.isEmpty(details.name) && TextUtils.isEmpty(numberFormattedLabel)) { + if (!TextUtils.isEmpty(details.namePrimary) && TextUtils.isEmpty(numberFormattedLabel)) { numberFormattedLabel = details.displayNumber; } return numberFormattedLabel; } + @NeededForTesting + public void setPhoneTypeLabelForTest(CharSequence phoneTypeLabel) { + this.mPhoneTypeLabelForTest = phoneTypeLabel; + } + /** - * Get the call date/time of the call, relative to the current time. - * e.g. 3 minutes ago + * Get the call date/time of the call. For the call log this is relative to the current time. + * e.g. 3 minutes ago. For voicemail, see {@link #getGranularDateTime(PhoneCallDetails)} + * * @param details Call details to use. * @return String representing when the call occurred. */ public CharSequence getCallDate(PhoneCallDetails details) { - return DateUtils.getRelativeTimeSpanString(details.date, - getCurrentTimeMillis(), - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE); + if (details.callTypes[0] == Calls.VOICEMAIL_TYPE) { + return getGranularDateTime(details); + } + + return DateUtils.getRelativeTimeSpanString(details.date, getCurrentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE); + } + + /** + * Get the granular version of the call date/time of the call. The result is always in the form + * 'DATE at TIME'. The date value changes based on when the call was created. + * + * If created today, DATE is 'Today' + * If created this year, DATE is 'MMM dd' + * Otherwise, DATE is 'MMM dd, yyyy' + * + * TIME is the localized time format, e.g. 'hh:mm a' or 'HH:mm' + * + * @param details Call details to use + * @return String representing when the call occurred + */ + public CharSequence getGranularDateTime(PhoneCallDetails details) { + return mResources.getString(R.string.voicemailCallLogDateTimeFormat, + getGranularDate(details.date), + DateUtils.formatDateTime(mContext, details.date, DateUtils.FORMAT_SHOW_TIME)); + } + + /** + * Get the granular version of the call date. See {@link #getGranularDateTime(PhoneCallDetails)} + */ + private String getGranularDate(long date) { + if (DateUtils.isToday(date)) { + return mResources.getString(R.string.voicemailCallLogToday); + } + return DateUtils.formatDateTime(mContext, date, DateUtils.FORMAT_SHOW_DATE + | DateUtils.FORMAT_ABBREV_MONTH + | (shouldShowYear(date) ? DateUtils.FORMAT_SHOW_YEAR : DateUtils.FORMAT_NO_YEAR)); + } + + /** + * Determines whether the year should be shown for the given date + * + * @return {@code true} if date is within the current year, {@code false} otherwise + */ + private boolean shouldShowYear(long date) { + mCalendar.setTimeInMillis(getCurrentTimeMillis()); + int currentYear = mCalendar.get(Calendar.YEAR); + mCalendar.setTimeInMillis(date); + return currentYear != mCalendar.get(Calendar.YEAR); } /** Sets the text of the header view for the details page of a phone call. */ @NeededForTesting public void setCallDetailsHeader(TextView nameView, PhoneCallDetails details) { final CharSequence nameText; - if (!TextUtils.isEmpty(details.name)) { - nameText = details.name; + if (!TextUtils.isEmpty(details.namePrimary)) { + nameText = details.namePrimary; } else if (!TextUtils.isEmpty(details.displayNumber)) { nameText = details.displayNumber; } else { @@ -250,10 +313,11 @@ public class PhoneCallDetailsHelper { } } - /** Sets the call count and date. */ - private void setCallCountAndDate(PhoneCallDetailsViews views, Integer callCount, - CharSequence dateText) { + /** Sets the call count, date, and if it is a voicemail, sets the duration. */ + private void setDetailText(PhoneCallDetailsViews views, Integer callCount, + PhoneCallDetails details) { // Combine the count (if present) and the date. + CharSequence dateText = getCallLocationAndDate(details); final CharSequence text; if (callCount != null) { text = mResources.getString( @@ -262,6 +326,22 @@ public class PhoneCallDetailsHelper { text = dateText; } - views.callLocationAndDate.setText(text); + if (details.callTypes[0] == Calls.VOICEMAIL_TYPE && details.duration > 0) { + views.callLocationAndDate.setText(mResources.getString( + R.string.voicemailCallLogDateTimeFormatWithDuration, text, + getVoicemailDuration(details))); + } else { + views.callLocationAndDate.setText(text); + } + + } + + private String getVoicemailDuration(PhoneCallDetails details) { + long minutes = TimeUnit.SECONDS.toMinutes(details.duration); + long seconds = details.duration - TimeUnit.MINUTES.toSeconds(minutes); + if (minutes > 99) { + minutes = 99; + } + return mResources.getString(R.string.voicemailDurationFormat, minutes, seconds); } } diff --git a/src/com/android/dialer/calllog/PhoneNumberDisplayUtil.java b/src/com/android/dialer/calllog/PhoneNumberDisplayUtil.java index 5030efd48..5b1fc9e3a 100644 --- a/src/com/android/dialer/calllog/PhoneNumberDisplayUtil.java +++ b/src/com/android/dialer/calllog/PhoneNumberDisplayUtil.java @@ -17,10 +17,8 @@ package com.android.dialer.calllog; import android.content.Context; -import android.content.res.Resources; import android.provider.CallLog.Calls; import android.text.TextUtils; -import android.util.Log; import com.android.dialer.R; import com.android.dialer.util.PhoneNumberUtil; @@ -67,6 +65,7 @@ public class PhoneNumberDisplayUtil { CharSequence number, int presentation, CharSequence formattedNumber, + CharSequence postDialDigits, boolean isVoicemail) { final CharSequence displayName = getDisplayName(context, number, presentation, isVoicemail); if (!TextUtils.isEmpty(displayName)) { @@ -76,9 +75,9 @@ public class PhoneNumberDisplayUtil { if (!TextUtils.isEmpty(formattedNumber)) { return formattedNumber; } else if (!TextUtils.isEmpty(number)) { - return number; + return number.toString() + postDialDigits; } else { - return ""; + return context.getResources().getString(R.string.unknown); } } } diff --git a/src/com/android/dialer/calllog/PhoneQuery.java b/src/com/android/dialer/calllog/PhoneQuery.java index 719052204..f1f14c66e 100644 --- a/src/com/android/dialer/calllog/PhoneQuery.java +++ b/src/com/android/dialer/calllog/PhoneQuery.java @@ -16,14 +16,27 @@ package com.android.dialer.calllog; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; import android.provider.ContactsContract.PhoneLookup; +import com.android.contacts.common.compat.CompatUtils; +import com.android.contacts.common.compat.PhoneLookupSdkCompat; +import com.android.contacts.common.ContactsUtils; + /** - * The query to look up the {@link ContactInfo} for a given number in the Call Log. + * The queries to look up the {@link ContactInfo} for a given number in the Call Log. */ final class PhoneQuery { - public static final String[] _PROJECTION = new String[] { - PhoneLookup._ID, + + /** + * Projection to look up the ContactInfo. Does not include DISPLAY_NAME_ALTERNATIVE as that + * column isn't available in ContactsCommon.PhoneLookup. + * We should always use this projection starting from NYC onward. + */ + private static final String[] PHONE_LOOKUP_PROJECTION = new String[] { + PhoneLookupSdkCompat.CONTACT_ID, PhoneLookup.DISPLAY_NAME, PhoneLookup.TYPE, PhoneLookup.LABEL, @@ -31,7 +44,36 @@ final class PhoneQuery { PhoneLookup.NORMALIZED_NUMBER, PhoneLookup.PHOTO_ID, PhoneLookup.LOOKUP_KEY, - PhoneLookup.PHOTO_URI}; + PhoneLookup.PHOTO_URI + }; + + /** + * Similar to {@link PHONE_LOOKUP_PROJECTION}. In pre-N, contact id is stored in + * {@link PhoneLookup#_ID} in non-sip query. + */ + private static final String[] BACKWARD_COMPATIBLE_NON_SIP_PHONE_LOOKUP_PROJECTION = + new String[] { + PhoneLookup._ID, + PhoneLookup.DISPLAY_NAME, + PhoneLookup.TYPE, + PhoneLookup.LABEL, + PhoneLookup.NUMBER, + PhoneLookup.NORMALIZED_NUMBER, + PhoneLookup.PHOTO_ID, + PhoneLookup.LOOKUP_KEY, + PhoneLookup.PHOTO_URI + }; + + public static String[] getPhoneLookupProjection(Uri phoneLookupUri) { + if (CompatUtils.isNCompatible()) { + return PHONE_LOOKUP_PROJECTION; + } + // Pre-N + boolean isSip = phoneLookupUri.getBooleanQueryParameter( + ContactsContract.PhoneLookup.QUERY_PARAMETER_SIP_ADDRESS, false); + return (isSip) ? PHONE_LOOKUP_PROJECTION + : BACKWARD_COMPATIBLE_NON_SIP_PHONE_LOOKUP_PROJECTION; + } public static final int PERSON_ID = 0; public static final int NAME = 1; @@ -42,4 +84,13 @@ final class PhoneQuery { public static final int PHOTO_ID = 6; public static final int LOOKUP_KEY = 7; public static final int PHOTO_URI = 8; + + /** + * Projection to look up a contact's DISPLAY_NAME_ALTERNATIVE + */ + public static final String[] DISPLAY_NAME_ALTERNATIVE_PROJECTION = new String[] { + Contacts.DISPLAY_NAME_ALTERNATIVE, + }; + + public static final int NAME_ALTERNATIVE = 0; } diff --git a/src/com/android/dialer/calllog/PromoCardViewHolder.java b/src/com/android/dialer/calllog/PromoCardViewHolder.java index 4c9602759..f5a7501fc 100644 --- a/src/com/android/dialer/calllog/PromoCardViewHolder.java +++ b/src/com/android/dialer/calllog/PromoCardViewHolder.java @@ -15,14 +15,17 @@ */ package com.android.dialer.calllog; -import com.android.dialer.R; - +import android.content.Context; import android.support.v7.widget.CardView; import android.support.v7.widget.RecyclerView; import android.view.View; +import com.android.contacts.common.testing.NeededForTesting; +import com.android.dialer.R; + /** - * View holder class for a promo card which will appear in the voicemail tab. + * Generic ViewHolder class for a promo card with a primary and secondary action. + * Example: the promo card which appears in the voicemail tab. */ public class PromoCardViewHolder extends RecyclerView.ViewHolder { public static PromoCardViewHolder create(View rootView) { @@ -30,14 +33,15 @@ public class PromoCardViewHolder extends RecyclerView.ViewHolder { } /** - * The "Settings" button view. + * The primary action button view. */ - private View mSettingsTextView; + private View mPrimaryActionView; /** + * The secondary action button view. * The "Ok" button view. */ - private View mOkTextView; + private View mSecondaryActionView; /** * Creates an instance of the {@link ViewHolder}. @@ -47,25 +51,33 @@ public class PromoCardViewHolder extends RecyclerView.ViewHolder { private PromoCardViewHolder(View rootView) { super(rootView); - mSettingsTextView = rootView.findViewById(R.id.settings_action); - mOkTextView = rootView.findViewById(R.id.ok_action); + mPrimaryActionView = rootView.findViewById(R.id.primary_action); + mSecondaryActionView = rootView.findViewById(R.id.secondary_action); } - /** - * Retrieves the "Settings" button. + /** + * Retrieves the "primary" action button (eg. "OK"). * * @return The view. */ - public View getSettingsTextView() { - return mSettingsTextView; + public View getPrimaryActionView() { + return mPrimaryActionView; } /** - * Retrieves the "Ok" button. + * Retrieves the "secondary" action button (eg. "Cancel" or "More Info"). * * @return The view. */ - public View getOkTextView() { - return mOkTextView; + public View getSecondaryActionView() { + return mSecondaryActionView; + } + + @NeededForTesting + public static PromoCardViewHolder createForTest(Context context) { + PromoCardViewHolder viewHolder = new PromoCardViewHolder(new View(context)); + viewHolder.mPrimaryActionView = new View(context); + viewHolder.mSecondaryActionView = new View(context); + return viewHolder; } } diff --git a/src/com/android/dialer/calllog/ShowCallHistoryViewHolder.java b/src/com/android/dialer/calllog/ShowCallHistoryViewHolder.java deleted file mode 100644 index af36a4d33..000000000 --- a/src/com/android/dialer/calllog/ShowCallHistoryViewHolder.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (C) 2015 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.content.Intent; -import android.support.v7.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.android.dialer.R; - -public final class ShowCallHistoryViewHolder extends RecyclerView.ViewHolder { - - private ShowCallHistoryViewHolder(final Context context, View view) { - super(view); - view.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - final Intent intent = new Intent(context, CallLogActivity.class); - context.startActivity(intent); - } - }); - } - - public static ShowCallHistoryViewHolder create(Context context, ViewGroup parent) { - LayoutInflater inflater = LayoutInflater.from(context); - View view = inflater.inflate(R.layout.show_call_history_list_item, parent, false); - return new ShowCallHistoryViewHolder(context, view); - } -} diff --git a/src/com/android/dialer/calllog/VisualVoicemailCallLogFragment.java b/src/com/android/dialer/calllog/VisualVoicemailCallLogFragment.java new file mode 100644 index 000000000..311ff7dc5 --- /dev/null +++ b/src/com/android/dialer/calllog/VisualVoicemailCallLogFragment.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2016 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.database.ContentObserver; +import android.os.Bundle; +import android.provider.CallLog; +import android.provider.VoicemailContract; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.dialer.R; +import com.android.dialer.list.ListsFragment; +import com.android.dialer.voicemail.VoicemailPlaybackPresenter; + +public class VisualVoicemailCallLogFragment extends CallLogFragment { + + private VoicemailPlaybackPresenter mVoicemailPlaybackPresenter; + private final ContentObserver mVoicemailStatusObserver = new CustomContentObserver(); + + public VisualVoicemailCallLogFragment() { + super(CallLog.Calls.VOICEMAIL_TYPE); + } + + @Override + public void onCreate(Bundle state) { + super.onCreate(state); + mVoicemailPlaybackPresenter = VoicemailPlaybackPresenter.getInstance(getActivity(), state); + getActivity().getContentResolver().registerContentObserver( + VoicemailContract.Status.CONTENT_URI, true, mVoicemailStatusObserver); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) { + View view = inflater.inflate(R.layout.call_log_fragment, container, false); + setupView(view, mVoicemailPlaybackPresenter); + return view; + } + + @Override + public void onResume() { + super.onResume(); + mVoicemailPlaybackPresenter.onResume(); + } + + @Override + public void onPause() { + mVoicemailPlaybackPresenter.onPause(); + super.onPause(); + } + + @Override + public void onDestroy() { + mVoicemailPlaybackPresenter.onDestroy(); + getActivity().getContentResolver().unregisterContentObserver(mVoicemailStatusObserver); + super.onDestroy(); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + mVoicemailPlaybackPresenter.onSaveInstanceState(outState); + } + + @Override + public void fetchCalls() { + super.fetchCalls(); + ((ListsFragment) getParentFragment()).updateTabUnreadCounts(); + } +} diff --git a/src/com/android/dialer/calllog/VoicemailQueryHandler.java b/src/com/android/dialer/calllog/VoicemailQueryHandler.java index 26f9bd172..c6e644c32 100644 --- a/src/com/android/dialer/calllog/VoicemailQueryHandler.java +++ b/src/com/android/dialer/calllog/VoicemailQueryHandler.java @@ -59,7 +59,8 @@ public class VoicemailQueryHandler extends AsyncQueryHandler { if (token == UPDATE_MARK_VOICEMAILS_AS_OLD_TOKEN) { if (mContext != null) { Intent serviceIntent = new Intent(mContext, CallLogNotificationsService.class); - serviceIntent.setAction(CallLogNotificationsService.ACTION_UPDATE_NOTIFICATIONS); + serviceIntent.setAction( + CallLogNotificationsService.ACTION_UPDATE_VOICEMAIL_NOTIFICATIONS); mContext.startService(serviceIntent); } else { Log.w(TAG, "Unknown update completed: ignoring: " + token); diff --git a/src/com/android/dialer/calllog/calllogcache/CallLogCache.java b/src/com/android/dialer/calllog/calllogcache/CallLogCache.java new file mode 100644 index 000000000..dc1217cf5 --- /dev/null +++ b/src/com/android/dialer/calllog/calllogcache/CallLogCache.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2015 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.calllogcache; + +import android.content.Context; +import android.telecom.PhoneAccountHandle; + +import com.android.contacts.common.CallUtil; +import com.android.contacts.common.compat.CompatUtils; +import com.android.dialer.calllog.CallLogAdapter; + +/** + * This is the base class for the CallLogCaches. + * + * Keeps a cache of recently made queries to the Telecom/Telephony processes. The aim of this cache + * is to reduce the number of cross-process requests to TelecomManager, which can negatively affect + * performance. + * + * This is designed with the specific use case of the {@link CallLogAdapter} in mind. + */ +public abstract class CallLogCache { + // TODO: Dialer should be fixed so as not to check isVoicemail() so often but at the time of + // this writing, that was a much larger undertaking than creating this cache. + + protected final Context mContext; + + private boolean mHasCheckedForVideoEnabled; + private boolean mIsVideoEnabled; + + public CallLogCache(Context context) { + mContext = context; + } + + /** + * Return the most compatible version of the TelecomCallLogCache. + */ + public static CallLogCache getCallLogCache(Context context) { + if (CompatUtils.isClassAvailable("android.telecom.PhoneAccountHandle")) { + return new CallLogCacheLollipopMr1(context); + } + return new CallLogCacheLollipop(context); + } + + public void reset() { + mHasCheckedForVideoEnabled = false; + mIsVideoEnabled = false; + } + + /** + * Returns true if the given number is the number of the configured voicemail. To be able to + * mock-out this, it is not a static method. + */ + public abstract boolean isVoicemailNumber(PhoneAccountHandle accountHandle, + CharSequence number); + + public boolean isVideoEnabled() { + if (!mHasCheckedForVideoEnabled) { + mIsVideoEnabled = CallUtil.isVideoEnabled(mContext); + mHasCheckedForVideoEnabled = true; + } + return mIsVideoEnabled; + } + + /** + * Extract account label from PhoneAccount object. + */ + public abstract String getAccountLabel(PhoneAccountHandle accountHandle); + + /** + * Extract account color from PhoneAccount object. + */ + public abstract int getAccountColor(PhoneAccountHandle accountHandle); + + /** + * Determines if the PhoneAccount supports specifying a call subject (i.e. calling with a note) + * for outgoing calls. + * + * @param accountHandle The PhoneAccount handle. + * @return {@code true} if calling with a note is supported, {@code false} otherwise. + */ + public abstract boolean doesAccountSupportCallSubject(PhoneAccountHandle accountHandle); +} diff --git a/src/com/android/dialer/calllog/calllogcache/CallLogCacheLollipop.java b/src/com/android/dialer/calllog/calllogcache/CallLogCacheLollipop.java new file mode 100644 index 000000000..770cc9d3e --- /dev/null +++ b/src/com/android/dialer/calllog/calllogcache/CallLogCacheLollipop.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2015 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.calllogcache; + +import android.content.Context; +import android.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; + +/** + * This is a compatibility class for the CallLogCache for versions of dialer before Lollipop Mr1 + * (the introduction of phone accounts). + * + * This class should not be initialized directly and instead be acquired from + * {@link CallLogCache#getCallLogCache}. + */ +class CallLogCacheLollipop extends CallLogCache { + private String mVoicemailNumber; + + /* package */ CallLogCacheLollipop(Context context) { + super(context); + } + + @Override + public boolean isVoicemailNumber(PhoneAccountHandle accountHandle, CharSequence number) { + if (TextUtils.isEmpty(number)) { + return false; + } + + String numberString = number.toString(); + + if (!TextUtils.isEmpty(mVoicemailNumber)) { + return PhoneNumberUtils.compare(numberString, mVoicemailNumber); + } + + if (PhoneNumberUtils.isVoiceMailNumber(numberString)) { + mVoicemailNumber = numberString; + return true; + } + + return false; + } + + @Override + public String getAccountLabel(PhoneAccountHandle accountHandle) { + return null; + } + + @Override + public int getAccountColor(PhoneAccountHandle accountHandle) { + return PhoneAccount.NO_HIGHLIGHT_COLOR; + } + + @Override + public boolean doesAccountSupportCallSubject(PhoneAccountHandle accountHandle) { + return false; + } +} diff --git a/src/com/android/dialer/calllog/TelecomCallLogCache.java b/src/com/android/dialer/calllog/calllogcache/CallLogCacheLollipopMr1.java index 7071669e5..d1e3f7bcf 100644 --- a/src/com/android/dialer/calllog/TelecomCallLogCache.java +++ b/src/com/android/dialer/calllog/calllogcache/CallLogCacheLollipopMr1.java @@ -14,67 +14,50 @@ * limitations under the License */ -package com.android.dialer.calllog; +package com.android.dialer.calllog.calllogcache; import android.content.Context; -import android.provider.CallLog; -import android.telecom.PhoneAccount; import android.telecom.PhoneAccountHandle; -import android.telecom.TelecomManager; import android.text.TextUtils; -import android.util.Log; import android.util.Pair; -import com.android.contacts.common.CallUtil; -import com.android.contacts.common.util.PhoneNumberHelper; +import com.android.dialer.calllog.PhoneAccountUtils; import com.android.dialer.util.PhoneNumberUtil; -import com.google.common.collect.Sets; import java.util.HashMap; import java.util.Map; -import java.util.Set; /** - * Keeps a cache of recently made queries to the Telecom process. The aim of this cache is to - * reduce the number of cross-process requests to TelecomManager, which can negatively affect - * performance. + * This is the CallLogCache for versions of dialer Lollipop Mr1 and above with support for + * multi-SIM devices. * - * This is designed with the specific use case of the {@link CallLogAdapter} in mind. + * This class should not be initialized directly and instead be acquired from + * {@link CallLogCache#getCallLogCache}. */ -public class TelecomCallLogCache { - private final Context mContext; - +class CallLogCacheLollipopMr1 extends CallLogCache { // Maps from a phone-account/number pair to a boolean because multiple numbers could return true // for the voicemail number if those numbers are not pre-normalized. - // TODO: Dialer should be fixed so as not to check isVoicemail() so often but at the time of - // this writing, that was a much larger undertaking than creating this cache. private final Map<Pair<PhoneAccountHandle, CharSequence>, Boolean> mVoicemailQueryCache = new HashMap<>(); private final Map<PhoneAccountHandle, String> mPhoneAccountLabelCache = new HashMap<>(); private final Map<PhoneAccountHandle, Integer> mPhoneAccountColorCache = new HashMap<>(); private final Map<PhoneAccountHandle, Boolean> mPhoneAccountCallWithNoteCache = new HashMap<>(); - private boolean mHasCheckedForVideoEnabled; - private boolean mIsVideoEnabled; - - public TelecomCallLogCache(Context context) { - mContext = context; + /* package */ CallLogCacheLollipopMr1(Context context) { + super(context); } + @Override public void reset() { mVoicemailQueryCache.clear(); mPhoneAccountLabelCache.clear(); mPhoneAccountColorCache.clear(); mPhoneAccountCallWithNoteCache.clear(); - mHasCheckedForVideoEnabled = false; - mIsVideoEnabled = false; + super.reset(); } - /** - * Returns true if the given number is the number of the configured voicemail. To be able to - * mock-out this, it is not a static method. - */ + @Override public boolean isVoicemailNumber(PhoneAccountHandle accountHandle, CharSequence number) { if (TextUtils.isEmpty(number)) { return false; @@ -91,9 +74,7 @@ public class TelecomCallLogCache { } } - /** - * Extract account label from PhoneAccount object. - */ + @Override public String getAccountLabel(PhoneAccountHandle accountHandle) { if (mPhoneAccountLabelCache.containsKey(accountHandle)) { return mPhoneAccountLabelCache.get(accountHandle); @@ -104,9 +85,7 @@ public class TelecomCallLogCache { } } - /** - * Extract account color from PhoneAccount object. - */ + @Override public int getAccountColor(PhoneAccountHandle accountHandle) { if (mPhoneAccountColorCache.containsKey(accountHandle)) { return mPhoneAccountColorCache.get(accountHandle); @@ -117,20 +96,7 @@ public class TelecomCallLogCache { } } - public boolean isVideoEnabled() { - if (!mHasCheckedForVideoEnabled) { - mIsVideoEnabled = CallUtil.isVideoEnabled(mContext); - } - return mIsVideoEnabled; - } - - /** - * Determines if the PhoneAccount supports specifying a call subject (i.e. calling with a note) - * for outgoing calls. - * - * @param accountHandle The PhoneAccount handle. - * @return {@code true} if calling with a note is supported, {@code false} otherwise. - */ + @Override public boolean doesAccountSupportCallSubject(PhoneAccountHandle accountHandle) { if (mPhoneAccountCallWithNoteCache.containsKey(accountHandle)) { return mPhoneAccountCallWithNoteCache.get(accountHandle); diff --git a/src/com/android/dialer/compat/DialerCompatUtils.java b/src/com/android/dialer/compat/DialerCompatUtils.java new file mode 100644 index 000000000..a9c9c5319 --- /dev/null +++ b/src/com/android/dialer/compat/DialerCompatUtils.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2015 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.compat; + +import com.android.contacts.common.compat.CompatUtils; + +public final class DialerCompatUtils { + /** + * Determines if this version has access to the + * {@link android.provider.CallLog.Calls.CACHED_PHOTO_URI} column + * + * @return {@code true} if {@link android.provider.CallLog.Calls.CACHED_PHOTO_URI} is available, + * {@code false} otherwise + */ + public static boolean isCallsCachedPhotoUriCompatible() { + return CompatUtils.isMarshmallowCompatible(); + } +}
\ No newline at end of file diff --git a/src/com/android/dialer/compat/FilteredNumberCompat.java b/src/com/android/dialer/compat/FilteredNumberCompat.java new file mode 100644 index 000000000..c6c714b27 --- /dev/null +++ b/src/com/android/dialer/compat/FilteredNumberCompat.java @@ -0,0 +1,296 @@ +/* + * Copyright (C) 2016 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.compat; + +import com.google.common.base.Preconditions; + +import android.app.FragmentManager; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.preference.PreferenceManager; +import android.support.annotation.Nullable; +import android.telecom.TelecomManager; +import android.telephony.PhoneNumberUtils; + +import com.android.contacts.common.compat.CompatUtils; +import com.android.contacts.common.compat.TelecomManagerUtil; +import com.android.contacts.common.testing.NeededForTesting; +import com.android.dialer.DialerApplication; +import com.android.dialer.database.FilteredNumberContract.FilteredNumber; +import com.android.dialer.database.FilteredNumberContract.FilteredNumberColumns; +import com.android.dialer.database.FilteredNumberContract.FilteredNumberSources; +import com.android.dialer.database.FilteredNumberContract.FilteredNumberTypes; +import com.android.dialer.filterednumber.BlockNumberDialogFragment; +import com.android.dialer.filterednumber.BlockNumberDialogFragment.Callback; +import com.android.dialer.filterednumber.BlockedNumbersMigrator; +import com.android.dialer.filterednumber.BlockedNumbersSettingsActivity; +import com.android.dialer.filterednumber.MigrateBlockedNumbersDialogFragment; +import com.android.dialerbind.ObjectFactory; +import com.android.incallui.Log; + +import java.util.ArrayList; +import java.util.List; + +/** + * Compatibility class to encapsulate logic to switch between call blocking using + * {@link com.android.dialer.database.FilteredNumberContract} and using + * {@link android.provider.BlockedNumberContract}. This class should be used rather than explicitly + * referencing columns from either contract class in situations where both blocking solutions may be + * used. + */ +public class FilteredNumberCompat { + + private static final String TAG = "FilteredNumberCompat"; + + protected static final String HAS_MIGRATED_TO_NEW_BLOCKING_KEY = "migratedToNewBlocking"; + + private static Boolean isEnabledForTest; + + /** + * @return The column name for ID in the filtered number database. + */ + public static String getIdColumnName() { + return useNewFiltering() ? BlockedNumbersSdkCompat._ID : FilteredNumberColumns._ID; + } + + /** + * @return The column name for type in the filtered number database. Will be {@code null} for + * the framework blocking implementation. + */ + @Nullable + public static String getTypeColumnName() { + return useNewFiltering() ? null : FilteredNumberColumns.TYPE; + } + + /** + * @return The column name for source in the filtered number database. Will be {@code null} for + * the framework blocking implementation + */ + @Nullable + public static String getSourceColumnName() { + return useNewFiltering() ? null : FilteredNumberColumns.SOURCE; + } + + /** + * @return The column name for the original number in the filtered number database. + */ + public static String getOriginalNumberColumnName() { + return useNewFiltering() ? BlockedNumbersSdkCompat.COLUMN_ORIGINAL_NUMBER + : FilteredNumberColumns.NUMBER; + } + + /** + * @return The column name for country iso in the filtered number database. Will be {@code null} + * the framework blocking implementation + */ + @Nullable + public static String getCountryIsoColumnName() { + return useNewFiltering() ? null : FilteredNumberColumns.COUNTRY_ISO; + } + + /** + * @return The column name for the e164 formatted number in the filtered number database. + */ + public static String getE164NumberColumnName() { + return useNewFiltering() ? BlockedNumbersSdkCompat.E164_NUMBER + : FilteredNumberColumns.NORMALIZED_NUMBER; + } + + /** + * @return {@code true} if the current SDK version supports using new filtering, {@code false} + * otherwise. + */ + public static boolean canUseNewFiltering() { + if (isEnabledForTest != null) { + return CompatUtils.isNCompatible() && isEnabledForTest; + } + return CompatUtils.isNCompatible() && ObjectFactory + .isNewBlockingEnabled(DialerApplication.getContext()); + } + + /** + * @return {@code true} if the new filtering should be used, i.e. it's enabled and any necessary + * migration has been performed, {@code false} otherwise. + */ + public static boolean useNewFiltering() { + return canUseNewFiltering() && hasMigratedToNewBlocking(); + } + + /** + * @return {@code true} if the user has migrated to use + * {@link android.provider.BlockedNumberContract} blocking, {@code false} otherwise. + */ + public static boolean hasMigratedToNewBlocking() { + return PreferenceManager.getDefaultSharedPreferences(DialerApplication.getContext()) + .getBoolean(HAS_MIGRATED_TO_NEW_BLOCKING_KEY, false); + } + + /** + * Called to inform this class whether the user has fully migrated to use + * {@link android.provider.BlockedNumberContract} blocking or not. + * + * @param hasMigrated {@code true} if the user has migrated, {@code false} otherwise. + */ + @NeededForTesting + public static void setHasMigratedToNewBlocking(boolean hasMigrated) { + PreferenceManager.getDefaultSharedPreferences(DialerApplication.getContext()).edit() + .putBoolean(HAS_MIGRATED_TO_NEW_BLOCKING_KEY, hasMigrated).apply(); + } + + @NeededForTesting + public static void setIsEnabledForTest(Boolean isEnabled) { + isEnabledForTest = isEnabled; + } + + /** + * Gets the content {@link Uri} for number filtering. + * + * @param id The optional id to append with the base content uri. + * @return The Uri for number filtering. + */ + public static Uri getContentUri(@Nullable Integer id) { + if (id == null) { + return getBaseUri(); + } + return ContentUris.withAppendedId(getBaseUri(), id); + } + + + private static Uri getBaseUri() { + return useNewFiltering() ? BlockedNumbersSdkCompat.CONTENT_URI : FilteredNumber.CONTENT_URI; + } + + /** + * Removes any null column names from the given projection array. This method is intended to be + * used to strip out any column names that aren't available in every version of number blocking. + * Example: + * {@literal + * getContext().getContentResolver().query( + * someUri, + * // Filtering ensures that no non-existant columns are queried + * FilteredNumberCompat.filter(new String[] {FilteredNumberCompat.getIdColumnName(), + * FilteredNumberCompat.getTypeColumnName()}, + * FilteredNumberCompat.getE164NumberColumnName() + " = ?", + * new String[] {e164Number}); + * } + * + * @param projection The projection array. + * @return The filtered projection array. + */ + @Nullable + public static String[] filter(@Nullable String[] projection) { + if (projection == null) { + return null; + } + List<String> filtered = new ArrayList<>(); + for (String column : projection) { + if (column != null) { + filtered.add(column); + } + } + return filtered.toArray(new String[filtered.size()]); + } + + /** + * Creates a new {@link ContentValues} suitable for inserting in the filtered number table. + * + * @param number The unformatted number to insert. + * @param e164Number (optional) The number to insert formatted to E164 standard. + * @param countryIso (optional) The country iso to use to format the number. + * @return The ContentValues to insert. + * @throws NullPointerException If number is null. + */ + public static ContentValues newBlockNumberContentValues(String number, + @Nullable String e164Number, @Nullable String countryIso) { + ContentValues contentValues = new ContentValues(); + contentValues.put(getOriginalNumberColumnName(), Preconditions.checkNotNull(number)); + if (!useNewFiltering()) { + if (e164Number == null) { + e164Number = PhoneNumberUtils.formatNumberToE164(number, countryIso); + } + contentValues.put(getE164NumberColumnName(), e164Number); + contentValues.put(getCountryIsoColumnName(), countryIso); + contentValues.put(getTypeColumnName(), FilteredNumberTypes.BLOCKED_NUMBER); + contentValues.put(getSourceColumnName(), FilteredNumberSources.USER); + } + return contentValues; + } + + /** + * Shows the flow of {@link android.app.DialogFragment}s for blocking or unblocking numbers. + * + * @param blockId The id into the blocked numbers database. + * @param number The number to block or unblock. + * @param countryIso The countryIso used to format the given number. + * @param displayNumber The form of the number to block, suitable for displaying. + * @param parentViewId The id for the containing view of the Dialog. + * @param fragmentManager The {@link FragmentManager} used to show fragments. + * @param callback (optional) The {@link Callback} to call when the block or unblock operation + * is complete. + */ + public static void showBlockNumberDialogFlow(final ContentResolver contentResolver, + final Integer blockId, final String number, final String countryIso, + final String displayNumber, final Integer parentViewId, + final FragmentManager fragmentManager, @Nullable final Callback callback) { + Log.i(TAG, "showBlockNumberDialogFlow - start"); + // If the user is blocking a number and isn't using the framework solution when they + // should be, show the migration dialog + if (shouldShowMigrationDialog(blockId == null)) { + Log.i(TAG, "showBlockNumberDialogFlow - showing migration dialog"); + MigrateBlockedNumbersDialogFragment + .newInstance(new BlockedNumbersMigrator(contentResolver), + new BlockedNumbersMigrator.Listener() { + @Override + public void onComplete() { + Log.i(TAG, "showBlockNumberDialogFlow - listener showing block " + + "number dialog"); + BlockNumberDialogFragment + .show(null, number, countryIso, displayNumber, + parentViewId, + fragmentManager, callback); + } + }).show(fragmentManager, "MigrateBlockedNumbers"); + return; + } + Log.i(TAG, "showBlockNumberDialogFlow - showing block number dialog"); + BlockNumberDialogFragment + .show(blockId, number, countryIso, displayNumber, parentViewId, fragmentManager, + callback); + } + + private static boolean shouldShowMigrationDialog(boolean isBlocking) { + return isBlocking && canUseNewFiltering() && !hasMigratedToNewBlocking(); + } + + /** + * Creates the {@link Intent} which opens the blocked numbers management interface. + * + * @param context The {@link Context}. + * @return The intent. + */ + public static Intent createManageBlockedNumbersIntent(Context context) { + if (canUseNewFiltering() && hasMigratedToNewBlocking()) { + return TelecomManagerUtil.createManageBlockedNumbersIntent( + (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE)); + } + return new Intent(context, BlockedNumbersSettingsActivity.class); + } +} diff --git a/src/com/android/dialer/compat/SettingsCompat.java b/src/com/android/dialer/compat/SettingsCompat.java new file mode 100644 index 000000000..474a600a4 --- /dev/null +++ b/src/com/android/dialer/compat/SettingsCompat.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2015 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.compat; + +import android.content.Context; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.provider.Settings; + +import com.android.contacts.common.compat.SdkVersionOverride; + +/** + * Compatibility class for {@link android.provider.Settings} + */ +public class SettingsCompat { + + public static class System { + + /** + * Compatibility version of {@link android.provider.Settings.System#canWrite(Context)} + * + * Note: Since checking preferences at runtime started in M, this method always returns + * {@code true} for SDK versions prior to 23. In those versions, the app wouldn't be + * installed if it didn't have the proper permission + */ + public static boolean canWrite(Context context) { + if (SdkVersionOverride.getSdkVersion(VERSION_CODES.LOLLIPOP) >= Build.VERSION_CODES.M) { + return Settings.System.canWrite(context); + } + return true; + } + } + +} diff --git a/src/com/android/dialer/compat/UserManagerCompat.java b/src/com/android/dialer/compat/UserManagerCompat.java new file mode 100644 index 000000000..576703364 --- /dev/null +++ b/src/com/android/dialer/compat/UserManagerCompat.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2015 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.compat; + +import android.content.Context; +import android.os.Process; +import android.os.UserHandle; +import android.os.UserManager; + +import com.android.contacts.common.compat.CompatUtils; + +/** + * Compatibility class for {@link UserManager}. + */ +public class UserManagerCompat { + /** + * A user id constant to indicate the "system" user of the device. Copied from + * {@link UserHandle}. + */ + private static final int USER_SYSTEM = 0; + /** + * Range of uids allocated for a user. + */ + private static final int PER_USER_RANGE = 100000; + + /** + * Used to check if this process is running under the system user. The system user is the + * initial user that is implicitly created on first boot and hosts most of the system services. + * + * @return whether this process is running under the system user. + */ + public static boolean isSystemUser(UserManager userManager) { + if (CompatUtils.isMarshmallowCompatible()) { + return userManager.isSystemUser(); + } + // Adapted from {@link UserManager} and {@link UserHandle}. + return (Process.myUid() / PER_USER_RANGE) == USER_SYSTEM; + } + + /** + * Return whether the calling user is running in an "unlocked" state. A user + * is unlocked only after they've entered their credentials (such as a lock + * pattern or PIN), and credential-encrypted private app data storage is + * available. + * + * TODO b/26688153 + * + * @param context the current context + * @return {@code true} if the user is unlocked, {@code false} otherwise + * @throws NullPointerException if context is null + */ + public static boolean isUserUnlocked(Context context) { + if (CompatUtils.isNCompatible()) { + return UserManagerSdkCompat.isUserUnlocked(context); + } + return true; + } +} diff --git a/src/com/android/dialer/contactinfo/ContactInfoCache.java b/src/com/android/dialer/contactinfo/ContactInfoCache.java index 568f48886..1e2457957 100644 --- a/src/com/android/dialer/contactinfo/ContactInfoCache.java +++ b/src/com/android/dialer/contactinfo/ContactInfoCache.java @@ -162,7 +162,7 @@ public class ContactInfoCache { // 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)) { + } 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 @@ -309,8 +309,7 @@ public class ContactInfoCache { * 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. + // 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); diff --git a/src/com/android/dialer/contactinfo/ContactPhotoLoader.java b/src/com/android/dialer/contactinfo/ContactPhotoLoader.java new file mode 100644 index 000000000..f36c438f6 --- /dev/null +++ b/src/com/android/dialer/contactinfo/ContactPhotoLoader.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2016 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.contactinfo; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.provider.MediaStore; +import android.support.annotation.Nullable; +import android.support.v4.graphics.drawable.RoundedBitmapDrawable; +import android.support.v4.graphics.drawable.RoundedBitmapDrawableFactory; +import android.util.Log; + +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.lettertiles.LetterTileDrawable; +import com.android.dialer.R; +import com.android.dialer.calllog.ContactInfo; +import com.android.dialer.calllog.ContactInfoHelper; +import com.android.dialer.util.Assert; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; + +import java.io.IOException; +/** + * Class to create the appropriate contact icon from a ContactInfo. + * This class is for synchronous, blocking calls to generate bitmaps, while + * ContactCommons.ContactPhotoManager is to cache, manage and update a ImageView asynchronously. + */ +public class ContactPhotoLoader { + + private static final String TAG = "ContactPhotoLoader"; + + private final Context mContext; + private final ContactInfo mContactInfo; + + public ContactPhotoLoader(Context context, ContactInfo contactInfo) { + mContext = Preconditions.checkNotNull(context); + mContactInfo = Preconditions.checkNotNull(contactInfo); + } + + /** + * Create a contact photo icon bitmap appropriate for the ContactInfo. + */ + public Bitmap loadPhotoIcon() { + Assert.assertNotUiThread("ContactPhotoLoader#loadPhotoIcon called on UI thread"); + int photoSize = mContext.getResources().getDimensionPixelSize(R.dimen.contact_photo_size); + return drawableToBitmap(getIcon(), photoSize, photoSize); + } + + @VisibleForTesting + Drawable getIcon() { + Drawable drawable = createPhotoIconDrawable(); + if (drawable == null) { + drawable = createLetterTileDrawable(); + } + return drawable; + } + + /** + * @return a {@link Drawable} of circular photo icon if the photo can be loaded, {@code null} + * otherwise. + */ + @Nullable + private Drawable createPhotoIconDrawable() { + if (mContactInfo.photoUri == null) { + return null; + } + try { + Bitmap bitmap = MediaStore.Images.Media.getBitmap(mContext.getContentResolver(), + mContactInfo.photoUri); + final RoundedBitmapDrawable drawable = + RoundedBitmapDrawableFactory.create(mContext.getResources(), bitmap); + drawable.setAntiAlias(true); + drawable.setCornerRadius(bitmap.getHeight() / 2); + return drawable; + } catch (IOException e) { + Log.e(TAG, e.toString()); + return null; + } + } + + /** + * @return a {@link LetterTileDrawable} based on the ContactInfo. + */ + private Drawable createLetterTileDrawable() { + LetterTileDrawable drawable = new LetterTileDrawable(mContext.getResources()); + drawable.setIsCircular(true); + ContactInfoHelper helper = + new ContactInfoHelper(mContext, GeoUtil.getCurrentCountryIso(mContext)); + if (helper.isBusiness(mContactInfo.sourceType)) { + drawable.setContactType(LetterTileDrawable.TYPE_BUSINESS); + } + drawable.setLetterAndColorFromContactDetails(mContactInfo.name, mContactInfo.lookupKey); + return drawable; + } + + private static Bitmap drawableToBitmap(Drawable drawable, int width, int height) { + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + return bitmap; + } + +} diff --git a/src/com/android/dialer/database/DialerDatabaseHelper.java b/src/com/android/dialer/database/DialerDatabaseHelper.java index eec24f5bc..5edfb270d 100644 --- a/src/com/android/dialer/database/DialerDatabaseHelper.java +++ b/src/com/android/dialer/database/DialerDatabaseHelper.java @@ -38,6 +38,8 @@ import android.util.Log; import com.android.contacts.common.util.PermissionsUtil; import com.android.contacts.common.util.StopWatch; +import com.android.dialer.database.FilteredNumberContract.FilteredNumberColumns; +import com.android.dialer.database.VoicemailArchiveContract.VoicemailArchive; import com.android.dialer.R; import com.android.dialer.dialpad.SmartDialNameMatcher; import com.android.dialer.dialpad.SmartDialPrefix; @@ -60,6 +62,7 @@ import java.util.concurrent.atomic.AtomicBoolean; public class DialerDatabaseHelper extends SQLiteOpenHelper { private static final String TAG = "DialerDatabaseHelper"; private static final boolean DEBUG = false; + private boolean mIsTestInstance = false; private static DialerDatabaseHelper sSingleton = null; @@ -73,7 +76,7 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { * 0-98 KitKat * </pre> */ - public static final int DATABASE_VERSION = 4; + public static final int DATABASE_VERSION = 9; public static final String DATABASE_NAME = "dialer.db"; /** @@ -86,10 +89,14 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { private static final int MAX_ENTRIES = 20; public interface Tables { + /** Saves a list of numbers to be blocked.*/ + static final String FILTERED_NUMBER_TABLE = "filtered_numbers_table"; /** Saves the necessary smart dial information of all contacts. */ static final String SMARTDIAL_TABLE = "smartdial_table"; /** Saves all possible prefixes to refer to a contacts.*/ static final String PREFIX_TABLE = "prefix_table"; + /** Saves all archived voicemail information. */ + static final String VOICEMAIL_ARCHIVE_TABLE = "voicemail_archive_table"; /** Database properties for internal use */ static final String PROPERTIES = "properties"; } @@ -111,6 +118,7 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { static final String IS_SUPER_PRIMARY = "is_super_primary"; static final String IN_VISIBLE_GROUP = "in_visible_group"; static final String IS_PRIMARY = "is_primary"; + static final String CARRIER_PRESENCE = "carrier_presence"; static final String LAST_SMARTDIAL_UPDATE_TIME = "last_smartdial_update_time"; } @@ -147,6 +155,7 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { Data.IS_SUPER_PRIMARY, // 11 Contacts.IN_VISIBLE_GROUP, // 12 Data.IS_PRIMARY, // 13 + Data.CARRIER_PRESENCE, // 14 }; static final int PHONE_ID = 0; @@ -163,6 +172,7 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { static final int PHONE_IS_SUPER_PRIMARY = 11; static final int PHONE_IN_VISIBLE_GROUP = 12; static final int PHONE_IS_PRIMARY = 13; + static final int PHONE_CARRIER_PRESENCE = 14; /** Selects only rows that have been updated after a certain time stamp.*/ static final String SELECT_UPDATED_CLAUSE = @@ -180,6 +190,23 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { SELECT_IGNORE_LOOKUP_KEY_TOO_LONG_CLAUSE; } + /** + * Query for all contacts that have been updated since the last time the smart dial database + * was updated. + */ + public static interface UpdatedContactQuery { + static final Uri URI = ContactsContract.Contacts.CONTENT_URI; + + static final String[] PROJECTION = new String[] { + ContactsContract.Contacts._ID // 0 + }; + + static final int UPDATED_CONTACT_ID = 0; + + static final String SELECT_UPDATED_CLAUSE = + ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + " > ?"; + } + /** Query options for querying the deleted contact database.*/ public static interface DeleteContactQuery { static final Uri URI = ContactsContract.DeletedContacts.CONTENT_URI; @@ -247,20 +274,23 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { public final String phoneNumber; public final String lookupKey; public final long photoId; + public final int carrierPresence; public ContactNumber(long id, long dataID, String displayName, String phoneNumber, - String lookupKey, long photoId) { + String lookupKey, long photoId, int carrierPresence) { this.dataId = dataID; this.id = id; this.displayName = displayName; this.phoneNumber = phoneNumber; this.lookupKey = lookupKey; this.photoId = photoId; + this.carrierPresence = carrierPresence; } @Override public int hashCode() { - return Objects.hashCode(id, dataId, displayName, phoneNumber, lookupKey, photoId); + return Objects.hashCode(id, dataId, displayName, phoneNumber, lookupKey, photoId, + carrierPresence); } @Override @@ -275,7 +305,8 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { && Objects.equal(this.displayName, that.displayName) && Objects.equal(this.phoneNumber, that.phoneNumber) && Objects.equal(this.lookupKey, that.lookupKey) - && Objects.equal(this.photoId, that.photoId); + && Objects.equal(this.photoId, that.photoId) + && Objects.equal(this.carrierPresence, that.carrierPresence); } return false; } @@ -334,7 +365,12 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { */ @VisibleForTesting static DialerDatabaseHelper getNewInstanceForTest(Context context) { - return new DialerDatabaseHelper(context, null); + return new DialerDatabaseHelper(context, null, true); + } + + protected DialerDatabaseHelper(Context context, String databaseName, boolean isTestInstance) { + this(context, databaseName, DATABASE_VERSION); + mIsTestInstance = isTestInstance; } protected DialerDatabaseHelper(Context context, String databaseName) { @@ -358,42 +394,63 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { private void setupTables(SQLiteDatabase db) { dropTables(db); - db.execSQL("CREATE TABLE " + Tables.SMARTDIAL_TABLE + " (" + - SmartDialDbColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + - SmartDialDbColumns.DATA_ID + " INTEGER, " + - SmartDialDbColumns.NUMBER + " TEXT," + - SmartDialDbColumns.CONTACT_ID + " INTEGER," + - SmartDialDbColumns.LOOKUP_KEY + " TEXT," + - SmartDialDbColumns.DISPLAY_NAME_PRIMARY + " TEXT, " + - SmartDialDbColumns.PHOTO_ID + " INTEGER, " + - SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME + " LONG, " + - SmartDialDbColumns.LAST_TIME_USED + " LONG, " + - SmartDialDbColumns.TIMES_USED + " INTEGER, " + - SmartDialDbColumns.STARRED + " INTEGER, " + - SmartDialDbColumns.IS_SUPER_PRIMARY + " INTEGER, " + - SmartDialDbColumns.IN_VISIBLE_GROUP + " INTEGER, " + - SmartDialDbColumns.IS_PRIMARY + " INTEGER" + - ");"); - - db.execSQL("CREATE TABLE " + Tables.PREFIX_TABLE + " (" + - PrefixColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + - PrefixColumns.PREFIX + " TEXT COLLATE NOCASE, " + - PrefixColumns.CONTACT_ID + " INTEGER" + - ");"); - - db.execSQL("CREATE TABLE " + Tables.PROPERTIES + " (" + - PropertiesColumns.PROPERTY_KEY + " TEXT PRIMARY KEY, " + - PropertiesColumns.PROPERTY_VALUE + " TEXT " + - ");"); - + db.execSQL("CREATE TABLE " + Tables.SMARTDIAL_TABLE + " (" + + SmartDialDbColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + SmartDialDbColumns.DATA_ID + " INTEGER, " + + SmartDialDbColumns.NUMBER + " TEXT," + + SmartDialDbColumns.CONTACT_ID + " INTEGER," + + SmartDialDbColumns.LOOKUP_KEY + " TEXT," + + SmartDialDbColumns.DISPLAY_NAME_PRIMARY + " TEXT, " + + SmartDialDbColumns.PHOTO_ID + " INTEGER, " + + SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME + " LONG, " + + SmartDialDbColumns.LAST_TIME_USED + " LONG, " + + SmartDialDbColumns.TIMES_USED + " INTEGER, " + + SmartDialDbColumns.STARRED + " INTEGER, " + + SmartDialDbColumns.IS_SUPER_PRIMARY + " INTEGER, " + + SmartDialDbColumns.IN_VISIBLE_GROUP + " INTEGER, " + + SmartDialDbColumns.IS_PRIMARY + " INTEGER, " + + SmartDialDbColumns.CARRIER_PRESENCE + " INTEGER NOT NULL DEFAULT 0" + + ");"); + + db.execSQL("CREATE TABLE " + Tables.PREFIX_TABLE + " (" + + PrefixColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + PrefixColumns.PREFIX + " TEXT COLLATE NOCASE, " + + PrefixColumns.CONTACT_ID + " INTEGER" + + ");"); + + db.execSQL("CREATE TABLE " + Tables.PROPERTIES + " (" + + PropertiesColumns.PROPERTY_KEY + " TEXT PRIMARY KEY, " + + PropertiesColumns.PROPERTY_VALUE + " TEXT " + + ");"); + + // This will need to also be updated in setupTablesForFilteredNumberTest and onUpgrade. + // Hardcoded so we know on glance what columns are updated in setupTables, + // and to be able to guarantee the state of the DB at each upgrade step. + db.execSQL("CREATE TABLE " + Tables.FILTERED_NUMBER_TABLE + " (" + + FilteredNumberColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + FilteredNumberColumns.NORMALIZED_NUMBER + " TEXT UNIQUE," + + FilteredNumberColumns.NUMBER + " TEXT," + + FilteredNumberColumns.COUNTRY_ISO + " TEXT," + + FilteredNumberColumns.TIMES_FILTERED + " INTEGER," + + FilteredNumberColumns.LAST_TIME_FILTERED + " LONG," + + FilteredNumberColumns.CREATION_TIME + " LONG," + + FilteredNumberColumns.TYPE + " INTEGER," + + FilteredNumberColumns.SOURCE + " INTEGER" + + ");"); + + createVoicemailArchiveTable(db); setProperty(db, DATABASE_VERSION_PROPERTY, String.valueOf(DATABASE_VERSION)); - resetSmartDialLastUpdatedTime(); + if (!mIsTestInstance) { + resetSmartDialLastUpdatedTime(); + } } public void dropTables(SQLiteDatabase db) { db.execSQL("DROP TABLE IF EXISTS " + Tables.PREFIX_TABLE); db.execSQL("DROP TABLE IF EXISTS " + Tables.SMARTDIAL_TABLE); db.execSQL("DROP TABLE IF EXISTS " + Tables.PROPERTIES); + db.execSQL("DROP TABLE IF EXISTS " + Tables.FILTERED_NUMBER_TABLE); + db.execSQL("DROP TABLE IF EXISTS " + Tables.VOICEMAIL_ARCHIVE_TABLE); } @Override @@ -414,6 +471,33 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { return; } + if (oldVersion < 7) { + db.execSQL("DROP TABLE IF EXISTS " + Tables.FILTERED_NUMBER_TABLE); + db.execSQL("CREATE TABLE " + Tables.FILTERED_NUMBER_TABLE + " (" + + FilteredNumberColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + FilteredNumberColumns.NORMALIZED_NUMBER + " TEXT UNIQUE," + + FilteredNumberColumns.NUMBER + " TEXT," + + FilteredNumberColumns.COUNTRY_ISO + " TEXT," + + FilteredNumberColumns.TIMES_FILTERED + " INTEGER," + + FilteredNumberColumns.LAST_TIME_FILTERED + " LONG," + + FilteredNumberColumns.CREATION_TIME + " LONG," + + FilteredNumberColumns.TYPE + " INTEGER," + + FilteredNumberColumns.SOURCE + " INTEGER" + + ");"); + oldVersion = 7; + } + + if (oldVersion < 8) { + upgradeToVersion8(db); + oldVersion = 8; + } + + if (oldVersion < 9) { + db.execSQL("DROP TABLE IF EXISTS " + Tables.VOICEMAIL_ARCHIVE_TABLE); + createVoicemailArchiveTable(db); + oldVersion = 9; + } + if (oldVersion != DATABASE_VERSION) { throw new IllegalStateException( "error upgrading the database to version " + DATABASE_VERSION); @@ -422,6 +506,10 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { setProperty(db, DATABASE_VERSION_PROPERTY, String.valueOf(DATABASE_VERSION)); } + public void upgradeToVersion8(SQLiteDatabase db) { + db.execSQL("ALTER TABLE smartdial_table ADD carrier_presence INTEGER NOT NULL DEFAULT 0"); + } + /** * Stores a key-value pair in the {@link Tables#PROPERTIES} table. */ @@ -521,15 +609,11 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { * Removes rows in the smartdial database that matches the contacts that have been deleted * by other apps since last update. * - * @param db Database pointer to the dialer database. - * @param last_update_time Time stamp of last update on the smartdial database + * @param db Database to operate on. + * @param deletedContactCursor Cursor containing rows of deleted contacts */ - private void removeDeletedContacts(SQLiteDatabase db, String last_update_time) { - final Cursor deletedContactCursor = mContext.getContentResolver().query( - DeleteContactQuery.URI, - DeleteContactQuery.PROJECTION, - DeleteContactQuery.SELECT_UPDATED_CLAUSE, - new String[] {last_update_time}, null); + @VisibleForTesting + void removeDeletedContacts(SQLiteDatabase db, Cursor deletedContactCursor) { if (deletedContactCursor == null) { return; } @@ -552,6 +636,15 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { } } + private Cursor getDeletedContactCursor(String lastUpdateMillis) { + return mContext.getContentResolver().query( + DeleteContactQuery.URI, + DeleteContactQuery.PROJECTION, + DeleteContactQuery.SELECT_UPDATED_CLAUSE, + new String[] {lastUpdateMillis}, + null); + } + /** * Removes potentially corrupted entries in the database. These contacts may be added before * the previous instance of the dialer was destroyed for some reason. For data integrity, we @@ -572,6 +665,41 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { } /** + * All columns excluding MIME_TYPE, _DATA, ARCHIVED, SERVER_ID, are the same as + * the columns in the {@link android.provider.CallLog.Calls} table. + * + * @param db Database pointer to the dialer database. + */ + private void createVoicemailArchiveTable(SQLiteDatabase db) { + db.execSQL("CREATE TABLE " + Tables.VOICEMAIL_ARCHIVE_TABLE + " (" + + VoicemailArchive._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + VoicemailArchive.NUMBER + " TEXT," + + VoicemailArchive.DATE + " LONG," + + VoicemailArchive.DURATION + " LONG," + + VoicemailArchive.MIME_TYPE + " TEXT," + + VoicemailArchive.COUNTRY_ISO + " TEXT," + + VoicemailArchive._DATA + " TEXT," + + VoicemailArchive.GEOCODED_LOCATION + " TEXT," + + VoicemailArchive.CACHED_NAME + " TEXT," + + VoicemailArchive.CACHED_NUMBER_TYPE + " INTEGER," + + VoicemailArchive.CACHED_NUMBER_LABEL + " TEXT," + + VoicemailArchive.CACHED_LOOKUP_URI + " TEXT," + + VoicemailArchive.CACHED_MATCHED_NUMBER + " TEXT," + + VoicemailArchive.CACHED_NORMALIZED_NUMBER + " TEXT," + + VoicemailArchive.CACHED_PHOTO_ID + " LONG," + + VoicemailArchive.CACHED_FORMATTED_NUMBER + " TEXT," + + VoicemailArchive.ARCHIVED + " INTEGER," + + VoicemailArchive.NUMBER_PRESENTATION + " INTEGER," + + VoicemailArchive.ACCOUNT_COMPONENT_NAME + " TEXT," + + VoicemailArchive.ACCOUNT_ID + " TEXT," + + VoicemailArchive.FEATURES + " INTEGER," + + VoicemailArchive.SERVER_ID + " INTEGER," + + VoicemailArchive.TRANSCRIPTION + " TEXT," + + VoicemailArchive.CACHED_PHOTO_URI + " TEXT" + + ");"); + } + + /** * Removes all entries in the smartdial contact database. */ @VisibleForTesting @@ -595,11 +723,14 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { * @param db Database pointer to the smartdial database * @param updatedContactCursor Cursor pointing to the list of recently updated contacts. */ - private void removeUpdatedContacts(SQLiteDatabase db, Cursor updatedContactCursor) { + @VisibleForTesting + void removeUpdatedContacts(SQLiteDatabase db, Cursor updatedContactCursor) { db.beginTransaction(); try { + updatedContactCursor.moveToPosition(-1); while (updatedContactCursor.moveToNext()) { - final Long contactId = updatedContactCursor.getLong(PhoneQuery.PHONE_CONTACT_ID); + final Long contactId = + updatedContactCursor.getLong(UpdatedContactQuery.UPDATED_CONTACT_ID); db.delete(Tables.SMARTDIAL_TABLE, SmartDialDbColumns.CONTACT_ID + "=" + contactId, null); @@ -638,8 +769,9 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { SmartDialDbColumns.IS_SUPER_PRIMARY + ", " + SmartDialDbColumns.IN_VISIBLE_GROUP+ ", " + SmartDialDbColumns.IS_PRIMARY + ", " + + SmartDialDbColumns.CARRIER_PRESENCE + ", " + SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME + ") " + - " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; final SQLiteStatement insert = db.compileStatement(sqlInsert); final String numberSqlInsert = "INSERT INTO " + Tables.PREFIX_TABLE + " (" + @@ -686,7 +818,8 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { insert.bindLong(10, updatedContactCursor.getInt(PhoneQuery.PHONE_IS_SUPER_PRIMARY)); insert.bindLong(11, updatedContactCursor.getInt(PhoneQuery.PHONE_IN_VISIBLE_GROUP)); insert.bindLong(12, updatedContactCursor.getInt(PhoneQuery.PHONE_IS_PRIMARY)); - insert.bindLong(13, currentMillis); + insert.bindLong(13, updatedContactCursor.getInt(PhoneQuery.PHONE_CARRIER_PRESENCE)); + insert.bindLong(14, currentMillis); insert.executeInsert(); final String contactPhoneNumber = updatedContactCursor.getString(PhoneQuery.PHONE_NUMBER); @@ -772,59 +905,75 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { if (DEBUG) { Log.v(TAG, "Last updated at " + lastUpdateMillis); } - /** Queries the contact database to get contacts that have been updated since the last - * update time. - */ - final Cursor updatedContactCursor = mContext.getContentResolver().query(PhoneQuery.URI, - PhoneQuery.PROJECTION, PhoneQuery.SELECTION, - new String[]{lastUpdateMillis}, null); - if (updatedContactCursor == null) { - if (DEBUG) { - Log.e(TAG, "SmartDial query received null for cursor"); - } - return; - } /** Sets the time after querying the database as the current update time. */ final Long currentMillis = System.currentTimeMillis(); - try { - if (DEBUG) { - stopWatch.lap("Queried the Contacts database"); - } + if (DEBUG) { + stopWatch.lap("Queried the Contacts database"); + } - /** Prevents the app from reading the dialer database when updating. */ - sInUpdate.getAndSet(true); + /** Prevents the app from reading the dialer database when updating. */ + sInUpdate.getAndSet(true); - /** Removes contacts that have been deleted. */ - removeDeletedContacts(db, lastUpdateMillis); - removePotentiallyCorruptedContacts(db, lastUpdateMillis); + /** Removes contacts that have been deleted. */ + removeDeletedContacts(db, getDeletedContactCursor(lastUpdateMillis)); + removePotentiallyCorruptedContacts(db, lastUpdateMillis); - if (DEBUG) { - stopWatch.lap("Finished deleting deleted entries"); - } + if (DEBUG) { + stopWatch.lap("Finished deleting deleted entries"); + } - /** If the database did not exist before, jump through deletion as there is nothing - * to delete. + /** If the database did not exist before, jump through deletion as there is nothing + * to delete. + */ + if (!lastUpdateMillis.equals("0")) { + /** Removes contacts that have been updated. Updated contact information will be + * inserted later. Note that this has to use a separate result set from + * updatePhoneCursor, since it is possible for a contact to be updated (e.g. + * phone number deleted), but have no results show up in updatedPhoneCursor (since + * all of its phone numbers have been deleted). */ - if (!lastUpdateMillis.equals("0")) { - /** Removes contacts that have been updated. Updated contact information will be - * inserted later. - */ + final Cursor updatedContactCursor = mContext.getContentResolver().query( + UpdatedContactQuery.URI, + UpdatedContactQuery.PROJECTION, + UpdatedContactQuery.SELECT_UPDATED_CLAUSE, + new String[] {lastUpdateMillis}, + null + ); + if (updatedContactCursor == null) { + Log.e(TAG, "SmartDial query received null for cursor"); + return; + } + try { removeUpdatedContacts(db, updatedContactCursor); - if (DEBUG) { - stopWatch.lap("Finished deleting updated entries"); - } + } finally { + updatedContactCursor.close(); + } + if (DEBUG) { + stopWatch.lap("Finished deleting entries belonging to updated contacts"); } + } - /** Inserts recently updated contacts to the smartdial database.*/ - insertUpdatedContactsAndNumberPrefix(db, updatedContactCursor, currentMillis); + /** Queries the contact database to get all phone numbers that have been updated since the last + * update time. + */ + final Cursor updatedPhoneCursor = mContext.getContentResolver().query(PhoneQuery.URI, + PhoneQuery.PROJECTION, PhoneQuery.SELECTION, + new String[]{lastUpdateMillis}, null); + if (updatedPhoneCursor == null) { + Log.e(TAG, "SmartDial query received null for cursor"); + return; + } + + try { + /** Inserts recently updated phone numbers to the smartdial database.*/ + insertUpdatedContactsAndNumberPrefix(db, updatedPhoneCursor, currentMillis); if (DEBUG) { stopWatch.lap("Finished building the smart dial table"); } } finally { - /** Inserts prefixes of phone numbers into the prefix table.*/ - updatedContactCursor.close(); + updatedPhoneCursor.close(); } /** Gets a list of distinct contacts which have been updated, and adds the name prefixes @@ -937,7 +1086,8 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { SmartDialDbColumns.PHOTO_ID + ", " + SmartDialDbColumns.NUMBER + ", " + SmartDialDbColumns.CONTACT_ID + ", " + - SmartDialDbColumns.LOOKUP_KEY + + SmartDialDbColumns.LOOKUP_KEY + ", " + + SmartDialDbColumns.CARRIER_PRESENCE + " FROM " + Tables.SMARTDIAL_TABLE + " WHERE " + SmartDialDbColumns.CONTACT_ID + " IN " + " (SELECT " + PrefixColumns.CONTACT_ID + @@ -961,6 +1111,7 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { final int columnNumber = 3; final int columnId = 4; final int columnLookupKey = 5; + final int columnCarrierPresence = 6; if (DEBUG) { stopWatch.lap("Found column IDs"); } @@ -978,6 +1129,7 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { final long id = cursor.getLong(columnId); final long photoId = cursor.getLong(columnPhotoId); final String lookupKey = cursor.getString(columnLookupKey); + final int carrierPresence = cursor.getInt(columnCarrierPresence); /** If a contact already exists and another phone number of the contact is being * processed, skip the second instance. @@ -998,7 +1150,7 @@ public class DialerDatabaseHelper extends SQLiteOpenHelper { /** If a contact has not been added, add it to the result and the hash set.*/ duplicates.add(contactMatch); result.add(new ContactNumber(id, dataID, displayName, phoneNumber, lookupKey, - photoId)); + photoId, carrierPresence)); counter++; if (DEBUG) { stopWatch.lap("Added one result: Name: " + displayName); diff --git a/src/com/android/dialer/database/FilteredNumberAsyncQueryHandler.java b/src/com/android/dialer/database/FilteredNumberAsyncQueryHandler.java new file mode 100644 index 000000000..7af1a1339 --- /dev/null +++ b/src/com/android/dialer/database/FilteredNumberAsyncQueryHandler.java @@ -0,0 +1,267 @@ +/* + * Copyright (C) 2015 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.database; + +import android.content.AsyncQueryHandler; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabaseCorruptException; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; + +import com.android.dialer.compat.FilteredNumberCompat; +import com.android.dialer.database.FilteredNumberContract.FilteredNumber; +import com.android.dialer.database.FilteredNumberContract.FilteredNumberColumns; +import com.android.dialer.database.FilteredNumberContract.FilteredNumberTypes; + +public class FilteredNumberAsyncQueryHandler extends AsyncQueryHandler { + private static final int NO_TOKEN = 0; + + public FilteredNumberAsyncQueryHandler(ContentResolver cr) { + super(cr); + } + + /** + * Methods for FilteredNumberAsyncQueryHandler result returns. + */ + private static abstract class Listener { + protected void onQueryComplete(int token, Object cookie, Cursor cursor) { + } + protected void onInsertComplete(int token, Object cookie, Uri uri) { + } + protected void onUpdateComplete(int token, Object cookie, int result) { + } + protected void onDeleteComplete(int token, Object cookie, int result) { + } + } + + public interface OnCheckBlockedListener { + /** + * Invoked after querying if a number is blocked. + * @param id The ID of the row if blocked, null otherwise. + */ + void onCheckComplete(Integer id); + } + + public interface OnBlockNumberListener { + /** + * Invoked after inserting a blocked number. + * @param uri The uri of the newly created row. + */ + void onBlockComplete(Uri uri); + } + + public interface OnUnblockNumberListener { + /** + * Invoked after removing a blocked number + * @param rows The number of rows affected (expected value 1). + * @param values The deleted data (used for restoration). + */ + void onUnblockComplete(int rows, ContentValues values); + } + + public interface OnHasBlockedNumbersListener { + /** + * @param hasBlockedNumbers {@code true} if any blocked numbers are stored. + * {@code false} otherwise. + */ + void onHasBlockedNumbers(boolean hasBlockedNumbers); + } + + @Override + protected void onQueryComplete(int token, Object cookie, Cursor cursor) { + if (cookie != null) { + ((Listener) cookie).onQueryComplete(token, cookie, cursor); + } + } + + @Override + protected void onInsertComplete(int token, Object cookie, Uri uri) { + if (cookie != null) { + ((Listener) cookie).onInsertComplete(token, cookie, uri); + } + } + + @Override + protected void onUpdateComplete(int token, Object cookie, int result) { + if (cookie != null) { + ((Listener) cookie).onUpdateComplete(token, cookie, result); + } + } + + @Override + protected void onDeleteComplete(int token, Object cookie, int result) { + if (cookie != null) { + ((Listener) cookie).onDeleteComplete(token, cookie, result); + } + } + + public final void incrementFilteredCount(Integer id) { + // No concept of counts with new filtering + if (FilteredNumberCompat.useNewFiltering()) { + return; + } + startUpdate(NO_TOKEN, null, + ContentUris.withAppendedId(FilteredNumber.CONTENT_URI_INCREMENT_FILTERED_COUNT, id), + null, null, null); + } + + public final void hasBlockedNumbers(final OnHasBlockedNumbersListener listener) { + startQuery(NO_TOKEN, + new Listener() { + @Override + protected void onQueryComplete(int token, Object cookie, Cursor cursor) { + listener.onHasBlockedNumbers(cursor != null && cursor.getCount() > 0); + } + }, + FilteredNumberCompat.getContentUri(null), + new String[]{ FilteredNumberCompat.getIdColumnName() }, + FilteredNumberCompat.useNewFiltering() ? null : FilteredNumberColumns.TYPE + + "=" + FilteredNumberTypes.BLOCKED_NUMBER, + null, + null); + } + + /** + * Check if this number has been blocked. + * + * @return {@code false} if the number was invalid and couldn't be checked, + * {@code true} otherwise, + */ + public boolean isBlockedNumber( + final OnCheckBlockedListener listener, String number, String countryIso) { + final String e164Number = PhoneNumberUtils.formatNumberToE164(number, countryIso); + if (TextUtils.isEmpty(e164Number)) { + return false; + } + + startQuery(NO_TOKEN, + new Listener() { + @Override + protected void onQueryComplete(int token, Object cookie, Cursor cursor) { + if (cursor == null || cursor.getCount() != 1) { + listener.onCheckComplete(null); + return; + } + cursor.moveToFirst(); + // New filtering doesn't have a concept of type + if (!FilteredNumberCompat.useNewFiltering() + && cursor.getInt(cursor.getColumnIndex(FilteredNumberColumns.TYPE)) + != FilteredNumberTypes.BLOCKED_NUMBER) { + listener.onCheckComplete(null); + return; + } + listener.onCheckComplete( + cursor.getInt(cursor.getColumnIndex(FilteredNumberColumns._ID))); + } + }, + FilteredNumberCompat.getContentUri(null), + FilteredNumberCompat.filter(new String[]{FilteredNumberCompat.getIdColumnName(), + FilteredNumberCompat.getTypeColumnName()}), + FilteredNumberCompat.getE164NumberColumnName() + " = ?", + new String[]{e164Number}, + null); + + return true; + } + + public void blockNumber( + final OnBlockNumberListener listener, String number, @Nullable String countryIso) { + blockNumber(listener, null, number, countryIso); + } + + /** + * Add a number manually blocked by the user. + */ + public void blockNumber( + final OnBlockNumberListener listener, + @Nullable String normalizedNumber, + String number, + @Nullable String countryIso) { + blockNumber(listener, FilteredNumberCompat.newBlockNumberContentValues(number, + normalizedNumber, countryIso)); + } + + /** + * Block a number with specified ContentValues. Can be manually added or a restored row + * from performing the 'undo' action after unblocking. + */ + public void blockNumber(final OnBlockNumberListener listener, ContentValues values) { + startInsert(NO_TOKEN, + new Listener() { + @Override + public void onInsertComplete(int token, Object cookie, Uri uri) { + if (listener != null ) { + listener.onBlockComplete(uri); + } + } + }, FilteredNumberCompat.getContentUri(null), values); + } + + /** + * Unblocks the number with the given id. + * + * @param listener (optional) The {@link OnUnblockNumberListener} called after the number is + * unblocked. + * @param id The id of the number to unblock. + */ + public void unblock(@Nullable final OnUnblockNumberListener listener, Integer id) { + if (id == null) { + throw new IllegalArgumentException("Null id passed into unblock"); + } + unblock(listener, FilteredNumberCompat.getContentUri(id)); + } + + /** + * Removes row from database. + * @param listener (optional) The {@link OnUnblockNumberListener} called after the number is + * unblocked. + * @param uri The uri of row to remove, from + * {@link FilteredNumberAsyncQueryHandler#blockNumber}. + */ + public void unblock(@Nullable final OnUnblockNumberListener listener, final Uri uri) { + startQuery(NO_TOKEN, new Listener() { + @Override + public void onQueryComplete(int token, Object cookie, Cursor cursor) { + int rowsReturned = cursor == null ? 0 : cursor.getCount(); + if (rowsReturned != 1) { + throw new SQLiteDatabaseCorruptException + ("Returned " + rowsReturned + " rows for uri " + + uri + "where 1 expected."); + } + cursor.moveToFirst(); + final ContentValues values = new ContentValues(); + DatabaseUtils.cursorRowToContentValues(cursor, values); + values.remove(FilteredNumberCompat.getIdColumnName()); + + startDelete(NO_TOKEN, new Listener() { + @Override + public void onDeleteComplete(int token, Object cookie, int result) { + if (listener != null) { + listener.onUnblockComplete(result, values); + } + } + }, uri, null, null); + } + }, uri, null, null, null, null); + } +} diff --git a/src/com/android/dialer/database/FilteredNumberContract.java b/src/com/android/dialer/database/FilteredNumberContract.java new file mode 100644 index 000000000..f3966816c --- /dev/null +++ b/src/com/android/dialer/database/FilteredNumberContract.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2015 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.database; + +import android.net.Uri; +import android.provider.BaseColumns; + +import com.android.dialerbind.ObjectFactory; + +/** + * <p> + * The contract between the filtered number provider and applications. Contains + * definitions for the supported URIs and columns. + * Currently only accessible within Dialer. + * </p> + */ +public final class FilteredNumberContract { + + /** The authority for the filtered numbers provider */ + public static final String AUTHORITY = ObjectFactory.getFilteredNumberProviderAuthority(); + + /** A content:// style uri to the authority for the filtered numbers provider */ + public static final Uri AUTHORITY_URI = Uri.parse("content://" + AUTHORITY); + + /** The type of filtering to be applied, e.g. block the number or whitelist the number. */ + public interface FilteredNumberTypes { + static final int UNDEFINED = 0; + /** + * Dialer will disconnect the call without sending the caller to voicemail. + */ + static final int BLOCKED_NUMBER = 1; + } + + /** The original source of the filtered number, e.g. the user manually added it. */ + public interface FilteredNumberSources { + static final int UNDEFINED = 0; + /** + * The user manually added this number through Dialer (e.g. from the call log or InCallUI). + */ + static final int USER = 1; + } + + public interface FilteredNumberColumns { + // TYPE: INTEGER + static final String _ID = "_id"; + /** + * Represents the number to be filtered, normalized to compare phone numbers for equality. + * + * TYPE: TEXT + */ + static final String NORMALIZED_NUMBER = "normalized_number"; + /** + * Represents the number to be filtered, for formatting and + * used with country iso for contact lookups. + * + * TYPE: TEXT + */ + static final String NUMBER = "number"; + /** + * The country code representing the country detected when + * the phone number was added to the database. + * Most numbers don't have the country code, so a best guess is provided by + * the country detector system. The country iso is also needed in order to format + * phone numbers correctly. + * + * TYPE: TEXT + */ + static final String COUNTRY_ISO = "country_iso"; + /** + * The number of times the number has been filtered by Dialer. + * When this number is incremented, LAST_TIME_FILTERED should also be updated to + * the current time. + * + * TYPE: INTEGER + */ + static final String TIMES_FILTERED = "times_filtered"; + /** + * Set to the current time when the phone number is filtered. + * When this is updated, TIMES_FILTERED should also be incremented. + * + * TYPE: LONG + */ + static final String LAST_TIME_FILTERED = "last_time_filtered"; + // TYPE: LONG + static final String CREATION_TIME = "creation_time"; + /** + * Indicates the type of filtering to be applied. + * + * TYPE: INTEGER + * See {@link FilteredNumberTypes} + */ + static final String TYPE = "type"; + /** + * Integer representing the original source of the filtered number. + * + * TYPE: INTEGER + * See {@link FilteredNumberSources} + */ + static final String SOURCE = "source"; + } + + /** + * <p> + * Constants for the table of filtered numbers. + * </p> + * <h3>Operations</h3> + * <dl> + * <dt><b>Insert</b></dt> + * <dd>Required fields: NUMBER, NORMALIZED_NUMBER, TYPE, SOURCE. + * A default value will be used for the other fields if left null.</dd> + * <dt><b>Update</b></dt> + * <dt><b>Delete</b></dt> + * <dt><b>Query</b></dt> + * <dd>{@link #CONTENT_URI} can be used for any query, append an ID to + * retrieve a specific filtered number entry.</dd> + * </dl> + */ + public static class FilteredNumber implements BaseColumns { + + public static final String FILTERED_NUMBERS_TABLE = "filtered_numbers_table"; + public static final String FILTERED_NUMBERS_INCREMENT_FILTERED_COUNT = + "filtered_numbers_increment_filtered_count"; + + public static final Uri CONTENT_URI = Uri.withAppendedPath( + AUTHORITY_URI, + FILTERED_NUMBERS_TABLE); + + public static final Uri CONTENT_URI_INCREMENT_FILTERED_COUNT = Uri.withAppendedPath( + AUTHORITY_URI, + FILTERED_NUMBERS_INCREMENT_FILTERED_COUNT); + + /** + * This utility class cannot be instantiated. + */ + private FilteredNumber () {} + + /** + * The MIME type of {@link #CONTENT_URI} providing a directory of + * filtered numbers. + */ + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/filtered_numbers_table"; + + /** + * The MIME type of a {@link #CONTENT_URI} single filtered number. + */ + public static final String CONTENT_ITEM_TYPE = + "vnd.android.cursor.item/filtered_numbers_table"; + } +} diff --git a/src/com/android/dialer/database/FilteredNumberProvider.java b/src/com/android/dialer/database/FilteredNumberProvider.java new file mode 100644 index 000000000..3b63d4b50 --- /dev/null +++ b/src/com/android/dialer/database/FilteredNumberProvider.java @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2015 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.database; + +import android.content.ContentProvider; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteQueryBuilder; +import android.net.Uri; +import android.os.Binder; +import android.text.TextUtils; +import android.util.Log; + +import com.android.contacts.common.GeoUtil; +import com.android.dialer.database.FilteredNumberContract.FilteredNumberColumns; +import com.android.dialerbind.DatabaseHelperManager; +import com.android.dialerbind.ObjectFactory; +import com.google.common.annotations.VisibleForTesting; + +import java.util.Arrays; + +/** + * Filtered number content provider. + */ +public class FilteredNumberProvider extends ContentProvider { + + private static String TAG = FilteredNumberProvider.class.getSimpleName(); + + private DialerDatabaseHelper mDialerDatabaseHelper; + + private static final int FILTERED_NUMBERS_TABLE = 1; + private static final int FILTERED_NUMBERS_TABLE_ID = 2; + private static final int FILTERED_NUMBERS_INCREMENT_FILTERED_COUNT = 3; + + private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + + @Override + public boolean onCreate() { + mDialerDatabaseHelper = getDatabaseHelper(getContext()); + if (mDialerDatabaseHelper == null) { + return false; + } + sUriMatcher.addURI(ObjectFactory.getFilteredNumberProviderAuthority(), + FilteredNumberContract.FilteredNumber.FILTERED_NUMBERS_TABLE, + FILTERED_NUMBERS_TABLE); + sUriMatcher.addURI(ObjectFactory.getFilteredNumberProviderAuthority(), + FilteredNumberContract.FilteredNumber.FILTERED_NUMBERS_TABLE + "/#", + FILTERED_NUMBERS_TABLE_ID); + sUriMatcher.addURI(ObjectFactory.getFilteredNumberProviderAuthority(), + FilteredNumberContract.FilteredNumber.FILTERED_NUMBERS_INCREMENT_FILTERED_COUNT + + "/#", + FILTERED_NUMBERS_INCREMENT_FILTERED_COUNT); + return true; + } + + @VisibleForTesting + protected DialerDatabaseHelper getDatabaseHelper(Context context) { + return DatabaseHelperManager.getDatabaseHelper(context); + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + final SQLiteDatabase db = mDialerDatabaseHelper.getReadableDatabase(); + SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); + qb.setTables(DialerDatabaseHelper.Tables.FILTERED_NUMBER_TABLE); + final int match = sUriMatcher.match(uri); + switch (match) { + case FILTERED_NUMBERS_TABLE: + break; + case FILTERED_NUMBERS_TABLE_ID: + qb.appendWhere(FilteredNumberColumns._ID + "=" + ContentUris.parseId(uri)); + break; + default: + throw new IllegalArgumentException("Unknown uri: " + uri); + } + final Cursor c = qb.query(db, projection, selection, selectionArgs, null, null, null); + if (c != null) { + c.setNotificationUri(getContext().getContentResolver(), + FilteredNumberContract.FilteredNumber.CONTENT_URI); + } else { + Log.d(TAG, "CURSOR WAS NULL"); + } + return c; + } + + @Override + public String getType(Uri uri) { + return FilteredNumberContract.FilteredNumber.CONTENT_ITEM_TYPE; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + SQLiteDatabase db = mDialerDatabaseHelper.getWritableDatabase(); + setDefaultValues(values); + long id = db.insert(DialerDatabaseHelper.Tables.FILTERED_NUMBER_TABLE, null, values); + if (id < 0) { + return null; + } + notifyChange(uri); + return ContentUris.withAppendedId(uri, id); + } + + @VisibleForTesting + protected long getCurrentTimeMs() { + return System.currentTimeMillis(); + } + + private void setDefaultValues(ContentValues values) { + if (values.getAsString(FilteredNumberColumns.COUNTRY_ISO) == null) { + values.put(FilteredNumberColumns.COUNTRY_ISO, + GeoUtil.getCurrentCountryIso(getContext())); + } + if (values.getAsInteger(FilteredNumberColumns.TIMES_FILTERED) == null) { + values.put(FilteredNumberContract.FilteredNumberColumns.TIMES_FILTERED, 0); + } + if (values.getAsLong(FilteredNumberColumns.CREATION_TIME) == null) { + values.put(FilteredNumberColumns.CREATION_TIME, getCurrentTimeMs()); + } + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + SQLiteDatabase db = mDialerDatabaseHelper.getWritableDatabase(); + final int match = sUriMatcher.match(uri); + switch (match) { + case FILTERED_NUMBERS_TABLE: + break; + case FILTERED_NUMBERS_TABLE_ID: + selection = getSelectionWithId(selection, ContentUris.parseId(uri)); + break; + default: + throw new IllegalArgumentException("Unknown uri: " + uri); + } + int rows = db.delete(DialerDatabaseHelper.Tables.FILTERED_NUMBER_TABLE, + selection, + selectionArgs); + if (rows > 0) { + notifyChange(uri); + } + return rows; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + SQLiteDatabase db = mDialerDatabaseHelper.getWritableDatabase(); + final int match = sUriMatcher.match(uri); + switch (match) { + case FILTERED_NUMBERS_TABLE: + break; + case FILTERED_NUMBERS_TABLE_ID: + selection = getSelectionWithId(selection, ContentUris.parseId(uri)); + break; + case FILTERED_NUMBERS_INCREMENT_FILTERED_COUNT: + final long id = ContentUris.parseId(uri); + try { + db.execSQL(" UPDATE " + DialerDatabaseHelper.Tables.FILTERED_NUMBER_TABLE + + " SET" + FilteredNumberColumns.TIMES_FILTERED + "=" + + FilteredNumberColumns.TIMES_FILTERED + "+1," + + FilteredNumberColumns.LAST_TIME_FILTERED + "=" + + getCurrentTimeMs() + + " WHERE " + FilteredNumberColumns._ID + "=" + id); + } catch (SQLException e) { + Log.d(TAG, "Could not update blocked statistics for " + id); + return 0; + } + return 1; + default: + throw new IllegalArgumentException("Unknown uri: " + uri); + } + int rows = db.update(DialerDatabaseHelper.Tables.FILTERED_NUMBER_TABLE, + values, + selection, + selectionArgs); + if (rows > 0 ) { + notifyChange(uri); + } + return rows; + } + + private String getSelectionWithId(String selection, long id) { + if (TextUtils.isEmpty(selection)) { + return FilteredNumberContract.FilteredNumberColumns._ID + "=" + id; + } else { + return selection + "AND " + FilteredNumberContract.FilteredNumberColumns._ID + "=" + id; + } + } + + private void notifyChange(Uri uri) { + getContext().getContentResolver().notifyChange(uri, null); + } +} diff --git a/src/com/android/dialer/database/VoicemailArchiveContract.java b/src/com/android/dialer/database/VoicemailArchiveContract.java new file mode 100644 index 000000000..92d9c17ef --- /dev/null +++ b/src/com/android/dialer/database/VoicemailArchiveContract.java @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2016 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.database; + +import android.net.Uri; +import android.provider.BaseColumns; +import android.provider.CallLog; +import android.provider.OpenableColumns; + +/** + * Contains definitions for the supported URIs and columns for the voicemail archive table. + * All the fields excluding MIME_TYPE, _DATA, ARCHIVED, SERVER_ID, mirror the fields in the + * contract provided in {@link CallLog.Calls}. + */ +public final class VoicemailArchiveContract { + + /** The authority used by the voicemail archive provider. */ + public static final String AUTHORITY = "com.android.dialer.database.voicemailarchiveprovider"; + + /** A content:// style uri for the voicemail archive provider */ + public static final Uri AUTHORITY_URI = Uri.parse("content://" + AUTHORITY); + + public static final class VoicemailArchive implements BaseColumns, OpenableColumns { + + public static final String VOICEMAIL_ARCHIVE_TABLE = "voicemail_archive_table"; + + public static final Uri CONTENT_URI = Uri.withAppendedPath( + AUTHORITY_URI, + VOICEMAIL_ARCHIVE_TABLE); + + /** + * @see android.provider.CallLog.Calls#NUMBER + * TYPE: TEXT + */ + public static final String NUMBER = CallLog.Calls.NUMBER; + + /** + * @see android.provider.CallLog.Calls#DATE + * TYPE: LONG + */ + public static final String DATE = CallLog.Calls.DATE; + + /** + * @see android.provider.CallLog.Calls#DURATION + * TYPE: LONG + */ + public static final String DURATION = CallLog.Calls.DURATION; + + /** + * The mime type of the archived voicemail file. + * TYPE: TEXT + */ + public static final String MIME_TYPE = "mime_type"; + + /** + * @see android.provider.CallLog.Calls#COUNTRY_ISO + * TYPE: LONG + */ + public static final String COUNTRY_ISO = CallLog.Calls.COUNTRY_ISO; + + /** + * The path of the archived voicemail file. + * TYPE: TEXT + */ + public static final String _DATA = "_data"; + + /** + * @see android.provider.CallLog.Calls#GEOCODED_LOCATION + * TYPE: TEXT + */ + public static final String GEOCODED_LOCATION = CallLog.Calls.GEOCODED_LOCATION; + + /** + * @see android.provider.CallLog.Calls#CACHED_NAME + * TYPE: TEXT + */ + public static final String CACHED_NAME = CallLog.Calls.CACHED_NAME; + + /** + * @see android.provider.CallLog.Calls#CACHED_NUMBER_TYPE + * TYPE: INTEGER + */ + public static final String CACHED_NUMBER_TYPE = CallLog.Calls.CACHED_NUMBER_TYPE; + + /** + * @see android.provider.CallLog.Calls#CACHED_NUMBER_LABEL + * TYPE: TEXT + */ + public static final String CACHED_NUMBER_LABEL = CallLog.Calls.CACHED_NUMBER_LABEL; + + /** + * @see android.provider.CallLog.Calls#CACHED_LOOKUP_URI + * TYPE: TEXT + */ + public static final String CACHED_LOOKUP_URI = CallLog.Calls.CACHED_LOOKUP_URI; + + /** + * @see android.provider.CallLog.Calls#CACHED_MATCHED_NUMBER + * TYPE: TEXT + */ + public static final String CACHED_MATCHED_NUMBER = CallLog.Calls.CACHED_MATCHED_NUMBER; + + /** + * @see android.provider.CallLog.Calls#CACHED_NORMALIZED_NUMBER + * TYPE: TEXT + */ + public static final String CACHED_NORMALIZED_NUMBER = + CallLog.Calls.CACHED_NORMALIZED_NUMBER; + + /** + * @see android.provider.CallLog.Calls#CACHED_PHOTO_ID + * TYPE: LONG + */ + public static final String CACHED_PHOTO_ID = CallLog.Calls.CACHED_PHOTO_ID; + + /** + * @see android.provider.CallLog.Calls#CACHED_FORMATTED_NUMBER + * TYPE: TEXT + */ + public static final String CACHED_FORMATTED_NUMBER = CallLog.Calls.CACHED_FORMATTED_NUMBER; + + /** + * If the voicemail was archived by the user by pressing the archive button, this is set to + * 1 (true). If the voicemail was archived for the purpose of forwarding to other + * applications, this is set to 0 (false). + * TYPE: INTEGER + */ + public static final String ARCHIVED = "archived_by_user"; + + /** + * @see android.provider.CallLog.Calls#NUMBER_PRESENTATION + * TYPE: INTEGER + */ + public static final String NUMBER_PRESENTATION = CallLog.Calls.NUMBER_PRESENTATION; + + /** + * @see android.provider.CallLog.Calls#PHONE_ACCOUNT_COMPONENT_NAME + * TYPE: TEXT + */ + public static final String ACCOUNT_COMPONENT_NAME = + CallLog.Calls.PHONE_ACCOUNT_COMPONENT_NAME; + + /** + * @see android.provider.CallLog.Calls#PHONE_ACCOUNT_ID + * TYPE: TEXT + */ + public static final String ACCOUNT_ID = CallLog.Calls.PHONE_ACCOUNT_ID; + + /** + * @see android.provider.CallLog.Calls#FEATURES + * TYPE: INTEGER + */ + public static final String FEATURES = CallLog.Calls.FEATURES; + + /** + * The id of the voicemail on the server. + * TYPE: INTEGER + */ + public static final String SERVER_ID = "server_id"; + + /** + * @see android.provider.CallLog.Calls#TRANSCRIPTION + * TYPE: TEXT + */ + public static final String TRANSCRIPTION = CallLog.Calls.TRANSCRIPTION; + + /** + * @see android.provider.CallLog.Calls#CACHED_PHOTO_URI + * TYPE: TEXT + */ + public static final String CACHED_PHOTO_URI = CallLog.Calls.CACHED_PHOTO_URI; + + /** + * The MIME type of a {@link #CONTENT_URI} single voicemail. + */ + public static final String CONTENT_ITEM_TYPE = + "vnd.android.cursor.item/voicmail_archive_table"; + + public static final Uri buildWithId(int id) { + return Uri.withAppendedPath(CONTENT_URI, Integer.toString(id)); + } + + /** Not instantiable. */ + private VoicemailArchive() { + } + } +} diff --git a/src/com/android/dialer/database/VoicemailArchiveProvider.java b/src/com/android/dialer/database/VoicemailArchiveProvider.java new file mode 100644 index 000000000..b3306bc4c --- /dev/null +++ b/src/com/android/dialer/database/VoicemailArchiveProvider.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2016 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.database; + +import android.content.ContentProvider; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteQueryBuilder; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.webkit.MimeTypeMap; + +import com.android.dialerbind.DatabaseHelperManager; +import com.google.common.annotations.VisibleForTesting; + +import java.io.File; +import java.io.FileNotFoundException; + +/** + * An implementation of the Voicemail Archive content provider. This class performs + * all database level operations on the voicemail_archive_table. + */ +public class VoicemailArchiveProvider extends ContentProvider { + private static final String TAG = "VMArchiveProvider"; + private static final int VOICEMAIL_ARCHIVE_TABLE = 1; + private static final int VOICEMAIL_ARCHIVE_TABLE_ID = 2; + private static final String VOICEMAIL_FOLDER = "voicemails"; + + private DialerDatabaseHelper mDialerDatabaseHelper; + private final UriMatcher mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + + @Override + public boolean onCreate() { + mDialerDatabaseHelper = getDatabaseHelper(getContext()); + if (mDialerDatabaseHelper == null) { + return false; + } + mUriMatcher.addURI(VoicemailArchiveContract.AUTHORITY, + VoicemailArchiveContract.VoicemailArchive.VOICEMAIL_ARCHIVE_TABLE, + VOICEMAIL_ARCHIVE_TABLE); + mUriMatcher.addURI(VoicemailArchiveContract.AUTHORITY, + VoicemailArchiveContract.VoicemailArchive.VOICEMAIL_ARCHIVE_TABLE + "/#", + VOICEMAIL_ARCHIVE_TABLE_ID); + return true; + } + + @VisibleForTesting + protected DialerDatabaseHelper getDatabaseHelper(Context context) { + return DatabaseHelperManager.getDatabaseHelper(context); + } + + /** + * Used by the test class because it extends {@link android.test.ProviderTestCase2} in which the + * {@link android.test.IsolatedContext} returns /dev/null when getFilesDir() is called. + * + * @see android.test.IsolatedContext#getFilesDir + */ + @VisibleForTesting + protected File getFilesDir() { + return getContext().getFilesDir(); + } + + @Nullable + @Override + public Cursor query(Uri uri, + @Nullable String[] projection, + @Nullable String selection, + @Nullable String[] selectionArgs, + @Nullable String sortOrder) { + SQLiteDatabase db = mDialerDatabaseHelper.getReadableDatabase(); + SQLiteQueryBuilder queryBuilder = getQueryBuilder(uri); + Cursor cursor = queryBuilder + .query(db, projection, selection, selectionArgs, null, null, sortOrder); + if (cursor != null) { + cursor.setNotificationUri(getContext().getContentResolver(), + VoicemailArchiveContract.VoicemailArchive.CONTENT_URI); + } + return cursor; + } + + @Override + public String getType(Uri uri) { + return VoicemailArchiveContract.VoicemailArchive.CONTENT_ITEM_TYPE; + } + + @Nullable + @Override + public Uri insert(Uri uri, ContentValues values) { + SQLiteDatabase db = mDialerDatabaseHelper.getWritableDatabase(); + long id = db.insert(DialerDatabaseHelper.Tables.VOICEMAIL_ARCHIVE_TABLE, + null, values); + if (id < 0) { + return null; + } + notifyChange(uri); + // Create the directory for archived voicemails if it doesn't already exist + File directory = new File(getFilesDir(), VOICEMAIL_FOLDER); + directory.mkdirs(); + Uri newUri = ContentUris.withAppendedId(uri, id); + + // Create new file only if path is not provided to one + if (!values.containsKey(VoicemailArchiveContract.VoicemailArchive._DATA)) { + String fileExtension = MimeTypeMap.getSingleton().getExtensionFromMimeType( + values.getAsString(VoicemailArchiveContract.VoicemailArchive.MIME_TYPE)); + File voicemailFile = new File(directory, + TextUtils.isEmpty(fileExtension) ? Long.toString(id) : + id + "." + fileExtension); + values.put(VoicemailArchiveContract.VoicemailArchive._DATA, voicemailFile.getPath()); + } + update(newUri, values, null, null); + return newUri; + } + + + @Override + public int delete(Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { + SQLiteDatabase db = mDialerDatabaseHelper.getWritableDatabase(); + SQLiteQueryBuilder queryBuilder = getQueryBuilder(uri); + Cursor cursor = queryBuilder.query(db, null, selection, selectionArgs, null, null, null); + + // Delete all the voicemail files related to the selected rows + while (cursor.moveToNext()) { + deleteFile(cursor.getString(cursor.getColumnIndex( + VoicemailArchiveContract.VoicemailArchive._DATA))); + } + + int rows = db.delete(DialerDatabaseHelper.Tables.VOICEMAIL_ARCHIVE_TABLE, + getSelectionWithId(selection, uri), + selectionArgs); + if (rows > 0) { + notifyChange(uri); + } + return rows; + } + + @Override + public int update(Uri uri, + ContentValues values, + @Nullable String selection, + @Nullable String[] selectionArgs) { + SQLiteDatabase db = mDialerDatabaseHelper.getWritableDatabase(); + selection = getSelectionWithId(selection, uri); + int rows = db.update(DialerDatabaseHelper.Tables.VOICEMAIL_ARCHIVE_TABLE, + values, + selection, + selectionArgs); + if (rows > 0) { + notifyChange(uri); + } + return rows; + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + if (mUriMatcher.match(uri) != VOICEMAIL_ARCHIVE_TABLE_ID) { + throw new IllegalArgumentException("URI Invalid."); + } + return openFileHelper(uri, mode); + } + + private void deleteFile(@Nullable String path) { + if (TextUtils.isEmpty(path)) { + return; + } + File file = new File(path); + if (file.exists()) { + file.delete(); + } + } + + private SQLiteQueryBuilder getQueryBuilder(Uri uri) { + SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); + queryBuilder.setTables(DialerDatabaseHelper.Tables.VOICEMAIL_ARCHIVE_TABLE); + String selectionWithId = getSelectionWithId(null, uri); + if (!TextUtils.isEmpty(selectionWithId)) { + queryBuilder.appendWhere(selectionWithId); + } + return queryBuilder; + } + + private String getSelectionWithId(String selection, Uri uri) { + int match = mUriMatcher.match(uri); + switch (match) { + case VOICEMAIL_ARCHIVE_TABLE: + return selection; + case VOICEMAIL_ARCHIVE_TABLE_ID: + String idStr = VoicemailArchiveContract.VoicemailArchive._ID + "=" + + ContentUris.parseId(uri); + return TextUtils.isEmpty(selection) ? idStr : selection + " AND " + idStr; + default: + throw new IllegalArgumentException("Unknown uri: " + uri); + } + } + + private void notifyChange(Uri uri) { + getContext().getContentResolver().notifyChange(uri, null); + } +} diff --git a/src/com/android/dialer/dialpad/DialpadFragment.java b/src/com/android/dialer/dialpad/DialpadFragment.java index 3792a1d9f..55d534676 100644 --- a/src/com/android/dialer/dialpad/DialpadFragment.java +++ b/src/com/android/dialer/dialpad/DialpadFragment.java @@ -16,6 +16,8 @@ package com.android.dialer.dialpad; +import com.google.common.annotations.VisibleForTesting; + import android.app.Activity; import android.app.AlertDialog; import android.app.Dialog; @@ -27,7 +29,6 @@ import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; -import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; @@ -42,7 +43,6 @@ import android.provider.Contacts.PhonesColumns; import android.provider.Settings; import android.telecom.PhoneAccount; import android.telecom.PhoneAccountHandle; -import android.telecom.TelecomManager; import android.telephony.PhoneNumberUtils; import android.telephony.TelephonyManager; import android.text.Editable; @@ -81,12 +81,13 @@ import com.android.dialer.R; import com.android.dialer.SpecialCharSequenceMgr; import com.android.dialer.calllog.PhoneAccountUtils; import com.android.dialer.util.DialerUtils; -import com.android.dialer.util.IntentUtil; +import com.android.dialer.util.IntentUtil.CallIntentBuilder; +import com.android.dialer.util.TelecomUtil; +import com.android.incallui.Call.LogState; import com.android.phone.common.CallLogAsync; import com.android.phone.common.animation.AnimUtils; import com.android.phone.common.dialpad.DialpadKeyButton; import com.android.phone.common.dialpad.DialpadView; -import com.google.common.annotations.VisibleForTesting; import java.util.HashSet; import java.util.List; @@ -272,8 +273,9 @@ public class DialpadFragment extends Fragment return (TelephonyManager) getActivity().getSystemService(Context.TELEPHONY_SERVICE); } - private TelecomManager getTelecomManager() { - return (TelecomManager) getActivity().getSystemService(Context.TELECOM_SERVICE); + @Override + public Context getContext() { + return getActivity(); } @Override @@ -417,6 +419,7 @@ public class DialpadFragment extends Fragment return mDigits != null; } + @VisibleForTesting public EditText getDigitsWidget() { return mDigits; } @@ -481,7 +484,10 @@ public class DialpadFragment extends Fragment * @param intent The intent. * @return {@literal true} if add call operation was requested. {@literal false} otherwise. */ - private static boolean isAddCallMode(Intent intent) { + public static boolean isAddCallMode(Intent intent) { + if (intent == null) { + return false; + } final String action = intent.getAction(); if (Intent.ACTION_DIAL.equals(action) || Intent.ACTION_VIEW.equals(action)) { // see if we are "adding a call" from the InCallScreen; false by default. @@ -560,19 +566,48 @@ public class DialpadFragment extends Fragment * Sets formatted digits to digits field. */ private void setFormattedDigits(String data, String normalizedNumber) { - // strip the non-dialable numbers out of the data string. - String dialString = PhoneNumberUtils.extractNetworkPortion(data); - dialString = - PhoneNumberUtils.formatNumber(dialString, normalizedNumber, mCurrentCountryIso); - if (!TextUtils.isEmpty(dialString)) { + final String formatted = getFormattedDigits(data, normalizedNumber, mCurrentCountryIso); + if (!TextUtils.isEmpty(formatted)) { Editable digits = mDigits.getText(); - digits.replace(0, digits.length(), dialString); + digits.replace(0, digits.length(), formatted); // for some reason this isn't getting called in the digits.replace call above.. // but in any case, this will make sure the background drawable looks right afterTextChanged(digits); } } + /** + * Format the provided string of digits into one that represents a properly formatted phone + * number. + * + * @param dialString String of characters to format + * @param normalizedNumber the E164 format number whose country code is used if the given + * phoneNumber doesn't have the country code. + * @param countryIso The country code representing the format to use if the provided normalized + * number is null or invalid. + * @return the provided string of digits as a formatted phone number, retaining any + * post-dial portion of the string. + */ + @VisibleForTesting + static String getFormattedDigits(String dialString, String normalizedNumber, String countryIso) { + String number = PhoneNumberUtils.extractNetworkPortion(dialString); + // Also retrieve the post dial portion of the provided data, so that the entire dial + // string can be reconstituted later. + final String postDial = PhoneNumberUtils.extractPostDialPortion(dialString); + + if (TextUtils.isEmpty(number)) { + return postDial; + } + + number = PhoneNumberUtils.formatNumber(number, normalizedNumber, countryIso); + + if (TextUtils.isEmpty(postDial)) { + return number; + } + + return number.concat(postDial); + } + private void configureKeypadListeners(View fragmentView) { final int[] buttonIds = new int[] {R.id.one, R.id.two, R.id.three, R.id.four, R.id.five, R.id.six, R.id.seven, R.id.eight, R.id.nine, R.id.star, R.id.zero, R.id.pound}; @@ -787,13 +822,12 @@ public class DialpadFragment extends Fragment @Override public boolean onKey(View view, int keyCode, KeyEvent event) { - switch (view.getId()) { - case R.id.digits: - if (keyCode == KeyEvent.KEYCODE_ENTER) { - handleDialButtonPressed(); - return true; - } - break; + if (view.getId() == R.id.digits) { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + handleDialButtonPressed(); + return true; + } + } return false; } @@ -808,59 +842,33 @@ public class DialpadFragment extends Fragment public void onPressed(View view, boolean pressed) { if (DEBUG) Log.d(TAG, "onPressed(). view: " + view + ", pressed: " + pressed); if (pressed) { - switch (view.getId()) { - case R.id.one: { - keyPressed(KeyEvent.KEYCODE_1); - break; - } - case R.id.two: { - keyPressed(KeyEvent.KEYCODE_2); - break; - } - case R.id.three: { - keyPressed(KeyEvent.KEYCODE_3); - break; - } - case R.id.four: { - keyPressed(KeyEvent.KEYCODE_4); - break; - } - case R.id.five: { - keyPressed(KeyEvent.KEYCODE_5); - break; - } - case R.id.six: { - keyPressed(KeyEvent.KEYCODE_6); - break; - } - case R.id.seven: { - keyPressed(KeyEvent.KEYCODE_7); - break; - } - case R.id.eight: { - keyPressed(KeyEvent.KEYCODE_8); - break; - } - case R.id.nine: { - keyPressed(KeyEvent.KEYCODE_9); - break; - } - case R.id.zero: { - keyPressed(KeyEvent.KEYCODE_0); - break; - } - case R.id.pound: { - keyPressed(KeyEvent.KEYCODE_POUND); - break; - } - case R.id.star: { - keyPressed(KeyEvent.KEYCODE_STAR); - break; - } - default: { - Log.wtf(TAG, "Unexpected onTouch(ACTION_DOWN) event from: " + view); - break; - } + int resId = view.getId(); + if (resId == R.id.one) { + keyPressed(KeyEvent.KEYCODE_1); + } else if (resId == R.id.two) { + keyPressed(KeyEvent.KEYCODE_2); + } else if (resId == R.id.three) { + keyPressed(KeyEvent.KEYCODE_3); + } else if (resId == R.id.four) { + keyPressed(KeyEvent.KEYCODE_4); + } else if (resId == R.id.five) { + keyPressed(KeyEvent.KEYCODE_5); + } else if (resId == R.id.six) { + keyPressed(KeyEvent.KEYCODE_6); + } else if (resId == R.id.seven) { + keyPressed(KeyEvent.KEYCODE_7); + } else if (resId == R.id.eight) { + keyPressed(KeyEvent.KEYCODE_8); + } else if (resId == R.id.nine) { + keyPressed(KeyEvent.KEYCODE_9); + } else if (resId == R.id.zero) { + keyPressed(KeyEvent.KEYCODE_0); + } else if (resId == R.id.pound) { + keyPressed(KeyEvent.KEYCODE_POUND); + } else if (resId == R.id.star) { + keyPressed(KeyEvent.KEYCODE_STAR); + } else { + Log.wtf(TAG, "Unexpected onTouch(ACTION_DOWN) event from: " + view); } mPressedDialpadKeys.add(view); } else { @@ -901,29 +909,21 @@ public class DialpadFragment extends Fragment @Override public void onClick(View view) { - switch (view.getId()) { - case R.id.dialpad_floating_action_button: - view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); - handleDialButtonPressed(); - break; - case R.id.deleteButton: { - keyPressed(KeyEvent.KEYCODE_DEL); - break; - } - case R.id.digits: { - if (!isDigitsEmpty()) { - mDigits.setCursorVisible(true); - } - break; - } - case R.id.dialpad_overflow: { - mOverflowPopupMenu.show(); - break; - } - default: { - Log.wtf(TAG, "Unexpected onClick() event from: " + view); - return; + int resId = view.getId(); + if (resId == R.id.dialpad_floating_action_button) { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); + handleDialButtonPressed(); + } else if (resId == R.id.deleteButton) { + keyPressed(KeyEvent.KEYCODE_DEL); + } else if (resId == R.id.digits) { + if (!isDigitsEmpty()) { + mDigits.setCursorVisible(true); } + } else if (resId == R.id.dialpad_overflow) { + mOverflowPopupMenu.show(); + } else { + Log.wtf(TAG, "Unexpected onClick() event from: " + view); + return; } } @@ -931,88 +931,86 @@ public class DialpadFragment extends Fragment public boolean onLongClick(View view) { final Editable digits = mDigits.getText(); final int id = view.getId(); - switch (id) { - case R.id.deleteButton: { - digits.clear(); - return true; - } - case R.id.one: { - // '1' may be already entered since we rely on onTouch() event for numeric buttons. - // Just for safety we also check if the digits field is empty or not. - if (isDigitsEmpty() || TextUtils.equals(mDigits.getText(), "1")) { - // We'll try to initiate voicemail and thus we want to remove irrelevant string. - removePreviousDigitIfPossible(); - - List<PhoneAccountHandle> subscriptionAccountHandles = - PhoneAccountUtils.getSubscriptionPhoneAccounts(getActivity()); - boolean hasUserSelectedDefault = subscriptionAccountHandles.contains( - getTelecomManager().getDefaultOutgoingPhoneAccount( - PhoneAccount.SCHEME_VOICEMAIL)); - boolean needsAccountDisambiguation = subscriptionAccountHandles.size() > 1 - && !hasUserSelectedDefault; - - if (needsAccountDisambiguation || isVoicemailAvailable()) { - // On a multi-SIM phone, if the user has not selected a default - // subscription, initiate a call to voicemail so they can select an account - // from the "Call with" dialog. - callVoicemail(); - } else if (getActivity() != null) { - // Voicemail is unavailable maybe because Airplane mode is turned on. - // Check the current status and show the most appropriate error message. - final boolean isAirplaneModeOn = - Settings.System.getInt(getActivity().getContentResolver(), - Settings.System.AIRPLANE_MODE_ON, 0) != 0; - if (isAirplaneModeOn) { - DialogFragment dialogFragment = ErrorDialogFragment.newInstance( - R.string.dialog_voicemail_airplane_mode_message); - dialogFragment.show(getFragmentManager(), - "voicemail_request_during_airplane_mode"); - } else { - DialogFragment dialogFragment = ErrorDialogFragment.newInstance( - R.string.dialog_voicemail_not_ready_message); - dialogFragment.show(getFragmentManager(), "voicemail_not_ready"); - } + if (id == R.id.deleteButton) { + digits.clear(); + return true; + } else if (id == R.id.one) { + if (isDigitsEmpty() || TextUtils.equals(mDigits.getText(), "1")) { + // We'll try to initiate voicemail and thus we want to remove irrelevant string. + removePreviousDigitIfPossible('1'); + + List<PhoneAccountHandle> subscriptionAccountHandles = + PhoneAccountUtils.getSubscriptionPhoneAccounts(getActivity()); + boolean hasUserSelectedDefault = subscriptionAccountHandles.contains( + TelecomUtil.getDefaultOutgoingPhoneAccount(getActivity(), + PhoneAccount.SCHEME_VOICEMAIL)); + boolean needsAccountDisambiguation = subscriptionAccountHandles.size() > 1 + && !hasUserSelectedDefault; + + if (needsAccountDisambiguation || isVoicemailAvailable()) { + // On a multi-SIM phone, if the user has not selected a default + // subscription, initiate a call to voicemail so they can select an account + // from the "Call with" dialog. + callVoicemail(); + } else if (getActivity() != null) { + // Voicemail is unavailable maybe because Airplane mode is turned on. + // Check the current status and show the most appropriate error message. + final boolean isAirplaneModeOn = + Settings.System.getInt(getActivity().getContentResolver(), + Settings.System.AIRPLANE_MODE_ON, 0) != 0; + if (isAirplaneModeOn) { + DialogFragment dialogFragment = ErrorDialogFragment.newInstance( + R.string.dialog_voicemail_airplane_mode_message); + dialogFragment.show(getFragmentManager(), + "voicemail_request_during_airplane_mode"); + } else { + DialogFragment dialogFragment = ErrorDialogFragment.newInstance( + R.string.dialog_voicemail_not_ready_message); + dialogFragment.show(getFragmentManager(), "voicemail_not_ready"); } - return true; } - return false; - } - case R.id.zero: { - // Remove tentative input ('0') done by onTouch(). - removePreviousDigitIfPossible(); - keyPressed(KeyEvent.KEYCODE_PLUS); - - // Stop tone immediately - stopTone(); - mPressedDialpadKeys.remove(view); - return true; } - case R.id.digits: { - // Right now EditText does not show the "paste" option when cursor is not visible. - // To show that, make the cursor visible, and return false, letting the EditText - // show the option by itself. - mDigits.setCursorVisible(true); - return false; + return false; + } else if (id == R.id.zero) { + if (mPressedDialpadKeys.contains(view)) { + // If the zero key is currently pressed, then the long press occurred by touch + // (and not via other means like certain accessibility input methods). + // Remove the '0' that was input when the key was first pressed. + removePreviousDigitIfPossible('0'); } + keyPressed(KeyEvent.KEYCODE_PLUS); + stopTone(); + mPressedDialpadKeys.remove(view); + return true; + } else if (id == R.id.digits) { + mDigits.setCursorVisible(true); + return false; } return false; } /** - * Remove the digit just before the current position. This can be used if we want to replace - * the previous digit or cancel previously entered character. + * Remove the digit just before the current position of the cursor, iff the following conditions + * are true: + * 1) The cursor is not positioned at index 0. + * 2) The digit before the current cursor position matches the current digit. + * + * @param digit to remove from the digits view. */ - private void removePreviousDigitIfPossible() { + private void removePreviousDigitIfPossible(char digit) { final int currentPosition = mDigits.getSelectionStart(); - if (currentPosition > 0) { + if (currentPosition > 0 && digit == mDigits.getText().charAt(currentPosition - 1)) { mDigits.setSelection(currentPosition); mDigits.getText().delete(currentPosition - 1, currentPosition); } } public void callVoicemail() { - DialerUtils.startActivityWithErrorToast(getActivity(), IntentUtil.getVoicemailIntent()); + DialerUtils.startActivityWithErrorToast(getActivity(), + new CallIntentBuilder(CallUtil.getVoicemailUri()) + .setCallInitiationType(LogState.INITIATION_DIALPAD) + .build()); hideAndClearDialpad(false); } @@ -1108,9 +1106,9 @@ public class DialpadFragment extends Fragment // Clear the digits just in case. clearDialpad(); } else { - final Intent intent = IntentUtil.getCallIntent(number, - (getActivity() instanceof DialtactsActivity ? - ((DialtactsActivity) getActivity()).getCallOrigin() : null)); + final Intent intent = new CallIntentBuilder(number). + setCallInitiationType(LogState.INITIATION_DIALPAD) + .build(); DialerUtils.startActivityWithErrorToast(getActivity(), intent); hideAndClearDialpad(false); } @@ -1391,31 +1389,20 @@ public class DialpadFragment extends Fragment DialpadChooserAdapter.ChoiceItem item = (DialpadChooserAdapter.ChoiceItem) parent.getItemAtPosition(position); int itemId = item.id; - switch (itemId) { - case DialpadChooserAdapter.DIALPAD_CHOICE_USE_DTMF_DIALPAD: - // Log.i(TAG, "DIALPAD_CHOICE_USE_DTMF_DIALPAD"); - // Fire off an intent to go back to the in-call UI - // with the dialpad visible. - returnToInCallScreen(true); - break; - - case DialpadChooserAdapter.DIALPAD_CHOICE_RETURN_TO_CALL: - // Log.i(TAG, "DIALPAD_CHOICE_RETURN_TO_CALL"); - // Fire off an intent to go back to the in-call UI - // (with the dialpad hidden). - returnToInCallScreen(false); - break; - - case DialpadChooserAdapter.DIALPAD_CHOICE_ADD_NEW_CALL: - // Log.i(TAG, "DIALPAD_CHOICE_ADD_NEW_CALL"); - // Ok, guess the user really did want to be here (in the - // regular Dialer) after all. Bring back the normal Dialer UI. - showDialpadChooser(false); - break; - - default: - Log.w(TAG, "onItemClick: unexpected itemId: " + itemId); - break; + if (itemId == DialpadChooserAdapter.DIALPAD_CHOICE_USE_DTMF_DIALPAD) {// Log.i(TAG, "DIALPAD_CHOICE_USE_DTMF_DIALPAD"); + // Fire off an intent to go back to the in-call UI + // with the dialpad visible. + returnToInCallScreen(true); + } else if (itemId == DialpadChooserAdapter.DIALPAD_CHOICE_RETURN_TO_CALL) {// Log.i(TAG, "DIALPAD_CHOICE_RETURN_TO_CALL"); + // Fire off an intent to go back to the in-call UI + // (with the dialpad hidden). + returnToInCallScreen(false); + } else if (itemId == DialpadChooserAdapter.DIALPAD_CHOICE_ADD_NEW_CALL) {// Log.i(TAG, "DIALPAD_CHOICE_ADD_NEW_CALL"); + // Ok, guess the user really did want to be here (in the + // regular Dialer) after all. Bring back the normal Dialer UI. + showDialpadChooser(false); + } else { + Log.w(TAG, "onItemClick: unexpected itemId: " + itemId); } } @@ -1425,7 +1412,7 @@ public class DialpadFragment extends Fragment * or "return to call" from the dialpad chooser. */ private void returnToInCallScreen(boolean showDialpad) { - getTelecomManager().showInCallScreen(showDialpad); + TelecomUtil.showInCallScreen(getActivity(), showDialpad); // Finally, finish() ourselves so that we don't stay on the // activity stack. @@ -1442,8 +1429,12 @@ public class DialpadFragment extends Fragment * @return true if the phone is "in use", meaning that at least one line * is active (ie. off hook or ringing or dialing, or on hold). */ - public boolean isPhoneInUse() { - return getTelecomManager().isInCall(); + private boolean isPhoneInUse() { + final Context context = getActivity(); + if (context != null) { + return TelecomUtil.isInCall(context); + } + return false; } /** @@ -1455,19 +1446,19 @@ public class DialpadFragment extends Fragment @Override public boolean onMenuItemClick(MenuItem item) { - switch (item.getItemId()) { - case R.id.menu_2s_pause: - updateDialString(PAUSE); - return true; - case R.id.menu_add_wait: - updateDialString(WAIT); - return true; - case R.id.menu_call_with_note: - CallSubjectDialog.start(getActivity(), mDigits.getText().toString()); - hideAndClearDialpad(false); - return true; - default: - return false; + int resId = item.getItemId(); + if (resId == R.id.menu_2s_pause) { + updateDialString(PAUSE); + return true; + } else if (resId == R.id.menu_add_wait) { + updateDialString(WAIT); + return true; + } else if (resId == R.id.menu_call_with_note) { + CallSubjectDialog.start(getActivity(), mDigits.getText().toString()); + hideAndClearDialpad(false); + return true; + } else { + return false; } } @@ -1538,20 +1529,19 @@ public class DialpadFragment extends Fragment * * @return true if voicemail is enabled and accessible. Note that this can be false * "temporarily" after the app boot. - * @see TelecomManager#getVoiceMailNumber(PhoneAccountHandle) */ private boolean isVoicemailAvailable() { try { PhoneAccountHandle defaultUserSelectedAccount = - getTelecomManager().getDefaultOutgoingPhoneAccount( + TelecomUtil.getDefaultOutgoingPhoneAccount(getActivity(), PhoneAccount.SCHEME_VOICEMAIL); if (defaultUserSelectedAccount == null) { // In a single-SIM phone, there is no default outgoing phone account selected by // the user, so just call TelephonyManager#getVoicemailNumber directly. return !TextUtils.isEmpty(getTelephonyManager().getVoiceMailNumber()); } else { - return !TextUtils.isEmpty( - getTelecomManager().getVoiceMailNumber(defaultUserSelectedAccount)); + return !TextUtils.isEmpty(TelecomUtil.getVoicemailNumber(getActivity(), + defaultUserSelectedAccount)); } } catch (SecurityException se) { // Possibly no READ_PHONE_STATE privilege. @@ -1635,7 +1625,7 @@ public class DialpadFragment extends Fragment } private Intent newFlashIntent() { - final Intent intent = IntentUtil.getCallIntent(EMPTY_NUMBER); + final Intent intent = new CallIntentBuilder(EMPTY_NUMBER).build(); intent.putExtra(EXTRA_SEND_EMPTY_FLASH, true); return intent; } diff --git a/src/com/android/dialer/dialpad/SmartDialCursorLoader.java b/src/com/android/dialer/dialpad/SmartDialCursorLoader.java index f83f18cd7..93b649b6d 100644 --- a/src/com/android/dialer/dialpad/SmartDialCursorLoader.java +++ b/src/com/android/dialer/dialpad/SmartDialCursorLoader.java @@ -102,6 +102,7 @@ public class SmartDialCursorLoader extends AsyncTaskLoader<Cursor> { row[PhoneQuery.LOOKUP_KEY] = contact.lookupKey; row[PhoneQuery.PHOTO_ID] = contact.photoId; row[PhoneQuery.DISPLAY_NAME] = contact.displayName; + row[PhoneQuery.CARRIER_PRESENCE] = contact.carrierPresence; cursor.addRow(row); } return cursor; diff --git a/src/com/android/dialer/dialpad/SmartDialNameMatcher.java b/src/com/android/dialer/dialpad/SmartDialNameMatcher.java index 01268641d..a54fe1618 100644 --- a/src/com/android/dialer/dialpad/SmartDialNameMatcher.java +++ b/src/com/android/dialer/dialpad/SmartDialNameMatcher.java @@ -16,6 +16,7 @@ package com.android.dialer.dialpad; +import android.support.annotation.Nullable; import android.text.TextUtils; import com.android.dialer.dialpad.SmartDialPrefix.PhoneNumberTokens; @@ -123,7 +124,11 @@ public class SmartDialNameMatcher { * SmartDialMatchPosition with the matching positions otherwise */ @VisibleForTesting + @Nullable public SmartDialMatchPosition matchesNumber(String phoneNumber, String query, boolean useNanp) { + if (TextUtils.isEmpty(phoneNumber)) { + return null; + } StringBuilder builder = new StringBuilder(); constructEmptyMask(builder, phoneNumber.length()); mPhoneNumberMatchMask = builder.toString(); diff --git a/src/com/android/dialer/filterednumber/BlockNumberDialogFragment.java b/src/com/android/dialer/filterednumber/BlockNumberDialogFragment.java new file mode 100644 index 000000000..de4fe99f1 --- /dev/null +++ b/src/com/android/dialer/filterednumber/BlockNumberDialogFragment.java @@ -0,0 +1,317 @@ +/* + * Copyright (C) 2015 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.filterednumber; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.app.FragmentManager; +import android.content.ContentValues; +import android.content.Context; +import android.content.DialogInterface; +import android.net.Uri; +import android.os.Bundle; +import android.support.design.widget.Snackbar; +import android.text.TextUtils; +import android.view.View; +import android.widget.Toast; + +import com.android.contacts.common.util.ContactDisplayUtils; +import com.android.dialer.R; +import com.android.dialer.database.FilteredNumberAsyncQueryHandler; +import com.android.dialer.database.FilteredNumberAsyncQueryHandler.OnBlockNumberListener; +import com.android.dialer.database.FilteredNumberAsyncQueryHandler.OnUnblockNumberListener; +import com.android.dialer.voicemail.VisualVoicemailEnabledChecker; +import com.android.dialer.logging.InteractionEvent; +import com.android.dialer.logging.Logger; + +/** + * Fragment for confirming and enacting blocking/unblocking a number. Also invokes snackbar + * providing undo functionality. + */ +public class BlockNumberDialogFragment extends DialogFragment { + + /** + * Use a callback interface to update UI after success/undo. Favor this approach over other + * more standard paradigms because of the variety of scenarios in which the DialogFragment + * can be invoked (by an Activity, by a fragment, by an adapter, by an adapter list item). + * Because of this, we do NOT support retaining state on rotation, and will dismiss the dialog + * upon rotation instead. + */ + public interface Callback { + /** + * Called when a number is successfully added to the set of filtered numbers + */ + void onFilterNumberSuccess(); + + /** + * Called when a number is successfully removed from the set of filtered numbers + */ + void onUnfilterNumberSuccess(); + + /** + * Called when the action of filtering or unfiltering a number is undone + */ + void onChangeFilteredNumberUndo(); + } + + private static final String BLOCK_DIALOG_FRAGMENT = "BlockNumberDialog"; + + private static final String ARG_BLOCK_ID = "argBlockId"; + private static final String ARG_NUMBER = "argNumber"; + private static final String ARG_COUNTRY_ISO = "argCountryIso"; + private static final String ARG_DISPLAY_NUMBER = "argDisplayNumber"; + private static final String ARG_PARENT_VIEW_ID = "parentViewId"; + + private String mNumber; + private String mDisplayNumber; + private String mCountryIso; + + private FilteredNumberAsyncQueryHandler mHandler; + private View mParentView; + private VisualVoicemailEnabledChecker mVoicemailEnabledChecker; + private Callback mCallback; + + public static void show( + Integer blockId, + String number, + String countryIso, + String displayNumber, + Integer parentViewId, + FragmentManager fragmentManager, + Callback callback) { + final BlockNumberDialogFragment newFragment = BlockNumberDialogFragment.newInstance( + blockId, number, countryIso, displayNumber, parentViewId); + + newFragment.setCallback(callback); + newFragment.show(fragmentManager, BlockNumberDialogFragment.BLOCK_DIALOG_FRAGMENT); + } + + private static BlockNumberDialogFragment newInstance( + Integer blockId, + String number, + String countryIso, + String displayNumber, + Integer parentViewId) { + final BlockNumberDialogFragment fragment = new BlockNumberDialogFragment(); + final Bundle args = new Bundle(); + if (blockId != null) { + args.putInt(ARG_BLOCK_ID, blockId.intValue()); + } + if (parentViewId != null) { + args.putInt(ARG_PARENT_VIEW_ID, parentViewId.intValue()); + } + args.putString(ARG_NUMBER, number); + args.putString(ARG_COUNTRY_ISO, countryIso); + args.putString(ARG_DISPLAY_NUMBER, displayNumber); + fragment.setArguments(args); + return fragment; + } + + @Override + public Context getContext() { + return getActivity(); + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + super.onCreateDialog(savedInstanceState); + final boolean isBlocked = getArguments().containsKey(ARG_BLOCK_ID); + + mNumber = getArguments().getString(ARG_NUMBER); + mDisplayNumber = getArguments().getString(ARG_DISPLAY_NUMBER); + mCountryIso = getArguments().getString(ARG_COUNTRY_ISO); + + if (TextUtils.isEmpty(mDisplayNumber)) { + mDisplayNumber = mNumber; + } + + mHandler = new FilteredNumberAsyncQueryHandler(getContext().getContentResolver()); + mVoicemailEnabledChecker = new VisualVoicemailEnabledChecker(getActivity(), null); + /** + * Choose not to update VoicemailEnabledChecker, as checks should already been done in + * all current use cases. + */ + mParentView = getActivity().findViewById(getArguments().getInt(ARG_PARENT_VIEW_ID)); + + CharSequence title; + String okText; + String message; + if (isBlocked) { + title = ContactDisplayUtils.getTtsSpannedPhoneNumber(getResources(), + R.string.unblock_number_confirmation_title, + mDisplayNumber); + okText = getString(R.string.unblock_number_ok); + message = getString(R.string.unblock_number_confirmation_message); + } else { + title = ContactDisplayUtils.getTtsSpannedPhoneNumber(getResources(), + R.string.block_number_confirmation_title, + mDisplayNumber); + okText = getString(R.string.block_number_ok); + if (mVoicemailEnabledChecker.isVisualVoicemailEnabled()) { + message = getString(R.string.block_number_confirmation_message_vvm); + } else { + message = getString(R.string.block_number_confirmation_message_no_vvm); + } + } + + + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()) + .setTitle(title) + .setMessage(message) + .setPositiveButton(okText, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + if (isBlocked) { + unblockNumber(); + } else { + blockNumber(); + } + } + }) + .setNegativeButton(android.R.string.cancel, null); + return builder.create(); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + if (!FilteredNumbersUtil.canBlockNumber(getActivity(), mNumber, mCountryIso)) { + dismiss(); + Toast.makeText(getContext(), + ContactDisplayUtils.getTtsSpannedPhoneNumber( + getResources(), R.string.invalidNumber, mDisplayNumber), + Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onPause() { + // Dismiss on rotation. + dismiss(); + mCallback = null; + + super.onPause(); + } + + public void setCallback(Callback callback) { + mCallback = callback; + } + + private CharSequence getBlockedMessage() { + return ContactDisplayUtils.getTtsSpannedPhoneNumber(getResources(), + R.string.snackbar_number_blocked, mDisplayNumber); + } + + private CharSequence getUnblockedMessage() { + return ContactDisplayUtils.getTtsSpannedPhoneNumber(getResources(), + R.string.snackbar_number_unblocked, mDisplayNumber); + } + + private int getActionTextColor() { + return getContext().getResources().getColor(R.color.dialer_snackbar_action_text_color); + } + + private void blockNumber() { + final CharSequence message = getBlockedMessage(); + final CharSequence undoMessage = getUnblockedMessage(); + final Callback callback = mCallback; + final int actionTextColor = getActionTextColor(); + final Context context = getContext(); + + final OnUnblockNumberListener onUndoListener = new OnUnblockNumberListener() { + @Override + public void onUnblockComplete(int rows, ContentValues values) { + Snackbar.make(mParentView, undoMessage, Snackbar.LENGTH_LONG).show(); + if (callback != null) { + callback.onChangeFilteredNumberUndo(); + } + } + }; + + final OnBlockNumberListener onBlockNumberListener = new OnBlockNumberListener() { + @Override + public void onBlockComplete(final Uri uri) { + final View.OnClickListener undoListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + // Delete the newly created row on 'undo'. + Logger.logInteraction(InteractionEvent.UNDO_BLOCK_NUMBER); + mHandler.unblock(onUndoListener, uri); + } + }; + + Snackbar.make(mParentView, message, Snackbar.LENGTH_LONG) + .setAction(R.string.block_number_undo, undoListener) + .setActionTextColor(actionTextColor) + .show(); + + if (callback != null) { + callback.onFilterNumberSuccess(); + } + + if (context != null && FilteredNumbersUtil.hasRecentEmergencyCall(context)) { + FilteredNumbersUtil.maybeNotifyCallBlockingDisabled(context); + } + } + }; + + mHandler.blockNumber( + onBlockNumberListener, + mNumber, + mCountryIso); + } + + private void unblockNumber() { + final CharSequence message = getUnblockedMessage(); + final CharSequence undoMessage = getBlockedMessage(); + final Callback callback = mCallback; + final int actionTextColor = getActionTextColor(); + + final OnBlockNumberListener onUndoListener = new OnBlockNumberListener() { + @Override + public void onBlockComplete(final Uri uri) { + Snackbar.make(mParentView, undoMessage, Snackbar.LENGTH_LONG).show(); + if (callback != null) { + callback.onChangeFilteredNumberUndo(); + } + } + }; + + mHandler.unblock(new OnUnblockNumberListener() { + @Override + public void onUnblockComplete(int rows, final ContentValues values) { + final View.OnClickListener undoListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + // Re-insert the row on 'undo', with a new ID. + Logger.logInteraction(InteractionEvent.UNDO_UNBLOCK_NUMBER); + mHandler.blockNumber(onUndoListener, values); + } + }; + + Snackbar.make(mParentView, message, Snackbar.LENGTH_LONG) + .setAction(R.string.block_number_undo, undoListener) + .setActionTextColor(actionTextColor) + .show(); + + if (callback != null) { + callback.onUnfilterNumberSuccess(); + } + } + }, getArguments().getInt(ARG_BLOCK_ID)); + } +} diff --git a/src/com/android/dialer/filterednumber/BlockedNumbersAdapter.java b/src/com/android/dialer/filterednumber/BlockedNumbersAdapter.java new file mode 100644 index 000000000..10a4f5abd --- /dev/null +++ b/src/com/android/dialer/filterednumber/BlockedNumbersAdapter.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2015 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.filterednumber; + +import android.app.FragmentManager; +import android.content.Context; +import android.database.Cursor; +import android.telephony.PhoneNumberUtils; +import android.view.View; + +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.GeoUtil; +import com.android.dialer.R; +import com.android.dialer.calllog.ContactInfoHelper; +import com.android.dialer.database.FilteredNumberContract.FilteredNumberColumns; +import com.android.dialer.logging.InteractionEvent; +import com.android.dialer.logging.Logger; + +public class BlockedNumbersAdapter extends NumbersAdapter { + + private BlockedNumbersAdapter( + Context context, + FragmentManager fragmentManager, + ContactInfoHelper contactInfoHelper, + ContactPhotoManager contactPhotoManager) { + super(context, fragmentManager, contactInfoHelper, contactPhotoManager); + } + + public static BlockedNumbersAdapter newBlockedNumbersAdapter( + Context context, FragmentManager fragmentManager) { + return new BlockedNumbersAdapter( + context, + fragmentManager, + new ContactInfoHelper(context, GeoUtil.getCurrentCountryIso(context)), + ContactPhotoManager.getInstance(context)); + } + + @Override + public void bindView(View view, final Context context, Cursor cursor) { + super.bindView(view, context, cursor); + final Integer id = cursor.getInt(cursor.getColumnIndex(FilteredNumberColumns._ID)); + final String countryIso = cursor.getString(cursor.getColumnIndex( + FilteredNumberColumns.COUNTRY_ISO)); + final String number = cursor.getString(cursor.getColumnIndex(FilteredNumberColumns.NUMBER)); + final String normalizedNumber = cursor.getString(cursor.getColumnIndex( + FilteredNumberColumns.NORMALIZED_NUMBER)); + + final View deleteButton = view.findViewById(R.id.delete_button); + deleteButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + BlockNumberDialogFragment.show( + id, + number, + countryIso, + PhoneNumberUtils.formatNumber(number, countryIso), + R.id.blocked_numbers_activity_container, + getFragmentManager(), + new BlockNumberDialogFragment.Callback() { + @Override + public void onFilterNumberSuccess() {} + + @Override + public void onUnfilterNumberSuccess() { + Logger.logInteraction( + InteractionEvent.UNBLOCK_NUMBER_MANAGEMENT_SCREEN); + } + + @Override + public void onChangeFilteredNumberUndo() {} + }); + } + }); + + updateView(view, number, countryIso); + } + + @Override + public boolean isEmpty() { + // Always return false, so that the header with blocking-related options always shows. + return false; + } +} diff --git a/src/com/android/dialer/filterednumber/BlockedNumbersFragment.java b/src/com/android/dialer/filterednumber/BlockedNumbersFragment.java new file mode 100644 index 000000000..38615cce8 --- /dev/null +++ b/src/com/android/dialer/filterednumber/BlockedNumbersFragment.java @@ -0,0 +1,225 @@ +/* + * Copyright (C) 2015 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.filterednumber; + +import android.app.ListFragment; +import android.app.LoaderManager; +import android.content.Context; +import android.content.CursorLoader; +import android.content.Loader; +import android.database.Cursor; +import android.graphics.drawable.ColorDrawable; +import android.os.Bundle; +import android.support.v4.app.ActivityCompat; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.contacts.common.compat.CompatUtils; +import com.android.contacts.common.lettertiles.LetterTileDrawable; +import com.android.dialer.R; +import com.android.dialer.compat.FilteredNumberCompat; +import com.android.dialer.database.FilteredNumberContract; +import com.android.dialer.filterednumber.FilteredNumbersUtil.CheckForSendToVoicemailContactListener; +import com.android.dialer.filterednumber.FilteredNumbersUtil.ImportSendToVoicemailContactsListener; +import com.android.dialer.voicemail.VisualVoicemailEnabledChecker; + +public class BlockedNumbersFragment extends ListFragment + implements LoaderManager.LoaderCallbacks<Cursor>, View.OnClickListener, + VisualVoicemailEnabledChecker.Callback { + private static final char ADD_BLOCKED_NUMBER_ICON_LETTER = '+'; + + private BlockedNumbersAdapter mAdapter; + private VisualVoicemailEnabledChecker mVoicemailEnabledChecker; + + private View mImportSettings; + private View mBlockedNumbersDisabledForEmergency; + private View mBlockedNumberListDivider; + + @Override + public Context getContext() { + return getActivity(); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + LayoutInflater inflater = + (LayoutInflater) getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + getListView().addHeaderView(inflater.inflate(R.layout.blocked_number_header, null)); + getListView().addFooterView(inflater.inflate(R.layout.blocked_number_footer, null)); + //replace the icon for add number with LetterTileDrawable(), so it will have identical style + ImageView addNumberIcon = (ImageView) getActivity().findViewById(R.id.add_number_icon); + LetterTileDrawable drawable = new LetterTileDrawable(getResources()); + drawable.setLetter(ADD_BLOCKED_NUMBER_ICON_LETTER); + drawable.setColor(ActivityCompat.getColor(getActivity(), + R.color.add_blocked_number_icon_color)); + drawable.setIsCircular(true); + addNumberIcon.setImageDrawable(drawable); + + if (mAdapter == null) { + mAdapter = BlockedNumbersAdapter.newBlockedNumbersAdapter( + getContext(), getActivity().getFragmentManager()); + } + setListAdapter(mAdapter); + + mImportSettings = getListView().findViewById(R.id.import_settings); + mBlockedNumbersDisabledForEmergency = + getListView().findViewById(R.id.blocked_numbers_disabled_for_emergency); + mBlockedNumberListDivider = getActivity().findViewById(R.id.blocked_number_list_divider); + getListView().findViewById(R.id.import_button).setOnClickListener(this); + getListView().findViewById(R.id.view_numbers_button).setOnClickListener(this); + getListView().findViewById(R.id.add_number_linear_layout).setOnClickListener(this); + + mVoicemailEnabledChecker = new VisualVoicemailEnabledChecker(getContext(),this); + mVoicemailEnabledChecker.asyncUpdate(); + updateActiveVoicemailProvider(); + } + + @Override + public void onDestroy() { + setListAdapter(null); + super.onDestroy(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getLoaderManager().initLoader(0, null, this); + } + + @Override + public void onResume() { + super.onResume(); + + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + ColorDrawable backgroundDrawable = new ColorDrawable( + ActivityCompat.getColor(getActivity(), R.color.dialer_theme_color)); + actionBar.setBackgroundDrawable(backgroundDrawable); + actionBar.setDisplayShowCustomEnabled(false); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowHomeEnabled(true); + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setTitle(R.string.manage_blocked_numbers_label); + + // If the device can use the framework blocking solution, users should not be able to add + // new blocked numbers from the Blocked Management UI. They will be shown a promo card + // asking them to migrate to new blocking instead. + if (FilteredNumberCompat.canUseNewFiltering()) { + getListView().findViewById(R.id.add_number_linear_layout).setVisibility(View.GONE); + getListView().findViewById(R.id.add_number_linear_layout).setOnClickListener(null); + mBlockedNumberListDivider.setVisibility(View.INVISIBLE); + } + + FilteredNumbersUtil.checkForSendToVoicemailContact( + getActivity(), new CheckForSendToVoicemailContactListener() { + @Override + public void onComplete(boolean hasSendToVoicemailContact) { + final int visibility = hasSendToVoicemailContact ? View.VISIBLE : View.GONE; + mImportSettings.setVisibility(visibility); + } + }); + + if (FilteredNumbersUtil.hasRecentEmergencyCall(getContext())) { + mBlockedNumbersDisabledForEmergency.setVisibility(View.VISIBLE); + } else { + mBlockedNumbersDisabledForEmergency.setVisibility(View.GONE); + } + + mVoicemailEnabledChecker.asyncUpdate(); + } + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.blocked_number_fragment, container, false); + } + + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + final String[] projection = { + FilteredNumberContract.FilteredNumberColumns._ID, + FilteredNumberContract.FilteredNumberColumns.COUNTRY_ISO, + FilteredNumberContract.FilteredNumberColumns.NUMBER, + FilteredNumberContract.FilteredNumberColumns.NORMALIZED_NUMBER + }; + final String selection = FilteredNumberContract.FilteredNumberColumns.TYPE + + "=" + FilteredNumberContract.FilteredNumberTypes.BLOCKED_NUMBER; + return new CursorLoader( + getContext(), FilteredNumberContract.FilteredNumber.CONTENT_URI, projection, + selection, null, null); + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor data) { + mAdapter.swapCursor(data); + if (FilteredNumberCompat.canUseNewFiltering() || data.getCount() == 0) { + mBlockedNumberListDivider.setVisibility(View.INVISIBLE); + } else { + mBlockedNumberListDivider.setVisibility(View.VISIBLE); + } + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + mAdapter.swapCursor(null); + } + + @Override + public void onClick(final View view) { + BlockedNumbersSettingsActivity activity = (BlockedNumbersSettingsActivity) getActivity(); + if (activity == null) { + return; + } + + int resId = view.getId(); + if (resId == R.id.add_number_linear_layout) { + activity.showSearchUi(); + } else if (resId == R.id.view_numbers_button) { + activity.showNumbersToImportPreviewUi(); + } else if (resId == R.id.import_button) { + FilteredNumbersUtil.importSendToVoicemailContacts(activity, + new ImportSendToVoicemailContactsListener() { + @Override + public void onImportComplete() { + mImportSettings.setVisibility(View.GONE); + } + }); + } + } + @Override + public void onVisualVoicemailEnabledStatusChanged(boolean newStatus){ + updateActiveVoicemailProvider(); + } + + private void updateActiveVoicemailProvider(){ + if (getActivity() == null || getActivity().isFinishing()) { + return; + } + TextView footerText = (TextView) getActivity().findViewById( + R.id.blocked_number_footer_textview); + if (mVoicemailEnabledChecker.isVisualVoicemailEnabled()) { + footerText.setText(R.string.block_number_footer_message_vvm); + } else { + footerText.setText(R.string.block_number_footer_message_no_vvm); + } + } +} diff --git a/src/com/android/dialer/filterednumber/BlockedNumbersMigrator.java b/src/com/android/dialer/filterednumber/BlockedNumbersMigrator.java new file mode 100644 index 000000000..373403046 --- /dev/null +++ b/src/com/android/dialer/filterednumber/BlockedNumbersMigrator.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2015 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.filterednumber; + +import com.google.common.base.Preconditions; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.database.Cursor; +import android.os.AsyncTask; + +import com.android.dialer.compat.BlockedNumbersSdkCompat; +import com.android.dialer.compat.FilteredNumberCompat; +import com.android.dialer.database.FilteredNumberContract; +import com.android.dialer.database.FilteredNumberContract.FilteredNumber; +import com.android.dialer.database.FilteredNumberContract.FilteredNumberColumns; +import com.android.incallui.Log; + +/** + * Class which should be used to migrate numbers from {@link FilteredNumberContract} blocking to + * {@link android.provider.BlockedNumberContract} blocking. + */ +public class BlockedNumbersMigrator { + + private static final String TAG = "BlockedNumbersMigrator"; + + /** + * Listener for the operation to migrate from {@link FilteredNumberContract} blocking to + * {@link android.provider.BlockedNumberContract} blocking. + */ + public interface Listener { + + /** + * Called when the migration operation is finished. + */ + void onComplete(); + } + + private final ContentResolver mContentResolver; + + /** + * Creates a new BlockedNumbersMigrate, using the given {@link ContentResolver} to perform + * queries against the blocked numbers tables. + * + * @param contentResolver The ContentResolver + * @throws NullPointerException if contentResolver is null + */ + public BlockedNumbersMigrator(ContentResolver contentResolver) { + mContentResolver = Preconditions.checkNotNull(contentResolver); + } + + /** + * Copies all of the numbers in the {@link FilteredNumberContract} block list to the + * {@link android.provider.BlockedNumberContract} block list. + * + * @param listener {@link Listener} called once the migration is complete. + * @return {@code true} if the migrate can be attempted, {@code false} otherwise. + * @throws NullPointerException if listener is null + */ + public boolean migrate(final Listener listener) { + Log.i(TAG, "migrate - start"); + if (!FilteredNumberCompat.canUseNewFiltering()) { + Log.i(TAG, "migrate - can't use new filtering"); + return false; + } + Preconditions.checkNotNull(listener); + new AsyncTask<Void, Void, Boolean>() { + @Override + protected Boolean doInBackground(Void... params) { + Log.i(TAG, "migrate - start background migration"); + return migrateToNewBlockingInBackground(mContentResolver); + } + + @Override + protected void onPostExecute(Boolean isSuccessful) { + Log.i(TAG, "migrate - marking migration complete"); + FilteredNumberCompat.setHasMigratedToNewBlocking(isSuccessful); + Log.i(TAG, "migrate - calling listener"); + listener.onComplete(); + } + }.execute(); + return true; + } + + private static boolean migrateToNewBlockingInBackground(ContentResolver resolver) { + try (Cursor cursor = resolver.query(FilteredNumber.CONTENT_URI, + new String[]{FilteredNumberColumns.NUMBER}, null, null, null)) { + if (cursor == null) { + Log.i(TAG, "migrate - cursor was null"); + return false; + } + + Log.i(TAG, "migrate - attempting to migrate " + cursor.getCount() + "numbers"); + + int numMigrated = 0; + while (cursor.moveToNext()) { + String originalNumber = cursor + .getString(cursor.getColumnIndex(FilteredNumberColumns.NUMBER)); + if (isNumberInNewBlocking(resolver, originalNumber)) { + Log.i(TAG, "migrate - number was already blocked in new blocking"); + continue; + } + ContentValues values = new ContentValues(); + values.put(BlockedNumbersSdkCompat.COLUMN_ORIGINAL_NUMBER, originalNumber); + resolver.insert(BlockedNumbersSdkCompat.CONTENT_URI, values); + ++numMigrated; + } + Log.i(TAG, "migrate - migration complete. " + numMigrated + " numbers migrated."); + return true; + } + } + + private static boolean isNumberInNewBlocking(ContentResolver resolver, String originalNumber) { + try (Cursor cursor = resolver.query(BlockedNumbersSdkCompat.CONTENT_URI, + new String[]{BlockedNumbersSdkCompat._ID}, + BlockedNumbersSdkCompat.COLUMN_ORIGINAL_NUMBER + " = ?", + new String[] {originalNumber}, null)) { + return cursor != null && cursor.getCount() != 0; + } + } +} diff --git a/src/com/android/dialer/filterednumber/BlockedNumbersSettingsActivity.java b/src/com/android/dialer/filterednumber/BlockedNumbersSettingsActivity.java new file mode 100644 index 000000000..5ce9d21f1 --- /dev/null +++ b/src/com/android/dialer/filterednumber/BlockedNumbersSettingsActivity.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2015 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.filterednumber; + +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.support.v7.app.AppCompatActivity; +import android.util.Log; +import android.view.MenuItem; +import android.widget.FrameLayout; +import android.widget.FrameLayout.LayoutParams; +import android.widget.Toast; + +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.dialog.IndeterminateProgressDialog; +import com.android.contacts.common.list.OnPhoneNumberPickerActionListener; +import com.android.dialer.R; +import com.android.dialer.database.FilteredNumberAsyncQueryHandler; +import com.android.dialer.list.BlockedListSearchAdapter; +import com.android.dialer.list.OnListFragmentScrolledListener; +import com.android.dialer.list.BlockedListSearchFragment; +import com.android.dialer.list.SearchFragment; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.ScreenEvent; + +public class BlockedNumbersSettingsActivity extends AppCompatActivity + implements SearchFragment.HostInterface { + + private static final String TAG_BLOCKED_MANAGEMENT_FRAGMENT = "blocked_management"; + private static final String TAG_BLOCKED_SEARCH_FRAGMENT = "blocked_search"; + private static final String TAG_VIEW_NUMBERS_TO_IMPORT_FRAGMENT = "view_numbers_to_import"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.blocked_numbers_activity); + + // If savedInstanceState != null, the Activity will automatically restore the last fragment. + if (savedInstanceState == null) { + showManagementUi(); + } + } + + /** + * Shows fragment with the list of currently blocked numbers and settings related to blocking. + */ + public void showManagementUi() { + BlockedNumbersFragment fragment = (BlockedNumbersFragment) getFragmentManager() + .findFragmentByTag(TAG_BLOCKED_MANAGEMENT_FRAGMENT); + if (fragment == null) { + fragment = new BlockedNumbersFragment(); + } + + getFragmentManager().beginTransaction() + .replace(R.id.blocked_numbers_activity_container, fragment, + TAG_BLOCKED_MANAGEMENT_FRAGMENT) + .commit(); + + Logger.logScreenView(ScreenEvent.BLOCKED_NUMBER_MANAGEMENT, this); + } + + /** + * Shows fragment with search UI for browsing/finding numbers to block. + */ + public void showSearchUi() { + BlockedListSearchFragment fragment = (BlockedListSearchFragment) getFragmentManager() + .findFragmentByTag(TAG_BLOCKED_SEARCH_FRAGMENT); + if (fragment == null) { + fragment = new BlockedListSearchFragment(); + fragment.setHasOptionsMenu(false); + fragment.setShowEmptyListForNullQuery(true); + fragment.setDirectorySearchEnabled(false); + } + + getFragmentManager().beginTransaction() + .replace(R.id.blocked_numbers_activity_container, fragment, + TAG_BLOCKED_SEARCH_FRAGMENT) + .addToBackStack(null) + .commit(); + + Logger.logScreenView(ScreenEvent.BLOCKED_NUMBER_ADD_NUMBER, this); + } + + /** + * Shows fragment with UI to preview the numbers of contacts currently marked as + * send-to-voicemail in Contacts. These numbers can be imported into Dialer's blocked number + * list. + */ + public void showNumbersToImportPreviewUi() { + ViewNumbersToImportFragment fragment = (ViewNumbersToImportFragment) getFragmentManager() + .findFragmentByTag(TAG_VIEW_NUMBERS_TO_IMPORT_FRAGMENT); + if (fragment == null) { + fragment = new ViewNumbersToImportFragment(); + } + + getFragmentManager().beginTransaction() + .replace(R.id.blocked_numbers_activity_container, fragment, + TAG_VIEW_NUMBERS_TO_IMPORT_FRAGMENT) + .addToBackStack(null) + .commit(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + return false; + } + + @Override + public void onBackPressed() { + // TODO: Achieve back navigation without overriding onBackPressed. + if (getFragmentManager().getBackStackEntryCount() > 0) { + getFragmentManager().popBackStack(); + } else { + super.onBackPressed(); + } + } + + @Override + public boolean isActionBarShowing() { + return false; + } + + @Override + public boolean isDialpadShown() { + return false; + } + + @Override + public int getDialpadHeight() { + return 0; + } + + @Override + public int getActionBarHideOffset() { + return 0; + } + + @Override + public int getActionBarHeight() { + return 0; + } +} diff --git a/src/com/android/dialer/filterednumber/FilteredNumbersUtil.java b/src/com/android/dialer/filterednumber/FilteredNumbersUtil.java new file mode 100644 index 000000000..e3870ded9 --- /dev/null +++ b/src/com/android/dialer/filterednumber/FilteredNumbersUtil.java @@ -0,0 +1,363 @@ +/* + * Copyright (C) 2015 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.filterednumber; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.os.AsyncTask; +import android.preference.PreferenceManager; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.Contacts; +import android.provider.Settings; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import android.widget.Toast; + +import com.android.contacts.common.testing.NeededForTesting; +import com.android.dialer.R; +import com.android.dialer.database.FilteredNumberAsyncQueryHandler; +import com.android.dialer.database.FilteredNumberAsyncQueryHandler.OnHasBlockedNumbersListener; +import com.android.dialer.database.FilteredNumberContract.FilteredNumber; +import com.android.dialer.database.FilteredNumberContract.FilteredNumberColumns; +import com.android.dialer.logging.InteractionEvent; +import com.android.dialer.logging.Logger; + +import java.util.concurrent.TimeUnit; + +/** + * Utility to help with tasks related to filtered numbers. + */ +public class FilteredNumbersUtil { + + // Disable incoming call blocking if there was a call within the past 2 days. + private static final long RECENT_EMERGENCY_CALL_THRESHOLD_MS = 1000 * 60 * 60 * 24 * 2; + + // Pref key for storing the time of end of the last emergency call in milliseconds after epoch. + protected static final String LAST_EMERGENCY_CALL_MS_PREF_KEY = "last_emergency_call_ms"; + + // Pref key for storing whether a notification has been dispatched to notify the user that call + // blocking has been disabled because of a recent emergency call. + protected static final String NOTIFIED_CALL_BLOCKING_DISABLED_BY_EMERGENCY_CALL_PREF_KEY = + "notified_call_blocking_disabled_by_emergency_call"; + + public static final String CALL_BLOCKING_NOTIFICATION_TAG = "call_blocking"; + public static final int CALL_BLOCKING_DISABLED_BY_EMERGENCY_CALL_NOTIFICATION_ID = 10; + + /** + * Used for testing to specify that a custom threshold should be used instead of the default. + * This custom threshold will only be used when setting this log tag to VERBOSE: + * + * adb shell setprop log.tag.DebugEmergencyCall VERBOSE + * + */ + @NeededForTesting + private static final String DEBUG_EMERGENCY_CALL_TAG = "DebugEmergencyCall"; + + /** + * Used for testing to specify the custom threshold value, in milliseconds for whether an + * emergency call is "recent". The default value will be used if this custom threshold is less + * than zero. For example, to set this threshold to 60 seconds: + * + * adb shell settings put system dialer_emergency_call_threshold_ms 60000 + * + */ + @NeededForTesting + private static final String RECENT_EMERGENCY_CALL_THRESHOLD_SETTINGS_KEY = + "dialer_emergency_call_threshold_ms"; + + public interface CheckForSendToVoicemailContactListener { + public void onComplete(boolean hasSendToVoicemailContact); + } + + public interface ImportSendToVoicemailContactsListener { + public void onImportComplete(); + } + + private static class ContactsQuery { + static final String[] PROJECTION = { + Contacts._ID + }; + + static final String SELECT_SEND_TO_VOICEMAIL_TRUE = Contacts.SEND_TO_VOICEMAIL + "=1"; + + static final int ID_COLUMN_INDEX = 0; + } + + public static class PhoneQuery { + static final String[] PROJECTION = { + Contacts._ID, + Phone.NORMALIZED_NUMBER, + Phone.NUMBER + }; + + static final int ID_COLUMN_INDEX = 0; + static final int NORMALIZED_NUMBER_COLUMN_INDEX = 1; + static final int NUMBER_COLUMN_INDEX = 2; + + static final String SELECT_SEND_TO_VOICEMAIL_TRUE = Contacts.SEND_TO_VOICEMAIL + "=1"; + } + + /** + * Checks if there exists a contact with {@code Contacts.SEND_TO_VOICEMAIL} set to true. + */ + public static void checkForSendToVoicemailContact( + final Context context, final CheckForSendToVoicemailContactListener listener) { + final AsyncTask task = new AsyncTask<Object, Void, Boolean>() { + @Override + public Boolean doInBackground(Object[] params) { + if (context == null) { + return false; + } + + final Cursor cursor = context.getContentResolver().query( + Contacts.CONTENT_URI, + ContactsQuery.PROJECTION, + ContactsQuery.SELECT_SEND_TO_VOICEMAIL_TRUE, + null, + null); + + boolean hasSendToVoicemailContacts = false; + if (cursor != null) { + try { + hasSendToVoicemailContacts = cursor.getCount() > 0; + } finally { + cursor.close(); + } + } + + return hasSendToVoicemailContacts; + } + + @Override + public void onPostExecute(Boolean hasSendToVoicemailContact) { + if (listener != null) { + listener.onComplete(hasSendToVoicemailContact); + } + } + }; + task.execute(); + } + + /** + * Blocks all the phone numbers of any contacts marked as SEND_TO_VOICEMAIL, then clears the + * SEND_TO_VOICEMAIL flag on those contacts. + */ + public static void importSendToVoicemailContacts( + final Context context, final ImportSendToVoicemailContactsListener listener) { + Logger.logInteraction(InteractionEvent.IMPORT_SEND_TO_VOICEMAIL); + final FilteredNumberAsyncQueryHandler mFilteredNumberAsyncQueryHandler = + new FilteredNumberAsyncQueryHandler(context.getContentResolver()); + + final AsyncTask<Object, Void, Boolean> task = new AsyncTask<Object, Void, Boolean>() { + @Override + public Boolean doInBackground(Object[] params) { + if (context == null) { + return false; + } + + // Get the phone number of contacts marked as SEND_TO_VOICEMAIL. + final Cursor phoneCursor = context.getContentResolver().query( + Phone.CONTENT_URI, + PhoneQuery.PROJECTION, + PhoneQuery.SELECT_SEND_TO_VOICEMAIL_TRUE, + null, + null); + + if (phoneCursor == null) { + return false; + } + + try { + while (phoneCursor.moveToNext()) { + final String normalizedNumber = phoneCursor.getString( + PhoneQuery.NORMALIZED_NUMBER_COLUMN_INDEX); + final String number = phoneCursor.getString( + PhoneQuery.NUMBER_COLUMN_INDEX); + if (normalizedNumber != null) { + // Block the phone number of the contact. + mFilteredNumberAsyncQueryHandler.blockNumber( + null, normalizedNumber, number, null); + } + } + } finally { + phoneCursor.close(); + } + + // Clear SEND_TO_VOICEMAIL on all contacts. The setting has been imported to Dialer. + ContentValues newValues = new ContentValues(); + newValues.put(Contacts.SEND_TO_VOICEMAIL, 0); + context.getContentResolver().update( + Contacts.CONTENT_URI, + newValues, + ContactsQuery.SELECT_SEND_TO_VOICEMAIL_TRUE, + null); + + return true; + } + + @Override + public void onPostExecute(Boolean success) { + if (success) { + if (listener != null) { + listener.onImportComplete(); + } + } else if (context != null) { + String toastStr = context.getString(R.string.send_to_voicemail_import_failed); + Toast.makeText(context, toastStr, Toast.LENGTH_SHORT).show(); + } + } + }; + task.execute(); + } + + /** + * WARNING: This method should NOT be executed on the UI thread. + * Use {@code FilteredNumberAsyncQueryHandler} to asynchronously check if a number is blocked. + */ + public static boolean shouldBlockVoicemail( + Context context, String number, String countryIso, long voicemailDateMs) { + final String normalizedNumber = PhoneNumberUtils.formatNumberToE164(number, countryIso); + if (TextUtils.isEmpty(normalizedNumber)) { + return false; + } + + if (hasRecentEmergencyCall(context)) { + return false; + } + + final Cursor cursor = context.getContentResolver().query( + FilteredNumber.CONTENT_URI, + new String[] { + FilteredNumberColumns.CREATION_TIME + }, + FilteredNumberColumns.NORMALIZED_NUMBER + "=?", + new String[] { normalizedNumber }, + null); + if (cursor == null) { + return false; + } + try { + /* + * Block if number is found and it was added before this voicemail was received. + * The VVM's date is reported with precision to the minute, even though its + * magnitude is in milliseconds, so we perform the comparison in minutes. + */ + return cursor.moveToFirst() && + TimeUnit.MINUTES.convert(voicemailDateMs, TimeUnit.MILLISECONDS) >= + TimeUnit.MINUTES.convert(cursor.getLong(0), TimeUnit.MILLISECONDS); + } finally { + cursor.close(); + } + } + + public static boolean hasRecentEmergencyCall(Context context) { + if (context == null) { + return false; + } + + Long lastEmergencyCallTime = PreferenceManager.getDefaultSharedPreferences(context) + .getLong(LAST_EMERGENCY_CALL_MS_PREF_KEY, 0); + if (lastEmergencyCallTime == 0) { + return false; + } + + return (System.currentTimeMillis() - lastEmergencyCallTime) + < getRecentEmergencyCallThresholdMs(context); + } + + public static void recordLastEmergencyCallTime(Context context) { + if (context == null) { + return; + } + + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putLong(LAST_EMERGENCY_CALL_MS_PREF_KEY, System.currentTimeMillis()) + .putBoolean(NOTIFIED_CALL_BLOCKING_DISABLED_BY_EMERGENCY_CALL_PREF_KEY, false) + .apply(); + + maybeNotifyCallBlockingDisabled(context); + } + + public static void maybeNotifyCallBlockingDisabled(final Context context) { + // Skip if the user has already received a notification for the most recent emergency call. + if (PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(NOTIFIED_CALL_BLOCKING_DISABLED_BY_EMERGENCY_CALL_PREF_KEY, false)) { + return; + } + + // If the user has blocked numbers, notify that call blocking is temporarily disabled. + FilteredNumberAsyncQueryHandler queryHandler = + new FilteredNumberAsyncQueryHandler(context.getContentResolver()); + queryHandler.hasBlockedNumbers(new OnHasBlockedNumbersListener() { + @Override + public void onHasBlockedNumbers(boolean hasBlockedNumbers) { + if (context == null || !hasBlockedNumbers) { + return; + } + + NotificationManager notificationManager = (NotificationManager) + context.getSystemService(Context.NOTIFICATION_SERVICE); + Notification.Builder builder = new Notification.Builder(context) + .setSmallIcon(R.drawable.ic_block_24dp) + .setContentTitle(context.getString( + R.string.call_blocking_disabled_notification_title)) + .setContentText(context.getString( + R.string.call_blocking_disabled_notification_text)) + .setAutoCancel(true); + + final Intent contentIntent = + new Intent(context, BlockedNumbersSettingsActivity.class); + builder.setContentIntent(PendingIntent.getActivity( + context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT)); + + notificationManager.notify( + CALL_BLOCKING_NOTIFICATION_TAG, + CALL_BLOCKING_DISABLED_BY_EMERGENCY_CALL_NOTIFICATION_ID, + builder.build()); + + // Record that the user has been notified for this emergency call. + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean(NOTIFIED_CALL_BLOCKING_DISABLED_BY_EMERGENCY_CALL_PREF_KEY, true) + .apply(); + } + }); + } + + public static boolean canBlockNumber(Context context, String number, String countryIso) { + final String normalizedNumber = PhoneNumberUtils.formatNumberToE164(number, countryIso); + return !TextUtils.isEmpty(normalizedNumber) + && !PhoneNumberUtils.isEmergencyNumber(normalizedNumber); + } + + private static long getRecentEmergencyCallThresholdMs(Context context) { + if (android.util.Log.isLoggable( + DEBUG_EMERGENCY_CALL_TAG, android.util.Log.VERBOSE)) { + long thresholdMs = Settings.System.getLong( + context.getContentResolver(), + RECENT_EMERGENCY_CALL_THRESHOLD_SETTINGS_KEY, 0); + return thresholdMs > 0 ? thresholdMs : RECENT_EMERGENCY_CALL_THRESHOLD_MS; + } else { + return RECENT_EMERGENCY_CALL_THRESHOLD_MS; + } + } +} diff --git a/src/com/android/dialer/filterednumber/MigrateBlockedNumbersDialogFragment.java b/src/com/android/dialer/filterednumber/MigrateBlockedNumbersDialogFragment.java new file mode 100644 index 000000000..209665292 --- /dev/null +++ b/src/com/android/dialer/filterednumber/MigrateBlockedNumbersDialogFragment.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2016 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.filterednumber; + +import com.google.common.base.Preconditions; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.DialogInterface; +import android.content.DialogInterface.OnShowListener; +import android.os.Bundle; +import android.view.View; +import com.android.dialer.R; +import com.android.dialer.filterednumber.BlockedNumbersMigrator.Listener; + +/** + * Dialog fragment shown to users when they need to migrate to use + * {@link android.provider.BlockedNumberContract} for blocking. + */ +public class MigrateBlockedNumbersDialogFragment extends DialogFragment { + + private BlockedNumbersMigrator mBlockedNumbersMigrator; + private BlockedNumbersMigrator.Listener mMigrationListener; + + /** + * Creates a new MigrateBlockedNumbersDialogFragment. + * + * @param blockedNumbersMigrator The {@link BlockedNumbersMigrator} which will be used to + * migrate the numbers. + * @param migrationListener The {@link BlockedNumbersMigrator.Listener} to call when the + * migration is complete. + * @return The new MigrateBlockedNumbersDialogFragment. + * @throws NullPointerException if blockedNumbersMigrator or migrationListener are {@code null}. + */ + public static DialogFragment newInstance(BlockedNumbersMigrator blockedNumbersMigrator, + BlockedNumbersMigrator.Listener migrationListener) { + MigrateBlockedNumbersDialogFragment fragment = new MigrateBlockedNumbersDialogFragment(); + fragment.mBlockedNumbersMigrator = Preconditions.checkNotNull(blockedNumbersMigrator); + fragment.mMigrationListener = Preconditions.checkNotNull(migrationListener); + return fragment; + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + super.onCreateDialog(savedInstanceState); + AlertDialog dialog = new AlertDialog.Builder(getActivity()) + .setTitle(R.string.migrate_blocked_numbers_dialog_title) + .setMessage(R.string.migrate_blocked_numbers_dialog_message) + .setPositiveButton(R.string.migrate_blocked_numbers_dialog_allow_button, null) + .setNegativeButton(R.string.migrate_blocked_numbers_dialog_cancel_button, null) + .create(); + // The Dialog's buttons aren't available until show is called, so an OnShowListener + // is used to set the positive button callback. + dialog.setOnShowListener(new OnShowListener() { + @Override + public void onShow(DialogInterface dialog) { + final AlertDialog alertDialog = (AlertDialog) dialog; + alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) + .setOnClickListener(newPositiveButtonOnClickListener(alertDialog)); + } + }); + return dialog; + } + + /* + * Creates a new View.OnClickListener to be used as the positive button in this dialog. The + * OnClickListener will grey out the dialog's positive and negative buttons while the migration + * is underway, and close the dialog once the migrate is complete. + */ + private View.OnClickListener newPositiveButtonOnClickListener(final AlertDialog alertDialog) { + return new View.OnClickListener() { + @Override + public void onClick(View v) { + alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); + alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setEnabled(false); + mBlockedNumbersMigrator.migrate(new Listener() { + @Override + public void onComplete() { + alertDialog.dismiss(); + mMigrationListener.onComplete(); + } + }); + } + }; + } + + @Override + public void onPause() { + // The dialog is dismissed and state is cleaned up onPause, i.e. rotation. + dismiss(); + mBlockedNumbersMigrator = null; + mMigrationListener = null; + super.onPause(); + } +} diff --git a/src/com/android/dialer/filterednumber/NumbersAdapter.java b/src/com/android/dialer/filterednumber/NumbersAdapter.java new file mode 100644 index 000000000..17d5db343 --- /dev/null +++ b/src/com/android/dialer/filterednumber/NumbersAdapter.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2015 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.filterednumber; + +import android.app.FragmentManager; +import android.content.Context; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.text.BidiFormatter; +import android.text.TextDirectionHeuristics; +import android.text.TextUtils; +import android.view.View; +import android.widget.QuickContactBadge; +import android.widget.SimpleCursorAdapter; +import android.widget.TextView; + +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest; +import com.android.contacts.common.compat.CompatUtils; +import com.android.contacts.common.util.UriUtils; +import com.android.dialer.R; +import com.android.dialer.calllog.ContactInfo; +import com.android.dialer.calllog.ContactInfoHelper; +import com.android.dialer.util.PhoneNumberUtil; + +public class NumbersAdapter extends SimpleCursorAdapter { + + private Context mContext; + private FragmentManager mFragmentManager; + private ContactInfoHelper mContactInfoHelper; + private BidiFormatter mBidiFormatter = BidiFormatter.getInstance(); + private ContactPhotoManager mContactPhotoManager; + + public NumbersAdapter( + Context context, + FragmentManager fragmentManager, + ContactInfoHelper contactInfoHelper, + ContactPhotoManager contactPhotoManager) { + super(context, R.layout.blocked_number_item, null, new String[]{}, new int[]{}, 0); + mContext = context; + mFragmentManager = fragmentManager; + mContactInfoHelper = contactInfoHelper; + mContactPhotoManager = contactPhotoManager; + } + + public void updateView(View view, String number, String countryIso) { + final TextView callerName = (TextView) view.findViewById(R.id.caller_name); + final TextView callerNumber = (TextView) view.findViewById(R.id.caller_number); + final QuickContactBadge quickContactBadge = + (QuickContactBadge) view.findViewById(R.id.quick_contact_photo); + quickContactBadge.setOverlay(null); + if (CompatUtils.hasPrioritizedMimeType()) { + quickContactBadge.setPrioritizedMimeType(Phone.CONTENT_ITEM_TYPE); + } + + ContactInfo info = mContactInfoHelper.lookupNumber(number, countryIso); + if (info == null) { + info = new ContactInfo(); + info.number = number; + } + final CharSequence locationOrType = getNumberTypeOrLocation(info); + final String displayNumber = getDisplayNumber(info); + final String displayNumberStr = mBidiFormatter.unicodeWrap(displayNumber, + TextDirectionHeuristics.LTR); + + String nameForDefaultImage; + if (!TextUtils.isEmpty(info.name)) { + nameForDefaultImage = info.name; + callerName.setText(info.name); + callerNumber.setText(locationOrType + " " + displayNumberStr); + } else { + nameForDefaultImage = displayNumber; + callerName.setText(displayNumberStr); + if (!TextUtils.isEmpty(locationOrType)) { + callerNumber.setText(locationOrType); + callerNumber.setVisibility(View.VISIBLE); + } else { + callerNumber.setVisibility(View.GONE); + } + } + loadContactPhoto(info, nameForDefaultImage, quickContactBadge); + } + + private void loadContactPhoto(ContactInfo info, String displayName, QuickContactBadge badge) { + final String lookupKey = info.lookupUri == null + ? null : UriUtils.getLookupKeyFromUri(info.lookupUri); + final int contactType = mContactInfoHelper.isBusiness(info.sourceType) + ? ContactPhotoManager.TYPE_BUSINESS : ContactPhotoManager.TYPE_DEFAULT; + final DefaultImageRequest request = new DefaultImageRequest(displayName, lookupKey, + contactType, true /* isCircular */); + badge.assignContactUri(info.lookupUri); + badge.setContentDescription( + mContext.getResources().getString(R.string.description_contact_details, displayName)); + mContactPhotoManager.loadDirectoryPhoto(badge, info.photoUri, + false /* darkTheme */, true /* isCircular */, request); + } + + private String getDisplayNumber(ContactInfo info) { + if (!TextUtils.isEmpty(info.formattedNumber)) { + return info.formattedNumber; + } else if (!TextUtils.isEmpty(info.number)) { + return info.number; + } else { + return ""; + } + } + + private CharSequence getNumberTypeOrLocation(ContactInfo info) { + if (!TextUtils.isEmpty(info.name)) { + return ContactsContract.CommonDataKinds.Phone.getTypeLabel( + mContext.getResources(), info.type, info.label); + } else { + return PhoneNumberUtil.getGeoDescription(mContext, info.number); + } + } + + protected Context getContext() { + return mContext; + } + + protected FragmentManager getFragmentManager() { + return mFragmentManager; + } +} diff --git a/src/com/android/dialer/filterednumber/ViewNumbersToImportAdapter.java b/src/com/android/dialer/filterednumber/ViewNumbersToImportAdapter.java new file mode 100644 index 000000000..58fe1d46c --- /dev/null +++ b/src/com/android/dialer/filterednumber/ViewNumbersToImportAdapter.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2015 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.filterednumber; + +import android.app.FragmentManager; +import android.database.Cursor; +import android.content.Context; +import android.view.View; + +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.GeoUtil; +import com.android.dialer.R; +import com.android.dialer.calllog.ContactInfoHelper; + +public class ViewNumbersToImportAdapter extends NumbersAdapter { + + private ViewNumbersToImportAdapter( + Context context, + FragmentManager fragmentManager, + ContactInfoHelper contactInfoHelper, + ContactPhotoManager contactPhotoManager) { + super(context, fragmentManager, contactInfoHelper, contactPhotoManager); + } + + public static ViewNumbersToImportAdapter newViewNumbersToImportAdapter( + Context context, FragmentManager fragmentManager) { + return new ViewNumbersToImportAdapter( + context, + fragmentManager, + new ContactInfoHelper(context, GeoUtil.getCurrentCountryIso(context)), + ContactPhotoManager.getInstance(context)); + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + super.bindView(view, context, cursor); + + final String number = cursor.getString( + FilteredNumbersUtil.PhoneQuery.NUMBER_COLUMN_INDEX); + + view.findViewById(R.id.delete_button).setVisibility(View.GONE); + updateView(view, number, null /* countryIso */); + } +} diff --git a/src/com/android/dialer/filterednumber/ViewNumbersToImportFragment.java b/src/com/android/dialer/filterednumber/ViewNumbersToImportFragment.java new file mode 100644 index 000000000..8b24c06da --- /dev/null +++ b/src/com/android/dialer/filterednumber/ViewNumbersToImportFragment.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2015 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.filterednumber; + +import android.app.ListFragment; +import android.app.LoaderManager; +import android.content.Context; +import android.content.CursorLoader; +import android.content.Loader; +import android.database.Cursor; +import android.os.AsyncTask; +import android.os.Bundle; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.Contacts; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.dialer.R; +import com.android.dialer.database.FilteredNumberContract; +import com.android.dialer.filterednumber.FilteredNumbersUtil.ImportSendToVoicemailContactsListener; + +public class ViewNumbersToImportFragment extends ListFragment + implements LoaderManager.LoaderCallbacks<Cursor>, + View.OnClickListener { + + private ViewNumbersToImportAdapter mAdapter; + + @Override + public Context getContext() { + return getActivity(); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (mAdapter == null) { + mAdapter = ViewNumbersToImportAdapter.newViewNumbersToImportAdapter( + getContext(), getActivity().getFragmentManager()); + } + setListAdapter(mAdapter); + } + + @Override + public void onDestroy() { + setListAdapter(null); + super.onDestroy(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getLoaderManager().initLoader(0, null, this); + } + + @Override + public void onResume() { + super.onResume(); + + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + actionBar.setTitle(R.string.import_send_to_voicemail_numbers_label); + actionBar.setDisplayShowCustomEnabled(false); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowHomeEnabled(true); + actionBar.setDisplayShowTitleEnabled(true); + + getActivity().findViewById(R.id.cancel_button).setOnClickListener(this); + getActivity().findViewById(R.id.import_button).setOnClickListener(this); + } + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.view_numbers_to_import_fragment, container, false); + } + + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + final CursorLoader cursorLoader = new CursorLoader( + getContext(), + Phone.CONTENT_URI, + FilteredNumbersUtil.PhoneQuery.PROJECTION, + FilteredNumbersUtil.PhoneQuery.SELECT_SEND_TO_VOICEMAIL_TRUE, + null, + null); + return cursorLoader; + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor data) { + mAdapter.swapCursor(data); + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + mAdapter.swapCursor(null); + } + + @Override + public void onClick(final View view) { + if (view.getId() == R.id.import_button) { + FilteredNumbersUtil.importSendToVoicemailContacts(getContext(), + new ImportSendToVoicemailContactsListener() { + @Override + public void onImportComplete() { + if (getActivity() != null) { + getActivity().onBackPressed(); + } + } + }); + } else if (view.getId() == R.id.cancel_button) { + getActivity().onBackPressed(); + } + } +} diff --git a/src/com/android/dialer/interactions/PhoneNumberInteraction.java b/src/com/android/dialer/interactions/PhoneNumberInteraction.java index 8455f2423..0c3ae510a 100644 --- a/src/com/android/dialer/interactions/PhoneNumberInteraction.java +++ b/src/com/android/dialer/interactions/PhoneNumberInteraction.java @@ -48,11 +48,13 @@ import android.widget.TextView; import com.android.contacts.common.Collapser; import com.android.contacts.common.Collapser.Collapsible; import com.android.contacts.common.MoreContactUtils; -import com.android.contacts.common.activity.TransactionSafeActivity; import com.android.contacts.common.util.ContactDisplayUtils; import com.android.dialer.R; +import com.android.dialer.TransactionSafeActivity; import com.android.dialer.contact.ContactUpdateService; import com.android.dialer.util.IntentUtil; +import com.android.dialer.util.IntentUtil.CallIntentBuilder; +import com.android.incallui.Call.LogState; import com.android.dialer.util.DialerUtils; import com.google.common.annotations.VisibleForTesting; @@ -188,31 +190,38 @@ public class PhoneNumberInteraction implements OnLoadCompleteListener<Cursor> { private static final String ARG_PHONE_LIST = "phoneList"; private static final String ARG_INTERACTION_TYPE = "interactionType"; - private static final String ARG_CALL_ORIGIN = "callOrigin"; + private static final String ARG_CALL_INITIATION_TYPE = "callInitiation"; + private static final String ARG_IS_VIDEO_CALL = "is_video_call"; private int mInteractionType; private ListAdapter mPhonesAdapter; private List<PhoneItem> mPhoneList; - private String mCallOrigin; + private int mCallInitiationType; + private boolean mIsVideoCall; - public static void show(FragmentManager fragmentManager, - ArrayList<PhoneItem> phoneList, int interactionType, - String callOrigin) { + public static void show(FragmentManager fragmentManager, ArrayList<PhoneItem> phoneList, + int interactionType, boolean isVideoCall, int callInitiationType) { PhoneDisambiguationDialogFragment fragment = new PhoneDisambiguationDialogFragment(); Bundle bundle = new Bundle(); bundle.putParcelableArrayList(ARG_PHONE_LIST, phoneList); - bundle.putSerializable(ARG_INTERACTION_TYPE, interactionType); - bundle.putString(ARG_CALL_ORIGIN, callOrigin); + bundle.putInt(ARG_INTERACTION_TYPE, interactionType); + bundle.putInt(ARG_CALL_INITIATION_TYPE, callInitiationType); + bundle.putBoolean(ARG_IS_VIDEO_CALL, isVideoCall); fragment.setArguments(bundle); fragment.show(fragmentManager, TAG); } + public PhoneDisambiguationDialogFragment() { + super(); + } + @Override public Dialog onCreateDialog(Bundle savedInstanceState) { final Activity activity = getActivity(); mPhoneList = getArguments().getParcelableArrayList(ARG_PHONE_LIST); mInteractionType = getArguments().getInt(ARG_INTERACTION_TYPE); - mCallOrigin = getArguments().getString(ARG_CALL_ORIGIN); + mCallInitiationType = getArguments().getInt(ARG_CALL_INITIATION_TYPE); + mIsVideoCall = getArguments().getBoolean(ARG_IS_VIDEO_CALL); mPhonesAdapter = new PhoneItemAdapter(activity, mPhoneList, mInteractionType); final LayoutInflater inflater = activity.getLayoutInflater(); @@ -241,7 +250,7 @@ public class PhoneNumberInteraction implements OnLoadCompleteListener<Cursor> { } PhoneNumberInteraction.performAction(activity, phoneItem.phoneNumber, - mInteractionType, mCallOrigin); + mInteractionType, mIsVideoCall, mCallInitiationType); } else { dialog.dismiss(); } @@ -280,13 +289,14 @@ public class PhoneNumberInteraction implements OnLoadCompleteListener<Cursor> { private final OnDismissListener mDismissListener; private final int mInteractionType; - private final String mCallOrigin; + private final int mCallInitiationType; private boolean mUseDefault; private static final int UNKNOWN_CONTACT_ID = -1; private long mContactId = UNKNOWN_CONTACT_ID; private CursorLoader mLoader; + private boolean mIsVideoCall; /** * Constructs a new {@link PhoneNumberInteraction}. The constructor takes in a {@link Context} @@ -297,24 +307,28 @@ public class PhoneNumberInteraction implements OnLoadCompleteListener<Cursor> { @VisibleForTesting /* package */ PhoneNumberInteraction(Context context, int interactionType, DialogInterface.OnDismissListener dismissListener) { - this(context, interactionType, dismissListener, null); + this(context, interactionType, dismissListener, false /*isVideoCall*/, + LogState.INITIATION_UNKNOWN); } private PhoneNumberInteraction(Context context, int interactionType, - DialogInterface.OnDismissListener dismissListener, String callOrigin) { + DialogInterface.OnDismissListener dismissListener, boolean isVideoCall, + int callInitiationType) { mContext = context; mInteractionType = interactionType; mDismissListener = dismissListener; - mCallOrigin = callOrigin; + mCallInitiationType = callInitiationType; + mIsVideoCall = isVideoCall; } private void performAction(String phoneNumber) { - PhoneNumberInteraction.performAction(mContext, phoneNumber, mInteractionType, mCallOrigin); + PhoneNumberInteraction.performAction(mContext, phoneNumber, mInteractionType, mIsVideoCall, + mCallInitiationType); } private static void performAction( Context context, String phoneNumber, int interactionType, - String callOrigin) { + boolean isVideoCall, int callInitiationType) { Intent intent; switch (interactionType) { case ContactDisplayUtils.INTERACTION_SMS: @@ -322,7 +336,10 @@ public class PhoneNumberInteraction implements OnLoadCompleteListener<Cursor> { Intent.ACTION_SENDTO, Uri.fromParts("sms", phoneNumber, null)); break; default: - intent = IntentUtil.getCallIntent(phoneNumber, callOrigin); + intent = new CallIntentBuilder(phoneNumber) + .setCallInitiationType(callInitiationType) + .setIsVideoCall(isVideoCall) + .build(); break; } DialerUtils.startActivityWithErrorToast(context, intent); @@ -447,54 +464,16 @@ public class PhoneNumberInteraction implements OnLoadCompleteListener<Cursor> { } /** - * Start call action using given contact Uri. If there are multiple candidates for the phone - * call, dialog is automatically shown and the user is asked to choose one. - * * @param activity that is calling this interaction. This must be of type * {@link TransactionSafeActivity} because we need to check on the activity state after the * phone numbers have been queried for. - * @param uri contact Uri (built from {@link Contacts#CONTENT_URI}) or data Uri - * (built from {@link Data#CONTENT_URI}). Contact Uri may show the disambiguation dialog while - * data Uri won't. - */ - public static void startInteractionForPhoneCall(TransactionSafeActivity activity, Uri uri) { - (new PhoneNumberInteraction(activity, ContactDisplayUtils.INTERACTION_CALL, null)) - .startInteraction(uri, true); - } - - /** - * Start call action using given contact Uri. If there are multiple candidates for the phone - * call, dialog is automatically shown and the user is asked to choose one. - * - * @param activity that is calling this interaction. This must be of type - * {@link TransactionSafeActivity} because we need to check on the activity state after the - * phone numbers have been queried for. - * @param uri contact Uri (built from {@link Contacts#CONTENT_URI}) or data Uri - * (built from {@link Data#CONTENT_URI}). Contact Uri may show the disambiguation dialog while - * data Uri won't. - * @param useDefault Whether or not to use the primary(default) phone number. If true, the - * primary phone number will always be used by default if one is available. If false, a - * disambiguation dialog will be shown regardless of whether or not a primary phone number - * is available. + * @param isVideoCall {@code true} if the call is a video call, {@code false} otherwise. + * @param callInitiationType Indicates the UI affordance that was used to initiate the call. */ public static void startInteractionForPhoneCall(TransactionSafeActivity activity, Uri uri, - boolean useDefault) { - (new PhoneNumberInteraction(activity, ContactDisplayUtils.INTERACTION_CALL, null)) - .startInteraction(uri, useDefault); - } - - /** - * @param activity that is calling this interaction. This must be of type - * {@link TransactionSafeActivity} because we need to check on the activity state after the - * phone numbers have been queried for. - * @param callOrigin If non null, {@link PhoneConstants#EXTRA_CALL_ORIGIN} will be - * appended to the Intent initiating phone call. See comments in Phone package (PhoneApp) - * for more detail. - */ - public static void startInteractionForPhoneCall(TransactionSafeActivity activity, Uri uri, - String callOrigin) { - (new PhoneNumberInteraction(activity, ContactDisplayUtils.INTERACTION_CALL, null, callOrigin)) - .startInteraction(uri, true); + boolean isVideoCall, int callInitiationType) { + (new PhoneNumberInteraction(activity, ContactDisplayUtils.INTERACTION_CALL, null, + isVideoCall, callInitiationType)).startInteraction(uri, true); } /** @@ -521,7 +500,17 @@ public class PhoneNumberInteraction implements OnLoadCompleteListener<Cursor> { @VisibleForTesting /* package */ void showDisambiguationDialog(ArrayList<PhoneItem> phoneList) { - PhoneDisambiguationDialogFragment.show(((Activity)mContext).getFragmentManager(), - phoneList, mInteractionType, mCallOrigin); + final Activity activity = (Activity) mContext; + if (activity.isDestroyed()) { + // Check whether the activity is still running + return; + } + try { + PhoneDisambiguationDialogFragment.show(activity.getFragmentManager(), + phoneList, mInteractionType, mIsVideoCall, mCallInitiationType); + } catch (IllegalStateException e) { + // ignore to be safe. Shouldn't happen because we checked the + // activity wasn't destroyed, but to be safe. + } } } diff --git a/src/com/android/dialer/list/AllContactsFragment.java b/src/com/android/dialer/list/AllContactsFragment.java index 0f31ff88f..7e76279d9 100644 --- a/src/com/android/dialer/list/AllContactsFragment.java +++ b/src/com/android/dialer/list/AllContactsFragment.java @@ -28,11 +28,13 @@ import android.database.Cursor; import android.net.Uri; import android.provider.ContactsContract.CommonDataKinds.Phone; import android.provider.ContactsContract.QuickContact; +import android.support.v13.app.FragmentCompat; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; +import com.android.contacts.common.compat.CompatUtils; import com.android.contacts.common.list.ContactEntryListAdapter; import com.android.contacts.common.list.ContactEntryListFragment; import com.android.contacts.common.list.ContactListFilter; @@ -49,7 +51,8 @@ import com.android.dialer.widget.EmptyContentView.OnEmptyViewActionButtonClicked * Fragments to show all contacts with phone numbers. */ public class AllContactsFragment extends ContactEntryListFragment<ContactEntryListAdapter> - implements OnEmptyViewActionButtonClickedListener { + implements OnEmptyViewActionButtonClickedListener, + FragmentCompat.OnRequestPermissionsResultCallback { private static final int READ_CONTACTS_PERMISSION_REQUEST_CODE = 1; @@ -150,8 +153,13 @@ public class AllContactsFragment extends ContactEntryListFragment<ContactEntryLi public void onItemClick(AdapterView<?> parent, View view, int position, long id) { final Uri uri = (Uri) view.getTag(); if (uri != null) { - QuickContact.showQuickContact(getContext(), view, uri, null, - Phone.CONTENT_ITEM_TYPE); + if (CompatUtils.hasPrioritizedMimeType()) { + QuickContact.showQuickContact(getContext(), view, uri, null, + Phone.CONTENT_ITEM_TYPE); + } else { + QuickContact.showQuickContact(getActivity(), view, uri, QuickContact.MODE_LARGE, + null); + } } } @@ -168,7 +176,8 @@ public class AllContactsFragment extends ContactEntryListFragment<ContactEntryLi } if (!PermissionsUtil.hasPermission(activity, READ_CONTACTS)) { - requestPermissions(new String[] {READ_CONTACTS}, READ_CONTACTS_PERMISSION_REQUEST_CODE); + FragmentCompat.requestPermissions(this, new String[] {READ_CONTACTS}, + READ_CONTACTS_PERMISSION_REQUEST_CODE); } else { // Add new contact DialerUtils.startActivityWithErrorToast(activity, IntentUtil.getNewContactIntent(), diff --git a/src/com/android/dialer/list/BlockedListSearchAdapter.java b/src/com/android/dialer/list/BlockedListSearchAdapter.java new file mode 100644 index 000000000..1618826bd --- /dev/null +++ b/src/com/android/dialer/list/BlockedListSearchAdapter.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2015 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.list; + +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Color; +import android.view.View; + +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.list.ContactListItemView; +import com.android.dialer.R; +import com.android.dialer.database.FilteredNumberAsyncQueryHandler; + +/** + * List adapter to display search results for adding a blocked number. + */ +public class BlockedListSearchAdapter extends RegularSearchListAdapter { + + private Resources mResources; + private FilteredNumberAsyncQueryHandler mFilteredNumberAsyncQueryHandler; + + public BlockedListSearchAdapter(Context context) { + super(context); + mResources = context.getResources(); + disableAllShortcuts(); + setShortcutEnabled(SHORTCUT_BLOCK_NUMBER, true); + + mFilteredNumberAsyncQueryHandler = + new FilteredNumberAsyncQueryHandler(context.getContentResolver()); + } + + @Override + protected boolean isChanged(boolean showNumberShortcuts) { + return setShortcutEnabled(SHORTCUT_BLOCK_NUMBER, showNumberShortcuts || mIsQuerySipAddress); + } + + public void setViewBlocked(ContactListItemView view, Integer id) { + view.setTag(R.id.block_id, id); + final int textColor = mResources.getColor(R.color.blocked_number_block_color); + view.getDataView().setTextColor(textColor); + view.getLabelView().setTextColor(textColor); + //TODO: Add icon + } + + public void setViewUnblocked(ContactListItemView view) { + view.setTag(R.id.block_id, null); + final int textColor = mResources.getColor(R.color.dialtacts_secondary_text_color); + view.getDataView().setTextColor(textColor); + view.getLabelView().setTextColor(textColor); + //TODO: Remove icon + } + + @Override + protected void bindView(View itemView, int partition, Cursor cursor, int position) { + super.bindView(itemView, partition, cursor, position); + + final ContactListItemView view = (ContactListItemView) itemView; + // Reset view state to unblocked. + setViewUnblocked(view); + + final String number = getPhoneNumber(position); + final String countryIso = GeoUtil.getCurrentCountryIso(mContext); + final FilteredNumberAsyncQueryHandler.OnCheckBlockedListener onCheckListener = + new FilteredNumberAsyncQueryHandler.OnCheckBlockedListener() { + @Override + public void onCheckComplete(Integer id) { + if (id != null) { + setViewBlocked(view, id); + } + } + }; + mFilteredNumberAsyncQueryHandler.isBlockedNumber( + onCheckListener, number, countryIso); + } +} diff --git a/src/com/android/dialer/list/BlockedListSearchFragment.java b/src/com/android/dialer/list/BlockedListSearchFragment.java new file mode 100644 index 000000000..da6b42820 --- /dev/null +++ b/src/com/android/dialer/list/BlockedListSearchFragment.java @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2015 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.list; + +import android.app.Activity; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.telephony.PhoneNumberUtils; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.Log; +import android.util.TypedValue; +import android.view.View; +import android.widget.AdapterView; +import android.widget.EditText; +import android.widget.Toast; + +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.list.ContactEntryListAdapter; +import com.android.contacts.common.util.ContactDisplayUtils; +import com.android.dialer.R; +import com.android.dialer.database.FilteredNumberAsyncQueryHandler; +import com.android.dialer.database.FilteredNumberAsyncQueryHandler.OnCheckBlockedListener; +import com.android.dialer.filterednumber.BlockNumberDialogFragment; +import com.android.dialer.logging.InteractionEvent; +import com.android.dialer.logging.Logger; +import com.android.dialer.widget.SearchEditTextLayout; + +public class BlockedListSearchFragment extends RegularSearchFragment + implements BlockNumberDialogFragment.Callback { + private static final String TAG = BlockedListSearchFragment.class.getSimpleName(); + + private static final String KEY_SEARCH_QUERY = "search_query"; + + private FilteredNumberAsyncQueryHandler mFilteredNumberAsyncQueryHandler; + + private EditText mSearchView; + + private final TextWatcher mPhoneSearchQueryTextListener = new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + setQueryString(s.toString(), false); + } + + @Override + public void afterTextChanged(Editable s) {} + }; + + private final SearchEditTextLayout.Callback mSearchLayoutCallback = + new SearchEditTextLayout.Callback() { + @Override + public void onBackButtonClicked() { + getActivity().onBackPressed(); + } + + @Override + public void onSearchViewClicked() { + } + }; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setShowEmptyListForNullQuery(true); + /* + * Pass in the empty string here so ContactEntryListFragment#setQueryString interprets it as + * an empty search query, rather than as an uninitalized value. In the latter case, the + * adapter returned by #createListAdapter is used, which populates the view with contacts. + * Passing in the empty string forces ContactEntryListFragment to interpret it as an empty + * query, which results in showing an empty view + */ + setQueryString(getQueryString() == null ? "" : getQueryString(), false); + mFilteredNumberAsyncQueryHandler = new FilteredNumberAsyncQueryHandler( + getContext().getContentResolver()); + } + + @Override + public void onResume() { + super.onResume(); + + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + actionBar.setCustomView(R.layout.search_edittext); + actionBar.setDisplayShowCustomEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(false); + actionBar.setDisplayShowHomeEnabled(false); + + final SearchEditTextLayout searchEditTextLayout = (SearchEditTextLayout) actionBar + .getCustomView().findViewById(R.id.search_view_container); + searchEditTextLayout.expand(false, true); + searchEditTextLayout.setCallback(mSearchLayoutCallback); + searchEditTextLayout.setBackgroundDrawable(null); + + mSearchView = (EditText) searchEditTextLayout.findViewById(R.id.search_view); + mSearchView.addTextChangedListener(mPhoneSearchQueryTextListener); + mSearchView.setHint(R.string.block_number_search_hint); + + searchEditTextLayout.findViewById(R.id.search_box_expanded) + .setBackgroundColor(getContext().getResources().getColor(android.R.color.white)); + + if (!TextUtils.isEmpty(getQueryString())) { + mSearchView.setText(getQueryString()); + } + + // TODO: Don't set custom text size; use default search text size. + mSearchView.setTextSize(TypedValue.COMPLEX_UNIT_PX, + getResources().getDimension(R.dimen.blocked_number_search_text_size)); + } + + @Override + protected ContactEntryListAdapter createListAdapter() { + BlockedListSearchAdapter adapter = new BlockedListSearchAdapter(getActivity()); + adapter.setDisplayPhotos(true); + // Don't show SIP addresses. + adapter.setUseCallableUri(false); + // Keep in sync with the queryString set in #onCreate + adapter.setQueryString(getQueryString() == null ? "" : getQueryString()); + return adapter; + } + + + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + super.onItemClick(parent, view, position, id); + final int adapterPosition = position - getListView().getHeaderViewsCount(); + final BlockedListSearchAdapter adapter = (BlockedListSearchAdapter) getAdapter(); + final int shortcutType = adapter.getShortcutTypeFromPosition(adapterPosition); + final Integer blockId = (Integer) view.getTag(R.id.block_id); + final String number; + switch (shortcutType) { + case DialerPhoneNumberListAdapter.SHORTCUT_INVALID: + // Handles click on a search result, either contact or nearby places result. + number = adapter.getPhoneNumber(adapterPosition); + blockContactNumber(number, blockId); + break; + case DialerPhoneNumberListAdapter.SHORTCUT_BLOCK_NUMBER: + // Handles click on 'Block number' shortcut to add the user query as a number. + number = adapter.getQueryString(); + blockNumber(number); + break; + default: + Log.w(TAG, "Ignoring unsupported shortcut type: " + shortcutType); + break; + } + } + + @Override + protected void onItemClick(int position, long id) { + // Prevent SearchFragment.onItemClicked from being called. + } + + private void blockNumber(final String number) { + final String countryIso = GeoUtil.getCurrentCountryIso(getContext()); + final OnCheckBlockedListener onCheckListener = new OnCheckBlockedListener() { + @Override + public void onCheckComplete(Integer id) { + if (id == null) { + BlockNumberDialogFragment.show( + id, + number, + countryIso, + PhoneNumberUtils.formatNumber(number, countryIso), + R.id.blocked_numbers_activity_container, + getFragmentManager(), + BlockedListSearchFragment.this); + } else { + Toast.makeText(getContext(), + ContactDisplayUtils.getTtsSpannedPhoneNumber(getResources(), + R.string.alreadyBlocked, number), + Toast.LENGTH_SHORT).show(); + } + } + }; + final boolean success = mFilteredNumberAsyncQueryHandler.isBlockedNumber( + onCheckListener, number, countryIso); + if (!success) { + Toast.makeText(getContext(), + ContactDisplayUtils.getTtsSpannedPhoneNumber( + getResources(), R.string.invalidNumber, number), + Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFilterNumberSuccess() { + Logger.logInteraction(InteractionEvent.BLOCK_NUMBER_MANAGEMENT_SCREEN); + goBack(); + } + + @Override + public void onUnfilterNumberSuccess() { + Log.wtf(TAG, "Unblocked a number from the BlockedListSearchFragment"); + goBack(); + } + + private void goBack() { + Activity activity = getActivity(); + if (activity == null) { + return; + } + activity.onBackPressed(); + } + + @Override + public void onChangeFilteredNumberUndo() { + getAdapter().notifyDataSetChanged(); + } + + private void blockContactNumber(final String number, final Integer blockId) { + if (blockId != null) { + Toast.makeText(getContext(), ContactDisplayUtils.getTtsSpannedPhoneNumber( + getResources(), R.string.alreadyBlocked, number), + Toast.LENGTH_SHORT).show(); + return; + } + + BlockNumberDialogFragment.show( + blockId, + number, + GeoUtil.getCurrentCountryIso(getContext()), + number, + R.id.blocked_numbers_activity_container, + getFragmentManager(), + this); + } +} diff --git a/src/com/android/dialer/list/ContentChangedFilter.java b/src/com/android/dialer/list/ContentChangedFilter.java new file mode 100644 index 000000000..e552aa3f0 --- /dev/null +++ b/src/com/android/dialer/list/ContentChangedFilter.java @@ -0,0 +1,40 @@ +package com.android.dialer.list; + +import android.view.View; +import android.view.View.AccessibilityDelegate; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityEvent; + +/** + * AccessibilityDelegate that will filter out TYPE_WINDOW_CONTENT_CHANGED + * Used to suppress "Showing items x of y" from firing of ListView whenever it's content changes. + * AccessibilityEvent can only be rejected at a view's parent once it is generated, + * use addToParent() to add this delegate to the parent. + */ +public class ContentChangedFilter extends AccessibilityDelegate { + //the view we don't want TYPE_WINDOW_CONTENT_CHANGED to fire. + private View mView; + + /** + * Add this delegate to the parent of @param view to filter out TYPE_WINDOW_CONTENT_CHANGED + */ + public static void addToParent(View view){ + View parent = (View) view.getParent(); + parent.setAccessibilityDelegate(new ContentChangedFilter(view)); + } + + private ContentChangedFilter(View view){ + super(); + mView = view; + } + @Override + public boolean onRequestSendAccessibilityEvent (ViewGroup host, View child, AccessibilityEvent event){ + if(child == mView){ + if(event.getEventType() == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED){ + return false; + } + } + return super.onRequestSendAccessibilityEvent(host,child,event); + } + +} diff --git a/src/com/android/dialer/list/DialerPhoneNumberListAdapter.java b/src/com/android/dialer/list/DialerPhoneNumberListAdapter.java index 401b0b641..7164de2d7 100644 --- a/src/com/android/dialer/list/DialerPhoneNumberListAdapter.java +++ b/src/com/android/dialer/list/DialerPhoneNumberListAdapter.java @@ -2,15 +2,19 @@ package com.android.dialer.list; import android.content.Context; import android.content.res.Resources; +import android.database.Cursor; import android.telephony.PhoneNumberUtils; import android.text.BidiFormatter; import android.text.TextDirectionHeuristics; +import android.util.Log; import android.view.View; import android.view.ViewGroup; +import com.android.contacts.common.CallUtil; import com.android.contacts.common.GeoUtil; import com.android.contacts.common.list.ContactListItemView; import com.android.contacts.common.list.PhoneNumberListAdapter; +import com.android.contacts.common.util.ContactDisplayUtils; import com.android.dialer.R; /** @@ -33,17 +37,20 @@ public class DialerPhoneNumberListAdapter extends PhoneNumberListAdapter { public final static int SHORTCUT_ADD_TO_EXISTING_CONTACT = 2; public final static int SHORTCUT_SEND_SMS_MESSAGE = 3; public final static int SHORTCUT_MAKE_VIDEO_CALL = 4; + public final static int SHORTCUT_BLOCK_NUMBER = 5; - public final static int SHORTCUT_COUNT = 5; + public final static int SHORTCUT_COUNT = 6; private final boolean[] mShortcutEnabled = new boolean[SHORTCUT_COUNT]; private final BidiFormatter mBidiFormatter = BidiFormatter.getInstance(); + private boolean mVideoCallingEnabled = false; public DialerPhoneNumberListAdapter(Context context) { super(context); mCountryIso = GeoUtil.getCurrentCountryIso(context); + mVideoCallingEnabled = CallUtil.isVideoEnabled(context); } @Override @@ -93,7 +100,8 @@ public class DialerPhoneNumberListAdapter extends PhoneNumberListAdapter { assignShortcutToView((ContactListItemView) convertView, shortcutType); return convertView; } else { - final ContactListItemView v = new ContactListItemView(getContext(), null); + final ContactListItemView v = new ContactListItemView(getContext(), null, + mVideoCallingEnabled); assignShortcutToView(v, shortcutType); return v; } @@ -102,6 +110,16 @@ public class DialerPhoneNumberListAdapter extends PhoneNumberListAdapter { } } + @Override + protected ContactListItemView newView( + Context context, int partition, Cursor cursor, int position, ViewGroup parent) { + final ContactListItemView view = super.newView(context, partition, cursor, position, + parent); + + view.setSupportVideoCallIcon(mVideoCallingEnabled); + return view; + } + /** * @param position The position of the item * @return The enabled shortcut type matching the given position if the item is a @@ -146,7 +164,7 @@ public class DialerPhoneNumberListAdapter extends PhoneNumberListAdapter { final String number = getFormattedQueryString(); switch (shortcutType) { case SHORTCUT_DIRECT_CALL: - text = resources.getString( + text = ContactDisplayUtils.getTtsSpannedPhoneNumber(resources, R.string.search_shortcut_call_number, mBidiFormatter.unicodeWrap(number, TextDirectionHeuristics.LTR)); drawableId = R.drawable.ic_search_phone; @@ -167,6 +185,10 @@ public class DialerPhoneNumberListAdapter extends PhoneNumberListAdapter { text = resources.getString(R.string.search_shortcut_make_video_call); drawableId = R.drawable.ic_videocam; break; + case SHORTCUT_BLOCK_NUMBER: + text = resources.getString(R.string.search_shortcut_block_number); + drawableId = R.drawable.ic_not_interested_googblue_24dp; + break; default: throw new IllegalArgumentException("Invalid shortcut type"); } diff --git a/src/com/android/dialer/list/ListsFragment.java b/src/com/android/dialer/list/ListsFragment.java index 33c977670..ceed61274 100644 --- a/src/com/android/dialer/list/ListsFragment.java +++ b/src/com/android/dialer/list/ListsFragment.java @@ -1,10 +1,22 @@ +/* + * Copyright (C) 2013 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.list; -import android.animation.LayoutTransition; -import android.app.ActionBar; import android.app.Fragment; import android.app.FragmentManager; -import android.content.Context; import android.content.SharedPreferences; import android.database.Cursor; import android.os.Bundle; @@ -14,28 +26,29 @@ import android.provider.CallLog.Calls; import android.support.v13.app.FragmentPagerAdapter; import android.support.v4.view.ViewPager; import android.support.v4.view.ViewPager.OnPageChangeListener; -import android.util.Log; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.AbsListView; -import android.widget.ListView; -import com.android.contacts.common.GeoUtil; import com.android.contacts.common.list.ViewPagerTabs; -import com.android.contacts.commonbind.analytics.AnalyticsUtil; import com.android.dialer.DialtactsActivity; import com.android.dialer.R; import com.android.dialer.calllog.CallLogFragment; +import com.android.dialer.calllog.CallLogNotificationsHelper; import com.android.dialer.calllog.CallLogQueryHandler; -import com.android.dialer.calllog.ContactInfoHelper; +import com.android.dialer.calllog.VisualVoicemailCallLogFragment; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.ScreenEvent; import com.android.dialer.util.DialerUtils; +import com.android.dialer.voicemail.VisualVoicemailEnabledChecker; import com.android.dialer.voicemail.VoicemailStatusHelper; import com.android.dialer.voicemail.VoicemailStatusHelperImpl; import com.android.dialer.widget.ActionBarController; -import com.android.dialerbind.ObjectFactory; import java.util.ArrayList; +import java.util.List; /** * Fragment that is used as the main screen of the Dialer. @@ -52,20 +65,13 @@ public class ListsFragment extends Fragment private static final String TAG = "ListsFragment"; public static final int TAB_INDEX_SPEED_DIAL = 0; - public static final int TAB_INDEX_RECENTS = 1; + public static final int TAB_INDEX_HISTORY = 1; public static final int TAB_INDEX_ALL_CONTACTS = 2; public static final int TAB_INDEX_VOICEMAIL = 3; public static final int TAB_COUNT_DEFAULT = 3; public static final int TAB_COUNT_WITH_VOICEMAIL = 4; - private static final int MAX_RECENTS_ENTRIES = 20; - // Oldest recents entry to display is 2 weeks old. - private static final long OLDEST_RECENTS_DATE = 1000L * 60 * 60 * 24 * 14; - - private static final String PREF_KEY_HAS_ACTIVE_VOICEMAIL_PROVIDER = - "has_active_voicemail_provider"; - public interface HostInterface { public ActionBarController getActionBarController(); } @@ -75,10 +81,9 @@ public class ListsFragment extends Fragment private ViewPagerTabs mViewPagerTabs; private ViewPagerAdapter mViewPagerAdapter; private RemoveView mRemoveView; - private View mRemoveViewContent; private SpeedDialFragment mSpeedDialFragment; - private CallLogFragment mRecentsFragment; + private CallLogFragment mHistoryFragment; private AllContactsFragment mAllContactsFragment; private CallLogFragment mVoicemailFragment; @@ -98,10 +103,16 @@ public class ListsFragment extends Fragment * The position of the currently selected tab. */ private int mTabIndex = TAB_INDEX_SPEED_DIAL; + private CallLogQueryHandler mCallLogQueryHandler; public class ViewPagerAdapter extends FragmentPagerAdapter { + private final List<Fragment> mFragments = new ArrayList<>(); + public ViewPagerAdapter(FragmentManager fm) { super(fm); + for (int i = 0; i < TAB_COUNT_WITH_VOICEMAIL; i++) { + mFragments.add(null); + } } @Override @@ -115,22 +126,21 @@ public class ListsFragment extends Fragment case TAB_INDEX_SPEED_DIAL: mSpeedDialFragment = new SpeedDialFragment(); return mSpeedDialFragment; - case TAB_INDEX_RECENTS: - mRecentsFragment = new CallLogFragment(CallLogQueryHandler.CALL_TYPE_ALL, - MAX_RECENTS_ENTRIES, System.currentTimeMillis() - OLDEST_RECENTS_DATE); - return mRecentsFragment; + case TAB_INDEX_HISTORY: + mHistoryFragment = new CallLogFragment(CallLogQueryHandler.CALL_TYPE_ALL); + return mHistoryFragment; case TAB_INDEX_ALL_CONTACTS: mAllContactsFragment = new AllContactsFragment(); return mAllContactsFragment; case TAB_INDEX_VOICEMAIL: - mVoicemailFragment = new CallLogFragment(Calls.VOICEMAIL_TYPE); + mVoicemailFragment = new VisualVoicemailCallLogFragment(); return mVoicemailFragment; } throw new IllegalStateException("No fragment at position " + position); } @Override - public Object instantiateItem(ViewGroup container, int position) { + public Fragment instantiateItem(ViewGroup container, int position) { // On rotation the FragmentManager handles rotation. Therefore getItem() isn't called. // Copy the fragments that the FragmentManager finds so that we can store them in // instance variables for later. @@ -138,16 +148,32 @@ public class ListsFragment extends Fragment (Fragment) super.instantiateItem(container, position); if (fragment instanceof SpeedDialFragment) { mSpeedDialFragment = (SpeedDialFragment) fragment; - } else if (fragment instanceof CallLogFragment && position == TAB_INDEX_RECENTS) { - mRecentsFragment = (CallLogFragment) fragment; + } else if (fragment instanceof CallLogFragment && position == TAB_INDEX_HISTORY) { + mHistoryFragment = (CallLogFragment) fragment; } else if (fragment instanceof AllContactsFragment) { mAllContactsFragment = (AllContactsFragment) fragment; } else if (fragment instanceof CallLogFragment && position == TAB_INDEX_VOICEMAIL) { mVoicemailFragment = (CallLogFragment) fragment; } + mFragments.set(position, fragment); return fragment; } + /** + * When {@link android.support.v4.view.PagerAdapter#notifyDataSetChanged} is called, + * this method is called on all pages to determine whether they need to be recreated. + * When the voicemail tab is removed, the view needs to be recreated by returning + * POSITION_NONE. If notifyDataSetChanged is called for some other reason, the voicemail + * tab is recreated only if it is active. All other tabs do not need to be recreated + * and POSITION_UNCHANGED is returned. + */ + @Override + public int getItemPosition(Object object) { + return !mHasActiveVoicemailProvider && + mFragments.indexOf(object) == TAB_INDEX_VOICEMAIL ? POSITION_NONE : + POSITION_UNCHANGED; + } + @Override public int getCount() { return mHasActiveVoicemailProvider ? TAB_COUNT_WITH_VOICEMAIL : TAB_COUNT_DEFAULT; @@ -164,16 +190,12 @@ public class ListsFragment extends Fragment Trace.beginSection(TAG + " onCreate"); super.onCreate(savedInstanceState); - Trace.beginSection(TAG + " getCurrentCountryIso"); - final String currentCountryIso = GeoUtil.getCurrentCountryIso(getActivity()); - Trace.endSection(); - mVoicemailStatusHelper = new VoicemailStatusHelperImpl(); mHasFetchedVoicemailStatus = false; mPrefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); mHasActiveVoicemailProvider = mPrefs.getBoolean( - PREF_KEY_HAS_ACTIVE_VOICEMAIL_PROVIDER, false); + VisualVoicemailEnabledChecker.PREF_KEY_HAS_ACTIVE_VOICEMAIL_PROVIDER, false); Trace.endSection(); } @@ -182,15 +204,17 @@ public class ListsFragment extends Fragment public void onResume() { Trace.beginSection(TAG + " onResume"); super.onResume(); - mActionBar = getActivity().getActionBar(); + + mActionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); if (getUserVisibleHint()) { sendScreenViewForCurrentPosition(); } // Fetch voicemail status to determine if we should show the voicemail tab. - CallLogQueryHandler callLogQueryHandler = + mCallLogQueryHandler = new CallLogQueryHandler(getActivity(), getActivity().getContentResolver(), this); - callLogQueryHandler.fetchVoicemailStatus(); + mCallLogQueryHandler.fetchVoicemailStatus(); + mCallLogQueryHandler.fetchMissedCallsUnreadCount(); Trace.endSection(); } @@ -211,23 +235,22 @@ public class ListsFragment extends Fragment mTabTitles = new String[TAB_COUNT_WITH_VOICEMAIL]; mTabTitles[TAB_INDEX_SPEED_DIAL] = getResources().getString(R.string.tab_speed_dial); - mTabTitles[TAB_INDEX_RECENTS] = getResources().getString(R.string.tab_recents); + mTabTitles[TAB_INDEX_HISTORY] = getResources().getString(R.string.tab_history); mTabTitles[TAB_INDEX_ALL_CONTACTS] = getResources().getString(R.string.tab_all_contacts); mTabTitles[TAB_INDEX_VOICEMAIL] = getResources().getString(R.string.tab_voicemail); mTabIcons = new int[TAB_COUNT_WITH_VOICEMAIL]; - mTabIcons[TAB_INDEX_SPEED_DIAL] = R.drawable.tab_speed_dial; - mTabIcons[TAB_INDEX_RECENTS] = R.drawable.tab_recents; - mTabIcons[TAB_INDEX_ALL_CONTACTS] = R.drawable.tab_contacts; - mTabIcons[TAB_INDEX_VOICEMAIL] = R.drawable.tab_voicemail; + mTabIcons[TAB_INDEX_SPEED_DIAL] = R.drawable.ic_grade_24dp; + mTabIcons[TAB_INDEX_HISTORY] = R.drawable.ic_schedule_24dp; + mTabIcons[TAB_INDEX_ALL_CONTACTS] = R.drawable.ic_people_24dp; + mTabIcons[TAB_INDEX_VOICEMAIL] = R.drawable.ic_voicemail_24dp; mViewPagerTabs = (ViewPagerTabs) parentView.findViewById(R.id.lists_pager_header); - mViewPagerTabs.setTabIcons(mTabIcons); + mViewPagerTabs.configureTabIcons(mTabIcons); mViewPagerTabs.setViewPager(mViewPager); addOnPageChangeListener(mViewPagerTabs); mRemoveView = (RemoveView) parentView.findViewById(R.id.remove_view); - mRemoveViewContent = parentView.findViewById(R.id.remove_view_content); Trace.endSection(); Trace.endSection(); @@ -253,7 +276,7 @@ public class ListsFragment extends Fragment // Try to show the voicemail tab after the voicemail status returns. mShowVoicemailTabAfterVoicemailStatusIsFetched = true; } - } else { + } else if (index < getTabCount()){ mViewPager.setCurrentItem(getRtlPosition(index)); } } @@ -305,11 +328,22 @@ public class ListsFragment extends Fragment if (hasActiveVoicemailProvider != mHasActiveVoicemailProvider) { mHasActiveVoicemailProvider = hasActiveVoicemailProvider; mViewPagerAdapter.notifyDataSetChanged(); - mViewPagerTabs.setViewPager(mViewPager); + + if (hasActiveVoicemailProvider) { + mViewPagerTabs.updateTab(TAB_INDEX_VOICEMAIL); + } else { + mViewPagerTabs.removeTab(TAB_INDEX_VOICEMAIL); + removeVoicemailFragment(); + } mPrefs.edit() - .putBoolean(PREF_KEY_HAS_ACTIVE_VOICEMAIL_PROVIDER, hasActiveVoicemailProvider) - .commit(); + .putBoolean(VisualVoicemailEnabledChecker.PREF_KEY_HAS_ACTIVE_VOICEMAIL_PROVIDER, + hasActiveVoicemailProvider) + .commit(); + } + + if (hasActiveVoicemailProvider) { + mCallLogQueryHandler.fetchVoicemailUnreadCount(); } if (mHasActiveVoicemailProvider && mShowVoicemailTabAfterVoicemailStatusIsFetched) { @@ -319,6 +353,40 @@ public class ListsFragment extends Fragment } @Override + public void onVoicemailUnreadCountFetched(Cursor cursor) { + if (getActivity() == null || getActivity().isFinishing() || cursor == null) { + return; + } + + int count = 0; + try { + count = cursor.getCount(); + } finally { + cursor.close(); + } + + mViewPagerTabs.setUnreadCount(count, TAB_INDEX_VOICEMAIL); + mViewPagerTabs.updateTab(TAB_INDEX_VOICEMAIL); + } + + @Override + public void onMissedCallsUnreadCountFetched(Cursor cursor) { + if (getActivity() == null || getActivity().isFinishing() || cursor == null) { + return; + } + + int count = 0; + try { + count = cursor.getCount(); + } finally { + cursor.close(); + } + + mViewPagerTabs.setUnreadCount(count, TAB_INDEX_HISTORY); + mViewPagerTabs.updateTab(TAB_INDEX_HISTORY); + } + + @Override public boolean onCallsFetched(Cursor statusCursor) { // Return false; did not take ownership of cursor return false; @@ -328,8 +396,33 @@ public class ListsFragment extends Fragment return mTabIndex; } + /** + * External method to update unread count because the unread count changes when the user + * expands a voicemail in the call log or when the user expands an unread call in the call + * history tab. + */ + public void updateTabUnreadCounts() { + if (mCallLogQueryHandler != null) { + mCallLogQueryHandler.fetchMissedCallsUnreadCount(); + if (mHasActiveVoicemailProvider) { + mCallLogQueryHandler.fetchVoicemailUnreadCount(); + } + } + } + + /** + * External method to mark all missed calls as read. + */ + public void markMissedCallsAsReadAndRemoveNotifications() { + if (mCallLogQueryHandler != null) { + mCallLogQueryHandler.markMissedCallsAsRead(); + CallLogNotificationsHelper.removeMissedCallNotifications(getActivity()); + } + } + + public void showRemoveView(boolean show) { - mRemoveViewContent.setVisibility(show ? View.VISIBLE : View.GONE); + mRemoveView.setVisibility(show ? View.VISIBLE : View.GONE); mRemoveView.setAlpha(show ? 0 : 1); mRemoveView.animate().alpha(show ? 1 : 0).start(); } @@ -363,22 +456,30 @@ public class ListsFragment extends Fragment return; } - String fragmentName; + int screenType; switch (getCurrentTabIndex()) { case TAB_INDEX_SPEED_DIAL: - fragmentName = SpeedDialFragment.class.getSimpleName(); + screenType = ScreenEvent.SPEED_DIAL; break; - case TAB_INDEX_RECENTS: - fragmentName = CallLogFragment.class.getSimpleName() + "#Recents"; + case TAB_INDEX_HISTORY: + screenType = ScreenEvent.CALL_LOG; break; case TAB_INDEX_ALL_CONTACTS: - fragmentName = AllContactsFragment.class.getSimpleName(); + screenType = ScreenEvent.ALL_CONTACTS; break; case TAB_INDEX_VOICEMAIL: - fragmentName = CallLogFragment.class.getSimpleName() + "#Voicemail"; + screenType = ScreenEvent.VOICEMAIL_LOG; default: return; } - AnalyticsUtil.sendScreenView(fragmentName, getActivity(), null); + Logger.logScreenView(screenType, getActivity()); + } + + private void removeVoicemailFragment() { + if (mVoicemailFragment != null) { + getChildFragmentManager().beginTransaction().remove(mVoicemailFragment) + .commitAllowingStateLoss(); + mVoicemailFragment = null; + } } } diff --git a/src/com/android/dialer/list/PhoneFavoriteSquareTileView.java b/src/com/android/dialer/list/PhoneFavoriteSquareTileView.java index 05780c66a..69a230c8a 100644 --- a/src/com/android/dialer/list/PhoneFavoriteSquareTileView.java +++ b/src/com/android/dialer/list/PhoneFavoriteSquareTileView.java @@ -24,6 +24,7 @@ import android.view.View; import android.widget.ImageButton; import android.widget.TextView; +import com.android.contacts.common.compat.CompatUtils; import com.android.contacts.common.list.ContactEntry; import com.android.dialer.R; @@ -63,8 +64,13 @@ public class PhoneFavoriteSquareTileView extends PhoneFavoriteTileView { } private void launchQuickContact() { - QuickContact.showQuickContact(getContext(), PhoneFavoriteSquareTileView.this, - getLookupUri(), null, Phone.CONTENT_ITEM_TYPE); + if (CompatUtils.hasPrioritizedMimeType()) { + QuickContact.showQuickContact(getContext(), PhoneFavoriteSquareTileView.this, + getLookupUri(), null, Phone.CONTENT_ITEM_TYPE); + } else { + QuickContact.showQuickContact(getContext(), PhoneFavoriteSquareTileView.this, + getLookupUri(), QuickContact.MODE_LARGE, null); + } } @Override @@ -95,6 +101,11 @@ public class PhoneFavoriteSquareTileView extends PhoneFavoriteTileView { setMeasuredDimension(width, height); } + @Override + protected String getNameForView(ContactEntry contactEntry) { + return contactEntry.getPreferredDisplayName(); + } + public ContactEntry getContactEntry() { return mContactEntry; } diff --git a/src/com/android/dialer/list/PhoneFavoritesTileAdapter.java b/src/com/android/dialer/list/PhoneFavoritesTileAdapter.java index e957c8321..77da7e937 100644 --- a/src/com/android/dialer/list/PhoneFavoritesTileAdapter.java +++ b/src/com/android/dialer/list/PhoneFavoritesTileAdapter.java @@ -38,13 +38,13 @@ import android.util.LongSparseArray; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; -import android.widget.FrameLayout; import com.android.contacts.common.ContactPhotoManager; import com.android.contacts.common.ContactTileLoaderFactory; import com.android.contacts.common.list.ContactEntry; import com.android.contacts.common.list.ContactTileAdapter.DisplayType; import com.android.contacts.common.list.ContactTileView; +import com.android.contacts.common.preference.ContactsPreferences; import com.android.dialer.R; import java.util.ArrayList; @@ -70,6 +70,7 @@ public class PhoneFavoritesTileAdapter extends BaseAdapter implements private Context mContext; private Resources mResources; + private ContactsPreferences mContactsPreferences; /** Contact data stored in cache. This is used to populate the associated view. */ protected ArrayList<ContactEntry> mContactEntries = null; @@ -92,7 +93,8 @@ public class PhoneFavoritesTileAdapter extends BaseAdapter implements protected int mIdIndex; protected int mLookupIndex; protected int mPhotoUriIndex; - protected int mNameIndex; + protected int mNamePrimaryIndex; + protected int mNameAlternativeIndex; protected int mPresenceIndex; protected int mStatusIndex; @@ -124,9 +126,17 @@ public class PhoneFavoritesTileAdapter extends BaseAdapter implements public int compare(ContactEntry lhs, ContactEntry rhs) { return ComparisonChain.start() .compare(lhs.pinned, rhs.pinned) - .compare(lhs.name, rhs.name) + .compare(getPreferredSortName(lhs), getPreferredSortName(rhs)) .result(); } + + private String getPreferredSortName(ContactEntry contactEntry) { + if (mContactsPreferences.getSortOrder() == ContactsPreferences.SORT_ORDER_PRIMARY + || TextUtils.isEmpty(contactEntry.nameAlternative)) { + return contactEntry.namePrimary; + } + return contactEntry.nameAlternative; + } }; public interface OnDataSetChangedForAnimationListener { @@ -140,6 +150,7 @@ public class PhoneFavoritesTileAdapter extends BaseAdapter implements mListener = listener; mContext = context; mResources = context.getResources(); + mContactsPreferences = new ContactsPreferences(mContext); mNumFrequents = 0; mContactEntries = new ArrayList<ContactEntry>(); @@ -172,19 +183,26 @@ public class PhoneFavoritesTileAdapter extends BaseAdapter implements */ protected void bindColumnIndices() { mIdIndex = ContactTileLoaderFactory.CONTACT_ID; - mLookupIndex = ContactTileLoaderFactory.LOOKUP_KEY; - mPhotoUriIndex = ContactTileLoaderFactory.PHOTO_URI; - mNameIndex = ContactTileLoaderFactory.DISPLAY_NAME; + mNamePrimaryIndex = ContactTileLoaderFactory.DISPLAY_NAME; + mNameAlternativeIndex = ContactTileLoaderFactory.DISPLAY_NAME_ALTERNATIVE; mStarredIndex = ContactTileLoaderFactory.STARRED; - mPresenceIndex = ContactTileLoaderFactory.CONTACT_PRESENCE; - mStatusIndex = ContactTileLoaderFactory.CONTACT_STATUS; - + mPhotoUriIndex = ContactTileLoaderFactory.PHOTO_URI; + mLookupIndex = ContactTileLoaderFactory.LOOKUP_KEY; mPhoneNumberIndex = ContactTileLoaderFactory.PHONE_NUMBER; mPhoneNumberTypeIndex = ContactTileLoaderFactory.PHONE_NUMBER_TYPE; mPhoneNumberLabelIndex = ContactTileLoaderFactory.PHONE_NUMBER_LABEL; - mIsDefaultNumberIndex = ContactTileLoaderFactory.IS_DEFAULT_NUMBER; mPinnedIndex = ContactTileLoaderFactory.PINNED; mContactIdIndex = ContactTileLoaderFactory.CONTACT_ID_FOR_DATA; + + + mPresenceIndex = ContactTileLoaderFactory.CONTACT_PRESENCE; + mStatusIndex = ContactTileLoaderFactory.CONTACT_STATUS; + mIsDefaultNumberIndex = ContactTileLoaderFactory.IS_DEFAULT_NUMBER; + } + + public void refreshContactsPreferences() { + mContactsPreferences.refreshValue(ContactsPreferences.DISPLAY_ORDER_KEY); + mContactsPreferences.refreshValue(ContactsPreferences.SORT_ORDER_KEY); } /** @@ -261,15 +279,19 @@ public class PhoneFavoritesTileAdapter extends BaseAdapter implements final String photoUri = cursor.getString(mPhotoUriIndex); final String lookupKey = cursor.getString(mLookupIndex); final int pinned = cursor.getInt(mPinnedIndex); - final String name = cursor.getString(mNameIndex); + final String name = cursor.getString(mNamePrimaryIndex); + final String nameAlternative = cursor.getString(mNameAlternativeIndex); final boolean isStarred = cursor.getInt(mStarredIndex) > 0; final boolean isDefaultNumber = cursor.getInt(mIsDefaultNumberIndex) > 0; final ContactEntry contact = new ContactEntry(); contact.id = id; - contact.name = (!TextUtils.isEmpty(name)) ? name : + contact.namePrimary = (!TextUtils.isEmpty(name)) ? name : + mResources.getString(R.string.missing_name); + contact.nameAlternative = (!TextUtils.isEmpty(nameAlternative)) ? nameAlternative : mResources.getString(R.string.missing_name); + contact.nameDisplayOrder = mContactsPreferences.getDisplayOrder(); contact.photoUri = (photoUri != null ? Uri.parse(photoUri) : null); contact.lookupKey = lookupKey; contact.lookupUri = ContentUris.withAppendedId( diff --git a/src/com/android/dialer/list/RegularSearchFragment.java b/src/com/android/dialer/list/RegularSearchFragment.java index b7e26d690..df18af044 100644 --- a/src/com/android/dialer/list/RegularSearchFragment.java +++ b/src/com/android/dialer/list/RegularSearchFragment.java @@ -15,11 +15,11 @@ */ package com.android.dialer.list; -import static android.Manifest.permission.ACCESS_FINE_LOCATION; import static android.Manifest.permission.READ_CONTACTS; import android.app.Activity; import android.content.pm.PackageManager; +import android.support.v13.app.FragmentCompat; import android.view.LayoutInflater; import android.view.ViewGroup; @@ -28,30 +28,34 @@ import com.android.contacts.common.list.PinnedHeaderListView; import com.android.contacts.common.util.PermissionsUtil; import com.android.contacts.commonbind.analytics.AnalyticsUtil; import com.android.dialerbind.ObjectFactory; +import com.android.incallui.Call.LogState; import com.android.dialer.R; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.ScreenEvent; import com.android.dialer.service.CachedNumberLookupService; import com.android.dialer.widget.EmptyContentView; import com.android.dialer.widget.EmptyContentView.OnEmptyViewActionButtonClickedListener; public class RegularSearchFragment extends SearchFragment - implements OnEmptyViewActionButtonClickedListener { + implements OnEmptyViewActionButtonClickedListener, + FragmentCompat.OnRequestPermissionsResultCallback { - private static final int READ_CONTACTS_PERMISSION_REQUEST_CODE = 1; + public static final int PERMISSION_REQUEST_CODE = 1; private static final int SEARCH_DIRECTORY_RESULT_LIMIT = 5; private static final CachedNumberLookupService mCachedNumberLookupService = ObjectFactory.newCachedNumberLookupService(); - public RegularSearchFragment() { - configureDirectorySearch(); + public interface CapabilityChecker { + public boolean isNearbyPlacesSearchEnabled(); } - @Override - public void onStart() { - super.onStart(); - AnalyticsUtil.sendScreenView(this); + protected String mPermissionToRequest; + + public RegularSearchFragment() { + configureDirectorySearch(); } public void configureDirectorySearch() { @@ -65,10 +69,12 @@ public class RegularSearchFragment extends SearchFragment ((PinnedHeaderListView) getListView()).setScrollToSectionOnHeaderTouch(true); } + @Override protected ContactEntryListAdapter createListAdapter() { RegularSearchListAdapter adapter = new RegularSearchListAdapter(getActivity()); adapter.setDisplayPhotos(true); adapter.setUseCallableUri(usesCallableUri()); + adapter.setListener(this); return adapter; } @@ -85,15 +91,29 @@ public class RegularSearchFragment extends SearchFragment @Override protected void setupEmptyView() { if (mEmptyView != null && getActivity() != null) { + final int imageResource; + final int actionLabelResource; + final int descriptionResource; + final OnEmptyViewActionButtonClickedListener listener; if (!PermissionsUtil.hasPermission(getActivity(), READ_CONTACTS)) { - mEmptyView.setImage(R.drawable.empty_contacts); - mEmptyView.setActionLabel(R.string.permission_single_turn_on); - mEmptyView.setDescription(R.string.permission_no_search); - mEmptyView.setActionClickedListener(this); + imageResource = R.drawable.empty_contacts; + actionLabelResource = R.string.permission_single_turn_on; + descriptionResource = R.string.permission_no_search; + listener = this; + mPermissionToRequest = READ_CONTACTS; } else { - mEmptyView.setImage(EmptyContentView.NO_IMAGE); - mEmptyView.setActionLabel(EmptyContentView.NO_LABEL); - mEmptyView.setDescription(EmptyContentView.NO_LABEL); + imageResource = EmptyContentView.NO_IMAGE; + actionLabelResource = EmptyContentView.NO_LABEL; + descriptionResource = EmptyContentView.NO_LABEL; + listener = null; + mPermissionToRequest = null; + } + + mEmptyView.setImage(imageResource); + mEmptyView.setActionLabel(actionLabelResource); + mEmptyView.setDescription(descriptionResource); + if (listener != null) { + mEmptyView.setActionClickedListener(listener); } } } @@ -105,14 +125,27 @@ public class RegularSearchFragment extends SearchFragment return; } - requestPermissions(new String[] {READ_CONTACTS}, READ_CONTACTS_PERMISSION_REQUEST_CODE); + if (READ_CONTACTS.equals(mPermissionToRequest)) { + FragmentCompat.requestPermissions(this, new String[] {mPermissionToRequest}, + PERMISSION_REQUEST_CODE); + } } @Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { - if (requestCode == READ_CONTACTS_PERMISSION_REQUEST_CODE) { + if (requestCode == PERMISSION_REQUEST_CODE) { setupEmptyView(); + if (grantResults != null && grantResults.length == 1 + && PackageManager.PERMISSION_GRANTED == grantResults[0]) { + PermissionsUtil.notifyPermissionGranted(getActivity(), mPermissionToRequest); + } } } + + @Override + protected int getCallInitiationType(boolean isRemoteDirectory) { + return isRemoteDirectory ? LogState.INITIATION_REMOTE_DIRECTORY + : LogState.INITIATION_REGULAR_SEARCH; + } } diff --git a/src/com/android/dialer/list/RegularSearchListAdapter.java b/src/com/android/dialer/list/RegularSearchListAdapter.java index 2be8a1dd7..afc621cf5 100644 --- a/src/com/android/dialer/list/RegularSearchListAdapter.java +++ b/src/com/android/dialer/list/RegularSearchListAdapter.java @@ -21,6 +21,8 @@ import android.net.Uri; import android.text.TextUtils; import com.android.contacts.common.CallUtil; +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.compat.DirectoryCompat; import com.android.contacts.common.list.DirectoryPartition; import com.android.contacts.common.util.PhoneNumberHelper; import com.android.dialer.calllog.ContactInfo; @@ -31,7 +33,7 @@ import com.android.dialer.service.CachedNumberLookupService.CachedContactInfo; * List adapter to display regular search results. */ public class RegularSearchListAdapter extends DialerPhoneNumberListAdapter { - private boolean mIsQuerySipAddress; + protected boolean mIsQuerySipAddress; public RegularSearchListAdapter(Context context) { super(context); @@ -45,21 +47,33 @@ public class RegularSearchListAdapter extends DialerPhoneNumberListAdapter { CachedContactInfo cacheInfo = lookupService.buildCachedContactInfo(info); final Cursor item = (Cursor) getItem(position); if (item != null) { + final DirectoryPartition partition = + (DirectoryPartition) getPartition(getPartitionForPosition(position)); + final long directoryId = partition.getDirectoryId(); + final boolean isExtendedDirectory = isExtendedDirectory(directoryId); + info.name = item.getString(PhoneQuery.DISPLAY_NAME); info.type = item.getInt(PhoneQuery.PHONE_TYPE); info.label = item.getString(PhoneQuery.PHONE_LABEL); info.number = item.getString(PhoneQuery.PHONE_NUMBER); final String photoUriStr = item.getString(PhoneQuery.PHOTO_URI); info.photoUri = photoUriStr == null ? null : Uri.parse(photoUriStr); + /* + * An extended directory is custom directory in the app, but not a directory provided by + * framework. So it can't be USER_TYPE_WORK. + * + * When a search result is selected, RegularSearchFragment calls getContactInfo and + * cache the resulting @{link ContactInfo} into local db. Set usertype to USER_TYPE_WORK + * only if it's NOT extended directory id and is enterprise directory. + */ + info.userType = !isExtendedDirectory + && DirectoryCompat.isEnterpriseDirectoryId(directoryId) + ? ContactsUtils.USER_TYPE_WORK : ContactsUtils.USER_TYPE_CURRENT; cacheInfo.setLookupKey(item.getString(PhoneQuery.LOOKUP_KEY)); - final int partitionIndex = getPartitionForPosition(position); - final DirectoryPartition partition = - (DirectoryPartition) getPartition(partitionIndex); - final long directoryId = partition.getDirectoryId(); final String sourceName = partition.getLabel(); - if (isExtendedDirectory(directoryId)) { + if (isExtendedDirectory) { cacheInfo.setExtendedSource(sourceName, directoryId); } else { cacheInfo.setDirectorySource(sourceName, directoryId); @@ -82,18 +96,22 @@ public class RegularSearchListAdapter extends DialerPhoneNumberListAdapter { // Don't show actions if the query string contains a letter. final boolean showNumberShortcuts = !TextUtils.isEmpty(getFormattedQueryString()) && hasDigitsInQueryString(); - // Email addresses that could be SIP addresses are an exception. mIsQuerySipAddress = PhoneNumberHelper.isUriNumber(queryString); + + if (isChanged(showNumberShortcuts)) { + notifyDataSetChanged(); + } + super.setQueryString(queryString); + } + + protected boolean isChanged(boolean showNumberShortcuts) { boolean changed = false; changed |= setShortcutEnabled(SHORTCUT_DIRECT_CALL, showNumberShortcuts || mIsQuerySipAddress); changed |= setShortcutEnabled(SHORTCUT_SEND_SMS_MESSAGE, showNumberShortcuts); changed |= setShortcutEnabled(SHORTCUT_MAKE_VIDEO_CALL, showNumberShortcuts && CallUtil.isVideoEnabled(getContext())); - if (changed) { - notifyDataSetChanged(); - } - super.setQueryString(queryString); + return changed; } /** diff --git a/src/com/android/dialer/list/RemoveView.java b/src/com/android/dialer/list/RemoveView.java index fdb08f6f5..41f41752e 100644 --- a/src/com/android/dialer/list/RemoveView.java +++ b/src/com/android/dialer/list/RemoveView.java @@ -6,6 +6,7 @@ import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.util.Log; import android.view.DragEvent; +import android.view.accessibility.AccessibilityEvent; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; @@ -53,6 +54,9 @@ public class RemoveView extends FrameLayout { final int action = event.getAction(); switch (action) { case DragEvent.ACTION_DRAG_ENTERED: + // TODO: This is temporary solution and should be removed once accessibility for + // drag and drop is supported by framework(b/26871588). + sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT); setAppearanceHighlighted(); break; case DragEvent.ACTION_DRAG_EXITED: @@ -65,6 +69,7 @@ public class RemoveView extends FrameLayout { } break; case DragEvent.ACTION_DROP: + sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT); if (mDragDropController != null) { mDragDropController.handleDragFinished((int) event.getX(), (int) event.getY(), true); diff --git a/src/com/android/dialer/list/SearchFragment.java b/src/com/android/dialer/list/SearchFragment.java index 315cfb914..82395b6f8 100644 --- a/src/com/android/dialer/list/SearchFragment.java +++ b/src/com/android/dialer/list/SearchFragment.java @@ -15,8 +15,6 @@ */ package com.android.dialer.list; -import static android.Manifest.permission.READ_CONTACTS; - import android.animation.Animator; import android.animation.AnimatorInflater; import android.animation.AnimatorListenerAdapter; @@ -25,13 +23,10 @@ import android.app.DialogFragment; import android.content.Intent; import android.content.res.Configuration; import android.content.res.Resources; -import android.net.Uri; import android.os.Bundle; -import android.provider.ContactsContract; import android.text.TextUtils; import android.util.Log; import android.view.LayoutInflater; -import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.animation.Interpolator; @@ -47,9 +42,8 @@ import com.android.contacts.common.list.OnPhoneNumberPickerActionListener; import com.android.contacts.common.list.PhoneNumberPickerFragment; import com.android.contacts.common.util.PermissionsUtil; import com.android.contacts.common.util.ViewUtil; -import com.android.contacts.commonbind.analytics.AnalyticsUtil; -import com.android.dialer.dialpad.DialpadFragment.ErrorDialogFragment; import com.android.dialer.R; +import com.android.dialer.dialpad.DialpadFragment.ErrorDialogFragment; import com.android.dialer.util.DialerUtils; import com.android.dialer.util.IntentUtil; import com.android.dialer.widget.EmptyContentView; @@ -105,8 +99,8 @@ public class SearchFragment extends PhoneNumberPickerFragment { try { mActivityScrollListener = (OnListFragmentScrolledListener) activity; } catch (ClassCastException e) { - throw new ClassCastException(activity.toString() - + " must implement OnListFragmentScrolledListener"); + Log.d(TAG, activity.toString() + " doesn't implement OnListFragmentScrolledListener. " + + "Ignoring."); } } @@ -140,10 +134,17 @@ public class SearchFragment extends PhoneNumberPickerFragment { listView.setBackgroundColor(res.getColor(R.color.background_dialer_results)); listView.setClipToPadding(false); setVisibleScrollbarEnabled(false); + + //Turn of accessibility live region as the list constantly update itself and spam messages. + listView.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_NONE); + ContentChangedFilter.addToParent(listView); + listView.setOnScrollListener(new OnScrollListener() { @Override public void onScrollStateChanged(AbsListView view, int scrollState) { - mActivityScrollListener.onListFragmentScrollStateChange(scrollState); + if (mActivityScrollListener != null) { + mActivityScrollListener.onListFragmentScrollStateChange(scrollState); + } } @Override @@ -229,6 +230,7 @@ public class SearchFragment extends PhoneNumberPickerFragment { DialerPhoneNumberListAdapter adapter = new DialerPhoneNumberListAdapter(getActivity()); adapter.setDisplayPhotos(true); adapter.setUseCallableUri(super.usesCallableUri()); + adapter.setListener(this); return adapter; } @@ -250,7 +252,8 @@ public class SearchFragment extends PhoneNumberPickerFragment { number = adapter.getQueryString(); listener = getOnPhoneNumberPickerListener(); if (listener != null && !checkForProhibitedPhoneNumber(number)) { - listener.onCallNumberDirectly(number); + listener.onPickPhoneNumber(number, false /* isVideoCall */, + getCallInitiationType(false /* isRemoteDirectory */)); } break; case DialerPhoneNumberListAdapter.SHORTCUT_CREATE_NEW_CONTACT: @@ -272,10 +275,12 @@ public class SearchFragment extends PhoneNumberPickerFragment { DialerUtils.startActivityWithErrorToast(getActivity(), intent); break; case DialerPhoneNumberListAdapter.SHORTCUT_MAKE_VIDEO_CALL: - number = adapter.getQueryString(); + number = TextUtils.isEmpty(mAddToContactNumber) ? + adapter.getQueryString() : mAddToContactNumber; listener = getOnPhoneNumberPickerListener(); if (listener != null && !checkForProhibitedPhoneNumber(number)) { - listener.onCallNumberDirectly(number, true /* isVideoCall */); + listener.onPickPhoneNumber(number, true /* isVideoCall */, + getCallInitiationType(false /* isRemoteDirectory */)); } break; } @@ -289,12 +294,12 @@ public class SearchFragment extends PhoneNumberPickerFragment { public void updatePosition(boolean animate) { // Use negative shadow height instead of 0 to account for the 9-patch's shadow. int startTranslationValue = - mActivity.isDialpadShown() ? mActionBarHeight - mShadowHeight: -mShadowHeight; + mActivity.isDialpadShown() ? mActionBarHeight - mShadowHeight : -mShadowHeight; int endTranslationValue = 0; // Prevents ListView from being translated down after a rotation when the ActionBar is up. if (animate || mActivity.isActionBarShowing()) { endTranslationValue = - mActivity.isDialpadShown() ? 0 : mActionBarHeight -mShadowHeight; + mActivity.isDialpadShown() ? 0 : mActionBarHeight - mShadowHeight; } if (animate) { // If the dialpad will be shown, then this animation involves sliding the list up. @@ -353,7 +358,11 @@ public class SearchFragment extends PhoneNumberPickerFragment { @Override protected void startLoading() { - if (PermissionsUtil.hasPermission(getActivity(), READ_CONTACTS)) { + if (getActivity() == null) { + return; + } + + if (PermissionsUtil.hasContactsPermissions(getActivity())) { super.startLoading(); } else if (TextUtils.isEmpty(getQueryString())) { // Clear out any existing call shortcuts. diff --git a/src/com/android/dialer/list/SmartDialSearchFragment.java b/src/com/android/dialer/list/SmartDialSearchFragment.java index 72d3abf68..fcb61ffe0 100644 --- a/src/com/android/dialer/list/SmartDialSearchFragment.java +++ b/src/com/android/dialer/list/SmartDialSearchFragment.java @@ -22,14 +22,18 @@ import android.content.Loader; import android.database.Cursor; import android.net.Uri; import android.os.Bundle; +import android.support.v13.app.FragmentCompat; import android.util.Log; import android.view.View; import com.android.contacts.common.list.ContactEntryListAdapter; import com.android.contacts.common.util.PermissionsUtil; import com.android.dialer.dialpad.SmartDialCursorLoader; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.ScreenEvent; import com.android.dialer.R; import com.android.dialer.widget.EmptyContentView; +import com.android.incallui.Call.LogState; import java.util.ArrayList; @@ -37,7 +41,8 @@ import java.util.ArrayList; * Implements a fragment to load and display SmartDial search results. */ public class SmartDialSearchFragment extends SearchFragment - implements EmptyContentView.OnEmptyViewActionButtonClickedListener { + implements EmptyContentView.OnEmptyViewActionButtonClickedListener, + FragmentCompat.OnRequestPermissionsResultCallback { private static final String TAG = SmartDialSearchFragment.class.getSimpleName(); private static final int CALL_PHONE_PERMISSION_REQUEST_CODE = 1; @@ -52,6 +57,7 @@ public class SmartDialSearchFragment extends SearchFragment adapter.setQuickContactEnabled(true); // Set adapter's query string to restore previous instance state. adapter.setQueryString(getQueryString()); + adapter.setListener(this); return adapter; } @@ -105,7 +111,8 @@ public class SmartDialSearchFragment extends SearchFragment return; } - requestPermissions(new String[] {CALL_PHONE}, CALL_PHONE_PERMISSION_REQUEST_CODE); + FragmentCompat.requestPermissions(this, new String[] {CALL_PHONE}, + CALL_PHONE_PERMISSION_REQUEST_CODE); } @Override @@ -116,6 +123,11 @@ public class SmartDialSearchFragment extends SearchFragment } } + @Override + protected int getCallInitiationType(boolean isRemoteDirectory) { + return LogState.INITIATION_SMART_DIAL; + } + public boolean isShowingPermissionRequest() { return mEmptyView != null && mEmptyView.isShowingContent(); } diff --git a/src/com/android/dialer/list/SpeedDialFragment.java b/src/com/android/dialer/list/SpeedDialFragment.java index 324caefb6..7e10297d0 100644 --- a/src/com/android/dialer/list/SpeedDialFragment.java +++ b/src/com/android/dialer/list/SpeedDialFragment.java @@ -31,6 +31,7 @@ import android.graphics.Rect; import android.net.Uri; import android.os.Bundle; import android.os.Trace; +import android.support.v13.app.FragmentCompat; import android.util.Log; import android.view.LayoutInflater; import android.view.View; @@ -52,8 +53,8 @@ import com.android.contacts.common.list.ContactTileView; import com.android.contacts.common.list.OnPhoneNumberPickerActionListener; import com.android.contacts.common.util.PermissionsUtil; import com.android.dialer.R; -import com.android.dialer.util.DialerUtils; import com.android.dialer.widget.EmptyContentView; +import com.android.incallui.Call.LogState; import java.util.ArrayList; import java.util.HashMap; @@ -63,7 +64,8 @@ import java.util.HashMap; */ public class SpeedDialFragment extends Fragment implements OnItemClickListener, PhoneFavoritesTileAdapter.OnDataSetChangedForAnimationListener, - EmptyContentView.OnEmptyViewActionButtonClickedListener { + EmptyContentView.OnEmptyViewActionButtonClickedListener, + FragmentCompat.OnRequestPermissionsResultCallback { private static final int READ_CONTACTS_PERMISSION_REQUEST_CODE = 1; @@ -115,14 +117,16 @@ public class SpeedDialFragment extends Fragment implements OnItemClickListener, @Override public void onContactSelected(Uri contactUri, Rect targetRect) { if (mPhoneNumberPickerActionListener != null) { - mPhoneNumberPickerActionListener.onPickPhoneNumberAction(contactUri); + mPhoneNumberPickerActionListener.onPickDataUri(contactUri, + false /* isVideoCall */, LogState.INITIATION_SPEED_DIAL); } } @Override public void onCallNumberDirectly(String phoneNumber) { if (mPhoneNumberPickerActionListener != null) { - mPhoneNumberPickerActionListener.onCallNumberDirectly(phoneNumber); + mPhoneNumberPickerActionListener.onPickPhoneNumber(phoneNumber, + false /* isVideoCall */, LogState.INITIATION_SPEED_DIAL); } } @@ -200,7 +204,9 @@ public class SpeedDialFragment extends Fragment implements OnItemClickListener, public void onResume() { Trace.beginSection(TAG + " onResume"); super.onResume(); - + if (mContactTileAdapter != null) { + mContactTileAdapter.refreshContactsPreferences(); + } if (PermissionsUtil.hasContactsPermissions(getActivity())) { if (getLoaderManager().getLoader(LOADER_ID_CONTACT_TILE) == null) { getLoaderManager().initLoader(LOADER_ID_CONTACT_TILE, null, @@ -251,6 +257,11 @@ public class SpeedDialFragment extends Fragment implements OnItemClickListener, mListView.setOnScrollListener(mScrollListener); mListView.setFastScrollEnabled(false); mListView.setFastScrollAlwaysVisible(false); + + //prevent content changes of the list from firing accessibility events. + mListView.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_NONE); + ContentChangedFilter.addToParent(mListView); + Trace.endSection(); return mParentView; } @@ -473,7 +484,8 @@ public class SpeedDialFragment extends Fragment implements OnItemClickListener, } if (!PermissionsUtil.hasPermission(activity, READ_CONTACTS)) { - requestPermissions(new String[] {READ_CONTACTS}, READ_CONTACTS_PERMISSION_REQUEST_CODE); + FragmentCompat.requestPermissions(this, new String[] {READ_CONTACTS}, + READ_CONTACTS_PERMISSION_REQUEST_CODE); } else { // Switch tabs ((HostInterface) activity).showAllContactsTab(); diff --git a/src/com/android/dialer/logging/InteractionEvent.java b/src/com/android/dialer/logging/InteractionEvent.java new file mode 100644 index 000000000..88518b47c --- /dev/null +++ b/src/com/android/dialer/logging/InteractionEvent.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2015 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.logging; + +/** + * Class holding constants for Dialer interactions + */ +public class InteractionEvent { + + public static final int UNKNOWN = 0; + + /** + * An incoming call was blocked + */ + public static final int CALL_BLOCKED = 15; + + /** + * The user blocked a number from the Call Log screen + */ + public static final int BLOCK_NUMBER_CALL_LOG = 16; + + /** + * The user blocked a number from the Call details screen + */ + public static final int BLOCK_NUMBER_CALL_DETAIL = 17; + + /** + * The user blocked a number from the Management screen + */ + public static final int BLOCK_NUMBER_MANAGEMENT_SCREEN = 18; + + /** + * The user unblocked a number from the Call Log screen + */ + public static final int UNBLOCK_NUMBER_CALL_LOG = 19; + + /** + * The user unblocked a number from the Call details screen + */ + public static final int UNBLOCK_NUMBER_CALL_DETAIL = 20; + + /** + * The user unblocked a number from the Management screen + */ + public static final int UNBLOCK_NUMBER_MANAGEMENT_SCREEN = 21; + + /** + * The user blocked numbers from contacts marked as send to voicemail + */ + public static final int IMPORT_SEND_TO_VOICEMAIL = 22; + + /** + * The user blocked a number then undid the block + */ + public static final int UNDO_BLOCK_NUMBER = 23; + + /** + * The user unblocked a number then undid the unblock + */ + public static final int UNDO_UNBLOCK_NUMBER = 24; + +} diff --git a/src/com/android/dialer/logging/Logger.java b/src/com/android/dialer/logging/Logger.java new file mode 100644 index 000000000..25b7268ad --- /dev/null +++ b/src/com/android/dialer/logging/Logger.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2015 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.logging; + +import android.app.Activity; +import android.text.TextUtils; +import android.util.Log; + +import com.android.contacts.commonbind.analytics.AnalyticsUtil; +import com.android.dialerbind.ObjectFactory; +import com.android.incallui.Call; + +/** + * Single entry point for all logging/analytics-related work for all user interactions. + */ +public abstract class Logger { + public static final String TAG = "Logger"; + + public static Logger getInstance() { + return ObjectFactory.getLoggerInstance(); + } + + /** + * Logs a call event. PII like the call's number or caller details should never be logged. + * + * @param call to log. + */ + public static void logCall(Call call) { + final Logger logger = getInstance(); + if (logger != null) { + logger.logCallImpl(call); + } + } + + /** + * Logs an event indicating that a screen was displayed. + * + * @param screenType integer identifier of the displayed screen + * @param activity Parent activity of the displayed screen. + */ + public static void logScreenView(int screenType, Activity activity) { + final Logger logger = getInstance(); + if (logger != null) { + logger.logScreenViewImpl(screenType); + } + + final String screenName = ScreenEvent.getScreenName(screenType); + if (!TextUtils.isEmpty(screenName)) { + AnalyticsUtil.sendScreenView(screenName, activity, null); + } else { + Log.w(TAG, "Unknown screenType: " + screenType); + } + } + + /** + * Logs an interaction that occurred + * + * @param interaction an integer representing what interaction occurred. + * {@see com.android.dialer.logging.InteractionEvent} + */ + public static void logInteraction(int interaction) { + final Logger logger = getInstance(); + if (logger != null) { + logger.logInteractionImpl(interaction); + } + } + + public abstract void logCallImpl(Call call); + public abstract void logScreenViewImpl(int screenType); + public abstract void logInteractionImpl(int dialerInteraction); +} diff --git a/src/com/android/dialer/logging/ScreenEvent.java b/src/com/android/dialer/logging/ScreenEvent.java new file mode 100644 index 000000000..e0d7b0026 --- /dev/null +++ b/src/com/android/dialer/logging/ScreenEvent.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2015 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.logging; + +import android.text.TextUtils; + +import com.android.contacts.common.dialog.ClearFrequentsDialog; +import com.android.contacts.common.interactions.ImportExportDialogFragment; +import com.android.dialer.calllog.CallLogFragment; +import com.android.dialer.dialpad.DialpadFragment; +import com.android.dialer.filterednumber.BlockedNumbersFragment; +import com.android.dialer.list.AllContactsFragment; +import com.android.dialer.list.BlockedListSearchFragment; +import com.android.dialer.list.RegularSearchFragment; +import com.android.dialer.list.SmartDialSearchFragment; +import com.android.dialer.list.SpeedDialFragment; +import com.android.dialer.settings.DialerSettingsActivity; +import com.android.incallui.AnswerFragment; +import com.android.incallui.CallCardFragment; +import com.android.incallui.ConferenceManagerFragment; +import com.android.incallui.InCallActivity; + +import java.util.HashMap; +import java.util.Map; + +/** + * Stores constants identifying individual screens/dialogs/fragments in the application, and also + * provides a mapping of integer id -> screen name mappings for analytics purposes. + */ +public class ScreenEvent { + private static final Map<Integer, String> sScreenNameMap = new HashMap<>(); + + public static final String FRAGMENT_TAG_SEPARATOR = "#"; + + public static final int UNKNOWN = 0; + + // The dialpad in the main Dialer activity + public static final int DIALPAD = 1; + + // The speed dial tab in the main Dialer activity + public static final int SPEED_DIAL = 2; + + // The recents tab in the main Dialer activity + public static final int CALL_LOG = 3; + + // The voicemail tab in the main Dialer activity + public static final int VOICEMAIL_LOG = 4; + + // The all contacts tab in the main Dialer activity + public static final int ALL_CONTACTS = 5; + + // List of search results returned by typing into the search box. + public static final int REGULAR_SEARCH = 6; + + // List of search results returned by typing into the dialpad. + public static final int SMART_DIAL_SEARCH = 7; + + // The All and Missed call log tabs in CallLogActivity + public static final int CALL_LOG_FILTER = 8; + + // Dialer settings screen. + public static final int SETTINGS = 9; + + // The "Import/export contacts" dialog launched via the overflow menu. + public static final int IMPORT_EXPORT_CONTACTS = 10; + + // The "Clear frequents" dialog launched via the overflow menu. + public static final int CLEAR_FREQUENTS = 11; + + // The "Send feedback" dialog launched via the overflow menu. + public static final int SEND_FEEDBACK = 12; + + // The main in call screen that displays caller details and contact photos + public static final int INCALL = 13; + + // The screen that displays the glowpad widget (slide right to answer, + // slide left to dismiss). + public static final int INCOMING_CALL = 14; + + // Conference management fragment displayed for conferences that support + // management of individual calls within the conference. + public static final int CONFERENCE_MANAGEMENT = 15; + + // The dialpad displayed in-call that is used to send dtmf tones. + public static final int INCALL_DIALPAD = 16; + + // Menu options displayed when long pressing on a call log entry. + public static final int CALL_LOG_CONTEXT_MENU = 17; + + // Screen displayed to allow the user to see an overview of all blocked + // numbers. + public static final int BLOCKED_NUMBER_MANAGEMENT = 18; + + // Screen displayed to allow the user to add a new blocked number. + public static final int BLOCKED_NUMBER_ADD_NUMBER = 19; + + static { + sScreenNameMap.put(ScreenEvent.DIALPAD, + getScreenNameWithTag(DialpadFragment.class.getSimpleName(), "Dialer")); + sScreenNameMap.put(ScreenEvent.SPEED_DIAL, SpeedDialFragment.class.getSimpleName()); + sScreenNameMap.put(ScreenEvent.CALL_LOG, + getScreenNameWithTag(CallLogFragment.class.getSimpleName(), "History")); + sScreenNameMap.put(ScreenEvent.VOICEMAIL_LOG, + getScreenNameWithTag(CallLogFragment.class.getSimpleName(), "Voicemail")); + sScreenNameMap.put(ScreenEvent.ALL_CONTACTS, AllContactsFragment.class.getSimpleName()); + sScreenNameMap.put(ScreenEvent.REGULAR_SEARCH, + RegularSearchFragment.class.getSimpleName()); + sScreenNameMap.put(ScreenEvent.SMART_DIAL_SEARCH, + SmartDialSearchFragment.class.getSimpleName()); + sScreenNameMap.put(ScreenEvent.CALL_LOG_FILTER, + getScreenNameWithTag(CallLogFragment.class.getSimpleName(), "Filtered")); + sScreenNameMap.put(ScreenEvent.SETTINGS, + DialerSettingsActivity.class.getSimpleName()); + sScreenNameMap.put(ScreenEvent.IMPORT_EXPORT_CONTACTS, + ImportExportDialogFragment.class.getSimpleName()); + sScreenNameMap.put(ScreenEvent.CLEAR_FREQUENTS, + ClearFrequentsDialog.class.getSimpleName()); + sScreenNameMap.put(ScreenEvent.SEND_FEEDBACK, "SendFeedback"); + sScreenNameMap.put(ScreenEvent.INCALL, InCallActivity.class.getSimpleName()); + sScreenNameMap.put(ScreenEvent.INCOMING_CALL, AnswerFragment.class.getSimpleName()); + sScreenNameMap.put(ScreenEvent.CONFERENCE_MANAGEMENT, + ConferenceManagerFragment.class.getSimpleName()); + sScreenNameMap.put(ScreenEvent.INCALL_DIALPAD, + getScreenNameWithTag(DialpadFragment.class.getSimpleName(), "InCall")); + sScreenNameMap.put(ScreenEvent.CALL_LOG_CONTEXT_MENU, "CallLogContextMenu"); + sScreenNameMap.put(ScreenEvent.BLOCKED_NUMBER_MANAGEMENT, + BlockedNumbersFragment.class.getSimpleName()); + sScreenNameMap.put(ScreenEvent.BLOCKED_NUMBER_ADD_NUMBER, + BlockedListSearchFragment.class.getSimpleName()); + } + + /** + * For a given screen type, returns the actual screen name that is used for logging/analytics + * purposes. + * + * @param screenType unique ID of a type of screen + * + * @return the tagged version of the screen name corresponding to the provided screenType, + * or {@null} if the provided screenType is unknown. + */ + public static String getScreenName(int screenType) { + return sScreenNameMap.get(screenType); + } + + /** + * Build a tagged version of the provided screenName if the tag is non-empty. + * + * @param screenName Name of the screen. + * @param tag Optional tag describing the screen. + * @return the unchanged screenName if the tag is {@code null} or empty, the tagged version of + * the screenName otherwise. + */ + public static String getScreenNameWithTag(String screenName, String tag) { + if (TextUtils.isEmpty(tag)) { + return screenName; + } + return screenName + FRAGMENT_TAG_SEPARATOR + tag; + } +} diff --git a/src/com/android/dialer/service/CachedNumberLookupService.java b/src/com/android/dialer/service/CachedNumberLookupService.java index e91d458ce..018ada93f 100644 --- a/src/com/android/dialer/service/CachedNumberLookupService.java +++ b/src/com/android/dialer/service/CachedNumberLookupService.java @@ -1,9 +1,13 @@ package com.android.dialer.service; import android.content.Context; +import android.net.Uri; +import android.support.annotation.Nullable; import com.android.dialer.calllog.ContactInfo; +import java.io.InputStream; + public interface CachedNumberLookupService { public interface CachedContactInfo { @@ -42,7 +46,10 @@ public interface CachedNumberLookupService { public boolean isBusiness(int sourceType); public boolean canReportAsInvalid(int sourceType, String objectId); - public boolean addPhoto(Context context, String number, byte[] photo); + /** + * @return return {@link Uri} to the photo or return {@code null} when failing to add photo + */ + public @Nullable Uri addPhoto(Context context, String number, InputStream in); /** * Remove all cached phone number entries from the cache, regardless of how old they diff --git a/src/com/android/dialer/service/ExtendedBlockingButtonRenderer.java b/src/com/android/dialer/service/ExtendedBlockingButtonRenderer.java new file mode 100644 index 000000000..f8d5ea048 --- /dev/null +++ b/src/com/android/dialer/service/ExtendedBlockingButtonRenderer.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2016 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.service; + +import android.support.annotation.Nullable; +import android.view.View; +import android.view.ViewStub; +import android.widget.QuickContactBadge; +import android.widget.TextView; + +import java.util.List; + +/** + * Interface responsible for rendering spam buttons. + */ +public interface ExtendedBlockingButtonRenderer { + + final class ViewHolderInfo { + + public final List<View> completeListItemViews; + public final List<View> extendedBlockedViews; + public final List<View> blockedNumberViews; + public final String phoneNumber; + public final String countryIso; + public final String nameOrNumber; + public final String displayNumber; + + public ViewHolderInfo( + /* All existing views amongst the list item actions, even if invisible */ + List<View> completeListItemViews, + /* Views that should be seen if the number is in the blacklist */ + List<View> extendedBlockedViews, + /* Views that should be seen if the number is in the extended blacklist */ + List<View> blockedNumberViews, + String phoneNumber, + String countryIso, + String nameOrNumber, + String displayNumber) { + + this.completeListItemViews = completeListItemViews; + this.extendedBlockedViews = extendedBlockedViews; + this.blockedNumberViews = blockedNumberViews; + this.phoneNumber = phoneNumber; + this.countryIso = countryIso; + this.nameOrNumber = nameOrNumber; + this.displayNumber = displayNumber; + } + } + + interface Listener { + void onBlockedNumber(String number, @Nullable String countryIso); + void onUnblockedNumber(String number, @Nullable String countryIso); + } + + /** + * Renders buttons for a phone number. + */ + void render(ViewStub viewStub); + + void setViewHolderInfo(ViewHolderInfo info); + + /** + * Updates the photo and label for the given phone number and country iso. + * + * @param number Phone number for which the rendering occurs. + * @param countryIso Two-letter country code. + * @param badge {@link QuickContactBadge} in which the photo should be rendered. + * @param view Textview that will hold the new label. + */ + void updatePhotoAndLabelIfNecessary( + String number, String countryIso, QuickContactBadge badge, TextView view); +} diff --git a/src/com/android/dialer/settings/AppCompatPreferenceActivity.java b/src/com/android/dialer/settings/AppCompatPreferenceActivity.java new file mode 100644 index 000000000..4e5d9c90e --- /dev/null +++ b/src/com/android/dialer/settings/AppCompatPreferenceActivity.java @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2015 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.settings; + +import android.content.res.Configuration; +import android.os.Bundle; +import android.preference.PreferenceActivity; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatDelegate; +import android.support.v7.widget.Toolbar; +import android.view.MenuInflater; +import android.view.View; +import android.view.ViewGroup; + +/** + * A {@link android.preference.PreferenceActivity} which implements and proxies the necessary calls + * to be used with AppCompat. + */ +public class AppCompatPreferenceActivity extends PreferenceActivity { + private AppCompatDelegate mDelegate; + + private boolean mIsSafeToCommitTransactions; + + @Override + protected void onCreate(Bundle savedInstanceState) { + getDelegate().installViewFactory(); + getDelegate().onCreate(savedInstanceState); + super.onCreate(savedInstanceState); + mIsSafeToCommitTransactions = true; + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + getDelegate().onPostCreate(savedInstanceState); + } + + public ActionBar getSupportActionBar() { + return getDelegate().getSupportActionBar(); + } + + public void setSupportActionBar(Toolbar toolbar) { + getDelegate().setSupportActionBar(toolbar); + } + + @Override + public MenuInflater getMenuInflater() { + return getDelegate().getMenuInflater(); + } + + @Override + public void setContentView(int layoutResID) { + getDelegate().setContentView(layoutResID); + } + + @Override + public void setContentView(View view) { + getDelegate().setContentView(view); + } + + @Override + public void setContentView(View view, ViewGroup.LayoutParams params) { + getDelegate().setContentView(view, params); + } + + @Override + public void addContentView(View view, ViewGroup.LayoutParams params) { + getDelegate().addContentView(view, params); + } + + @Override + protected void onPostResume() { + super.onPostResume(); + getDelegate().onPostResume(); + } + + @Override + protected void onTitleChanged(CharSequence title, int color) { + super.onTitleChanged(title, color); + getDelegate().setTitle(title); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + getDelegate().onConfigurationChanged(newConfig); + } + + @Override + protected void onStop() { + super.onStop(); + getDelegate().onStop(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + getDelegate().onDestroy(); + } + + @Override + public void invalidateOptionsMenu() { + getDelegate().invalidateOptionsMenu(); + } + + private AppCompatDelegate getDelegate() { + if (mDelegate == null) { + mDelegate = AppCompatDelegate.create(this, null); + } + return mDelegate; + } + + @Override + protected void onStart() { + super.onStart(); + mIsSafeToCommitTransactions = true; + } + + @Override + protected void onResume() { + super.onResume(); + mIsSafeToCommitTransactions = true; + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + mIsSafeToCommitTransactions = false; + } + + /** + * Returns true if it is safe to commit {@link FragmentTransaction}s at this time, based on + * whether {@link Activity#onSaveInstanceState} has been called or not. + * + * Make sure that the current activity calls into + * {@link super.onSaveInstanceState(Bundle outState)} (if that method is overridden), + * so the flag is properly set. + */ + public boolean isSafeToCommitTransactions() { + return mIsSafeToCommitTransactions; + } +} diff --git a/src/com/android/dialer/settings/DefaultRingtonePreference.java b/src/com/android/dialer/settings/DefaultRingtonePreference.java index a1743814a..a8a23fddf 100644 --- a/src/com/android/dialer/settings/DefaultRingtonePreference.java +++ b/src/com/android/dialer/settings/DefaultRingtonePreference.java @@ -16,17 +16,16 @@ package com.android.dialer.settings; -import android.app.AppOpsManager; import android.content.Context; import android.content.Intent; import android.media.RingtoneManager; import android.net.Uri; import android.preference.RingtonePreference; -import android.provider.Settings; import android.util.AttributeSet; import android.widget.Toast; import com.android.dialer.R; +import com.android.dialer.compat.SettingsCompat; /** * RingtonePreference which doesn't show default ringtone setting. @@ -49,7 +48,7 @@ public class DefaultRingtonePreference extends RingtonePreference { @Override protected void onSaveRingtone(Uri ringtoneUri) { - if (!Settings.System.canWrite(getContext())) { + if (!SettingsCompat.System.canWrite(getContext())) { Toast.makeText( getContext(), getContext().getResources().getString(R.string.toast_cannot_write_system_settings), diff --git a/src/com/android/dialer/settings/DialerSettingsActivity.java b/src/com/android/dialer/settings/DialerSettingsActivity.java index c459d35c5..bbcbd49fc 100644 --- a/src/com/android/dialer/settings/DialerSettingsActivity.java +++ b/src/com/android/dialer/settings/DialerSettingsActivity.java @@ -1,28 +1,42 @@ +/* + * Copyright (C) 2013 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.settings; -import android.app.AppOpsManager; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; -import android.os.Process; import android.os.UserManager; -import android.preference.PreferenceActivity; import android.preference.PreferenceManager; import android.provider.Settings; import android.telecom.TelecomManager; import android.telephony.TelephonyManager; -import android.util.Log; import android.view.MenuItem; import android.widget.Toast; -import com.android.contacts.common.util.PermissionsUtil; +import com.android.contacts.common.compat.CompatUtils; +import com.android.contacts.common.compat.TelephonyManagerCompat; import com.android.dialer.R; +import com.android.dialer.compat.FilteredNumberCompat; +import com.android.dialer.compat.SettingsCompat; +import com.android.dialer.compat.UserManagerCompat; import java.util.List; -public class DialerSettingsActivity extends PreferenceActivity { - +public class DialerSettingsActivity extends AppCompatPreferenceActivity { protected SharedPreferences mPreferences; @Override @@ -44,40 +58,51 @@ public class DialerSettingsActivity extends PreferenceActivity { soundSettingsHeader.id = R.id.settings_header_sounds_and_vibration; target.add(soundSettingsHeader); - Header quickResponseSettingsHeader = new Header(); - Intent quickResponseSettingsIntent = - new Intent(TelecomManager.ACTION_SHOW_RESPOND_VIA_SMS_SETTINGS); - quickResponseSettingsHeader.titleRes = R.string.respond_via_sms_setting_title; - quickResponseSettingsHeader.intent = quickResponseSettingsIntent; - target.add(quickResponseSettingsHeader); + if (CompatUtils.isMarshmallowCompatible()) { + Header quickResponseSettingsHeader = new Header(); + Intent quickResponseSettingsIntent = + new Intent(TelecomManager.ACTION_SHOW_RESPOND_VIA_SMS_SETTINGS); + quickResponseSettingsHeader.titleRes = R.string.respond_via_sms_setting_title; + quickResponseSettingsHeader.intent = quickResponseSettingsIntent; + target.add(quickResponseSettingsHeader); + } TelephonyManager telephonyManager = (TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE); - // Only show call setting menus if the current user is the primary/owner user. - if (isPrimaryUser()) { - // Show "Call Settings" if there is one SIM and "Phone Accounts" if there are more. - if (telephonyManager.getPhoneCount() <= 1) { - Header callSettingsHeader = new Header(); - Intent callSettingsIntent = new Intent(TelecomManager.ACTION_SHOW_CALL_SETTINGS); - callSettingsIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - - callSettingsHeader.titleRes = R.string.call_settings_label; - callSettingsHeader.intent = callSettingsIntent; - target.add(callSettingsHeader); - } else { - Header phoneAccountSettingsHeader = new Header(); - Intent phoneAccountSettingsIntent = - new Intent(TelecomManager.ACTION_CHANGE_PHONE_ACCOUNTS); - phoneAccountSettingsIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - - phoneAccountSettingsHeader.titleRes = R.string.phone_account_settings_label; - phoneAccountSettingsHeader.intent = phoneAccountSettingsIntent; - target.add(phoneAccountSettingsHeader); - } - - if (telephonyManager.isTtyModeSupported() - || telephonyManager.isHearingAidCompatibilitySupported()) { + // "Call Settings" (full settings) is shown if the current user is primary user and there + // is only one SIM. Before N, "Calling accounts" setting is shown if the current user is + // primary user and there are multiple SIMs. In N+, "Calling accounts" is shown whenever + // "Call Settings" is not shown. + boolean isPrimaryUser = isPrimaryUser(); + if (isPrimaryUser + && TelephonyManagerCompat.getPhoneCount(telephonyManager) <= 1) { + Header callSettingsHeader = new Header(); + Intent callSettingsIntent = new Intent(TelecomManager.ACTION_SHOW_CALL_SETTINGS); + callSettingsIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + + callSettingsHeader.titleRes = R.string.call_settings_label; + callSettingsHeader.intent = callSettingsIntent; + target.add(callSettingsHeader); + } else if (android.os.Build.VERSION.CODENAME.startsWith("N") || isPrimaryUser) { + Header phoneAccountSettingsHeader = new Header(); + Intent phoneAccountSettingsIntent = + new Intent(TelecomManager.ACTION_CHANGE_PHONE_ACCOUNTS); + phoneAccountSettingsIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + + phoneAccountSettingsHeader.titleRes = R.string.phone_account_settings_label; + phoneAccountSettingsHeader.intent = phoneAccountSettingsIntent; + target.add(phoneAccountSettingsHeader); + } + if (isPrimaryUser) { + Header blockedCallsHeader = new Header(); + blockedCallsHeader.titleRes = R.string.manage_blocked_numbers_label; + blockedCallsHeader.intent = FilteredNumberCompat.createManageBlockedNumbersIntent(this); + target.add(blockedCallsHeader); + + if (TelephonyManagerCompat.isTtyModeSupported(telephonyManager) + || TelephonyManagerCompat + .isHearingAidCompatibilitySupported(telephonyManager)) { Header accessibilitySettingsHeader = new Header(); Intent accessibilitySettingsIntent = new Intent(TelecomManager.ACTION_SHOW_CALL_ACCESSIBILITY_SETTINGS); @@ -94,7 +119,7 @@ public class DialerSettingsActivity extends PreferenceActivity { // If we don't have the permission to write to system settings, go to system sound // settings instead. Otherwise, perform the super implementation (which launches our // own preference fragment. - if (!Settings.System.canWrite(this)) { + if (!SettingsCompat.System.canWrite(this)) { Toast.makeText( this, getResources().getString(R.string.toast_cannot_write_system_settings), @@ -116,6 +141,14 @@ public class DialerSettingsActivity extends PreferenceActivity { } @Override + public void onBackPressed() { + if (!isSafeToCommitTransactions()) { + return; + } + super.onBackPressed(); + } + + @Override protected boolean isValidFragment(String fragmentName) { return true; } @@ -124,7 +157,6 @@ public class DialerSettingsActivity extends PreferenceActivity { * @return Whether the current user is the primary user. */ private boolean isPrimaryUser() { - final UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); - return userManager.isSystemUser(); + return UserManagerCompat.isSystemUser((UserManager) getSystemService(Context.USER_SERVICE)); } } diff --git a/src/com/android/dialer/settings/SoundSettingsFragment.java b/src/com/android/dialer/settings/SoundSettingsFragment.java index 83847004d..59f8798c3 100644 --- a/src/com/android/dialer/settings/SoundSettingsFragment.java +++ b/src/com/android/dialer/settings/SoundSettingsFragment.java @@ -16,10 +16,9 @@ package com.android.dialer.settings; -import android.app.AppOpsManager; import android.content.Context; -import android.content.Intent; import android.media.RingtoneManager; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Message; @@ -32,21 +31,13 @@ import android.preference.PreferenceScreen; import android.provider.Settings; import android.telephony.CarrierConfigManager; import android.telephony.TelephonyManager; -import android.view.MenuItem; import android.widget.Toast; -import com.android.contacts.common.util.PermissionsUtil; +import com.android.contacts.common.compat.SdkVersionOverride; import com.android.dialer.R; +import com.android.dialer.compat.SettingsCompat; import com.android.phone.common.util.SettingsUtil; -import java.lang.Boolean; -import java.lang.CharSequence; -import java.lang.Object; -import java.lang.Override; -import java.lang.Runnable; -import java.lang.String; -import java.lang.Thread; - public class SoundSettingsFragment extends PreferenceFragment implements Preference.OnPreferenceChangeListener { @@ -88,6 +79,11 @@ public class SoundSettingsFragment extends PreferenceFragment }; @Override + public Context getContext() { + return getActivity(); + } + + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -115,7 +111,8 @@ public class SoundSettingsFragment extends PreferenceFragment TelephonyManager telephonyManager = (TelephonyManager) getActivity().getSystemService(Context.TELEPHONY_SERVICE); - if (telephonyManager.canChangeDtmfToneLength() + if (SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.M) >= Build.VERSION_CODES.M + && telephonyManager.canChangeDtmfToneLength() && (telephonyManager.isWorldPhone() || !shouldHideCarrierSettings())) { mDtmfToneLength.setOnPreferenceChangeListener(this); mDtmfToneLength.setValueIndex( @@ -132,7 +129,7 @@ public class SoundSettingsFragment extends PreferenceFragment public void onResume() { super.onResume(); - if (!Settings.System.canWrite(getContext())) { + if (!SettingsCompat.System.canWrite(getContext())) { // If the user launches this setting fragment, then toggles the WRITE_SYSTEM_SETTINGS // AppOp, then close the fragment since there is nothing useful to do. getActivity().onBackPressed(); @@ -155,7 +152,7 @@ public class SoundSettingsFragment extends PreferenceFragment */ @Override public boolean onPreferenceChange(Preference preference, Object objValue) { - if (!Settings.System.canWrite(getContext())) { + if (!SettingsCompat.System.canWrite(getContext())) { // A user shouldn't be able to get here, but this protects against monkey crashes. Toast.makeText( getContext(), @@ -181,7 +178,7 @@ public class SoundSettingsFragment extends PreferenceFragment */ @Override public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen, Preference preference) { - if (!Settings.System.canWrite(getContext())) { + if (!SettingsCompat.System.canWrite(getContext())) { Toast.makeText( getContext(), getResources().getString(R.string.toast_cannot_write_system_settings), diff --git a/src/com/android/dialer/util/AppCompatConstants.java b/src/com/android/dialer/util/AppCompatConstants.java new file mode 100644 index 000000000..1d52eee1d --- /dev/null +++ b/src/com/android/dialer/util/AppCompatConstants.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2015 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.util; + +import android.provider.CallLog.Calls; + +public final class AppCompatConstants { + + public static final int CALLS_INCOMING_TYPE = Calls.INCOMING_TYPE; + public static final int CALLS_OUTGOING_TYPE = Calls.OUTGOING_TYPE; + public static final int CALLS_MISSED_TYPE = Calls.MISSED_TYPE; + public static final int CALLS_VOICEMAIL_TYPE = Calls.VOICEMAIL_TYPE; + // Added to android.provider.CallLog.Calls in N+. + public static final int CALLS_REJECTED_TYPE = 5; + // Added to android.provider.CallLog.Calls in N+. + public static final int CALLS_BLOCKED_TYPE = 6; +} diff --git a/src/com/android/dialer/util/Assert.java b/src/com/android/dialer/util/Assert.java new file mode 100644 index 000000000..ec0a6ccb6 --- /dev/null +++ b/src/com/android/dialer/util/Assert.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2016 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.util; + +import android.os.Looper; + +public class Assert { + public static void assertNotUiThread(String msg) { + if (Looper.myLooper() == Looper.getMainLooper()) { + throw new AssertionError(msg); + } + } + + public static void assertNotNull(Object object, String msg) { + if (object == null) { + throw new AssertionError(object); + } + } + + public static void assertNotNull(Object object) { + assertNotNull(object, null); + } +} diff --git a/src/com/android/dialer/util/DialerUtils.java b/src/com/android/dialer/util/DialerUtils.java index e25ada59d..95d6a81b6 100644 --- a/src/com/android/dialer/util/DialerUtils.java +++ b/src/com/android/dialer/util/DialerUtils.java @@ -33,18 +33,12 @@ import android.text.TextDirectionHeuristics; import android.text.TextUtils; import android.view.View; import android.view.inputmethod.InputMethodManager; -import android.widget.ImageView; -import android.widget.TextView; import android.widget.Toast; import com.android.contacts.common.ContactsUtils; import com.android.contacts.common.interactions.TouchPointManager; import com.android.dialer.R; -import com.android.dialer.widget.EmptyContentView; -import com.android.incallui.CallCardFragment; -import com.android.incallui.Log; -import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Locale; @@ -81,13 +75,25 @@ public class DialerUtils { // All dialer-initiated calls should pass the touch point to the InCallUI Point touchPoint = TouchPointManager.getInstance().getPoint(); if (touchPoint.x != 0 || touchPoint.y != 0) { - Bundle extras = new Bundle(); + Bundle extras; + // Make sure to not accidentally clobber any existing extras + if (intent.hasExtra(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS)) { + extras = intent.getParcelableExtra( + TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS); + } else { + extras = new Bundle(); + } extras.putParcelable(TouchPointManager.TOUCH_POINT, touchPoint); intent.putExtra(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, extras); } - final TelecomManager tm = - (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); - tm.placeCall(intent.getData(), intent.getExtras()); + + final boolean hasCallPermission = TelecomUtil.placeCall((Activity) context, intent); + if (!hasCallPermission) { + // TODO: Make calling activity show request permission dialog and handle + // callback results appropriately. + Toast.makeText(context, "Cannot place call without Phone permission", + Toast.LENGTH_SHORT); + } } else { context.startActivity(intent); } diff --git a/src/com/android/dialer/util/IntentUtil.java b/src/com/android/dialer/util/IntentUtil.java index 2ce3bd1f8..5a4a80bb1 100644 --- a/src/com/android/dialer/util/IntentUtil.java +++ b/src/com/android/dialer/util/IntentUtil.java @@ -18,14 +18,13 @@ package com.android.dialer.util; import android.content.Intent; import android.net.Uri; +import android.os.Bundle; import android.provider.ContactsContract; -import android.telecom.PhoneAccount; import android.telecom.PhoneAccountHandle; import android.telecom.TelecomManager; import android.telecom.VideoProfile; import com.android.contacts.common.CallUtil; -import com.android.phone.common.PhoneConstants; /** * Utilities for creation of intents in Dialer, such as {@link Intent#ACTION_CALL}. @@ -36,108 +35,65 @@ public class IntentUtil { private static final String SMS_URI_PREFIX = "sms:"; private static final int NO_PHONE_TYPE = -1; - /** - * Return an Intent for making a phone call. Scheme (e.g. tel, sip) will be determined - * automatically. - */ - public static Intent getCallIntent(String number) { - return getCallIntent(number, null, null); - } - - /** - * Return an Intent for making a phone call. A given Uri will be used as is (without any - * sanity check). - */ - public static Intent getCallIntent(Uri uri) { - return getCallIntent(uri, null, null); - } + public static final String EXTRA_CALL_INITIATION_TYPE + = "com.android.dialer.EXTRA_CALL_INITIATION_TYPE"; - /** - * A variant of {@link #getCallIntent(String)} but also accept a call origin. - * For more information about call origin, see comments in Phone package (PhoneApp). - */ - public static Intent getCallIntent(String number, String callOrigin) { - return getCallIntent(CallUtil.getCallUri(number), callOrigin, null); - } + public static class CallIntentBuilder { + private Uri mUri; + private int mCallInitiationType; + private PhoneAccountHandle mPhoneAccountHandle; + private boolean mIsVideoCall = false; - /** - * A variant of {@link #getCallIntent(String)} but also include {@code Account}. - */ - public static Intent getCallIntent(String number, PhoneAccountHandle accountHandle) { - return getCallIntent(number, null, accountHandle); - } - - /** - * A variant of {@link #getCallIntent(android.net.Uri)} but also include {@code Account}. - */ - public static Intent getCallIntent(Uri uri, PhoneAccountHandle accountHandle) { - return getCallIntent(uri, null, accountHandle); - } - - /** - * A variant of {@link #getCallIntent(String, String)} but also include {@code Account}. - */ - public static Intent getCallIntent( - String number, String callOrigin, PhoneAccountHandle accountHandle) { - return getCallIntent(CallUtil.getCallUri(number), callOrigin, accountHandle); - } + public CallIntentBuilder(Uri uri) { + mUri = uri; + } - /** - * A variant of {@link #getCallIntent(android.net.Uri)} but also accept a call - * origin and {@code Account}. - * For more information about call origin, see comments in Phone package (PhoneApp). - */ - public static Intent getCallIntent( - Uri uri, String callOrigin, PhoneAccountHandle accountHandle) { - return getCallIntent(uri, callOrigin, accountHandle, - VideoProfile.STATE_AUDIO_ONLY); - } + public CallIntentBuilder(String number) { + this(CallUtil.getCallUri(number)); + } - /** - * A variant of {@link #getCallIntent(String, String)} for starting a video call. - */ - public static Intent getVideoCallIntent(String number, String callOrigin) { - return getCallIntent(CallUtil.getCallUri(number), callOrigin, null, - VideoProfile.STATE_BIDIRECTIONAL); - } + public CallIntentBuilder setCallInitiationType(int initiationType) { + mCallInitiationType = initiationType; + return this; + } - /** - * A variant of {@link #getCallIntent(String, String, android.telecom.PhoneAccountHandle)} for - * starting a video call. - */ - public static Intent getVideoCallIntent( - String number, String callOrigin, PhoneAccountHandle accountHandle) { - return getCallIntent(CallUtil.getCallUri(number), callOrigin, accountHandle, - VideoProfile.STATE_BIDIRECTIONAL); - } + public CallIntentBuilder setPhoneAccountHandle(PhoneAccountHandle accountHandle) { + mPhoneAccountHandle = accountHandle; + return this; + } - /** - * A variant of {@link #getCallIntent(String, String, android.telecom.PhoneAccountHandle)} for - * starting a video call. - */ - public static Intent getVideoCallIntent(String number, PhoneAccountHandle accountHandle) { - return getVideoCallIntent(number, null, accountHandle); - } + public CallIntentBuilder setIsVideoCall(boolean isVideoCall) { + mIsVideoCall = isVideoCall; + return this; + } - /** - * A variant of {@link #getCallIntent(android.net.Uri)} for calling Voicemail. - */ - public static Intent getVoicemailIntent() { - return getCallIntent(Uri.fromParts(PhoneAccount.SCHEME_VOICEMAIL, "", null)); + public Intent build() { + return getCallIntent( + mUri, + mPhoneAccountHandle, + mIsVideoCall ? VideoProfile.STATE_BIDIRECTIONAL : VideoProfile.STATE_AUDIO_ONLY, + mCallInitiationType); + } } /** - * A variant of {@link #getCallIntent(android.net.Uri)} but also accept a call - * origin and {@code Account} and {@code VideoCallProfile} state. - * For more information about call origin, see comments in Phone package (PhoneApp). + * Create a call intent that can be used to place a call. + * + * @param uri Address to place the call to. + * @param accountHandle {@link PhoneAccountHandle} to place the call with. + * @param videoState Initial video state of the call. + * @param callIntiationType The UI affordance the call was initiated by. + * @return Call intent with provided extras and data. */ public static Intent getCallIntent( - Uri uri, String callOrigin, PhoneAccountHandle accountHandle, int videoState) { + Uri uri, PhoneAccountHandle accountHandle, int videoState, int callIntiationType) { final Intent intent = new Intent(CALL_ACTION, uri); intent.putExtra(TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE, videoState); - if (callOrigin != null) { - intent.putExtra(PhoneConstants.EXTRA_CALL_ORIGIN, callOrigin); - } + + final Bundle b = new Bundle(); + b.putInt(EXTRA_CALL_INITIATION_TYPE, callIntiationType); + intent.putExtra(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, b); + if (accountHandle != null) { intent.putExtra(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, accountHandle); } diff --git a/src/com/android/dialer/util/MoreStrings.java b/src/com/android/dialer/util/MoreStrings.java new file mode 100644 index 000000000..68956f25c --- /dev/null +++ b/src/com/android/dialer/util/MoreStrings.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2013 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.util; + +/** + * Static utility methods for Strings. + */ +public class MoreStrings { + + public static String toSafeString(String value) { + if (value == null) { + return null; + } + + // Do exactly same thing as Uri#toSafeString() does, which will enable us to compare + // sanitized phone numbers. + final StringBuilder builder = new StringBuilder(); + for (int i = 0; i < value.length(); i++) { + final char c = value.charAt(i); + if (c == '-' || c == '@' || c == '.') { + builder.append(c); + } else { + builder.append('x'); + } + } + return builder.toString(); + } + +} diff --git a/src/com/android/dialer/util/PhoneLookupUtil.java b/src/com/android/dialer/util/PhoneLookupUtil.java new file mode 100644 index 000000000..1a7239642 --- /dev/null +++ b/src/com/android/dialer/util/PhoneLookupUtil.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2016 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.util; + +import android.net.Uri; +import android.provider.ContactsContract; + +import com.android.contacts.common.compat.CompatUtils; +import com.android.contacts.common.compat.PhoneLookupSdkCompat; + +public final class PhoneLookupUtil { + /** + * @return the column name that stores contact id for phone lookup query. + */ + public static String getContactIdColumnNameForUri(Uri phoneLookupUri) { + if (CompatUtils.isNCompatible()) { + return PhoneLookupSdkCompat.CONTACT_ID; + } + // In pre-N, contact id is stored in {@link PhoneLookup#_ID} in non-sip query. + boolean isSip = phoneLookupUri.getBooleanQueryParameter( + ContactsContract.PhoneLookup.QUERY_PARAMETER_SIP_ADDRESS, false); + return (isSip) ? PhoneLookupSdkCompat.CONTACT_ID : ContactsContract.PhoneLookup._ID; + } + + private PhoneLookupUtil() {} +} diff --git a/src/com/android/dialer/util/PhoneNumberUtil.java b/src/com/android/dialer/util/PhoneNumberUtil.java index 84f58aa85..33f987359 100644 --- a/src/com/android/dialer/util/PhoneNumberUtil.java +++ b/src/com/android/dialer/util/PhoneNumberUtil.java @@ -19,19 +19,24 @@ package com.android.dialer.util; import android.content.Context; import android.provider.CallLog; import android.telecom.PhoneAccountHandle; -import android.telecom.TelecomManager; import android.text.TextUtils; import android.util.Log; import android.util.Pair; import com.android.contacts.common.util.PhoneNumberHelper; +import com.android.contacts.common.util.TelephonyManagerUtils; import com.google.common.collect.Sets; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.Phonenumber; +import com.google.i18n.phonenumbers.geocoding.PhoneNumberOfflineGeocoder; import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.Set; public class PhoneNumberUtil { + private static final String TAG = "PhoneNumberUtil"; private static final Set<String> LEGACY_UNKNOWN_NUMBERS = Sets.newHashSet("-1", "-2", "-3"); /** Returns true if it is possible to place a call to the given number. */ @@ -49,10 +54,7 @@ public class PhoneNumberUtil { if (TextUtils.isEmpty(number)) { return false; } - - final TelecomManager telecomManager = - (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); - return telecomManager.isVoiceMailNumber(accountHandle, number.toString()); + return TelecomUtil.isVoicemailNumber(context, accountHandle, number.toString()); } /** @@ -92,4 +94,45 @@ public class PhoneNumberUtil { public static boolean isLegacyUnknownNumbers(CharSequence number) { return number != null && LEGACY_UNKNOWN_NUMBERS.contains(number.toString()); } + + /** + * @return a geographical description string for the specified number. + * @see com.android.i18n.phonenumbers.PhoneNumberOfflineGeocoder + */ + public static String getGeoDescription(Context context, String number) { + Log.v(TAG, "getGeoDescription('" + pii(number) + "')..."); + + if (TextUtils.isEmpty(number)) { + return null; + } + + com.google.i18n.phonenumbers.PhoneNumberUtil util = + com.google.i18n.phonenumbers.PhoneNumberUtil.getInstance(); + PhoneNumberOfflineGeocoder geocoder = PhoneNumberOfflineGeocoder.getInstance(); + + Locale locale = context.getResources().getConfiguration().locale; + String countryIso = TelephonyManagerUtils.getCurrentCountryIso(context, locale); + Phonenumber.PhoneNumber pn = null; + try { + Log.v(TAG, "parsing '" + pii(number) + + "' for countryIso '" + countryIso + "'..."); + pn = util.parse(number, countryIso); + Log.v(TAG, "- parsed number: " + pii(pn)); + } catch (NumberParseException e) { + Log.v(TAG, "getGeoDescription: NumberParseException for incoming number '" + + pii(number) + "'"); + } + + if (pn != null) { + String description = geocoder.getDescriptionForNumber(pn, locale); + Log.v(TAG, "- got description: '" + description + "'"); + return description; + } + + return null; + } + + private static String pii(Object pii) { + return com.android.incallui.Log.pii(pii); + } } diff --git a/src/com/android/dialer/util/TelecomUtil.java b/src/com/android/dialer/util/TelecomUtil.java index 1cd270c9b..bd201c459 100644 --- a/src/com/android/dialer/util/TelecomUtil.java +++ b/src/com/android/dialer/util/TelecomUtil.java @@ -17,23 +17,54 @@ package com.android.dialer.util; import android.Manifest; +import android.app.Activity; import android.content.Context; +import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; import android.provider.CallLog.Calls; +import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; +import android.telecom.PhoneAccount; import android.telecom.PhoneAccountHandle; import android.telecom.TelecomManager; +import android.telephony.PhoneNumberUtils; +import android.telephony.TelephonyManager; import android.text.TextUtils; import android.util.Log; +import com.android.contacts.common.compat.CompatUtils; +import com.android.contacts.common.compat.telecom.TelecomManagerCompat; +import com.android.dialer.compat.DialerCompatUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Performs permission checks before calling into TelecomManager. Each method is self-explanatory - + * perform the required check and return the fallback default if the permission is missing, + * otherwise return the value from TelecomManager. + * + */ public class TelecomUtil { private static final String TAG = "TelecomUtil"; private static boolean sWarningLogged = false; + public static void showInCallScreen(Context context, boolean showDialpad) { + if (hasReadPhoneStatePermission(context)) { + try { + getTelecomManager(context).showInCallScreen(showDialpad); + } catch (SecurityException e) { + // Just in case + Log.w(TAG, "TelecomManager.showInCallScreen called without permission."); + } + } + } + public static void silenceRinger(Context context) { if (hasModifyPhoneStatePermission(context)) { try { - getTelecomManager(context).silenceRinger(); + TelecomManagerCompat.silenceRinger(getTelecomManager(context)); } catch (SecurityException e) { // Just in case Log.w(TAG, "TelecomManager.silenceRinger called without permission."); @@ -54,7 +85,8 @@ public class TelecomUtil { public static Uri getAdnUriForPhoneAccount(Context context, PhoneAccountHandle handle) { if (hasModifyPhoneStatePermission(context)) { try { - return getTelecomManager(context).getAdnUriForPhoneAccount(handle); + return TelecomManagerCompat.getAdnUriForPhoneAccount( + getTelecomManager(context), handle); } catch (SecurityException e) { Log.w(TAG, "TelecomManager.getAdnUriForPhoneAccount called without permission."); } @@ -66,11 +98,8 @@ public class TelecomUtil { PhoneAccountHandle handle) { if (hasModifyPhoneStatePermission(context)) { try { - if (handle == null) { - return getTelecomManager(context).handleMmi(dialString); - } else { - return getTelecomManager(context).handleMmi(dialString, handle); - } + return TelecomManagerCompat.handleMmi( + getTelecomManager(context), dialString, handle); } catch (SecurityException e) { Log.w(TAG, "TelecomManager.handleMmi called without permission."); } @@ -78,6 +107,69 @@ public class TelecomUtil { return false; } + @Nullable + public static PhoneAccountHandle getDefaultOutgoingPhoneAccount(Context context, + String uriScheme) { + if (hasReadPhoneStatePermission(context)) { + return TelecomManagerCompat.getDefaultOutgoingPhoneAccount( + getTelecomManager(context), uriScheme); + } + return null; + } + + public static PhoneAccount getPhoneAccount(Context context, PhoneAccountHandle handle) { + return TelecomManagerCompat.getPhoneAccount(getTelecomManager(context), handle); + } + + public static List<PhoneAccountHandle> getCallCapablePhoneAccounts(Context context) { + if (hasReadPhoneStatePermission(context)) { + return TelecomManagerCompat.getCallCapablePhoneAccounts(getTelecomManager(context)); + } + return new ArrayList<>(); + } + + public static boolean isInCall(Context context) { + if (hasReadPhoneStatePermission(context)) { + return getTelecomManager(context).isInCall(); + } + return false; + } + + public static boolean isVoicemailNumber(Context context, PhoneAccountHandle accountHandle, + String number) { + if (hasReadPhoneStatePermission(context)) { + return TelecomManagerCompat.isVoiceMailNumber(getTelecomManager(context), + accountHandle, number); + } + return false; + } + + @Nullable + public static String getVoicemailNumber(Context context, PhoneAccountHandle accountHandle) { + if (hasReadPhoneStatePermission(context)) { + return TelecomManagerCompat.getVoiceMailNumber(getTelecomManager(context), + getTelephonyManager(context), accountHandle); + } + return null; + } + + /** + * Tries to place a call using the {@link TelecomManager}. + * + * @param activity a valid activity. + * @param intent the call intent. + * + * @return {@code true} if we successfully attempted to place the call, {@code false} if it + * failed due to a permission check. + */ + public static boolean placeCall(Activity activity, Intent intent) { + if (hasCallPhonePermission(activity)) { + TelecomManagerCompat.placeCall(activity, getTelecomManager(activity), intent); + return true; + } + return false; + } + public static Uri getCallLogUri(Context context) { return hasReadWriteVoicemailPermissions(context) ? Calls.CONTENT_URI_WITH_VOICEMAIL : Calls.CONTENT_URI; @@ -94,14 +186,24 @@ public class TelecomUtil { || hasPermission(context, Manifest.permission.MODIFY_PHONE_STATE); } + public static boolean hasReadPhoneStatePermission(Context context) { + return isDefaultDialer(context) + || hasPermission(context, Manifest.permission.READ_PHONE_STATE); + } + + public static boolean hasCallPhonePermission(Context context) { + return isDefaultDialer(context) + || hasPermission(context, Manifest.permission.CALL_PHONE); + } + private static boolean hasPermission(Context context, String permission) { - return context.checkSelfPermission(permission) + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED; } public static boolean isDefaultDialer(Context context) { final boolean result = TextUtils.equals(context.getPackageName(), - getTelecomManager(context).getDefaultDialerPackage()); + TelecomManagerCompat.getDefaultDialerPackage(getTelecomManager(context))); if (result) { sWarningLogged = false; } else { @@ -117,4 +219,8 @@ public class TelecomUtil { private static TelecomManager getTelecomManager(Context context) { return (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); } + + private static TelephonyManager getTelephonyManager(Context context) { + return (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + } } diff --git a/src/com/android/dialer/voicemail/VisualVoicemailEnabledChecker.java b/src/com/android/dialer/voicemail/VisualVoicemailEnabledChecker.java new file mode 100644 index 000000000..80a0368bd --- /dev/null +++ b/src/com/android/dialer/voicemail/VisualVoicemailEnabledChecker.java @@ -0,0 +1,103 @@ +package com.android.dialer.voicemail; + + +import android.content.Context; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.preference.PreferenceManager; +import android.support.annotation.Nullable; + +import com.android.dialer.calllog.CallLogQueryHandler; + +/** + * Helper class to check whether visual voicemail is enabled. + * + * Call isVisualVoicemailEnabled() to retrieve the result. + * + * The result is cached and saved in a SharedPreferences, stored as a boolean in + * PREF_KEY_HAS_ACTIVE_VOICEMAIL_PROVIDER. Every time a new instance is created, it will try to + * restore the cached result from the SharedPreferences. + * + * Call asyncUpdate() to make a CallLogQuery to check the actual status. This is a async call so + * isVisualVoicemailEnabled() will not be affected immediately. + * + * If the status has changed as a result of asyncUpdate(), + * Callback.onVisualVoicemailEnabledStatusChanged() will be called with the new value. + */ +public class VisualVoicemailEnabledChecker implements CallLogQueryHandler.Listener { + + public static final String PREF_KEY_HAS_ACTIVE_VOICEMAIL_PROVIDER = + "has_active_voicemail_provider"; + private SharedPreferences mPrefs; + private boolean mHasActiveVoicemailProvider; + private CallLogQueryHandler mCallLogQueryHandler; + private VoicemailStatusHelper mVoicemailStatusHelper; + private Context mContext; + + public interface Callback { + + /** + * Callback to notify enabled status has changed to the @param newValue + */ + void onVisualVoicemailEnabledStatusChanged(boolean newValue); + } + + private Callback mCallback; + + public VisualVoicemailEnabledChecker(Context context, @Nullable Callback callback) { + mContext = context; + mCallback = callback; + mPrefs = PreferenceManager.getDefaultSharedPreferences(mContext); + mVoicemailStatusHelper = new VoicemailStatusHelperImpl(); + mHasActiveVoicemailProvider = mPrefs.getBoolean(PREF_KEY_HAS_ACTIVE_VOICEMAIL_PROVIDER, + false); + } + + /** + * @return whether visual voicemail is enabled. Result is cached, call asyncUpdate() to + * update the result. + */ + public boolean isVisualVoicemailEnabled() { + return mHasActiveVoicemailProvider; + } + + /** + * Perform an async query into the system to check the status of visual voicemail. + * If the status has changed, Callback.onVisualVoicemailEnabledStatusChanged() will be called. + */ + public void asyncUpdate() { + mCallLogQueryHandler = + new CallLogQueryHandler(mContext, mContext.getContentResolver(), this); + mCallLogQueryHandler.fetchVoicemailStatus(); + } + + @Override + public void onVoicemailStatusFetched(Cursor statusCursor) { + boolean hasActiveVoicemailProvider = + mVoicemailStatusHelper.getNumberActivityVoicemailSources(statusCursor) > 0; + if (hasActiveVoicemailProvider != mHasActiveVoicemailProvider) { + mHasActiveVoicemailProvider = hasActiveVoicemailProvider; + mPrefs.edit().putBoolean(PREF_KEY_HAS_ACTIVE_VOICEMAIL_PROVIDER, + mHasActiveVoicemailProvider); + if (mCallback != null) { + mCallback.onVisualVoicemailEnabledStatusChanged(mHasActiveVoicemailProvider); + } + } + } + + @Override + public void onVoicemailUnreadCountFetched(Cursor cursor) { + // Do nothing + } + + @Override + public void onMissedCallsUnreadCountFetched(Cursor cursor) { + // Do nothing + } + + @Override + public boolean onCallsFetched(Cursor combinedCursor) { + // Do nothing + return false; + } +} diff --git a/src/com/android/dialer/voicemail/VoicemailArchiveActivity.java b/src/com/android/dialer/voicemail/VoicemailArchiveActivity.java new file mode 100644 index 000000000..16b947cd3 --- /dev/null +++ b/src/com/android/dialer/voicemail/VoicemailArchiveActivity.java @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2016 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.voicemail; + +import android.content.Intent; +import android.database.Cursor; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.MenuItem; +import android.view.View; + +import com.android.contacts.common.GeoUtil; +import com.android.dialer.DialtactsActivity; +import com.android.dialer.R; +import com.android.dialer.TransactionSafeActivity; +import com.android.dialer.calllog.CallLogAdapter; +import com.android.dialer.calllog.CallLogQueryHandler; +import com.android.dialer.calllog.ContactInfoHelper; +import com.android.dialer.widget.EmptyContentView; +import com.android.dialerbind.ObjectFactory; + +/** + * This activity manages all the voicemails archived by the user. + */ +public class VoicemailArchiveActivity extends TransactionSafeActivity + implements CallLogAdapter.CallFetcher, CallLogQueryHandler.Listener { + private RecyclerView mRecyclerView; + private LinearLayoutManager mLayoutManager; + private EmptyContentView mEmptyListView; + private CallLogAdapter mAdapter; + private VoicemailPlaybackPresenter mVoicemailPlaybackPresenter; + private CallLogQueryHandler mCallLogQueryHandler; + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (!isSafeToCommitTransactions()) { + return true; + } + + switch (item.getItemId()) { + case android.R.id.home: + Intent intent = new Intent(this, DialtactsActivity.class); + // Clears any activities between VoicemailArchiveActivity and DialtactsActivity + // on the activity stack and reuses the existing instance of DialtactsActivity + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.call_log_fragment); + + // Make window opaque to reduce overdraw + getWindow().setBackgroundDrawable(null); + + ActionBar actionBar = getSupportActionBar(); + actionBar.setDisplayShowHomeEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setElevation(0); + + mCallLogQueryHandler = new CallLogQueryHandler(this, getContentResolver(), this); + mVoicemailPlaybackPresenter = VoicemailArchivePlaybackPresenter + .getInstance(this, savedInstanceState); + + mRecyclerView = (RecyclerView) findViewById(R.id.recycler_view); + mRecyclerView.setHasFixedSize(true); + mLayoutManager = new LinearLayoutManager(this); + mRecyclerView.setLayoutManager(mLayoutManager); + mEmptyListView = (EmptyContentView) findViewById(R.id.empty_list_view); + mEmptyListView.setDescription(R.string.voicemail_archive_empty); + mEmptyListView.setImage(R.drawable.empty_call_log); + + mAdapter = ObjectFactory.newCallLogAdapter( + this, + this, + new ContactInfoHelper(this, GeoUtil.getCurrentCountryIso(this)), + mVoicemailPlaybackPresenter, + CallLogAdapter.ACTIVITY_TYPE_ARCHIVE); + mRecyclerView.setAdapter(mAdapter); + fetchCalls(); + } + + @Override + protected void onPause() { + mVoicemailPlaybackPresenter.onPause(); + mAdapter.onPause(); + super.onPause(); + } + + @Override + public void onResume() { + super.onResume(); + mAdapter.onResume(); + mVoicemailPlaybackPresenter.onResume(); + } + + @Override + public void onDestroy() { + mVoicemailPlaybackPresenter.onDestroy(); + mAdapter.changeCursor(null); + super.onDestroy(); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + mVoicemailPlaybackPresenter.onSaveInstanceState(outState); + } + + @Override + public void fetchCalls() { + mCallLogQueryHandler.fetchVoicemailArchive(); + } + + @Override + public void onVoicemailStatusFetched(Cursor statusCursor) { + // Do nothing + } + + @Override + public void onVoicemailUnreadCountFetched(Cursor cursor) { + // Do nothing + } + + @Override + public void onMissedCallsUnreadCountFetched(Cursor cursor) { + // Do nothing + } + + @Override + public boolean onCallsFetched(Cursor cursor) { + mAdapter.changeCursorVoicemail(cursor); + boolean showListView = cursor != null && cursor.getCount() > 0; + mRecyclerView.setVisibility(showListView ? View.VISIBLE : View.GONE); + mEmptyListView.setVisibility(!showListView ? View.VISIBLE : View.GONE); + return true; + } +} diff --git a/src/com/android/dialer/voicemail/VoicemailArchivePlaybackPresenter.java b/src/com/android/dialer/voicemail/VoicemailArchivePlaybackPresenter.java new file mode 100644 index 000000000..5f73d1689 --- /dev/null +++ b/src/com/android/dialer/voicemail/VoicemailArchivePlaybackPresenter.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2016 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.voicemail; + +import android.app.Activity; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.util.Log; +import com.android.dialer.calllog.CallLogAsyncTaskUtil; +import com.android.dialer.database.VoicemailArchiveContract; +import java.io.FileNotFoundException; +import java.util.concurrent.TimeUnit; + +/** + * Similar to the {@link VoicemailPlaybackPresenter}, but for the archive voicemail tab. It checks + * whether the voicemail file exists locally before preparing it. + */ +public class VoicemailArchivePlaybackPresenter extends VoicemailPlaybackPresenter { + private static final String TAG = "VMPlaybackPresenter"; + private static VoicemailPlaybackPresenter sInstance; + + public VoicemailArchivePlaybackPresenter(Activity activity) { + super(activity); + } + + public static VoicemailPlaybackPresenter getInstance( + Activity activity, Bundle savedInstanceState) { + if (sInstance == null) { + sInstance = new VoicemailArchivePlaybackPresenter(activity); + } + + sInstance.init(activity, savedInstanceState); + return sInstance; + } + + @Override + protected void checkForContent(final OnContentCheckedListener callback) { + mAsyncTaskExecutor.submit(Tasks.CHECK_FOR_CONTENT, new AsyncTask<Void, Void, Boolean>() { + @Override + public Boolean doInBackground(Void... params) { + try { + // Check if the _data column of the archived voicemail is valid + if (mVoicemailUri != null) { + mContext.getContentResolver().openInputStream(mVoicemailUri); + return true; + } + } catch (FileNotFoundException e) { + Log.d(TAG, "Voicemail file not found for " + mVoicemailUri); + } + return false; + } + + @Override + public void onPostExecute(Boolean hasContent) { + callback.onContentChecked(hasContent); + } + }); + } + + @Override + protected void startArchiveVoicemailTask(final Uri voicemailUri, final boolean archivedByUser) { + // If a user wants to share an archived voicemail, no need for archiving, just go straight + // to share intent. + if (!archivedByUser) { + sendShareIntent(voicemailUri); + } + } + + @Override + protected boolean requestContent(int code) { + handleError(new FileNotFoundException("Voicemail archive file does not exist")); + return false; // No way for archive tab to request content + } +} diff --git a/src/com/android/dialer/voicemail/VoicemailAsyncTaskUtil.java b/src/com/android/dialer/voicemail/VoicemailAsyncTaskUtil.java new file mode 100644 index 000000000..7abf9a72c --- /dev/null +++ b/src/com/android/dialer/voicemail/VoicemailAsyncTaskUtil.java @@ -0,0 +1,346 @@ +/* + * Copyright (C) 2016 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.voicemail; + +import com.android.contacts.common.testing.NeededForTesting; +import com.android.dialer.calllog.CallLogQuery; +import com.android.dialer.database.VoicemailArchiveContract; +import com.android.dialer.util.AsyncTaskExecutor; +import com.android.dialer.util.AsyncTaskExecutors; +import com.google.common.base.Preconditions; +import com.google.common.io.ByteStreams; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.CallLog; +import android.provider.VoicemailContract; +import android.util.Log; +import com.android.common.io.MoreCloseables; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import javax.annotation.Nullable; + +/** + * Class containing asynchronous tasks for voicemails. + */ +@NeededForTesting +public class VoicemailAsyncTaskUtil { + private static final String TAG = "VoicemailAsyncTaskUtil"; + + /** The enumeration of {@link AsyncTask} objects we use in this class. */ + public enum Tasks { + GET_VOICEMAIL_FILE_PATH, + SET_VOICEMAIL_ARCHIVE_STATUS, + ARCHIVE_VOICEMAIL_CONTENT + } + + @NeededForTesting + public interface OnArchiveVoicemailListener { + /** + * Called after the voicemail has been archived. + * + * @param archivedVoicemailUri the URI of the archived voicemail + */ + void onArchiveVoicemail(@Nullable Uri archivedVoicemailUri); + } + + @NeededForTesting + public interface OnSetVoicemailArchiveStatusListener { + /** + * Called after the voicemail archived_by_user column is updated. + * + * @param success whether the update was successful or not + */ + void onSetVoicemailArchiveStatus(boolean success); + } + + @NeededForTesting + public interface OnGetArchivedVoicemailFilePathListener { + /** + * Called after the voicemail file path is obtained. + * + * @param filePath the file path of the archived voicemail + */ + void onGetArchivedVoicemailFilePath(@Nullable String filePath); + } + + private final ContentResolver mResolver; + private final AsyncTaskExecutor mAsyncTaskExecutor; + + @NeededForTesting + public VoicemailAsyncTaskUtil(ContentResolver contentResolver) { + mResolver = Preconditions.checkNotNull(contentResolver); + mAsyncTaskExecutor = AsyncTaskExecutors.createThreadPoolExecutor(); + } + + /** + * Returns the archived voicemail file path. + */ + @NeededForTesting + public void getVoicemailFilePath( + final OnGetArchivedVoicemailFilePathListener listener, + final Uri voicemailUri) { + Preconditions.checkNotNull(listener); + Preconditions.checkNotNull(voicemailUri); + mAsyncTaskExecutor.submit(Tasks.GET_VOICEMAIL_FILE_PATH, + new AsyncTask<Void, Void, String>() { + @Nullable + @Override + protected String doInBackground(Void... params) { + try (Cursor cursor = mResolver.query(voicemailUri, + new String[]{VoicemailArchiveContract.VoicemailArchive._DATA}, + null, null, null)) { + if (hasContent(cursor)) { + return cursor.getString(cursor.getColumnIndex( + VoicemailArchiveContract.VoicemailArchive._DATA)); + } + } + return null; + } + + @Override + protected void onPostExecute(String filePath) { + listener.onGetArchivedVoicemailFilePath(filePath); + } + }); + } + + /** + * Updates the archived_by_user flag of the archived voicemail. + */ + @NeededForTesting + public void setVoicemailArchiveStatus( + final OnSetVoicemailArchiveStatusListener listener, + final Uri voicemailUri, + final boolean archivedByUser) { + Preconditions.checkNotNull(listener); + Preconditions.checkNotNull(voicemailUri); + mAsyncTaskExecutor.submit(Tasks.SET_VOICEMAIL_ARCHIVE_STATUS, + new AsyncTask<Void, Void, Boolean>() { + @Override + protected Boolean doInBackground(Void... params) { + ContentValues values = new ContentValues(1); + values.put(VoicemailArchiveContract.VoicemailArchive.ARCHIVED, + archivedByUser); + return mResolver.update(voicemailUri, values, null, null) > 0; + } + + @Override + protected void onPostExecute(Boolean success) { + listener.onSetVoicemailArchiveStatus(success); + } + }); + } + + /** + * Checks if a voicemail has already been archived, if so, return the previously archived URI. + * Otherwise, copy the voicemail information to the local dialer database. If archive was + * successful, archived voicemail URI is returned to listener, otherwise null. + */ + @NeededForTesting + public void archiveVoicemailContent( + final OnArchiveVoicemailListener listener, + final Uri voicemailUri) { + Preconditions.checkNotNull(listener); + Preconditions.checkNotNull(voicemailUri); + mAsyncTaskExecutor.submit(Tasks.ARCHIVE_VOICEMAIL_CONTENT, + new AsyncTask<Void, Void, Uri>() { + @Nullable + @Override + protected Uri doInBackground(Void... params) { + Uri archivedVoicemailUri = getArchivedVoicemailUri(voicemailUri); + + // If previously archived, return uri, otherwise archive everything. + if (archivedVoicemailUri != null) { + return archivedVoicemailUri; + } + + // Combine call log and voicemail content info. + ContentValues values = getVoicemailContentValues(voicemailUri); + if (values == null) { + return null; + } + + Uri insertedVoicemailUri = mResolver.insert( + VoicemailArchiveContract.VoicemailArchive.CONTENT_URI, values); + if (insertedVoicemailUri == null) { + return null; + } + + // Copy voicemail content to a new file. + boolean copiedFile = false; + try (InputStream inputStream = mResolver.openInputStream(voicemailUri); + OutputStream outputStream = + mResolver.openOutputStream(insertedVoicemailUri)) { + if (inputStream != null && outputStream != null) { + ByteStreams.copy(inputStream, outputStream); + copiedFile = true; + return insertedVoicemailUri; + } + } catch (IOException e) { + Log.w(TAG, "Failed to copy voicemail content to new file: " + + e.toString()); + } finally { + if (!copiedFile) { + // Roll back insert if the voicemail content was not copied. + mResolver.delete(insertedVoicemailUri, null, null); + } + } + return null; + } + + @Override + protected void onPostExecute(Uri archivedVoicemailUri) { + listener.onArchiveVoicemail(archivedVoicemailUri); + } + }); + } + + /** + * Helper method to get the archived URI of a voicemail. + * + * @param voicemailUri a {@link android.provider.VoicemailContract.Voicemails#CONTENT_URI} URI. + * @return the URI of the archived voicemail or {@code null} + */ + @Nullable + private Uri getArchivedVoicemailUri(Uri voicemailUri) { + try (Cursor cursor = getArchiveExistsCursor(voicemailUri)) { + if (hasContent(cursor)) { + return VoicemailArchiveContract.VoicemailArchive + .buildWithId(cursor.getInt(cursor.getColumnIndex( + VoicemailArchiveContract.VoicemailArchive._ID))); + } + } + return null; + } + + /** + * Helper method to make a copy of all the values needed to display a voicemail. + * + * @param voicemailUri a {@link VoicemailContract.Voicemails#CONTENT_URI} URI. + * @return the combined call log and voicemail values for the given URI, or {@code null} + */ + @Nullable + private ContentValues getVoicemailContentValues(Uri voicemailUri) { + try (Cursor callLogInfo = getCallLogInfoCursor(voicemailUri); + Cursor contentInfo = getContentInfoCursor(voicemailUri)) { + + if (hasContent(callLogInfo) && hasContent(contentInfo)) { + // Create values to insert into database. + ContentValues values = new ContentValues(); + + // Insert voicemail call log info. + values.put(VoicemailArchiveContract.VoicemailArchive.COUNTRY_ISO, + callLogInfo.getString(CallLogQuery.COUNTRY_ISO)); + values.put(VoicemailArchiveContract.VoicemailArchive.GEOCODED_LOCATION, + callLogInfo.getString(CallLogQuery.GEOCODED_LOCATION)); + values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_NAME, + callLogInfo.getString(CallLogQuery.CACHED_NAME)); + values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_NUMBER_TYPE, + callLogInfo.getInt(CallLogQuery.CACHED_NUMBER_TYPE)); + values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_NUMBER_LABEL, + callLogInfo.getString(CallLogQuery.CACHED_NUMBER_LABEL)); + values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_LOOKUP_URI, + callLogInfo.getString(CallLogQuery.CACHED_LOOKUP_URI)); + values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_MATCHED_NUMBER, + callLogInfo.getString(CallLogQuery.CACHED_MATCHED_NUMBER)); + values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_NORMALIZED_NUMBER, + callLogInfo.getString(CallLogQuery.CACHED_NORMALIZED_NUMBER)); + values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_FORMATTED_NUMBER, + callLogInfo.getString(CallLogQuery.CACHED_FORMATTED_NUMBER)); + values.put(VoicemailArchiveContract.VoicemailArchive.NUMBER_PRESENTATION, + callLogInfo.getInt(CallLogQuery.NUMBER_PRESENTATION)); + values.put(VoicemailArchiveContract.VoicemailArchive.ACCOUNT_COMPONENT_NAME, + callLogInfo.getString(CallLogQuery.ACCOUNT_COMPONENT_NAME)); + values.put(VoicemailArchiveContract.VoicemailArchive.ACCOUNT_ID, + callLogInfo.getString(CallLogQuery.ACCOUNT_ID)); + values.put(VoicemailArchiveContract.VoicemailArchive.FEATURES, + callLogInfo.getInt(CallLogQuery.FEATURES)); + values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_PHOTO_URI, + callLogInfo.getString(CallLogQuery.CACHED_PHOTO_URI)); + + // Insert voicemail content info. + values.put(VoicemailArchiveContract.VoicemailArchive.SERVER_ID, + contentInfo.getInt(contentInfo.getColumnIndex( + VoicemailContract.Voicemails._ID))); + values.put(VoicemailArchiveContract.VoicemailArchive.NUMBER, + contentInfo.getString(contentInfo.getColumnIndex( + VoicemailContract.Voicemails.NUMBER))); + values.put(VoicemailArchiveContract.VoicemailArchive.DATE, + contentInfo.getLong(contentInfo.getColumnIndex( + VoicemailContract.Voicemails.DATE))); + values.put(VoicemailArchiveContract.VoicemailArchive.DURATION, + contentInfo.getLong(contentInfo.getColumnIndex( + VoicemailContract.Voicemails.DURATION))); + values.put(VoicemailArchiveContract.VoicemailArchive.MIME_TYPE, + contentInfo.getString(contentInfo.getColumnIndex( + VoicemailContract.Voicemails.MIME_TYPE))); + values.put(VoicemailArchiveContract.VoicemailArchive.TRANSCRIPTION, + contentInfo.getString(contentInfo.getColumnIndex( + VoicemailContract.Voicemails.TRANSCRIPTION))); + + // Achived is false by default because it is updated after insertion. + values.put(VoicemailArchiveContract.VoicemailArchive.ARCHIVED, false); + + return values; + } + } + return null; + } + + private boolean hasContent(@Nullable Cursor cursor) { + return cursor != null && cursor.moveToFirst(); + } + + @Nullable + private Cursor getCallLogInfoCursor(Uri voicemailUri) { + return mResolver.query( + ContentUris.withAppendedId(CallLog.Calls.CONTENT_URI_WITH_VOICEMAIL, + ContentUris.parseId(voicemailUri)), + CallLogQuery._PROJECTION, null, null, null); + } + + @Nullable + private Cursor getContentInfoCursor(Uri voicemailUri) { + return mResolver.query(voicemailUri, + new String[] { + VoicemailContract.Voicemails._ID, + VoicemailContract.Voicemails.NUMBER, + VoicemailContract.Voicemails.DATE, + VoicemailContract.Voicemails.DURATION, + VoicemailContract.Voicemails.MIME_TYPE, + VoicemailContract.Voicemails.TRANSCRIPTION, + }, null, null, null); + } + + @Nullable + private Cursor getArchiveExistsCursor(Uri voicemailUri) { + return mResolver.query(VoicemailArchiveContract.VoicemailArchive.CONTENT_URI, + new String[] {VoicemailArchiveContract.VoicemailArchive._ID}, + VoicemailArchiveContract.VoicemailArchive.SERVER_ID + "=" + + ContentUris.parseId(voicemailUri), + null, + null); + } +} diff --git a/src/com/android/dialer/voicemail/VoicemailAudioManager.java b/src/com/android/dialer/voicemail/VoicemailAudioManager.java new file mode 100644 index 000000000..fe6cf5f45 --- /dev/null +++ b/src/com/android/dialer/voicemail/VoicemailAudioManager.java @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2015 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.voicemail; + +import android.content.Context; +import android.media.AudioManager; +import android.media.AudioManager.OnAudioFocusChangeListener; +import android.telecom.CallAudioState; +import android.util.Log; + +import java.util.concurrent.RejectedExecutionException; + +/** + * This class manages all audio changes for voicemail playback. + */ +final class VoicemailAudioManager implements OnAudioFocusChangeListener, + WiredHeadsetManager.Listener { + private static final String TAG = VoicemailAudioManager.class.getSimpleName(); + + public static final int PLAYBACK_STREAM = AudioManager.STREAM_VOICE_CALL; + + private AudioManager mAudioManager; + private VoicemailPlaybackPresenter mVoicemailPlaybackPresenter; + private WiredHeadsetManager mWiredHeadsetManager; + private boolean mWasSpeakerOn; + private CallAudioState mCallAudioState; + + public VoicemailAudioManager(Context context, + VoicemailPlaybackPresenter voicemailPlaybackPresenter) { + mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + mVoicemailPlaybackPresenter = voicemailPlaybackPresenter; + mWiredHeadsetManager = new WiredHeadsetManager(context); + mWiredHeadsetManager.setListener(this); + + mCallAudioState = getInitialAudioState(); + Log.i(TAG, "Initial audioState = " + mCallAudioState); + } + + public void requestAudioFocus() { + int result = mAudioManager.requestAudioFocus( + this, + PLAYBACK_STREAM, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT); + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + throw new RejectedExecutionException("Could not capture audio focus."); + } + } + + public void abandonAudioFocus() { + mAudioManager.abandonAudioFocus(this); + } + + @Override + public void onAudioFocusChange(int focusChange) { + Log.d(TAG, "onAudioFocusChange: focusChange=" + focusChange); + mVoicemailPlaybackPresenter.onAudioFocusChange(focusChange == AudioManager.AUDIOFOCUS_GAIN); + } + + @Override + public void onWiredHeadsetPluggedInChanged(boolean oldIsPluggedIn, boolean newIsPluggedIn) { + Log.i(TAG, "wired headset was plugged in changed: " + oldIsPluggedIn + + " -> "+ newIsPluggedIn); + + if (oldIsPluggedIn == newIsPluggedIn) { + return; + } + + int newRoute = mCallAudioState.getRoute(); // start out with existing route + if (newIsPluggedIn) { + newRoute = CallAudioState.ROUTE_WIRED_HEADSET; + } else { + if (mWasSpeakerOn) { + newRoute = CallAudioState.ROUTE_SPEAKER; + } else { + newRoute = CallAudioState.ROUTE_EARPIECE; + } + } + + mVoicemailPlaybackPresenter.setSpeakerphoneOn(newRoute == CallAudioState.ROUTE_SPEAKER); + + // We need to call this every time even if we do not change the route because the supported + // routes changed either to include or not include WIRED_HEADSET. + setSystemAudioState( + new CallAudioState(false /* muted */, newRoute, calculateSupportedRoutes())); + } + + public void setSpeakerphoneOn(boolean on) { + setAudioRoute(on ? CallAudioState.ROUTE_SPEAKER : CallAudioState.ROUTE_WIRED_OR_EARPIECE); + } + + public boolean isWiredHeadsetPluggedIn() { + return mWiredHeadsetManager.isPluggedIn(); + } + + public void registerReceivers() { + // Receivers is plural because we expect to add bluetooth support. + mWiredHeadsetManager.registerReceiver(); + } + + public void unregisterReceivers() { + mWiredHeadsetManager.unregisterReceiver(); + } + + /** + * Change the audio route, for example from earpiece to speakerphone. + * + * @param route The new audio route to use. See {@link CallAudioState}. + */ + void setAudioRoute(int route) { + Log.v(TAG, "setAudioRoute, route: " + CallAudioState.audioRouteToString(route)); + + // Change ROUTE_WIRED_OR_EARPIECE to a single entry. + int newRoute = selectWiredOrEarpiece(route, mCallAudioState.getSupportedRouteMask()); + + // If route is unsupported, do nothing. + if ((mCallAudioState.getSupportedRouteMask() | newRoute) == 0) { + Log.w(TAG, "Asking to set to a route that is unsupported: " + newRoute); + return; + } + + if (mCallAudioState.getRoute() != newRoute) { + // Remember the new speaker state so it can be restored when the user plugs and unplugs + // a headset. + mWasSpeakerOn = newRoute == CallAudioState.ROUTE_SPEAKER; + setSystemAudioState(new CallAudioState(false /* muted */, newRoute, + mCallAudioState.getSupportedRouteMask())); + } + } + + private CallAudioState getInitialAudioState() { + int supportedRouteMask = calculateSupportedRoutes(); + int route = selectWiredOrEarpiece(CallAudioState.ROUTE_WIRED_OR_EARPIECE, + supportedRouteMask); + return new CallAudioState(false /* muted */, route, supportedRouteMask); + } + + private int calculateSupportedRoutes() { + int routeMask = CallAudioState.ROUTE_SPEAKER; + if (mWiredHeadsetManager.isPluggedIn()) { + routeMask |= CallAudioState.ROUTE_WIRED_HEADSET; + } else { + routeMask |= CallAudioState.ROUTE_EARPIECE; + } + return routeMask; + } + + private int selectWiredOrEarpiece(int route, int supportedRouteMask) { + // Since they are mutually exclusive and one is ALWAYS valid, we allow a special input of + // ROUTE_WIRED_OR_EARPIECE so that callers don't have to make a call to check which is + // supported before calling setAudioRoute. + if (route == CallAudioState.ROUTE_WIRED_OR_EARPIECE) { + route = CallAudioState.ROUTE_WIRED_OR_EARPIECE & supportedRouteMask; + if (route == 0) { + Log.wtf(TAG, "One of wired headset or earpiece should always be valid."); + // assume earpiece in this case. + route = CallAudioState.ROUTE_EARPIECE; + } + } + return route; + } + + private void setSystemAudioState(CallAudioState callAudioState) { + CallAudioState oldAudioState = mCallAudioState; + mCallAudioState = callAudioState; + + Log.i(TAG, "setSystemAudioState: changing from " + oldAudioState + " to " + + mCallAudioState); + + // Audio route. + if (mCallAudioState.getRoute() == CallAudioState.ROUTE_SPEAKER) { + turnOnSpeaker(true); + } else if (mCallAudioState.getRoute() == CallAudioState.ROUTE_EARPIECE || + mCallAudioState.getRoute() == CallAudioState.ROUTE_WIRED_HEADSET) { + // Just handle turning off the speaker, the system will handle switching between wired + // headset and earpiece. + turnOnSpeaker(false); + } + } + + private void turnOnSpeaker(boolean on) { + if (mAudioManager.isSpeakerphoneOn() != on) { + Log.i(TAG, "turning speaker phone on: " + on); + mAudioManager.setSpeakerphoneOn(on); + } + } +} diff --git a/src/com/android/dialer/voicemail/VoicemailPlaybackLayout.java b/src/com/android/dialer/voicemail/VoicemailPlaybackLayout.java index c918d7944..d4d294e8d 100644 --- a/src/com/android/dialer/voicemail/VoicemailPlaybackLayout.java +++ b/src/com/android/dialer/voicemail/VoicemailPlaybackLayout.java @@ -16,35 +16,46 @@ package com.android.dialer.voicemail; -import android.app.Activity; -import android.app.Fragment; +import android.content.ContentUris; import android.content.Context; -import android.media.MediaPlayer; +import android.content.Intent; +import android.database.Cursor; +import android.graphics.drawable.Drawable; import android.net.Uri; -import android.os.Bundle; -import android.os.PowerManager; -import android.provider.VoicemailContract; +import android.os.AsyncTask; +import android.os.Handler; import android.util.AttributeSet; -import android.util.Log; +import android.support.design.widget.Snackbar; import android.view.LayoutInflater; import android.view.View; -import android.view.ViewGroup; import android.widget.ImageButton; import android.widget.LinearLayout; import android.widget.SeekBar; import android.widget.SeekBar.OnSeekBarChangeListener; +import android.widget.Space; import android.widget.TextView; +import android.widget.Toast; import com.android.common.io.MoreCloseables; +import com.android.dialer.PhoneCallDetails; import com.android.dialer.R; import com.android.dialer.calllog.CallLogAsyncTaskUtil; -import com.google.common.base.Preconditions; +import com.android.dialer.database.VoicemailArchiveContract; +import com.android.dialer.database.VoicemailArchiveContract.VoicemailArchive; +import com.android.dialer.util.AsyncTaskExecutor; +import com.android.dialer.util.AsyncTaskExecutors; +import com.android.dialerbind.ObjectFactory; +import com.google.common.annotations.VisibleForTesting; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledExecutorService; +import javax.annotation.Nullable; import javax.annotation.concurrent.GuardedBy; import javax.annotation.concurrent.NotThreadSafe; import javax.annotation.concurrent.ThreadSafe; @@ -58,8 +69,16 @@ import javax.annotation.concurrent.ThreadSafe; */ @NotThreadSafe public class VoicemailPlaybackLayout extends LinearLayout - implements VoicemailPlaybackPresenter.PlaybackView { + implements VoicemailPlaybackPresenter.PlaybackView, + CallLogAsyncTaskUtil.CallLogAsyncTaskListener { private static final String TAG = VoicemailPlaybackLayout.class.getSimpleName(); + private static final int VOICEMAIL_DELETE_DELAY_MS = 3000; + private static final int VOICEMAIL_ARCHIVE_DELAY_MS = 3000; + + /** The enumeration of {@link AsyncTask} objects we use in this class. */ + public enum Tasks { + QUERY_ARCHIVED_STATUS + } /** * Controls the animation of the playback slider. @@ -145,6 +164,11 @@ public class VoicemailPlaybackLayout extends LinearLayout @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { setClipPosition(progress, seekBar.getMax()); + // Update the seek position if user manually changed it. This makes sure position gets + // updated when user use volume button to seek playback in talkback mode. + if (fromUser) { + mPresenter.seek(progress); + } } }; @@ -155,7 +179,7 @@ public class VoicemailPlaybackLayout extends LinearLayout @Override public void onClick(View v) { if (mPresenter != null) { - onSpeakerphoneOn(!mPresenter.isSpeakerphoneOn()); + mPresenter.toggleSpeakerphone(); } } }; @@ -185,26 +209,96 @@ public class VoicemailPlaybackLayout extends LinearLayout return; } mPresenter.pausePlayback(); - CallLogAsyncTaskUtil.deleteVoicemail(mContext, mVoicemailUri, null); mPresenter.onVoicemailDeleted(); + + final Uri deleteUri = mVoicemailUri; + final Runnable deleteCallback = new Runnable() { + @Override + public void run() { + if (Objects.equals(deleteUri, mVoicemailUri)) { + CallLogAsyncTaskUtil.deleteVoicemail(mContext, deleteUri, + VoicemailPlaybackLayout.this); + } + } + }; + + final Handler handler = new Handler(); + // Add a little buffer time in case the user clicked "undo" at the end of the delay + // window. + handler.postDelayed(deleteCallback, VOICEMAIL_DELETE_DELAY_MS + 50); + + Snackbar.make(VoicemailPlaybackLayout.this, R.string.snackbar_voicemail_deleted, + Snackbar.LENGTH_LONG) + .setDuration(VOICEMAIL_DELETE_DELAY_MS) + .setAction(R.string.snackbar_voicemail_deleted_undo, + new View.OnClickListener() { + @Override + public void onClick(View view) { + mPresenter.onVoicemailDeleteUndo(); + handler.removeCallbacks(deleteCallback); + } + }) + .setActionTextColor( + mContext.getResources().getColor( + R.color.dialer_snackbar_action_text_color)) + .show(); + } + }; + + private final View.OnClickListener mArchiveButtonListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mPresenter == null || isArchiving(mVoicemailUri)) { + return; + } + mIsArchiving.add(mVoicemailUri); + mPresenter.pausePlayback(); + updateArchiveUI(mVoicemailUri); + disableUiElements(); + mPresenter.archiveContent(mVoicemailUri, true); + } + }; + + private final View.OnClickListener mShareButtonListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mPresenter == null || isArchiving(mVoicemailUri)) { + return; + } + disableUiElements(); + mPresenter.archiveContent(mVoicemailUri, false); } }; private Context mContext; private VoicemailPlaybackPresenter mPresenter; private Uri mVoicemailUri; - + private final AsyncTaskExecutor mAsyncTaskExecutor = + AsyncTaskExecutors.createAsyncTaskExecutor(); private boolean mIsPlaying = false; + /** + * Keeps track of which voicemails are currently being archived in order to update the voicemail + * card UI every time a user opens a new card. + */ + private static final ArrayList<Uri> mIsArchiving = new ArrayList<>(); private SeekBar mPlaybackSeek; private ImageButton mStartStopButton; private ImageButton mPlaybackSpeakerphone; private ImageButton mDeleteButton; + private ImageButton mArchiveButton; + private ImageButton mShareButton; + + private Space mArchiveSpace; + private Space mShareSpace; + private TextView mStateText; private TextView mPositionText; private TextView mTotalDurationText; private PositionUpdater mPositionUpdater; + private Drawable mVoicemailSeekHandleEnabled; + private Drawable mVoicemailSeekHandleDisabled; public VoicemailPlaybackLayout(Context context) { this(context, null); @@ -212,7 +306,6 @@ public class VoicemailPlaybackLayout extends LinearLayout public VoicemailPlaybackLayout(Context context, AttributeSet attrs) { super(context, attrs); - mContext = context; LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); @@ -223,6 +316,16 @@ public class VoicemailPlaybackLayout extends LinearLayout public void setPresenter(VoicemailPlaybackPresenter presenter, Uri voicemailUri) { mPresenter = presenter; mVoicemailUri = voicemailUri; + if (ObjectFactory.isVoicemailArchiveEnabled(mContext)) { + updateArchiveUI(mVoicemailUri); + updateArchiveButton(mVoicemailUri); + } + + if (ObjectFactory.isVoicemailShareEnabled(mContext)) { + // Show share button and space before it + mShareSpace.setVisibility(View.VISIBLE); + mShareButton.setVisibility(View.VISIBLE); + } } @Override @@ -233,6 +336,12 @@ public class VoicemailPlaybackLayout extends LinearLayout mStartStopButton = (ImageButton) findViewById(R.id.playback_start_stop); mPlaybackSpeakerphone = (ImageButton) findViewById(R.id.playback_speakerphone); mDeleteButton = (ImageButton) findViewById(R.id.delete_voicemail); + mArchiveButton =(ImageButton) findViewById(R.id.archive_voicemail); + mShareButton = (ImageButton) findViewById(R.id.share_voicemail); + + mArchiveSpace = (Space) findViewById(R.id.space_before_archive_voicemail); + mShareSpace = (Space) findViewById(R.id.space_before_share_voicemail); + mStateText = (TextView) findViewById(R.id.playback_state_text); mPositionText = (TextView) findViewById(R.id.playback_position_text); mTotalDurationText = (TextView) findViewById(R.id.total_duration_text); @@ -241,6 +350,16 @@ public class VoicemailPlaybackLayout extends LinearLayout mStartStopButton.setOnClickListener(mStartStopButtonListener); mPlaybackSpeakerphone.setOnClickListener(mSpeakerphoneListener); mDeleteButton.setOnClickListener(mDeleteButtonListener); + mArchiveButton.setOnClickListener(mArchiveButtonListener); + mShareButton.setOnClickListener(mShareButtonListener); + + mPositionText.setText(formatAsMinutesAndSeconds(0)); + mTotalDurationText.setText(formatAsMinutesAndSeconds(0)); + + mVoicemailSeekHandleEnabled = getResources().getDrawable( + R.drawable.ic_voicemail_seek_handle, mContext.getTheme()); + mVoicemailSeekHandleDisabled = getResources().getDrawable( + R.drawable.ic_voicemail_seek_handle_disabled, mContext.getTheme()); } @Override @@ -249,10 +368,6 @@ public class VoicemailPlaybackLayout extends LinearLayout mStartStopButton.setImageResource(R.drawable.ic_pause); - if (mPresenter != null) { - onSpeakerphoneOn(mPresenter.isSpeakerphoneOn()); - } - if (mPositionUpdater != null) { mPositionUpdater.stopUpdating(); mPositionUpdater = null; @@ -283,12 +398,8 @@ public class VoicemailPlaybackLayout extends LinearLayout mStateText.setText(getString(R.string.voicemail_playback_error)); } - + @Override public void onSpeakerphoneOn(boolean on) { - if (mPresenter != null) { - mPresenter.setSpeakerphoneOn(on); - } - if (on) { mPlaybackSpeakerphone.setImageResource(R.drawable.ic_volume_up_24dp); // Speaker is now on, tapping button will turn it off. @@ -314,13 +425,6 @@ public class VoicemailPlaybackLayout extends LinearLayout mPositionText.setText(formatAsMinutesAndSeconds(seekBarPositionMs)); mTotalDurationText.setText(formatAsMinutesAndSeconds(durationMs)); - mStateText.setText(null); - } - - @Override - public void setIsBuffering() { - disableUiElements(); - mStateText.setText(getString(R.string.voicemail_buffering)); } @Override @@ -331,7 +435,7 @@ public class VoicemailPlaybackLayout extends LinearLayout @Override public void setFetchContentTimeout() { - disableUiElements(); + mStartStopButton.setEnabled(true); mStateText.setText(getString(R.string.voicemail_fetching_timout)); } @@ -343,21 +447,35 @@ public class VoicemailPlaybackLayout extends LinearLayout @Override public void disableUiElements() { mStartStopButton.setEnabled(false); - mPlaybackSpeakerphone.setEnabled(false); - mPlaybackSeek.setProgress(0); - mPlaybackSeek.setEnabled(false); - - mPositionText.setText(formatAsMinutesAndSeconds(0)); - mTotalDurationText.setText(formatAsMinutesAndSeconds(0)); + resetSeekBar(); } @Override public void enableUiElements() { + mDeleteButton.setEnabled(true); mStartStopButton.setEnabled(true); - mPlaybackSpeakerphone.setEnabled(true); mPlaybackSeek.setEnabled(true); + mPlaybackSeek.setThumb(mVoicemailSeekHandleEnabled); } + @Override + public void resetSeekBar() { + mPlaybackSeek.setProgress(0); + mPlaybackSeek.setEnabled(false); + mPlaybackSeek.setThumb(mVoicemailSeekHandleDisabled); + } + + @Override + public void onDeleteCall() {} + + @Override + public void onDeleteVoicemail() { + mPresenter.onVoicemailDeletedInDatabase(); + } + + @Override + public void onGetCallDetails(PhoneCallDetails[] details) {} + private String getString(int resId) { return mContext.getString(resId); } @@ -377,4 +495,139 @@ public class VoicemailPlaybackLayout extends LinearLayout } return String.format("%02d:%02d", minutes, seconds); } + + /** + * Called when a voicemail archive succeeded. If the expanded voicemail was being + * archived, update the card UI. Either way, display a snackbar linking user to archive. + */ + @Override + public void onVoicemailArchiveSucceded(Uri voicemailUri) { + if (isArchiving(voicemailUri)) { + mIsArchiving.remove(voicemailUri); + if (Objects.equals(voicemailUri, mVoicemailUri)) { + onVoicemailArchiveResult(); + hideArchiveButton(); + } + } + + Snackbar.make(this, R.string.snackbar_voicemail_archived, + Snackbar.LENGTH_LONG) + .setDuration(VOICEMAIL_ARCHIVE_DELAY_MS) + .setAction(R.string.snackbar_voicemail_archived_goto, + new View.OnClickListener() { + @Override + public void onClick(View view) { + Intent intent = new Intent(mContext, + VoicemailArchiveActivity.class); + mContext.startActivity(intent); + } + }) + .setActionTextColor( + mContext.getResources().getColor(R.color.dialer_snackbar_action_text_color)) + .show(); + } + + /** + * If a voicemail archive failed, and the expanded card was being archived, update the card UI. + * Either way, display a toast saying the voicemail archive failed. + */ + @Override + public void onVoicemailArchiveFailed(Uri voicemailUri) { + if (isArchiving(voicemailUri)) { + mIsArchiving.remove(voicemailUri); + if (Objects.equals(voicemailUri, mVoicemailUri)) { + onVoicemailArchiveResult(); + } + } + String toastStr = mContext.getString(R.string.voicemail_archive_failed); + Toast.makeText(mContext, toastStr, Toast.LENGTH_SHORT).show(); + } + + public void hideArchiveButton() { + mArchiveSpace.setVisibility(View.GONE); + mArchiveButton.setVisibility(View.GONE); + mArchiveButton.setClickable(false); + mArchiveButton.setEnabled(false); + } + + /** + * Whenever a voicemail archive succeeds or fails, clear the text displayed in the voicemail + * card. + */ + private void onVoicemailArchiveResult() { + enableUiElements(); + mStateText.setText(null); + mArchiveButton.setColorFilter(null); + } + + /** + * Whether or not the voicemail with the given uri is being archived. + */ + private boolean isArchiving(@Nullable Uri uri) { + return uri != null && mIsArchiving.contains(uri); + } + + /** + * Show the proper text and hide the archive button if the voicemail is still being archived. + */ + private void updateArchiveUI(@Nullable Uri voicemailUri) { + if (!Objects.equals(voicemailUri, mVoicemailUri)) { + return; + } + if (isArchiving(voicemailUri)) { + // If expanded card was in the middle of archiving, disable buttons and display message + disableUiElements(); + mDeleteButton.setEnabled(false); + mArchiveButton.setColorFilter(getResources().getColor(R.color.setting_disabled_color)); + mStateText.setText(getString(R.string.voicemail_archiving_content)); + } else { + onVoicemailArchiveResult(); + } + } + + /** + * Hides the archive button if the voicemail has already been archived, shows otherwise. + * @param voicemailUri the URI of the voicemail for which the archive button needs to be updated + */ + private void updateArchiveButton(@Nullable final Uri voicemailUri) { + if (voicemailUri == null || + !Objects.equals(voicemailUri, mVoicemailUri) || isArchiving(voicemailUri) || + Objects.equals(voicemailUri.getAuthority(),VoicemailArchiveContract.AUTHORITY)) { + return; + } + mAsyncTaskExecutor.submit(Tasks.QUERY_ARCHIVED_STATUS, + new AsyncTask<Void, Void, Boolean>() { + @Override + public Boolean doInBackground(Void... params) { + Cursor cursor = mContext.getContentResolver().query(VoicemailArchive.CONTENT_URI, + null, VoicemailArchive.SERVER_ID + "=" + ContentUris.parseId(mVoicemailUri) + + " AND " + VoicemailArchive.ARCHIVED + "= 1", null, null); + boolean archived = cursor != null && cursor.getCount() > 0; + cursor.close(); + return archived; + } + + @Override + public void onPostExecute(Boolean archived) { + if (!Objects.equals(voicemailUri, mVoicemailUri)) { + return; + } + + if (archived) { + hideArchiveButton(); + } else { + mArchiveSpace.setVisibility(View.VISIBLE); + mArchiveButton.setVisibility(View.VISIBLE); + mArchiveButton.setClickable(true); + mArchiveButton.setEnabled(true); + } + + } + }); + } + + @VisibleForTesting + public String getStateText() { + return mStateText.getText().toString(); + } } diff --git a/src/com/android/dialer/voicemail/VoicemailPlaybackPresenter.java b/src/com/android/dialer/voicemail/VoicemailPlaybackPresenter.java index ed6cc8b43..5924fb453 100644 --- a/src/com/android/dialer/voicemail/VoicemailPlaybackPresenter.java +++ b/src/com/android/dialer/voicemail/VoicemailPlaybackPresenter.java @@ -16,14 +16,14 @@ package com.android.dialer.voicemail; +import com.google.common.annotations.VisibleForTesting; + import android.app.Activity; import android.content.Context; import android.content.ContentResolver; import android.content.Intent; import android.database.ContentObserver; import android.database.Cursor; -import android.media.AudioManager; -import android.media.AudioManager.OnAudioFocusChangeListener; import android.media.MediaPlayer; import android.net.Uri; import android.os.AsyncTask; @@ -31,25 +31,24 @@ import android.os.Bundle; import android.os.Handler; import android.os.PowerManager; import android.provider.VoicemailContract; +import android.support.v4.content.FileProvider; import android.util.Log; -import android.view.View; import android.view.WindowManager.LayoutParams; -import android.widget.SeekBar; import com.android.dialer.R; +import com.android.dialer.calllog.CallLogAsyncTaskUtil; import com.android.dialer.util.AsyncTaskExecutor; import com.android.dialer.util.AsyncTaskExecutors; - import com.android.common.io.MoreCloseables; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Preconditions; +import java.io.File; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -62,7 +61,7 @@ import javax.annotation.concurrent.ThreadSafe; * {@link CallLogFragment} and {@link CallLogAdapter}. * <p> * This controls a single {@link com.android.dialer.voicemail.VoicemailPlaybackLayout}. A single - * instance can be reused for different such layouts, using {@link #setVoicemailPlaybackView}. This + * instance can be reused for different such layouts, using {@link #setPlaybackView}. This * is to facilitate reuse across different voicemail call log entries. * <p> * This class is not thread safe. The thread policy for this class is thread-confinement, all calls @@ -70,11 +69,10 @@ import javax.annotation.concurrent.ThreadSafe; */ @NotThreadSafe @VisibleForTesting -public class VoicemailPlaybackPresenter - implements OnAudioFocusChangeListener, MediaPlayer.OnPreparedListener, +public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListener, MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener { - private static final String TAG = VoicemailPlaybackPresenter.class.getSimpleName(); + private static final String TAG = "VmPlaybackPresenter"; /** Contract describing the behaviour we need from the ui we are controlling. */ public interface PlaybackView { @@ -87,26 +85,35 @@ public class VoicemailPlaybackPresenter void onSpeakerphoneOn(boolean on); void setClipPosition(int clipPositionInMillis, int clipLengthInMillis); void setFetchContentTimeout(); - void setIsBuffering(); void setIsFetchingContent(); + void onVoicemailArchiveSucceded(Uri voicemailUri); + void onVoicemailArchiveFailed(Uri voicemailUri); void setPresenter(VoicemailPlaybackPresenter presenter, Uri voicemailUri); + void resetSeekBar(); } public interface OnVoicemailDeletedListener { void onVoicemailDeleted(Uri uri); + void onVoicemailDeleteUndo(); + void onVoicemailDeletedInDatabase(); } /** The enumeration of {@link AsyncTask} objects we use in this class. */ public enum Tasks { CHECK_FOR_CONTENT, CHECK_CONTENT_AFTER_CHANGE, + ARCHIVE_VOICEMAIL + } + + protected interface OnContentCheckedListener { + void onContentChecked(boolean hasContent); } private static final String[] HAS_CONTENT_PROJECTION = new String[] { VoicemailContract.Voicemails.HAS_CONTENT, + VoicemailContract.Voicemails.DURATION }; - public static final int PLAYBACK_STREAM = AudioManager.STREAM_VOICE_CALL; private static final int NUMBER_OF_THREADS_IN_POOL = 2; // Time to wait for content to be fetched before timing out. private static final long FETCH_CONTENT_TIMEOUT_MS = 20000; @@ -121,6 +128,11 @@ public class VoicemailPlaybackPresenter // If present in the saved instance bundle, indicates where to set the playback slider. private static final String CLIP_POSITION_KEY = VoicemailPlaybackPresenter.class.getName() + ".CLIP_POSITION_KEY"; + private static final String IS_SPEAKERPHONE_ON_KEY = + VoicemailPlaybackPresenter.class.getName() + ".IS_SPEAKER_PHONE_ON"; + public static final int PLAYBACK_REQUEST = 0; + public static final int ARCHIVE_REQUEST = 1; + public static final int SHARE_REQUEST = 2; /** * The most recently cached duration. We cache this since we don't want to keep requesting it @@ -132,22 +144,23 @@ public class VoicemailPlaybackPresenter private static VoicemailPlaybackPresenter sInstance; private Activity mActivity; - private Context mContext; + protected Context mContext; private PlaybackView mView; - private Uri mVoicemailUri; + protected Uri mVoicemailUri; - private MediaPlayer mMediaPlayer; + protected MediaPlayer mMediaPlayer; private int mPosition; private boolean mIsPlaying; // MediaPlayer crashes on some method calls if not prepared but does not have a method which // exposes its prepared state. Store this locally, so we can check and prevent crashes. private boolean mIsPrepared; + private boolean mIsSpeakerphoneOn; private boolean mShouldResumePlaybackAfterSeeking; private int mInitialOrientation; // Used to run async tasks that need to interact with the UI. - private AsyncTaskExecutor mAsyncTaskExecutor; + protected AsyncTaskExecutor mAsyncTaskExecutor; private static ScheduledExecutorService mScheduledExecutorService; /** * Used to handle the result of a successful or time-out fetch result. @@ -155,11 +168,13 @@ public class VoicemailPlaybackPresenter * This variable is thread-contained, accessed only on the ui thread. */ private FetchResultHandler mFetchResultHandler; + private final List<FetchResultHandler> mArchiveResultHandlers = new ArrayList<>(); private Handler mHandler = new Handler(); private PowerManager.WakeLock mProximityWakeLock; - private AudioManager mAudioManager; + private VoicemailAudioManager mVoicemailAudioManager; private OnVoicemailDeletedListener mOnVoicemailDeletedListener; + private final VoicemailAsyncTaskUtil mVoicemailAsyncTaskUtil; /** * Obtain singleton instance of this class. Use a single instance to provide a consistent @@ -183,11 +198,11 @@ public class VoicemailPlaybackPresenter /** * Initialize variables which are activity-independent and state-independent. */ - private VoicemailPlaybackPresenter(Activity activity) { + protected VoicemailPlaybackPresenter(Activity activity) { Context context = activity.getApplicationContext(); mAsyncTaskExecutor = AsyncTaskExecutors.createAsyncTaskExecutor(); - mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); - + mVoicemailAudioManager = new VoicemailAudioManager(context, this); + mVoicemailAsyncTaskUtil = new VoicemailAsyncTaskUtil(context.getContentResolver()); PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); if (powerManager.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) { @@ -199,12 +214,12 @@ public class VoicemailPlaybackPresenter /** * Update variables which are activity-dependent or state-dependent. */ - private void init(Activity activity, Bundle savedInstanceState) { + protected void init(Activity activity, Bundle savedInstanceState) { mActivity = activity; mContext = activity; mInitialOrientation = mContext.getResources().getConfiguration().orientation; - mActivity.setVolumeControlStream(VoicemailPlaybackPresenter.PLAYBACK_STREAM); + mActivity.setVolumeControlStream(VoicemailAudioManager.PLAYBACK_STREAM); if (savedInstanceState != null) { // Restores playback state when activity is recreated, such as after rotation. @@ -212,6 +227,7 @@ public class VoicemailPlaybackPresenter mIsPrepared = savedInstanceState.getBoolean(IS_PREPARED_KEY); mPosition = savedInstanceState.getInt(CLIP_POSITION_KEY, 0); mIsPlaying = savedInstanceState.getBoolean(IS_PLAYING_STATE_KEY, false); + mIsSpeakerphoneOn = savedInstanceState.getBoolean(IS_SPEAKERPHONE_ON_KEY, false); } if (mMediaPlayer == null) { @@ -229,6 +245,7 @@ public class VoicemailPlaybackPresenter outState.putBoolean(IS_PREPARED_KEY, mIsPrepared); outState.putInt(CLIP_POSITION_KEY, mView.getDesiredClipPosition()); outState.putBoolean(IS_PLAYING_STATE_KEY, mIsPlaying); + outState.putBoolean(IS_SPEAKERPHONE_ON_KEY, mIsSpeakerphoneOn); } } @@ -240,28 +257,48 @@ public class VoicemailPlaybackPresenter mView = view; mView.setPresenter(this, voicemailUri); - if (mMediaPlayer != null && voicemailUri.equals(mVoicemailUri)) { - // Handles case where MediaPlayer was retained after an orientation change. + // Handles cases where the same entry is binded again when scrolling in list, or where + // the MediaPlayer was retained after an orientation change. + if (mMediaPlayer != null && mIsPrepared && voicemailUri.equals(mVoicemailUri)) { + // If the voicemail card was rebinded, we need to set the position to the appropriate + // point. Since we retain the media player, we can just set it to the position of the + // media player. + mPosition = mMediaPlayer.getCurrentPosition(); onPrepared(mMediaPlayer); - mView.onSpeakerphoneOn(isSpeakerphoneOn()); } else { if (!voicemailUri.equals(mVoicemailUri)) { + mVoicemailUri = voicemailUri; mPosition = 0; + // Default to earpiece. + setSpeakerphoneOn(false); + mVoicemailAudioManager.setSpeakerphoneOn(false); + } else { + // Update the view to the current speakerphone state. + mView.onSpeakerphoneOn(mIsSpeakerphoneOn); } - - mVoicemailUri = voicemailUri; - mDuration.set(0); + /* + * Check to see if the content field in the DB is set. If set, we proceed to + * prepareContent() method. We get the duration of the voicemail from the query and set + * it if the content is not available. + */ + checkForContent(new OnContentCheckedListener() { + @Override + public void onContentChecked(boolean hasContent) { + if (hasContent) { + prepareContent(); + } else if (mView != null) { + mView.resetSeekBar(); + mView.setClipPosition(0, mDuration.get()); + } + } + }); if (startPlayingImmediately) { // Since setPlaybackView can get called during the view binding process, we don't // want to reset mIsPlaying to false if the user is currently playing the // voicemail and the view is rebound. mIsPlaying = startPlayingImmediately; - checkForContent(); } - - // Default to earpiece. - mView.onSpeakerphoneOn(false); } } @@ -269,16 +306,20 @@ public class VoicemailPlaybackPresenter * Reset the presenter for playback back to its original state. */ public void resetAll() { - reset(); + pausePresenter(true); mView = null; mVoicemailUri = null; } /** - * Reset the presenter such that it is as if the voicemail has not been played. + * When navigating away from voicemail playback, we need to release the media player, + * pause the UI and save the position. + * + * @param reset {@code true} if we want to reset the position of the playback, {@code false} if + * we want to retain the current position (in case we return to the voicemail). */ - public void reset() { + public void pausePresenter(boolean reset) { if (mMediaPlayer != null) { mMediaPlayer.release(); mMediaPlayer = null; @@ -288,19 +329,35 @@ public class VoicemailPlaybackPresenter mIsPrepared = false; mIsPlaying = false; - mPosition = 0; - mDuration.set(0); + + if (reset) { + // We want to reset the position whether or not the view is valid. + mPosition = 0; + } if (mView != null) { mView.onPlaybackStopped(); - mView.setClipPosition(0, mDuration.get()); + if (reset) { + mView.setClipPosition(0, mDuration.get()); + } else { + mPosition = mView.getDesiredClipPosition(); + } } } /** + * Must be invoked when the parent activity is resumed. + */ + public void onResume() { + mVoicemailAudioManager.registerReceivers(); + } + + /** * Must be invoked when the parent activity is paused. */ public void onPause() { + mVoicemailAudioManager.unregisterReceivers(); + if (mContext != null && mIsPrepared && mInitialOrientation != mContext.getResources().getConfiguration().orientation) { // If an orientation change triggers the pause, retain the MediaPlayer. @@ -309,11 +366,12 @@ public class VoicemailPlaybackPresenter } // Release the media player, otherwise there may be failures. - reset(); + pausePresenter(false); if (mActivity != null) { mActivity.getWindow().clearFlags(LayoutParams.FLAG_KEEP_SCREEN_ON); } + } /** @@ -329,6 +387,13 @@ public class VoicemailPlaybackPresenter mScheduledExecutorService = null; } + if (!mArchiveResultHandlers.isEmpty()) { + for (FetchResultHandler fetchResultHandler : mArchiveResultHandlers) { + fetchResultHandler.destroy(); + } + mArchiveResultHandlers.clear(); + } + if (mFetchResultHandler != null) { mFetchResultHandler.destroy(); mFetchResultHandler = null; @@ -337,16 +402,8 @@ public class VoicemailPlaybackPresenter /** * Checks to see if we have content available for this voicemail. - * <p> - * This method will be called once, after the fragment has been created, before we know if the - * voicemail we've been asked to play has any content available. - * <p> - * Notify the user that we are fetching the content, then check to see if the content field in - * the DB is set. If set, we proceed to {@link #prepareContent()} method. If not set, make - * a request to fetch the content asynchronously via {@link #requestContent()}. */ - private void checkForContent() { - mView.setIsFetchingContent(); + protected void checkForContent(final OnContentCheckedListener callback) { mAsyncTaskExecutor.submit(Tasks.CHECK_FOR_CONTENT, new AsyncTask<Void, Void, Boolean>() { @Override public Boolean doInBackground(Void... params) { @@ -355,11 +412,7 @@ public class VoicemailPlaybackPresenter @Override public void onPostExecute(Boolean hasContent) { - if (hasContent) { - prepareContent(); - } else { - requestContent(); - } + callback.onContentChecked(hasContent); } }); } @@ -371,10 +424,14 @@ public class VoicemailPlaybackPresenter ContentResolver contentResolver = mContext.getContentResolver(); Cursor cursor = contentResolver.query( - voicemailUri, HAS_CONTENT_PROJECTION, null, null, null); + voicemailUri, null, null, null, null); try { if (cursor != null && cursor.moveToNext()) { - return cursor.getInt(cursor.getColumnIndexOrThrow( + int duration = cursor.getInt(cursor.getColumnIndex( + VoicemailContract.Voicemails.DURATION)); + // Convert database duration (seconds) into mDuration (milliseconds) + mDuration.set(duration > 0 ? duration * 1000 : 0); + return cursor.getInt(cursor.getColumnIndex( VoicemailContract.Voicemails.HAS_CONTENT)) == 1; } } finally { @@ -395,31 +452,51 @@ public class VoicemailPlaybackPresenter * proceed to {@link #prepareContent()}. If the has_content field does not * become true within the allowed time, we will update the ui to reflect the fact that content * was not available. + * + * @return whether issued request to fetch content */ - private void requestContent() { - if (mFetchResultHandler != null) { - mFetchResultHandler.destroy(); + protected boolean requestContent(int code) { + if (mContext == null || mVoicemailUri == null) { + return false; } - mFetchResultHandler = new FetchResultHandler(new Handler(), mVoicemailUri); + FetchResultHandler tempFetchResultHandler = + new FetchResultHandler(new Handler(), mVoicemailUri, code); + + switch (code) { + case ARCHIVE_REQUEST: + mArchiveResultHandlers.add(tempFetchResultHandler); + break; + default: + if (mFetchResultHandler != null) { + mFetchResultHandler.destroy(); + } + mView.setIsFetchingContent(); + mFetchResultHandler = tempFetchResultHandler; + break; + } // Send voicemail fetch request. Intent intent = new Intent(VoicemailContract.ACTION_FETCH_VOICEMAIL, mVoicemailUri); mContext.sendBroadcast(intent); + return true; } @ThreadSafe private class FetchResultHandler extends ContentObserver implements Runnable { private AtomicBoolean mIsWaitingForResult = new AtomicBoolean(true); private final Handler mFetchResultHandler; + private final Uri mVoicemailUri; + private final int mRequestCode; - public FetchResultHandler(Handler handler, Uri voicemailUri) { + public FetchResultHandler(Handler handler, Uri uri, int code) { super(handler); mFetchResultHandler = handler; - + mRequestCode = code; + mVoicemailUri = uri; if (mContext != null) { mContext.getContentResolver().registerContentObserver( - voicemailUri, false, this); + mVoicemailUri, false, this); mFetchResultHandler.postDelayed(this, FETCH_CONTENT_TIMEOUT_MS); } } @@ -448,6 +525,7 @@ public class VoicemailPlaybackPresenter public void onChange(boolean selfChange) { mAsyncTaskExecutor.submit(Tasks.CHECK_CONTENT_AFTER_CHANGE, new AsyncTask<Void, Void, Boolean>() { + @Override public Boolean doInBackground(Void... params) { return queryHasContent(mVoicemailUri); @@ -459,6 +537,11 @@ public class VoicemailPlaybackPresenter mContext.getContentResolver().unregisterContentObserver( FetchResultHandler.this); prepareContent(); + if (mRequestCode == ARCHIVE_REQUEST) { + startArchiveVoicemailTask(mVoicemailUri, true /* archivedByUser */); + } else if (mRequestCode == SHARE_REQUEST) { + startArchiveVoicemailTask(mVoicemailUri, false /* archivedByUser */); + } } } }); @@ -473,7 +556,7 @@ public class VoicemailPlaybackPresenter * media player. If preparation is successful, the media player will {@link #onPrepared()}, * and it will call {@link #onError()} otherwise. */ - private void prepareContent() { + protected void prepareContent() { if (mView == null) { return; } @@ -485,7 +568,7 @@ public class VoicemailPlaybackPresenter mMediaPlayer = null; } - mView.setIsBuffering(); + mView.disableUiElements(); mIsPrepared = false; try { @@ -496,7 +579,7 @@ public class VoicemailPlaybackPresenter mMediaPlayer.reset(); mMediaPlayer.setDataSource(mContext, mVoicemailUri); - mMediaPlayer.setAudioStreamType(PLAYBACK_STREAM); + mMediaPlayer.setAudioStreamType(VoicemailAudioManager.PLAYBACK_STREAM); mMediaPlayer.prepareAsync(); } catch (IOException e) { handleError(e); @@ -514,12 +597,15 @@ public class VoicemailPlaybackPresenter Log.d(TAG, "onPrepared"); mIsPrepared = true; + // Update the duration in the database if it was not previously retrieved + CallLogAsyncTaskUtil.updateVoicemailDuration(mContext, mVoicemailUri, + TimeUnit.MILLISECONDS.toSeconds(mMediaPlayer.getDuration())); + mDuration.set(mMediaPlayer.getDuration()); - mPosition = mMediaPlayer.getCurrentPosition(); - mView.enableUiElements(); Log.d(TAG, "onPrepared: mPosition=" + mPosition); mView.setClipPosition(mPosition, mDuration.get()); + mView.enableUiElements(); mMediaPlayer.seekTo(mPosition); if (mIsPlaying) { @@ -539,7 +625,7 @@ public class VoicemailPlaybackPresenter return true; } - private void handleError(Exception e) { + protected void handleError(Exception e) { Log.d(TAG, "handleError: Could not play voicemail " + e); if (mIsPrepared) { @@ -570,15 +656,22 @@ public class VoicemailPlaybackPresenter } } - @Override - public void onAudioFocusChange(int focusChange) { - Log.d(TAG, "onAudioFocusChange: focusChange=" + focusChange); - boolean lostFocus = focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT - || focusChange == AudioManager.AUDIOFOCUS_LOSS; - if (mIsPlaying && focusChange == AudioManager.AUDIOFOCUS_LOSS) { - pausePlayback(); - } else if (!mIsPlaying && focusChange == AudioManager.AUDIOFOCUS_GAIN) { + /** + * Only play voicemail when audio focus is granted. When it is lost (usually by another + * application requesting focus), pause playback. + * + * @param gainedFocus {@code true} if the audio focus was gained, {@code} false otherwise. + */ + public void onAudioFocusChange(boolean gainedFocus) { + if (mIsPlaying == gainedFocus) { + // Nothing new here, just exit. + return; + } + + if (!mIsPlaying) { resumePlayback(); + } else { + pausePlayback(); } } @@ -587,15 +680,30 @@ public class VoicemailPlaybackPresenter * playing. */ public void resumePlayback() { - if (mView == null || mContext == null) { + if (mView == null) { return; } if (!mIsPrepared) { - // If we haven't downloaded the voicemail yet, attempt to download it. - checkForContent(); - mIsPlaying = true; - + /* + * Check content before requesting content to avoid duplicated requests. It is possible + * that the UI doesn't know content has arrived if the fetch took too long causing a + * timeout, but succeeded. + */ + checkForContent(new OnContentCheckedListener() { + @Override + public void onContentChecked(boolean hasContent) { + if (!hasContent) { + // No local content, download from server. Queue playing if the request was + // issued, + mIsPlaying = requestContent(PLAYBACK_REQUEST); + } else { + // Queue playing once the media play loaded the content. + mIsPlaying = true; + prepareContent(); + } + } + }); return; } @@ -604,20 +712,15 @@ public class VoicemailPlaybackPresenter if (mMediaPlayer != null && !mMediaPlayer.isPlaying()) { // Clamp the start position between 0 and the duration. mPosition = Math.max(0, Math.min(mPosition, mDuration.get())); + mMediaPlayer.seekTo(mPosition); try { // Grab audio focus. - int result = mAudioManager.requestAudioFocus( - this, - PLAYBACK_STREAM, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT); - if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { - throw new RejectedExecutionException("Could not capture audio focus."); - } - // Can throw RejectedExecutionException. + mVoicemailAudioManager.requestAudioFocus(); mMediaPlayer.start(); + setSpeakerphoneOn(mIsSpeakerphoneOn); } catch (RejectedExecutionException e) { handleError(e); } @@ -625,11 +728,6 @@ public class VoicemailPlaybackPresenter Log.d(TAG, "Resumed playback at " + mPosition + "."); mView.onPlaybackStarted(mDuration.get(), getScheduledExecutorServiceInstance()); - if (isSpeakerphoneOn()) { - mActivity.getWindow().addFlags(LayoutParams.FLAG_KEEP_SCREEN_ON); - } else { - enableProximitySensor(); - } } /** @@ -653,7 +751,8 @@ public class VoicemailPlaybackPresenter if (mView != null) { mView.onPlaybackStopped(); } - mAudioManager.abandonAudioFocus(this); + + mVoicemailAudioManager.abandonAudioFocus(); if (mActivity != null) { mActivity.getWindow().clearFlags(LayoutParams.FLAG_KEEP_SCREEN_ON); @@ -680,8 +779,17 @@ public class VoicemailPlaybackPresenter } } + /** + * Seek to position. This is called when user manually seek the playback. It could be either + * by touch or volume button while in talkback mode. + * @param position + */ + public void seek(int position) { + mPosition = position; + } + private void enableProximitySensor() { - if (mProximityWakeLock == null || isSpeakerphoneOn() || !mIsPrepared + if (mProximityWakeLock == null || mIsSpeakerphoneOn || !mIsPrepared || mMediaPlayer == null || !mMediaPlayer.isPlaying()) { return; } @@ -707,26 +815,46 @@ public class VoicemailPlaybackPresenter } } + /** + * This is for use by UI interactions only. It simplifies UI logic. + */ + public void toggleSpeakerphone() { + mVoicemailAudioManager.setSpeakerphoneOn(!mIsSpeakerphoneOn); + setSpeakerphoneOn(!mIsSpeakerphoneOn); + } + + /** + * This method only handles app-level changes to the speakerphone. Audio layer changes should + * be handled separately. This is so that the VoicemailAudioManager can trigger changes to + * the presenter without the presenter triggering the audio manager and duplicating actions. + */ public void setSpeakerphoneOn(boolean on) { - mAudioManager.setSpeakerphoneOn(on); + if (mView == null) { + return; + } - if (on) { - disableProximitySensor(false /* waitForFarState */); - if (mIsPrepared && mMediaPlayer != null && mMediaPlayer.isPlaying()) { - mActivity.getWindow().addFlags(LayoutParams.FLAG_KEEP_SCREEN_ON); - } - } else { - enableProximitySensor(); - if (mActivity != null) { - mActivity.getWindow().clearFlags(LayoutParams.FLAG_KEEP_SCREEN_ON); + mView.onSpeakerphoneOn(on); + + mIsSpeakerphoneOn = on; + + // This should run even if speakerphone is not being toggled because we may be switching + // from earpiece to headphone and vise versa. Also upon initial setup the default audio + // source is the earpiece, so we want to trigger the proximity sensor. + if (mIsPlaying) { + if (on || mVoicemailAudioManager.isWiredHeadsetPluggedIn()) { + disableProximitySensor(false /* waitForFarState */); + if (mIsPrepared && mMediaPlayer != null && mMediaPlayer.isPlaying()) { + mActivity.getWindow().addFlags(LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } else { + enableProximitySensor(); + if (mActivity != null) { + mActivity.getWindow().clearFlags(LayoutParams.FLAG_KEEP_SCREEN_ON); + } } } } - public boolean isSpeakerphoneOn() { - return mAudioManager.isSpeakerphoneOn(); - } - public void setOnVoicemailDeletedListener(OnVoicemailDeletedListener listener) { mOnVoicemailDeletedListener = listener; } @@ -735,13 +863,38 @@ public class VoicemailPlaybackPresenter return mIsPrepared && mMediaPlayer != null ? mMediaPlayer.getCurrentPosition() : 0; } + public void notifyUiOfArchiveResult(Uri voicemailUri, boolean archived) { + if (mView == null) { + return; + } + if (archived) { + mView.onVoicemailArchiveSucceded(voicemailUri); + } else { + mView.onVoicemailArchiveFailed(voicemailUri); + } + } + /* package */ void onVoicemailDeleted() { - // Trampoline the event notification to the interested listener + // Trampoline the event notification to the interested listener. if (mOnVoicemailDeletedListener != null) { mOnVoicemailDeletedListener.onVoicemailDeleted(mVoicemailUri); } } + /* package */ void onVoicemailDeleteUndo() { + // Trampoline the event notification to the interested listener. + if (mOnVoicemailDeletedListener != null) { + mOnVoicemailDeletedListener.onVoicemailDeleteUndo(); + } + } + + /* package */ void onVoicemailDeletedInDatabase() { + // Trampoline the event notification to the interested listener. + if (mOnVoicemailDeletedListener != null) { + mOnVoicemailDeletedListener.onVoicemailDeletedInDatabase(); + } + } + private static synchronized ScheduledExecutorService getScheduledExecutorServiceInstance() { if (mScheduledExecutorService == null) { mScheduledExecutorService = Executors.newScheduledThreadPool(NUMBER_OF_THREADS_IN_POOL); @@ -749,8 +902,107 @@ public class VoicemailPlaybackPresenter return mScheduledExecutorService; } + /** + * If voicemail has already been downloaded, go straight to archiving. Otherwise, request + * the voicemail content first. + */ + public void archiveContent(final Uri voicemailUri, final boolean archivedByUser) { + checkForContent(new OnContentCheckedListener() { + @Override + public void onContentChecked(boolean hasContent) { + if (!hasContent) { + requestContent(archivedByUser ? ARCHIVE_REQUEST : SHARE_REQUEST); + } else { + startArchiveVoicemailTask(voicemailUri, archivedByUser); + } + } + }); + } + + /** + * Asynchronous task used to archive a voicemail given its uri. + */ + protected void startArchiveVoicemailTask(final Uri voicemailUri, final boolean archivedByUser) { + mVoicemailAsyncTaskUtil.archiveVoicemailContent( + new VoicemailAsyncTaskUtil.OnArchiveVoicemailListener() { + @Override + public void onArchiveVoicemail(final Uri archivedVoicemailUri) { + if (archivedVoicemailUri == null) { + notifyUiOfArchiveResult(voicemailUri, false); + return; + } + + if (archivedByUser) { + setArchivedVoicemailStatusAndUpdateUI(voicemailUri, + archivedVoicemailUri, true); + } else { + sendShareIntent(archivedVoicemailUri); + } + } + }, voicemailUri); + } + + /** + * Sends the intent for sharing the voicemail file. + */ + protected void sendShareIntent(final Uri voicemailUri) { + mVoicemailAsyncTaskUtil.getVoicemailFilePath( + new VoicemailAsyncTaskUtil.OnGetArchivedVoicemailFilePathListener() { + @Override + public void onGetArchivedVoicemailFilePath(String filePath) { + mView.enableUiElements(); + if (filePath == null) { + mView.setFetchContentTimeout(); + return; + } + Uri voicemailFileUri = FileProvider.getUriForFile( + mContext, + mContext.getString(R.string.contacts_file_provider_authority), + new File(filePath)); + mContext.startActivity(Intent.createChooser( + getShareIntent(voicemailFileUri), + mContext.getResources().getText( + R.string.call_log_share_voicemail))); + } + }, voicemailUri); + } + + /** Sets archived_by_user field to the given boolean and updates the URI. */ + private void setArchivedVoicemailStatusAndUpdateUI( + final Uri voicemailUri, + final Uri archivedVoicemailUri, + boolean status) { + mVoicemailAsyncTaskUtil.setVoicemailArchiveStatus( + new VoicemailAsyncTaskUtil.OnSetVoicemailArchiveStatusListener() { + @Override + public void onSetVoicemailArchiveStatus(boolean success) { + notifyUiOfArchiveResult(voicemailUri, success); + } + }, archivedVoicemailUri, status); + } + + private Intent getShareIntent(Uri voicemailFileUri) { + Intent shareIntent = new Intent(); + shareIntent.setAction(Intent.ACTION_SEND); + shareIntent.putExtra(Intent.EXTRA_STREAM, voicemailFileUri); + shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + shareIntent.setType(mContext.getContentResolver() + .getType(voicemailFileUri)); + return shareIntent; + } + @VisibleForTesting public boolean isPlaying() { return mIsPlaying; } + + @VisibleForTesting + public boolean isSpeakerphoneOn() { + return mIsSpeakerphoneOn; + } + + @VisibleForTesting + public void clearInstance() { + sInstance = null; + } } diff --git a/src/com/android/dialer/voicemail/WiredHeadsetManager.java b/src/com/android/dialer/voicemail/WiredHeadsetManager.java new file mode 100644 index 000000000..7351f4f01 --- /dev/null +++ b/src/com/android/dialer/voicemail/WiredHeadsetManager.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2015 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.voicemail; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; +import android.util.Log; + +/** Listens for and caches headset state. */ +class WiredHeadsetManager { + private static final String TAG = WiredHeadsetManager.class.getSimpleName(); + + interface Listener { + void onWiredHeadsetPluggedInChanged(boolean oldIsPluggedIn, boolean newIsPluggedIn); + } + + /** Receiver for wired headset plugged and unplugged events. */ + private class WiredHeadsetBroadcastReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (AudioManager.ACTION_HEADSET_PLUG.equals(intent.getAction())) { + boolean isPluggedIn = intent.getIntExtra("state", 0) == 1; + Log.v(TAG, "ACTION_HEADSET_PLUG event, plugged in: " + isPluggedIn); + onHeadsetPluggedInChanged(isPluggedIn); + } + } + } + + private final WiredHeadsetBroadcastReceiver mReceiver; + private boolean mIsPluggedIn; + private Listener mListener; + private Context mContext; + + WiredHeadsetManager(Context context) { + mContext = context; + mReceiver = new WiredHeadsetBroadcastReceiver(); + + AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + mIsPluggedIn = audioManager.isWiredHeadsetOn(); + + } + + void setListener(Listener listener) { + mListener = listener; + } + + boolean isPluggedIn() { + return mIsPluggedIn; + } + + void registerReceiver() { + // Register for misc other intent broadcasts. + IntentFilter intentFilter = new IntentFilter(Intent.ACTION_HEADSET_PLUG); + mContext.registerReceiver(mReceiver, intentFilter); + } + + void unregisterReceiver() { + mContext.unregisterReceiver(mReceiver); + } + + private void onHeadsetPluggedInChanged(boolean isPluggedIn) { + if (mIsPluggedIn != isPluggedIn) { + Log.v(TAG, "onHeadsetPluggedInChanged, mIsPluggedIn: " + mIsPluggedIn + " -> " + + isPluggedIn); + boolean oldIsPluggedIn = mIsPluggedIn; + mIsPluggedIn = isPluggedIn; + if (mListener != null) { + mListener.onWiredHeadsetPluggedInChanged(oldIsPluggedIn, mIsPluggedIn); + } + } + } +}
\ No newline at end of file diff --git a/src/com/android/dialer/widget/ActionBarController.java b/src/com/android/dialer/widget/ActionBarController.java index b9923d186..edf57b163 100644 --- a/src/com/android/dialer/widget/ActionBarController.java +++ b/src/com/android/dialer/widget/ActionBarController.java @@ -1,15 +1,28 @@ +/* + * Copyright (C) 2013 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 com.google.common.annotations.VisibleForTesting; import android.animation.ValueAnimator; import android.animation.ValueAnimator.AnimatorUpdateListener; -import android.app.ActionBar; import android.os.Bundle; import android.util.Log; import com.android.dialer.DialtactsActivity; -import com.android.phone.common.animation.AnimUtils; import com.android.phone.common.animation.AnimUtils.AnimationCallback; /** diff --git a/src/com/android/dialer/widget/EmptyContentView.java b/src/com/android/dialer/widget/EmptyContentView.java index f248967de..719fd3ff8 100644 --- a/src/com/android/dialer/widget/EmptyContentView.java +++ b/src/com/android/dialer/widget/EmptyContentView.java @@ -80,10 +80,11 @@ public class EmptyContentView extends LinearLayout implements View.OnClickListen } public void setImage(int resourceId) { - mImageView.setImageResource(resourceId); if (resourceId == NO_LABEL) { + mImageView.setImageDrawable(null); mImageView.setVisibility(View.GONE); } else { + mImageView.setImageResource(resourceId); mImageView.setVisibility(View.VISIBLE); } } diff --git a/src/com/android/dialer/widget/SearchEditTextLayout.java b/src/com/android/dialer/widget/SearchEditTextLayout.java index 544749f33..4f100dc44 100644 --- a/src/com/android/dialer/widget/SearchEditTextLayout.java +++ b/src/com/android/dialer/widget/SearchEditTextLayout.java @@ -230,7 +230,6 @@ public class SearchEditTextLayout extends FrameLayout { setElevation(0); setPaddingRelative(paddingStart, paddingTop, paddingEnd, paddingBottom); - setElevation(0); if (requestFocus) { mSearchView.requestFocus(); } diff --git a/src/com/android/dialerbind/ObjectFactory.java b/src/com/android/dialerbind/ObjectFactory.java index 1a36b05d6..d059d94d5 100644 --- a/src/com/android/dialerbind/ObjectFactory.java +++ b/src/com/android/dialerbind/ObjectFactory.java @@ -19,10 +19,15 @@ package com.android.dialerbind; import static com.android.dialer.calllog.CallLogAdapter.CallFetcher; import android.content.Context; +import android.support.annotation.Nullable; +import android.view.ViewStub; import com.android.dialer.calllog.CallLogAdapter; import com.android.dialer.calllog.ContactInfoHelper; +import com.android.dialer.list.RegularSearchFragment; +import com.android.dialer.logging.Logger; import com.android.dialer.service.CachedNumberLookupService; +import com.android.dialer.service.ExtendedBlockingButtonRenderer; import com.android.dialer.voicemail.VoicemailPlaybackPresenter; /** @@ -35,6 +40,28 @@ public class ObjectFactory { return null; } + public static String getFilteredNumberProviderAuthority() { + return "com.android.dialer.database.filterednumberprovider"; + } + + public static boolean isVoicemailArchiveEnabled(Context context) { + return false; + } + + public static boolean isVoicemailShareEnabled(Context context) { + return false; + } + + public static boolean isNewBlockingEnabled(Context context) { + return true; + } + + @Nullable + public static ExtendedBlockingButtonRenderer newExtendedBlockingButtonRenderer( + Context context, ExtendedBlockingButtonRenderer.Listener listener) { + return null; + } + /** * Create a new instance of the call log adapter. * @param context The context to use. @@ -47,12 +74,21 @@ public class ObjectFactory { CallFetcher callFetcher, ContactInfoHelper contactInfoHelper, VoicemailPlaybackPresenter voicemailPlaybackPresenter, - boolean isShowingRecentsTab) { + int activityType) { return new CallLogAdapter( context, callFetcher, contactInfoHelper, voicemailPlaybackPresenter, - isShowingRecentsTab); + activityType); + } + + public static Logger getLoggerInstance() { + // no-op + return null; + } + + public static RegularSearchFragment newRegularSearchFragment() { + return new RegularSearchFragment(); } } |