diff options
Diffstat (limited to 'java/com/android/dialer/app/calllog')
31 files changed, 7177 insertions, 0 deletions
diff --git a/java/com/android/dialer/app/calllog/BlockReportSpamListener.java b/java/com/android/dialer/app/calllog/BlockReportSpamListener.java new file mode 100644 index 000000000..66f40bcd7 --- /dev/null +++ b/java/com/android/dialer/app/calllog/BlockReportSpamListener.java @@ -0,0 +1,212 @@ +/* + * 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.app.calllog; + +import android.app.FragmentManager; +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.support.v7.widget.RecyclerView; +import com.android.dialer.blocking.BlockReportSpamDialogs; +import com.android.dialer.blocking.FilteredNumberAsyncQueryHandler; +import com.android.dialer.common.LogUtil; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.DialerImpression; +import com.android.dialer.logging.nano.ReportingLocation; +import com.android.dialer.spam.Spam; + +/** Listener to show dialogs for block and report spam actions. */ +public class BlockReportSpamListener implements CallLogListItemViewHolder.OnClickListener { + + private final Context mContext; + private final FragmentManager mFragmentManager; + private final RecyclerView.Adapter mAdapter; + private final FilteredNumberAsyncQueryHandler mFilteredNumberAsyncQueryHandler; + + public BlockReportSpamListener( + Context context, + FragmentManager fragmentManager, + RecyclerView.Adapter adapter, + FilteredNumberAsyncQueryHandler filteredNumberAsyncQueryHandler) { + mContext = context; + mFragmentManager = fragmentManager; + mAdapter = adapter; + mFilteredNumberAsyncQueryHandler = filteredNumberAsyncQueryHandler; + } + + @Override + public void onBlockReportSpam( + String displayNumber, + final String number, + final String countryIso, + final int callType, + final int contactSourceType) { + BlockReportSpamDialogs.BlockReportSpamDialogFragment.newInstance( + displayNumber, + Spam.get(mContext).isDialogReportSpamCheckedByDefault(), + new BlockReportSpamDialogs.OnSpamDialogClickListener() { + @Override + public void onClick(boolean isSpamChecked) { + LogUtil.i("BlockReportSpamListener.onBlockReportSpam", "onClick"); + if (isSpamChecked && Spam.get(mContext).isSpamEnabled()) { + Logger.get(mContext) + .logImpression( + DialerImpression.Type + .REPORT_CALL_AS_SPAM_VIA_CALL_LOG_BLOCK_REPORT_SPAM_SENT_VIA_BLOCK_NUMBER_DIALOG); + Spam.get(mContext) + .reportSpamFromCallHistory( + number, + countryIso, + callType, + ReportingLocation.Type.CALL_LOG_HISTORY, + contactSourceType); + } + mFilteredNumberAsyncQueryHandler.blockNumber( + new FilteredNumberAsyncQueryHandler.OnBlockNumberListener() { + @Override + public void onBlockComplete(Uri uri) { + Logger.get(mContext) + .logImpression(DialerImpression.Type.USER_ACTION_BLOCKED_NUMBER); + mAdapter.notifyDataSetChanged(); + } + }, + number, + countryIso); + } + }, + null) + .show(mFragmentManager, BlockReportSpamDialogs.BLOCK_REPORT_SPAM_DIALOG_TAG); + } + + @Override + public void onBlock( + String displayNumber, + final String number, + final String countryIso, + final int callType, + final int contactSourceType) { + BlockReportSpamDialogs.BlockDialogFragment.newInstance( + displayNumber, + Spam.get(mContext).isSpamEnabled(), + new BlockReportSpamDialogs.OnConfirmListener() { + @Override + public void onClick() { + LogUtil.i("BlockReportSpamListener.onBlock", "onClick"); + if (Spam.get(mContext).isSpamEnabled()) { + Logger.get(mContext) + .logImpression( + DialerImpression.Type + .DIALOG_ACTION_CONFIRM_NUMBER_SPAM_INDIRECTLY_VIA_BLOCK_NUMBER); + Spam.get(mContext) + .reportSpamFromCallHistory( + number, + countryIso, + callType, + ReportingLocation.Type.CALL_LOG_HISTORY, + contactSourceType); + } + mFilteredNumberAsyncQueryHandler.blockNumber( + new FilteredNumberAsyncQueryHandler.OnBlockNumberListener() { + @Override + public void onBlockComplete(Uri uri) { + Logger.get(mContext) + .logImpression(DialerImpression.Type.USER_ACTION_BLOCKED_NUMBER); + mAdapter.notifyDataSetChanged(); + } + }, + number, + countryIso); + } + }, + null) + .show(mFragmentManager, BlockReportSpamDialogs.BLOCK_DIALOG_TAG); + } + + @Override + public void onUnblock( + String displayNumber, + final String number, + final String countryIso, + final int callType, + final int contactSourceType, + final boolean isSpam, + final Integer blockId) { + BlockReportSpamDialogs.UnblockDialogFragment.newInstance( + displayNumber, + isSpam, + new BlockReportSpamDialogs.OnConfirmListener() { + @Override + public void onClick() { + LogUtil.i("BlockReportSpamListener.onUnblock", "onClick"); + if (isSpam && Spam.get(mContext).isSpamEnabled()) { + Logger.get(mContext) + .logImpression(DialerImpression.Type.REPORT_AS_NOT_SPAM_VIA_UNBLOCK_NUMBER); + Spam.get(mContext) + .reportNotSpamFromCallHistory( + number, + countryIso, + callType, + ReportingLocation.Type.CALL_LOG_HISTORY, + contactSourceType); + } + mFilteredNumberAsyncQueryHandler.unblock( + new FilteredNumberAsyncQueryHandler.OnUnblockNumberListener() { + @Override + public void onUnblockComplete(int rows, ContentValues values) { + Logger.get(mContext) + .logImpression(DialerImpression.Type.USER_ACTION_UNBLOCKED_NUMBER); + mAdapter.notifyDataSetChanged(); + } + }, + blockId); + } + }, + null) + .show(mFragmentManager, BlockReportSpamDialogs.UNBLOCK_DIALOG_TAG); + } + + @Override + public void onReportNotSpam( + String displayNumber, + final String number, + final String countryIso, + final int callType, + final int contactSourceType) { + BlockReportSpamDialogs.ReportNotSpamDialogFragment.newInstance( + displayNumber, + new BlockReportSpamDialogs.OnConfirmListener() { + @Override + public void onClick() { + LogUtil.i("BlockReportSpamListener.onReportNotSpam", "onClick"); + if (Spam.get(mContext).isSpamEnabled()) { + Logger.get(mContext) + .logImpression(DialerImpression.Type.DIALOG_ACTION_CONFIRM_NUMBER_NOT_SPAM); + Spam.get(mContext) + .reportNotSpamFromCallHistory( + number, + countryIso, + callType, + ReportingLocation.Type.CALL_LOG_HISTORY, + contactSourceType); + } + mAdapter.notifyDataSetChanged(); + } + }, + null) + .show(mFragmentManager, BlockReportSpamDialogs.NOT_SPAM_DIALOG_TAG); + } +} diff --git a/java/com/android/dialer/app/calllog/CallDetailHistoryAdapter.java b/java/com/android/dialer/app/calllog/CallDetailHistoryAdapter.java new file mode 100644 index 000000000..ab6ef7362 --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallDetailHistoryAdapter.java @@ -0,0 +1,214 @@ +/* + * 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.app.calllog; + +import android.content.Context; +import android.icu.lang.UCharacter; +import android.icu.text.BreakIterator; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.provider.CallLog.Calls; +import android.text.format.DateUtils; +import android.text.format.Formatter; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; +import com.android.dialer.app.PhoneCallDetails; +import com.android.dialer.app.R; +import com.android.dialer.util.CallUtil; +import com.android.dialer.util.DialerUtils; +import java.util.ArrayList; +import java.util.Locale; + +/** Adapter for a ListView containing history items from the details of a call. */ +public class CallDetailHistoryAdapter extends BaseAdapter { + + /** Each history item shows the detail of a call. */ + private static final int VIEW_TYPE_HISTORY_ITEM = 1; + + private final Context mContext; + private final LayoutInflater mLayoutInflater; + private final CallTypeHelper mCallTypeHelper; + private final PhoneCallDetails[] mPhoneCallDetails; + + /** List of items to be concatenated together for duration strings. */ + private ArrayList<CharSequence> mDurationItems = new ArrayList<>(); + + public CallDetailHistoryAdapter( + Context context, + LayoutInflater layoutInflater, + CallTypeHelper callTypeHelper, + PhoneCallDetails[] phoneCallDetails) { + mContext = context; + mLayoutInflater = layoutInflater; + mCallTypeHelper = callTypeHelper; + mPhoneCallDetails = phoneCallDetails; + } + + @Override + public boolean isEnabled(int position) { + // None of history will be clickable. + return false; + } + + @Override + public int getCount() { + return mPhoneCallDetails.length; + } + + @Override + public Object getItem(int position) { + return mPhoneCallDetails[position]; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getViewTypeCount() { + return 1; + } + + @Override + public int getItemViewType(int position) { + return VIEW_TYPE_HISTORY_ITEM; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + // 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]; + CallTypeIconsView callTypeIconView = + (CallTypeIconsView) result.findViewById(R.id.call_type_icon); + TextView callTypeTextView = (TextView) result.findViewById(R.id.call_type_text); + TextView dateView = (TextView) result.findViewById(R.id.date); + TextView durationView = (TextView) result.findViewById(R.id.duration); + + int callType = details.callTypes[0]; + boolean isVideoCall = + (details.features & Calls.FEATURES_VIDEO) == Calls.FEATURES_VIDEO + && CallUtil.isVideoEnabled(mContext); + boolean isPulledCall = + (details.features & Calls.FEATURES_PULLED_EXTERNALLY) == Calls.FEATURES_PULLED_EXTERNALLY; + + callTypeIconView.clear(); + callTypeIconView.add(callType); + callTypeIconView.setShowVideo(isVideoCall); + callTypeTextView.setText(mCallTypeHelper.getCallTypeText(callType, isVideoCall, isPulledCall)); + // Set the date. + dateView.setText(formatDate(details.date)); + // Set the duration + if (Calls.VOICEMAIL_TYPE == callType || CallTypeHelper.isMissedCallType(callType)) { + durationView.setVisibility(View.GONE); + } else { + durationView.setVisibility(View.VISIBLE); + durationView.setText(formatDurationAndDataUsage(details.duration, details.dataUsage)); + } + + return result; + } + + /** + * Formats the provided date into a value suitable for display in the current locale. + * + * <p>For example, returns a string like "Wednesday, May 25, 2016, 8:02PM" or "Chorshanba, 2016 + * may 25,20:02". + * + * <p>For pre-N devices, the returned value may not start with a capital if the local convention + * is to not capitalize day names. On N+ devices, the returned value is always capitalized. + */ + private CharSequence formatDate(long callDateMillis) { + CharSequence dateValue = + DateUtils.formatDateRange( + mContext, + callDateMillis /* startDate */, + callDateMillis /* endDate */, + DateUtils.FORMAT_SHOW_TIME + | DateUtils.FORMAT_SHOW_DATE + | DateUtils.FORMAT_SHOW_WEEKDAY + | DateUtils.FORMAT_SHOW_YEAR); + + // We want the beginning of the date string to be capitalized, even if the word at the beginning + // of the string is not usually capitalized. For example, "Wednesdsay" in Uzbek is "chorshanba” + // (not capitalized). To handle this issue we apply title casing to the start of the sentence so + // that "chorshanba, 2016 may 25,20:02" becomes "Chorshanba, 2016 may 25,20:02". + // + // The ICU library was not available in Android until N, so we can only do this in N+ devices. + // Pre-N devices will still see incorrect capitalization in some languages. + if (VERSION.SDK_INT < VERSION_CODES.N) { + return dateValue; + } + + // Using the ICU library is safer than just applying toUpperCase() on the first letter of the + // word because in some languages, there can be multiple starting characters which should be + // upper-cased together. For example in Dutch "ij" is a digraph in which both letters should be + // capitalized together. + + // TITLECASE_NO_LOWERCASE is necessary so that things that are already capitalized like the + // month ("May") are not lower-cased as part of the conversion. + return UCharacter.toTitleCase( + Locale.getDefault(), + dateValue.toString(), + BreakIterator.getSentenceInstance(), + UCharacter.TITLECASE_NO_LOWERCASE); + } + + private CharSequence formatDuration(long elapsedSeconds) { + long minutes = 0; + long seconds = 0; + + if (elapsedSeconds >= 60) { + minutes = elapsedSeconds / 60; + elapsedSeconds -= minutes * 60; + seconds = elapsedSeconds; + return mContext.getString(R.string.callDetailsDurationFormat, minutes, seconds); + } else { + seconds = elapsedSeconds; + return mContext.getString(R.string.callDetailsShortDurationFormat, seconds); + } + } + + /** + * Formats a string containing the call duration and the data usage (if specified). + * + * @param elapsedSeconds Total elapsed seconds. + * @param dataUsage Data usage in bytes, or null if not specified. + * @return String containing call duration and data usage. + */ + private CharSequence formatDurationAndDataUsage(long elapsedSeconds, Long dataUsage) { + CharSequence duration = formatDuration(elapsedSeconds); + + if (dataUsage != null) { + mDurationItems.clear(); + mDurationItems.add(duration); + mDurationItems.add(Formatter.formatShortFileSize(mContext, dataUsage)); + + return DialerUtils.join(mDurationItems); + } else { + return duration; + } + } +} diff --git a/java/com/android/dialer/app/calllog/CallLogAdapter.java b/java/com/android/dialer/app/calllog/CallLogAdapter.java new file mode 100644 index 000000000..ea09a8c0a --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallLogAdapter.java @@ -0,0 +1,915 @@ +/* + * 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.app.calllog; + +import android.app.Activity; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import android.os.Trace; +import android.provider.CallLog; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.support.annotation.WorkerThread; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.RecyclerView.ViewHolder; +import android.telecom.PhoneAccountHandle; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import android.util.ArrayMap; +import android.util.ArraySet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.dialer.app.Bindings; +import com.android.dialer.app.DialtactsActivity; +import com.android.dialer.app.PhoneCallDetails; +import com.android.dialer.app.R; +import com.android.dialer.app.calllog.CallLogGroupBuilder.GroupCreator; +import com.android.dialer.app.calllog.calllogcache.CallLogCache; +import com.android.dialer.app.contactinfo.ContactInfoCache; +import com.android.dialer.app.voicemail.VoicemailPlaybackPresenter; +import com.android.dialer.app.voicemail.VoicemailPlaybackPresenter.OnVoicemailDeletedListener; +import com.android.dialer.blocking.FilteredNumberAsyncQueryHandler; +import com.android.dialer.common.Assert; +import com.android.dialer.common.AsyncTaskExecutor; +import com.android.dialer.common.AsyncTaskExecutors; +import com.android.dialer.common.LogUtil; +import com.android.dialer.enrichedcall.EnrichedCallCapabilities; +import com.android.dialer.enrichedcall.EnrichedCallManager; +import com.android.dialer.enrichedcall.EnrichedCallManager.CapabilitiesListener; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.DialerImpression; +import com.android.dialer.phonenumbercache.CallLogQuery; +import com.android.dialer.phonenumbercache.ContactInfo; +import com.android.dialer.phonenumbercache.ContactInfoHelper; +import com.android.dialer.phonenumberutil.PhoneNumberHelper; +import com.android.dialer.spam.Spam; +import com.android.dialer.util.PermissionsUtil; +import java.util.Map; +import java.util.Set; + +/** Adapter class to fill in data for the Call Log. */ +public class CallLogAdapter extends GroupingListAdapter + implements GroupCreator, OnVoicemailDeletedListener, CapabilitiesListener { + + // 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_DIALTACTS = 2; + private static final int NO_EXPANDED_LIST_ITEM = -1; + public static final int ALERT_POSITION = 0; + private static final int VIEW_TYPE_ALERT = 1; + private static final int VIEW_TYPE_CALLLOG = 2; + + private static final String KEY_EXPANDED_POSITION = "expanded_position"; + private static final String KEY_EXPANDED_ROW_ID = "expanded_row_id"; + + public static final String LOAD_DATA_TASK_IDENTIFIER = "load_data"; + + protected final Activity mActivity; + protected final VoicemailPlaybackPresenter mVoicemailPlaybackPresenter; + /** Cache for repeated requests to Telecom/Telephony. */ + protected final CallLogCache mCallLogCache; + + private final CallFetcher mCallFetcher; + private final FilteredNumberAsyncQueryHandler mFilteredNumberAsyncQueryHandler; + private final int mActivityType; + + /** Instance of helper class for managing views. */ + private final CallLogListItemHelper mCallLogListItemHelper; + /** Helper to group call log entries. */ + private final CallLogGroupBuilder mCallLogGroupBuilder; + + private final AsyncTaskExecutor mAsyncTaskExecutor = AsyncTaskExecutors.createAsyncTaskExecutor(); + private ContactInfoCache mContactInfoCache; + // Tracks the position of the currently expanded list item. + private int mCurrentlyExpandedPosition = RecyclerView.NO_POSITION; + // 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 final CallLogAlertManager mCallLogAlertManager; + /** The OnClickListener used to expand or collapse the action buttons of a call log entry. */ + private final View.OnClickListener mExpandCollapseListener = + new View.OnClickListener() { + @Override + public void onClick(View v) { + CallLogListItemViewHolder viewHolder = (CallLogListItemViewHolder) v.getTag(); + if (viewHolder == null) { + return; + } + + if (mVoicemailPlaybackPresenter != null) { + // Always reset the voicemail playback state on expand or collapse. + mVoicemailPlaybackPresenter.resetAll(); + } + + if (viewHolder.rowId == mCurrentlyExpandedRowId) { + // Hide actions, if the clicked item is the expanded item. + viewHolder.showActions(false); + + mCurrentlyExpandedPosition = RecyclerView.NO_POSITION; + mCurrentlyExpandedRowId = NO_EXPANDED_LIST_ITEM; + } else { + if (viewHolder.callType == CallLog.Calls.MISSED_TYPE) { + CallLogAsyncTaskUtil.markCallAsRead(mActivity, viewHolder.callIds); + if (mActivityType == ACTIVITY_TYPE_DIALTACTS) { + ((DialtactsActivity) v.getContext()).updateTabUnreadCounts(); + } + } + expandViewHolderActions(viewHolder); + } + } + }; + + /** + * A list of {@link CallLogQuery#ID} that will be hidden. The hide might be temporary so instead + * if removing an item, it will be shown as an invisible view. This simplifies the calculation of + * item position. + */ + @NonNull private Set<Long> mHiddenRowIds = new ArraySet<>(); + /** + * Holds a list of URIs that are pending deletion or undo. If the activity ends before the undo + * timeout, all of the pending URIs will be deleted. + * + * <p>TODO: move this and OnVoicemailDeletedListener to somewhere like {@link + * VisualVoicemailCallLogFragment}. The CallLogAdapter does not need to know about what to do with + * hidden item or what to hide. + */ + @NonNull private final Set<Uri> mHiddenItemUris = new ArraySet<>(); + + private CallLogListItemViewHolder.OnClickListener mBlockReportSpamListener; + /** + * Map, keyed by call Id, used to track the day group for a call. As call log entries are put into + * the primary call groups in {@link com.android.dialer.app.calllog.CallLogGroupBuilder}, they are + * also assigned a secondary "day group". This map tracks the day group assigned to all calls in + * the call log. This information is used to trigger the display of a day group header above the + * call log entry at the start of a day group. Note: Multiple calls are grouped into a single + * primary "call group" in the call log, and the cursor used to bind rows includes all of these + * calls. When determining if a day group change has occurred it is necessary to look at the last + * entry in the call log to determine its day group. This map 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 Map<Long, Integer> mDayGroups = new ArrayMap<>(); + + private boolean mLoading = true; + private ContactsPreferences mContactsPreferences; + + private boolean mIsSpamEnabled; + + @NonNull private final EnrichedCallManager mEnrichedCallManager; + + public CallLogAdapter( + Activity activity, + ViewGroup alertContainer, + CallFetcher callFetcher, + CallLogCache callLogCache, + ContactInfoCache contactInfoCache, + VoicemailPlaybackPresenter voicemailPlaybackPresenter, + int activityType) { + super(); + + mActivity = activity; + mCallFetcher = callFetcher; + mVoicemailPlaybackPresenter = voicemailPlaybackPresenter; + if (mVoicemailPlaybackPresenter != null) { + mVoicemailPlaybackPresenter.setOnVoicemailDeletedListener(this); + } + + mActivityType = activityType; + + mContactInfoCache = contactInfoCache; + + if (!PermissionsUtil.hasContactsPermissions(activity)) { + mContactInfoCache.disableRequestProcessing(); + } + + Resources resources = mActivity.getResources(); + + mCallLogCache = callLogCache; + + PhoneCallDetailsHelper phoneCallDetailsHelper = + new PhoneCallDetailsHelper(mActivity, resources, mCallLogCache); + mCallLogListItemHelper = + new CallLogListItemHelper(phoneCallDetailsHelper, resources, mCallLogCache); + mCallLogGroupBuilder = new CallLogGroupBuilder(this); + mFilteredNumberAsyncQueryHandler = new FilteredNumberAsyncQueryHandler(mActivity); + + mContactsPreferences = new ContactsPreferences(mActivity); + + mBlockReportSpamListener = + new BlockReportSpamListener( + mActivity, + ((Activity) mActivity).getFragmentManager(), + this, + mFilteredNumberAsyncQueryHandler); + setHasStableIds(true); + + mCallLogAlertManager = + new CallLogAlertManager(this, LayoutInflater.from(mActivity), alertContainer); + mEnrichedCallManager = EnrichedCallManager.Accessor.getInstance(activity.getApplication()); + } + + private void expandViewHolderActions(CallLogListItemViewHolder viewHolder) { + if (!TextUtils.isEmpty(viewHolder.voicemailUri)) { + Logger.get(mActivity).logImpression(DialerImpression.Type.VOICEMAIL_EXPAND_ENTRY); + } + + int lastExpandedPosition = mCurrentlyExpandedPosition; + // Show the actions for the clicked list item. + viewHolder.showActions(true); + mCurrentlyExpandedPosition = viewHolder.getAdapterPosition(); + mCurrentlyExpandedRowId = viewHolder.rowId; + + // If another item is expanded, notify it that it has changed. Its actions will be + // hidden when it is re-binded because we change mCurrentlyExpandedRowId above. + if (lastExpandedPosition != RecyclerView.NO_POSITION) { + notifyItemChanged(lastExpandedPosition); + } + } + + public void onSaveInstanceState(Bundle outState) { + outState.putInt(KEY_EXPANDED_POSITION, mCurrentlyExpandedPosition); + outState.putLong(KEY_EXPANDED_ROW_ID, mCurrentlyExpandedRowId); + } + + public void onRestoreInstanceState(Bundle savedInstanceState) { + if (savedInstanceState != null) { + mCurrentlyExpandedPosition = + savedInstanceState.getInt(KEY_EXPANDED_POSITION, RecyclerView.NO_POSITION); + mCurrentlyExpandedRowId = + savedInstanceState.getLong(KEY_EXPANDED_ROW_ID, NO_EXPANDED_LIST_ITEM); + } + } + + /** Requery on background thread when {@link Cursor} changes. */ + @Override + protected void onContentChanged() { + mCallFetcher.fetchCalls(); + } + + public void setLoading(boolean loading) { + mLoading = loading; + } + + public boolean isEmpty() { + if (mLoading) { + // We don't want the empty state to show when loading. + return false; + } else { + return getItemCount() == 0; + } + } + + public void clearFilteredNumbersCache() { + mFilteredNumberAsyncQueryHandler.clearCache(); + } + + public void onResume() { + if (PermissionsUtil.hasPermission(mActivity, android.Manifest.permission.READ_CONTACTS)) { + mContactInfoCache.start(); + } + mContactsPreferences.refreshValue(ContactsPreferences.DISPLAY_ORDER_KEY); + mIsSpamEnabled = Spam.get(mActivity).isSpamEnabled(); + mEnrichedCallManager.registerCapabilitiesListener(this); + notifyDataSetChanged(); + } + + public void onPause() { + pauseCache(); + for (Uri uri : mHiddenItemUris) { + CallLogAsyncTaskUtil.deleteVoicemail(mActivity, uri, null); + } + mEnrichedCallManager.unregisterCapabilitiesListener(this); + } + + public void onStop() { + mEnrichedCallManager.clearCachedData(); + } + + public CallLogAlertManager getAlertManager() { + return mCallLogAlertManager; + } + + @VisibleForTesting + /* package */ void pauseCache() { + mContactInfoCache.stop(); + mCallLogCache.reset(); + } + + @Override + protected void addGroups(Cursor cursor) { + mCallLogGroupBuilder.addGroups(cursor); + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + if (viewType == VIEW_TYPE_ALERT) { + return mCallLogAlertManager.createViewHolder(parent); + } + return createCallLogEntryViewHolder(parent); + } + + /** + * Creates a new call log entry {@link ViewHolder}. + * + * @param parent the parent view. + * @return The {@link ViewHolder}. + */ + private ViewHolder createCallLogEntryViewHolder(ViewGroup parent) { + LayoutInflater inflater = LayoutInflater.from(mActivity); + View view = inflater.inflate(R.layout.call_log_list_item, parent, false); + CallLogListItemViewHolder viewHolder = + CallLogListItemViewHolder.create( + view, + mActivity, + mBlockReportSpamListener, + mExpandCollapseListener, + mCallLogCache, + mCallLogListItemHelper, + mVoicemailPlaybackPresenter); + + viewHolder.callLogEntryView.setTag(viewHolder); + + viewHolder.primaryActionView.setTag(viewHolder); + + return viewHolder; + } + + /** + * Binds the views in the entry to the data in the call log. 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 position The position of the entry. + */ + @Override + public void onBindViewHolder(ViewHolder viewHolder, int position) { + Trace.beginSection("onBindViewHolder: " + position); + switch (getItemViewType(position)) { + case VIEW_TYPE_ALERT: + //Do nothing + break; + default: + bindCallLogListViewHolder(viewHolder, position); + break; + } + Trace.endSection(); + } + + @Override + public void onViewRecycled(ViewHolder viewHolder) { + if (viewHolder.getItemViewType() == VIEW_TYPE_CALLLOG) { + CallLogListItemViewHolder views = (CallLogListItemViewHolder) viewHolder; + if (views.asyncTask != null) { + views.asyncTask.cancel(true); + } + } + } + + @Override + public void onViewAttachedToWindow(ViewHolder viewHolder) { + if (viewHolder.getItemViewType() == VIEW_TYPE_CALLLOG) { + ((CallLogListItemViewHolder) viewHolder).isAttachedToWindow = true; + } + } + + @Override + public void onViewDetachedFromWindow(ViewHolder viewHolder) { + if (viewHolder.getItemViewType() == VIEW_TYPE_CALLLOG) { + ((CallLogListItemViewHolder) viewHolder).isAttachedToWindow = false; + } + } + + /** + * Binds the view holder for the call log list item view. + * + * @param viewHolder The call log list item view holder. + * @param position The position of the list item. + */ + private void bindCallLogListViewHolder(final ViewHolder viewHolder, final int position) { + Cursor c = (Cursor) getItem(position); + if (c == null) { + return; + } + CallLogListItemViewHolder views = (CallLogListItemViewHolder) viewHolder; + views.isLoaded = false; + PhoneCallDetails details = createPhoneCallDetails(c, getGroupSize(position), views); + if (mHiddenRowIds.contains(c.getLong(CallLogQuery.ID))) { + views.callLogEntryView.setVisibility(View.GONE); + views.dayGroupHeader.setVisibility(View.GONE); + return; + } else { + views.callLogEntryView.setVisibility(View.VISIBLE); + // dayGroupHeader will be restored after loadAndRender() if it is needed. + } + if (mCurrentlyExpandedRowId == views.rowId) { + views.inflateActionViewStub(); + } + loadAndRender(views, views.rowId, details); + } + + private void loadAndRender( + final CallLogListItemViewHolder views, final long rowId, final PhoneCallDetails details) { + // Reset block and spam information since this view could be reused which may contain + // outdated data. + views.isSpam = false; + views.blockId = null; + views.isSpamFeatureEnabled = false; + views.isCallComposerCapable = + isCallComposerCapable(PhoneNumberUtils.formatNumberToE164(views.number, views.countryIso)); + final AsyncTask<Void, Void, Boolean> loadDataTask = + new AsyncTask<Void, Void, Boolean>() { + @Override + protected Boolean doInBackground(Void... params) { + views.blockId = + mFilteredNumberAsyncQueryHandler.getBlockedIdSynchronousForCalllogOnly( + views.number, views.countryIso); + details.isBlocked = views.blockId != null; + if (isCancelled()) { + return false; + } + if (mIsSpamEnabled) { + views.isSpamFeatureEnabled = true; + // Only display the call as a spam call if there are incoming calls in the list. + // Call log cards with only outgoing calls should never be displayed as spam. + views.isSpam = + details.hasIncomingCalls() + && Spam.get(mActivity) + .checkSpamStatusSynchronous(views.number, views.countryIso); + details.isSpam = views.isSpam; + if (isCancelled()) { + return false; + } + return loadData(views, rowId, details); + } else { + return loadData(views, rowId, details); + } + } + + @Override + protected void onPostExecute(Boolean success) { + views.isLoaded = true; + if (success) { + int currentGroup = getDayGroupForCall(views.rowId); + if (currentGroup != details.previousGroup) { + views.dayGroupHeaderVisibility = View.VISIBLE; + views.dayGroupHeaderText = getGroupDescription(currentGroup); + } else { + views.dayGroupHeaderVisibility = View.GONE; + } + render(views, details, rowId); + } + } + }; + + views.asyncTask = loadDataTask; + mAsyncTaskExecutor.submit(LOAD_DATA_TASK_IDENTIFIER, loadDataTask); + } + + @MainThread + private boolean isCallComposerCapable(@Nullable String e164Number) { + if (e164Number == null) { + return false; + } + + EnrichedCallCapabilities capabilities = mEnrichedCallManager.getCapabilities(e164Number); + if (capabilities == null) { + mEnrichedCallManager.requestCapabilities(e164Number); + return false; + } + return capabilities.supportsCallComposer(); + } + + /** + * Initialize PhoneCallDetails by reading all data from cursor. This method must be run on main + * thread since cursor is not thread safe. + */ + @MainThread + private PhoneCallDetails createPhoneCallDetails( + Cursor cursor, int count, final CallLogListItemViewHolder views) { + Assert.isMainThread(); + final String number = cursor.getString(CallLogQuery.NUMBER); + final String postDialDigits = + (VERSION.SDK_INT >= VERSION_CODES.N) ? cursor.getString(CallLogQuery.POST_DIAL_DIGITS) : ""; + final String viaNumber = + (VERSION.SDK_INT >= VERSION_CODES.N) ? cursor.getString(CallLogQuery.VIA_NUMBER) : ""; + final int numberPresentation = cursor.getInt(CallLogQuery.NUMBER_PRESENTATION); + final ContactInfo cachedContactInfo = ContactInfoHelper.getContactInfo(cursor); + final PhoneCallDetails details = + new PhoneCallDetails(number, numberPresentation, postDialDigits); + details.viaNumber = viaNumber; + details.countryIso = cursor.getString(CallLogQuery.COUNTRY_ISO); + details.date = cursor.getLong(CallLogQuery.DATE); + details.duration = cursor.getLong(CallLogQuery.DURATION); + details.features = getCallFeatures(cursor, count); + details.geocode = cursor.getString(CallLogQuery.GEOCODED_LOCATION); + details.transcription = cursor.getString(CallLogQuery.TRANSCRIPTION); + details.callTypes = getCallTypes(cursor, count); + + details.accountComponentName = cursor.getString(CallLogQuery.ACCOUNT_COMPONENT_NAME); + details.accountId = cursor.getString(CallLogQuery.ACCOUNT_ID); + details.cachedContactInfo = cachedContactInfo; + + if (!cursor.isNull(CallLogQuery.DATA_USAGE)) { + details.dataUsage = cursor.getLong(CallLogQuery.DATA_USAGE); + } + + views.rowId = cursor.getLong(CallLogQuery.ID); + // Stash away the Ids of the calls so that we can support deleting a row in the call log. + views.callIds = getCallIds(cursor, count); + details.previousGroup = getPreviousDayGroup(cursor); + + // Store values used when the actions ViewStub is inflated on expansion. + views.number = number; + views.countryIso = details.countryIso; + views.postDialDigits = details.postDialDigits; + views.numberPresentation = numberPresentation; + + if (details.callTypes[0] == CallLog.Calls.VOICEMAIL_TYPE + || details.callTypes[0] == CallLog.Calls.MISSED_TYPE) { + details.isRead = cursor.getInt(CallLogQuery.IS_READ) == 1; + } + views.callType = cursor.getInt(CallLogQuery.CALL_TYPE); + views.voicemailUri = cursor.getString(CallLogQuery.VOICEMAIL_URI); + + return details; + } + + /** + * Load data for call log. Any expensive operation should be put here to avoid blocking main + * thread. Do NOT put any cursor operation here since it's not thread safe. + */ + @WorkerThread + private boolean loadData(CallLogListItemViewHolder views, long rowId, PhoneCallDetails details) { + Assert.isWorkerThread(); + if (rowId != views.rowId) { + LogUtil.i( + "CallLogAdapter.loadData", + "rowId of viewHolder changed after load task is issued, aborting load"); + return false; + } + + final PhoneAccountHandle accountHandle = + PhoneAccountUtils.getAccount(details.accountComponentName, details.accountId); + + final boolean isVoicemailNumber = + mCallLogCache.isVoicemailNumber(accountHandle, details.number); + + // Note: Binding of the action buttons is done as required in configureActionViews when the + // user expands the actions ViewStub. + + ContactInfo info = ContactInfo.EMPTY; + if (PhoneNumberHelper.canPlaceCallsTo(details.number, details.numberPresentation) + && !isVoicemailNumber) { + // Lookup contacts with this number + // Only do remote lookup in first 5 rows. + info = + mContactInfoCache.getValue( + details.number + details.postDialDigits, + details.countryIso, + details.cachedContactInfo, + rowId + < Bindings.get(mActivity) + .getConfigProvider() + .getLong("number_of_call_to_do_remote_lookup", 5L)); + } + CharSequence formattedNumber = + info.formattedNumber == null + ? null + : PhoneNumberUtilsCompat.createTtsSpannable(info.formattedNumber); + details.updateDisplayNumber(mActivity, formattedNumber, isVoicemailNumber); + + views.displayNumber = details.displayNumber; + views.accountHandle = accountHandle; + details.accountHandle = accountHandle; + + if (!TextUtils.isEmpty(info.name) || !TextUtils.isEmpty(info.nameAlternative)) { + details.contactUri = info.lookupUri; + 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; + } + + views.info = info; + views.numberType = + (String) + Phone.getTypeLabel(mActivity.getResources(), details.numberType, details.numberLabel); + + mCallLogListItemHelper.updatePhoneCallDetails(details); + return true; + } + + /** + * Render item view given position. This is running on UI thread so DO NOT put any expensive + * operation into it. + */ + @MainThread + private void render(CallLogListItemViewHolder views, PhoneCallDetails details, long rowId) { + Assert.isMainThread(); + if (rowId != views.rowId) { + LogUtil.i( + "CallLogAdapter.render", + "rowId of viewHolder changed after load task is issued, aborting render"); + return; + } + + // 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); + + mCallLogListItemHelper.setPhoneCallDetails(views, details); + if (mCurrentlyExpandedRowId == views.rowId) { + // In case ViewHolders were added/removed, update the expanded position if the rowIds + // match so that we can restore the correct expanded state on rebind. + mCurrentlyExpandedPosition = views.getAdapterPosition(); + views.showActions(true); + } else { + views.showActions(false); + } + views.dayGroupHeader.setVisibility(views.dayGroupHeaderVisibility); + views.dayGroupHeader.setText(views.dayGroupHeaderText); + } + + @Override + public int getItemCount() { + return super.getItemCount() + (mCallLogAlertManager.isEmpty() ? 0 : 1); + } + + @Override + public int getItemViewType(int position) { + if (position == ALERT_POSITION && !mCallLogAlertManager.isEmpty()) { + return VIEW_TYPE_ALERT; + } + return VIEW_TYPE_CALLLOG; + } + + /** + * Retrieves an item at the specified position, taking into account the presence of a promo card. + * + * @param position The position to retrieve. + * @return The item at that position. + */ + @Override + public Object getItem(int position) { + return super.getItem(position - (mCallLogAlertManager.isEmpty() ? 0 : 1)); + } + + @Override + public long getItemId(int position) { + Cursor cursor = (Cursor) getItem(position); + if (cursor != null) { + return cursor.getLong(CallLogQuery.ID); + } else { + return 0; + } + } + + @Override + public int getGroupSize(int position) { + return super.getGroupSize(position - (mCallLogAlertManager.isEmpty() ? 0 : 1)); + } + + 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(CallLogListItemViewHolder viewHolder, Uri uri) { + mHiddenRowIds.add(viewHolder.rowId); + // Save the new hidden item uri in case the activity is suspend before the undo has timed out. + mHiddenItemUris.add(uri); + + collapseExpandedCard(); + notifyItemChanged(viewHolder.getAdapterPosition()); + // The next item might have to update its day group label + notifyItemChanged(viewHolder.getAdapterPosition() + 1); + } + + private void collapseExpandedCard() { + mCurrentlyExpandedRowId = NO_EXPANDED_LIST_ITEM; + mCurrentlyExpandedPosition = RecyclerView.NO_POSITION; + } + + /** When the list is changing all stored position is no longer valid. */ + public void invalidatePositions() { + mCurrentlyExpandedPosition = RecyclerView.NO_POSITION; + } + + /** When the user clicks "undo", the hidden item is unhidden. */ + @Override + public void onVoicemailDeleteUndo(long rowId, int adapterPosition, Uri uri) { + mHiddenItemUris.remove(uri); + mHiddenRowIds.remove(rowId); + notifyItemChanged(adapterPosition); + // The next item might have to update its day group label + notifyItemChanged(adapterPosition + 1); + } + + /** This callback signifies that a database deletion has completed. */ + @Override + public void onVoicemailDeletedInDatabase(long rowId, Uri uri) { + mHiddenItemUris.remove(uri); + } + + /** + * 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. + * + * @param cursor The call log cursor. + * @return The previous day group, or DAY_GROUP_NONE if this is the first call. + */ + private int getPreviousDayGroup(Cursor cursor) { + // We want to restore the position in the cursor at the end. + int startingPosition = cursor.getPosition(); + moveToPreviousNonHiddenRow(cursor); + if (cursor.isBeforeFirst()) { + cursor.moveToPosition(startingPosition); + return CallLogGroupBuilder.DAY_GROUP_NONE; + } + int result = getDayGroupForCall(cursor.getLong(CallLogQuery.ID)); + cursor.moveToPosition(startingPosition); + return result; + } + + private void moveToPreviousNonHiddenRow(Cursor cursor) { + while (cursor.moveToPrevious() && mHiddenRowIds.contains(cursor.getLong(CallLogQuery.ID))) {} + } + + /** + * Given a call Id, look up the day group that the call belongs to. The day group data is + * populated in {@link com.android.dialer.app.calllog.CallLogGroupBuilder}. + * + * @param callId The call to retrieve the day group for. + * @return The day group for the call. + */ + @MainThread + private int getDayGroupForCall(long callId) { + Integer result = mDayGroups.get(callId); + if (result != null) { + return result; + } + return CallLogGroupBuilder.DAY_GROUP_NONE; + } + + /** + * Returns the call types for the given number of items in the cursor. + * + * <p>It uses the next {@code count} rows in the cursor to extract the types. + * + * <p>It position in the cursor is unchanged by this function. + */ + private static int[] getCallTypes(Cursor cursor, int count) { + int position = cursor.getPosition(); + int[] callTypes = new int[count]; + for (int index = 0; index < count; ++index) { + callTypes[index] = cursor.getInt(CallLogQuery.CALL_TYPE); + cursor.moveToNext(); + } + cursor.moveToPosition(position); + return callTypes; + } + + /** + * Determine the features which were enabled for any of the calls that make up a call log entry. + * + * @param cursor The cursor. + * @param count The number of calls for the current call log entry. + * @return The features. + */ + private int getCallFeatures(Cursor cursor, int count) { + int features = 0; + int position = cursor.getPosition(); + for (int index = 0; index < count; ++index) { + features |= cursor.getInt(CallLogQuery.FEATURES); + cursor.moveToNext(); + } + cursor.moveToPosition(position); + return features; + } + + /** + * Sets whether processing of requests for contact details should be enabled. + * + * <p>This method should be called in tests to disable such processing of requests when not + * needed. + */ + @VisibleForTesting + void disableRequestProcessingForTest() { + // TODO: Remove this and test the cache directly. + mContactInfoCache.disableRequestProcessing(); + } + + @VisibleForTesting + void injectContactInfoForTest(String number, String countryIso, ContactInfo contactInfo) { + // TODO: Remove this and test the cache directly. + mContactInfoCache.injectContactInfoForTest(number, countryIso, contactInfo); + } + + /** + * Stores the day group associated with a call in the call log. + * + * @param rowId The row Id of the current call. + * @param dayGroup The day group the call belongs in. + */ + @Override + @MainThread + public void setDayGroup(long rowId, int dayGroup) { + if (!mDayGroups.containsKey(rowId)) { + mDayGroups.put(rowId, dayGroup); + } + } + + /** Clears the day group associations on re-bind of the call log. */ + @Override + @MainThread + public void clearDayGroups() { + mDayGroups.clear(); + } + + /** + * Retrieves the call Ids represented by the current call log row. + * + * @param cursor Call log cursor to retrieve call Ids from. + * @param groupSize Number of calls associated with the current call log row. + * @return Array of call Ids. + */ + private long[] getCallIds(final Cursor cursor, final int groupSize) { + // We want to restore the position in the cursor at the end. + int startingPosition = cursor.getPosition(); + long[] ids = new long[groupSize]; + // Copy the ids of the rows in the group. + for (int index = 0; index < groupSize; ++index) { + ids[index] = cursor.getLong(CallLogQuery.ID); + cursor.moveToNext(); + } + cursor.moveToPosition(startingPosition); + return ids; + } + + /** + * Determines the description for a day group. + * + * @param group The day group to retrieve the description for. + * @return The day group description. + */ + private CharSequence getGroupDescription(int group) { + if (group == CallLogGroupBuilder.DAY_GROUP_TODAY) { + return mActivity.getResources().getString(R.string.call_log_header_today); + } else if (group == CallLogGroupBuilder.DAY_GROUP_YESTERDAY) { + return mActivity.getResources().getString(R.string.call_log_header_yesterday); + } else { + return mActivity.getResources().getString(R.string.call_log_header_other); + } + } + + @Override + public void onCapabilitiesUpdated() { + notifyDataSetChanged(); + } + + /** Interface used to initiate a refresh of the content. */ + public interface CallFetcher { + + void fetchCalls(); + } +} diff --git a/java/com/android/dialer/app/calllog/CallLogAlertManager.java b/java/com/android/dialer/app/calllog/CallLogAlertManager.java new file mode 100644 index 000000000..40b30f001 --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallLogAlertManager.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.app.calllog; + +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import com.android.dialer.app.R; +import com.android.dialer.app.alert.AlertManager; +import com.android.dialer.common.Assert; + +/** Manages "alerts" to be shown at the top of an call log to gain the user's attention. */ +public class CallLogAlertManager implements AlertManager { + + private final CallLogAdapter adapter; + private final View view; + private final LayoutInflater inflater; + private final ViewGroup parent; + private final ViewGroup container; + + public CallLogAlertManager(CallLogAdapter adapter, LayoutInflater inflater, ViewGroup parent) { + this.adapter = adapter; + this.inflater = inflater; + this.parent = parent; + view = inflater.inflate(R.layout.call_log_alert_item, parent, false); + container = (ViewGroup) view.findViewById(R.id.container); + } + + @Override + public View inflate(int layoutId) { + return inflater.inflate(layoutId, container, false); + } + + public RecyclerView.ViewHolder createViewHolder(ViewGroup parent) { + Assert.checkArgument( + parent == this.parent, + "createViewHolder should be called with the same parent in constructor"); + return new AlertViewHolder(view); + } + + public boolean isEmpty() { + return container.getChildCount() == 0; + } + + public boolean contains(View view) { + return container.indexOfChild(view) != -1; + } + + @Override + public void clear() { + container.removeAllViews(); + adapter.notifyItemRemoved(CallLogAdapter.ALERT_POSITION); + } + + @Override + public void add(View view) { + if (contains(view)) { + return; + } + container.addView(view); + if (container.getChildCount() == 1) { + // Was empty before + adapter.notifyItemInserted(CallLogAdapter.ALERT_POSITION); + } + } + + /** + * Does nothing. The view this ViewHolder show is directly managed by {@link CallLogAlertManager} + */ + private static class AlertViewHolder extends RecyclerView.ViewHolder { + private AlertViewHolder(View view) { + super(view); + } + } +} diff --git a/java/com/android/dialer/app/calllog/CallLogAsync.java b/java/com/android/dialer/app/calllog/CallLogAsync.java new file mode 100644 index 000000000..f62deca89 --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallLogAsync.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2010 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.app.calllog; + +import android.content.Context; +import android.os.AsyncTask; +import android.provider.CallLog.Calls; +import com.android.dialer.common.Assert; + +/** + * Class to access the call log asynchronously to avoid carrying out database operations on the UI + * thread, using an {@link AsyncTask}. + * + * <pre class="prettyprint"> Typical usage: ============== + * + * // From an activity... String mLastNumber = ""; + * + * CallLogAsync log = new CallLogAsync(); + * + * CallLogAsync.GetLastOutgoingCallArgs lastCallArgs = new CallLogAsync.GetLastOutgoingCallArgs( + * this, new CallLogAsync.OnLastOutgoingCallComplete() { public void lastOutgoingCall(String number) + * { mLastNumber = number; } }); log.getLastOutgoingCall(lastCallArgs); </pre> + */ +public class CallLogAsync { + + /** CallLog.getLastOutgoingCall(...) */ + public AsyncTask getLastOutgoingCall(GetLastOutgoingCallArgs args) { + Assert.isMainThread(); + return new GetLastOutgoingCallTask(args.callback).execute(args); + } + + /** Interface to retrieve the last dialed number asynchronously. */ + public interface OnLastOutgoingCallComplete { + + /** @param number The last dialed number or an empty string if none exists yet. */ + void lastOutgoingCall(String number); + } + + /** Parameter object to hold the args to get the last outgoing call from the call log DB. */ + public static class GetLastOutgoingCallArgs { + + public final Context context; + public final OnLastOutgoingCallComplete callback; + + public GetLastOutgoingCallArgs(Context context, OnLastOutgoingCallComplete callback) { + this.context = context; + this.callback = callback; + } + } + + /** AsyncTask to get the last outgoing call from the DB. */ + private class GetLastOutgoingCallTask extends AsyncTask<GetLastOutgoingCallArgs, Void, String> { + + private final OnLastOutgoingCallComplete mCallback; + + public GetLastOutgoingCallTask(OnLastOutgoingCallComplete callback) { + mCallback = callback; + } + + // Happens on a background thread. We cannot run the callback + // here because only the UI thread can modify the view + // hierarchy (e.g enable/disable the dial button). The + // callback is ran rom the post execute method. + @Override + protected String doInBackground(GetLastOutgoingCallArgs... list) { + String number = ""; + for (GetLastOutgoingCallArgs args : list) { + // May block. Select only the last one. + number = Calls.getLastOutgoingCall(args.context); + } + return number; // passed to the onPostExecute method. + } + + // Happens on the UI thread, it is safe to run the callback + // that may do some work on the views. + @Override + protected void onPostExecute(String number) { + Assert.isMainThread(); + mCallback.lastOutgoingCall(number); + } + } +} diff --git a/java/com/android/dialer/app/calllog/CallLogAsyncTaskUtil.java b/java/com/android/dialer/app/calllog/CallLogAsyncTaskUtil.java new file mode 100644 index 000000000..b4e6fc5ad --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallLogAsyncTaskUtil.java @@ -0,0 +1,376 @@ +/* + * 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.app.calllog; + +import android.Manifest.permission; +import android.annotation.TargetApi; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.provider.CallLog; +import android.provider.VoicemailContract.Voicemails; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.support.v4.content.ContextCompat; +import android.telecom.PhoneAccountHandle; +import android.text.TextUtils; +import com.android.contacts.common.GeoUtil; +import com.android.dialer.app.PhoneCallDetails; +import com.android.dialer.common.AsyncTaskExecutor; +import com.android.dialer.common.AsyncTaskExecutors; +import com.android.dialer.common.LogUtil; +import com.android.dialer.phonenumbercache.ContactInfo; +import com.android.dialer.phonenumbercache.ContactInfoHelper; +import com.android.dialer.phonenumberutil.PhoneNumberHelper; +import com.android.dialer.telecom.TelecomUtil; +import com.android.dialer.util.PermissionsUtil; +import java.util.ArrayList; +import java.util.Arrays; + +@TargetApi(VERSION_CODES.M) +public class CallLogAsyncTaskUtil { + + private static final String TAG = "CallLogAsyncTaskUtil"; + private static AsyncTaskExecutor sAsyncTaskExecutor; + + private static void initTaskExecutor() { + sAsyncTaskExecutor = AsyncTaskExecutors.createThreadPoolExecutor(); + } + + public static void getCallDetails( + @NonNull final Context context, + @Nullable final CallLogAsyncTaskListener callLogAsyncTaskListener, + @NonNull final Uri... callUris) { + if (sAsyncTaskExecutor == null) { + initTaskExecutor(); + } + + sAsyncTaskExecutor.submit( + Tasks.GET_CALL_DETAILS, + new AsyncTask<Void, Void, PhoneCallDetails[]>() { + @Override + public PhoneCallDetails[] doInBackground(Void... params) { + if (ContextCompat.checkSelfPermission(context, permission.READ_CALL_LOG) + != PackageManager.PERMISSION_GRANTED) { + LogUtil.w("CallLogAsyncTaskUtil.getCallDetails", "missing READ_CALL_LOG permission"); + return null; + } + // TODO: All calls correspond to the same person, so make a single lookup. + final int numCalls = callUris.length; + PhoneCallDetails[] details = new PhoneCallDetails[numCalls]; + try { + for (int index = 0; index < numCalls; ++index) { + details[index] = getPhoneCallDetailsForUri(context, callUris[index]); + } + return details; + } catch (IllegalArgumentException e) { + // Something went wrong reading in our primary data. + LogUtil.e( + "CallLogAsyncTaskUtil.getCallDetails", "invalid URI starting call details", e); + return null; + } + } + + @Override + public void onPostExecute(PhoneCallDetails[] phoneCallDetails) { + if (callLogAsyncTaskListener != null) { + callLogAsyncTaskListener.onGetCallDetails(phoneCallDetails); + } + } + }); + } + + /** Return the phone call details for a given call log URI. */ + private static PhoneCallDetails getPhoneCallDetailsForUri( + @NonNull Context context, @NonNull Uri callUri) { + Cursor cursor = + context + .getContentResolver() + .query(callUri, CallDetailQuery.CALL_LOG_PROJECTION, null, null, null); + + try { + if (cursor == null || !cursor.moveToFirst()) { + throw new IllegalArgumentException("Cannot find content: " + callUri); + } + + // 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 = + (VERSION.SDK_INT >= VERSION_CODES.N) + ? cursor.getString(CallDetailQuery.POST_DIAL_DIGITS) + : ""; + final String viaNumber = + (VERSION.SDK_INT >= VERSION_CODES.N) ? cursor.getString(CallDetailQuery.VIA_NUMBER) : ""; + final int numberPresentation = + cursor.getInt(CallDetailQuery.NUMBER_PRESENTATION_COLUMN_INDEX); + + final PhoneAccountHandle accountHandle = + PhoneAccountUtils.getAccount( + cursor.getString(CallDetailQuery.ACCOUNT_COMPONENT_NAME), + cursor.getString(CallDetailQuery.ACCOUNT_ID)); + + // If this is not a regular number, there is no point in looking it up in the contacts. + ContactInfoHelper contactInfoHelper = + new ContactInfoHelper(context, GeoUtil.getCurrentCountryIso(context)); + boolean isVoicemail = PhoneNumberHelper.isVoicemailNumber(context, accountHandle, number); + boolean shouldLookupNumber = + PhoneNumberHelper.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(number, numberPresentation, postDialDigits); + details.updateDisplayNumber(context, info.formattedNumber, isVoicemail); + + details.viaNumber = viaNumber; + details.accountHandle = accountHandle; + details.contactUri = info.lookupUri; + details.namePrimary = info.name; + details.nameAlternative = info.nameAlternative; + details.numberType = info.type; + details.numberLabel = info.label; + details.photoUri = info.photoUri; + details.sourceType = info.sourceType; + details.objectId = info.objectId; + + details.callTypes = new int[] {cursor.getInt(CallDetailQuery.CALL_TYPE_COLUMN_INDEX)}; + details.date = cursor.getLong(CallDetailQuery.DATE_COLUMN_INDEX); + details.duration = cursor.getLong(CallDetailQuery.DURATION_COLUMN_INDEX); + details.features = cursor.getInt(CallDetailQuery.FEATURES); + details.geocode = cursor.getString(CallDetailQuery.GEOCODED_LOCATION_COLUMN_INDEX); + details.transcription = cursor.getString(CallDetailQuery.TRANSCRIPTION_COLUMN_INDEX); + + details.countryIso = + !TextUtils.isEmpty(countryIso) ? countryIso : GeoUtil.getCurrentCountryIso(context); + + if (!cursor.isNull(CallDetailQuery.DATA_USAGE)) { + details.dataUsage = cursor.getLong(CallDetailQuery.DATA_USAGE); + } + + return details; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * Delete specified calls from the call log. + * + * @param context The context. + * @param callIds String of the callIds to delete from the call log, delimited by commas (","). + * @param callLogAsyncTaskListener The listener to invoke after the entries have been deleted. + */ + public static void deleteCalls( + @NonNull final Context context, + final String callIds, + @Nullable final CallLogAsyncTaskListener callLogAsyncTaskListener) { + if (sAsyncTaskExecutor == null) { + initTaskExecutor(); + } + + 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(); + } + } + }); + } + + public static void markVoicemailAsRead( + @NonNull final Context context, @NonNull final Uri voicemailUri) { + if (sAsyncTaskExecutor == null) { + initTaskExecutor(); + } + + sAsyncTaskExecutor.submit( + Tasks.MARK_VOICEMAIL_READ, + new AsyncTask<Void, Void, Void>() { + @Override + public Void doInBackground(Void... params) { + ContentValues values = new ContentValues(); + values.put(Voicemails.IS_READ, true); + context + .getContentResolver() + .update(voicemailUri, values, Voicemails.IS_READ + " = 0", null); + + Intent intent = new Intent(context, CallLogNotificationsService.class); + intent.setAction(CallLogNotificationsService.ACTION_MARK_NEW_VOICEMAILS_AS_OLD); + context.startService(intent); + return null; + } + }); + } + + public static void deleteVoicemail( + @NonNull final Context context, + final Uri voicemailUri, + @Nullable final CallLogAsyncTaskListener callLogAsyncTaskListener) { + if (sAsyncTaskExecutor == null) { + 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; + } + + @Override + public void onPostExecute(Void result) { + if (callLogAsyncTaskListener != null) { + callLogAsyncTaskListener.onDeleteVoicemail(); + } + } + }); + } + + public static void markCallAsRead(@NonNull final Context context, @NonNull 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; + } + }); + } + + @VisibleForTesting + public static void resetForTest() { + sAsyncTaskExecutor = null; + } + + /** The enumeration of {@link AsyncTask} objects used in this class. */ + public enum Tasks { + DELETE_VOICEMAIL, + DELETE_CALL, + MARK_VOICEMAIL_READ, + MARK_CALL_READ, + GET_CALL_DETAILS, + UPDATE_DURATION, + } + + public interface CallLogAsyncTaskListener { + + void onDeleteCall(); + + void onDeleteVoicemail(); + + void onGetCallDetails(PhoneCallDetails[] details); + } + + private static final class CallDetailQuery { + + public static final String[] CALL_LOG_PROJECTION; + static final int DATE_COLUMN_INDEX = 0; + static final int DURATION_COLUMN_INDEX = 1; + static final int NUMBER_COLUMN_INDEX = 2; + static final int CALL_TYPE_COLUMN_INDEX = 3; + static final int COUNTRY_ISO_COLUMN_INDEX = 4; + static final int GEOCODED_LOCATION_COLUMN_INDEX = 5; + static final int NUMBER_PRESENTATION_COLUMN_INDEX = 6; + static final int ACCOUNT_COMPONENT_NAME = 7; + static final int ACCOUNT_ID = 8; + 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 final int VIA_NUMBER = 13; + private static final String[] CALL_LOG_PROJECTION_INTERNAL = + new String[] { + CallLog.Calls.DATE, + CallLog.Calls.DURATION, + CallLog.Calls.NUMBER, + CallLog.Calls.TYPE, + CallLog.Calls.COUNTRY_ISO, + CallLog.Calls.GEOCODED_LOCATION, + CallLog.Calls.NUMBER_PRESENTATION, + CallLog.Calls.PHONE_ACCOUNT_COMPONENT_NAME, + CallLog.Calls.PHONE_ACCOUNT_ID, + CallLog.Calls.FEATURES, + CallLog.Calls.DATA_USAGE, + CallLog.Calls.TRANSCRIPTION + }; + + static { + ArrayList<String> projectionList = new ArrayList<>(); + projectionList.addAll(Arrays.asList(CALL_LOG_PROJECTION_INTERNAL)); + if (VERSION.SDK_INT >= VERSION_CODES.N) { + projectionList.add(CallLog.Calls.POST_DIAL_DIGITS); + projectionList.add(CallLog.Calls.VIA_NUMBER); + } + projectionList.trimToSize(); + CALL_LOG_PROJECTION = projectionList.toArray(new String[projectionList.size()]); + } + } +} diff --git a/java/com/android/dialer/app/calllog/CallLogFragment.java b/java/com/android/dialer/app/calllog/CallLogFragment.java new file mode 100644 index 000000000..1ae68cd65 --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallLogFragment.java @@ -0,0 +1,528 @@ +/* + * 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.app.calllog; + +import static android.Manifest.permission.READ_CALL_LOG; + +import android.app.Activity; +import android.app.Fragment; +import android.app.KeyguardManager; +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.ContentObserver; +import android.database.Cursor; +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.support.annotation.CallSuper; +import android.support.annotation.Nullable; +import android.support.v13.app.FragmentCompat; +import android.support.v7.app.AppCompatActivity; +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.contacts.common.GeoUtil; +import com.android.dialer.app.Bindings; +import com.android.dialer.app.R; +import com.android.dialer.app.calllog.calllogcache.CallLogCache; +import com.android.dialer.app.contactinfo.ContactInfoCache; +import com.android.dialer.app.contactinfo.ContactInfoCache.OnContactInfoChangedListener; +import com.android.dialer.app.contactinfo.ExpirableCacheHeadlessFragment; +import com.android.dialer.app.list.ListsFragment; +import com.android.dialer.app.list.ListsFragment.ListsPage; +import com.android.dialer.app.voicemail.VoicemailPlaybackPresenter; +import com.android.dialer.app.widget.EmptyContentView; +import com.android.dialer.app.widget.EmptyContentView.OnEmptyViewActionButtonClickedListener; +import com.android.dialer.common.LogUtil; +import com.android.dialer.database.CallLogQueryHandler; +import com.android.dialer.phonenumbercache.ContactInfoHelper; +import com.android.dialer.util.PermissionsUtil; + +/** + * 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 ListsPage, + CallLogQueryHandler.Listener, + CallLogAdapter.CallFetcher, + OnEmptyViewActionButtonClickedListener, + FragmentCompat.OnRequestPermissionsResultCallback, + CallLogModalAlertManager.Listener { + private static final String KEY_FILTER_TYPE = "filter_type"; + private static final String KEY_HAS_READ_CALL_LOG_PERMISSION = "has_read_call_log_permission"; + private static final String KEY_REFRESH_DATA_REQUIRED = "refresh_data_required"; + + 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 final Handler mHandler = new Handler(); + // See issue 6363009 + private final ContentObserver mCallLogObserver = new CustomContentObserver(); + private final ContentObserver mContactsObserver = new CustomContentObserver(); + private RecyclerView mRecyclerView; + private LinearLayoutManager mLayoutManager; + private CallLogAdapter mAdapter; + private CallLogQueryHandler mCallLogQueryHandler; + private boolean mScrollToTop; + private EmptyContentView mEmptyListView; + private KeyguardManager mKeyguardManager; + private ContactInfoCache mContactInfoCache; + private final OnContactInfoChangedListener mOnContactInfoChangedListener = + new OnContactInfoChangedListener() { + @Override + public void onContactInfoChanged() { + if (mAdapter != null) { + mAdapter.notifyDataSetChanged(); + } + } + }; + private boolean mRefreshDataRequired; + private boolean mHasReadCallLogPermission; + // Exactly same variable is in Fragment as a package private. + private boolean mMenuVisible = true; + // Default to all calls. + protected int mCallTypeFilter = CallLogQueryHandler.CALL_TYPE_ALL; + + private final Handler mDisplayUpdateHandler = + new Handler() { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case EVENT_UPDATE_DISPLAY: + refreshData(); + rescheduleDisplayUpdate(); + break; + } + } + }; + protected CallLogModalAlertManager mModalAlertManager; + private ViewGroup mModalAlertView; + + @Override + public void onCreate(Bundle state) { + LogUtil.d("CallLogFragment.onCreate", toString()); + super.onCreate(state); + mRefreshDataRequired = true; + if (state != null) { + mCallTypeFilter = state.getInt(KEY_FILTER_TYPE, mCallTypeFilter); + mHasReadCallLogPermission = state.getBoolean(KEY_HAS_READ_CALL_LOG_PERMISSION, false); + mRefreshDataRequired = state.getBoolean(KEY_REFRESH_DATA_REQUIRED, mRefreshDataRequired); + } + + final Activity activity = getActivity(); + final ContentResolver resolver = activity.getContentResolver(); + mCallLogQueryHandler = new CallLogQueryHandler(activity, resolver, this); + mKeyguardManager = (KeyguardManager) activity.getSystemService(Context.KEYGUARD_SERVICE); + resolver.registerContentObserver(CallLog.CONTENT_URI, true, mCallLogObserver); + resolver.registerContentObserver( + ContactsContract.Contacts.CONTENT_URI, true, mContactsObserver); + setHasOptionsMenu(true); + } + + /** Called by the CallLogQueryHandler when the list of calls has been fetched or updated. */ + @Override + public boolean onCallsFetched(Cursor cursor) { + if (getActivity() == null || getActivity().isFinishing()) { + // Return false; we did not take ownership of the cursor + return false; + } + mAdapter.invalidatePositions(); + mAdapter.setLoading(false); + mAdapter.changeCursor(cursor); + // This will update the state of the "Clear call log" menu item. + getActivity().invalidateOptionsMenu(); + + if (cursor != null && cursor.getCount() > 0) { + mRecyclerView.setPaddingRelative( + mRecyclerView.getPaddingStart(), + 0, + mRecyclerView.getPaddingEnd(), + getResources().getDimensionPixelSize(R.dimen.floating_action_button_list_bottom_padding)); + mEmptyListView.setVisibility(View.GONE); + } else { + mRecyclerView.setPaddingRelative( + mRecyclerView.getPaddingStart(), 0, mRecyclerView.getPaddingEnd(), 0); + mEmptyListView.setVisibility(View.VISIBLE); + } + if (mScrollToTop) { + // The smooth-scroll animation happens over a fixed time period. + // As a result, if it scrolls through a large portion of the list, + // each frame will jump so far from the previous one that the user + // will not experience the illusion of downward motion. Instead, + // if we're not already near the top of the list, we instantly jump + // near the top, and animate from there. + if (mLayoutManager.findFirstVisibleItemPosition() > 5) { + // TODO: Jump to near the top, then begin smooth scroll. + mRecyclerView.smoothScrollToPosition(0); + } + // Workaround for framework issue: the smooth-scroll doesn't + // occur if setSelection() is called immediately before. + mHandler.post( + new Runnable() { + @Override + public void run() { + if (getActivity() == null || getActivity().isFinishing()) { + return; + } + mRecyclerView.smoothScrollToPosition(0); + } + }); + + mScrollToTop = false; + } + return true; + } + + @Override + public void onVoicemailStatusFetched(Cursor statusCursor) {} + + @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); + return view; + } + + protected void setupView(View view) { + mRecyclerView = (RecyclerView) view.findViewById(R.id.recycler_view); + mRecyclerView.setHasFixedSize(true); + mLayoutManager = new LinearLayoutManager(getActivity()); + mRecyclerView.setLayoutManager(mLayoutManager); + mEmptyListView = (EmptyContentView) view.findViewById(R.id.empty_list_view); + mEmptyListView.setImage(R.drawable.empty_call_log); + mEmptyListView.setActionClickedListener(this); + mModalAlertView = (ViewGroup) view.findViewById(R.id.modal_message_container); + mModalAlertManager = + new CallLogModalAlertManager(LayoutInflater.from(getContext()), mModalAlertView, this); + } + + protected void setupData() { + int activityType = CallLogAdapter.ACTIVITY_TYPE_DIALTACTS; + String currentCountryIso = GeoUtil.getCurrentCountryIso(getActivity()); + + mContactInfoCache = + new ContactInfoCache( + ExpirableCacheHeadlessFragment.attach((AppCompatActivity) getActivity()) + .getRetainedCache(), + new ContactInfoHelper(getActivity(), currentCountryIso), + mOnContactInfoChangedListener); + mAdapter = + Bindings.getLegacy(getActivity()) + .newCallLogAdapter( + getActivity(), + mRecyclerView, + this, + CallLogCache.getCallLogCache(getActivity()), + mContactInfoCache, + getVoicemailPlaybackPresenter(), + activityType); + mRecyclerView.setAdapter(mAdapter); + fetchCalls(); + } + + @Nullable + protected VoicemailPlaybackPresenter getVoicemailPlaybackPresenter() { + return null; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + setupData(); + mAdapter.onRestoreInstanceState(savedInstanceState); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + updateEmptyMessage(mCallTypeFilter); + } + + @Override + public void onResume() { + LogUtil.d("CallLogFragment.onResume", toString()); + super.onResume(); + final boolean hasReadCallLogPermission = + PermissionsUtil.hasPermission(getActivity(), READ_CALL_LOG); + if (!mHasReadCallLogPermission && hasReadCallLogPermission) { + // We didn't have the permission before, and now we do. Force a refresh of the call log. + // Note that this code path always happens on a fresh start, but mRefreshDataRequired + // is already true in that case anyway. + mRefreshDataRequired = true; + updateEmptyMessage(mCallTypeFilter); + } + + mHasReadCallLogPermission = hasReadCallLogPermission; + + /* + * Always clear the filtered numbers cache since users could have blocked/unblocked numbers + * from the settings page + */ + mAdapter.clearFilteredNumbersCache(); + refreshData(); + mAdapter.onResume(); + + rescheduleDisplayUpdate(); + } + + @Override + public void onPause() { + LogUtil.d("CallLogFragment.onPause", toString()); + cancelDisplayUpdate(); + mAdapter.onPause(); + super.onPause(); + } + + @Override + public void onStop() { + updateOnTransition(); + + super.onStop(); + mAdapter.onStop(); + } + + @Override + public void onDestroy() { + LogUtil.d("CallLogFragment.onDestroy", toString()); + mAdapter.changeCursor(null); + + getActivity().getContentResolver().unregisterContentObserver(mCallLogObserver); + getActivity().getContentResolver().unregisterContentObserver(mContactsObserver); + super.onDestroy(); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putInt(KEY_FILTER_TYPE, mCallTypeFilter); + outState.putBoolean(KEY_HAS_READ_CALL_LOG_PERMISSION, mHasReadCallLogPermission); + outState.putBoolean(KEY_REFRESH_DATA_REQUIRED, mRefreshDataRequired); + + mContactInfoCache.stop(); + + mAdapter.onSaveInstanceState(outState); + } + + @Override + public void fetchCalls() { + mCallLogQueryHandler.fetchCalls(mCallTypeFilter); + ((ListsFragment) getParentFragment()).updateTabUnreadCounts(); + } + + private void updateEmptyMessage(int filterType) { + final Context context = getActivity(); + if (context == null) { + return; + } + + if (!PermissionsUtil.hasPermission(context, READ_CALL_LOG)) { + mEmptyListView.setDescription(R.string.permission_no_calllog); + mEmptyListView.setActionLabel(R.string.permission_single_turn_on); + return; + } + + final int messageId; + switch (filterType) { + case Calls.MISSED_TYPE: + messageId = R.string.call_log_missed_empty; + break; + case Calls.VOICEMAIL_TYPE: + messageId = R.string.call_log_voicemail_empty; + break; + case CallLogQueryHandler.CALL_TYPE_ALL: + messageId = R.string.call_log_all_empty; + break; + default: + throw new IllegalArgumentException( + "Unexpected filter type in CallLogFragment: " + filterType); + } + mEmptyListView.setDescription(messageId); + if (filterType == CallLogQueryHandler.CALL_TYPE_ALL) { + mEmptyListView.setActionLabel(R.string.call_log_all_empty_action); + } + } + + public CallLogAdapter getAdapter() { + return mAdapter; + } + + @Override + public void setMenuVisibility(boolean menuVisible) { + super.setMenuVisibility(menuVisible); + if (mMenuVisible != menuVisible) { + mMenuVisible = menuVisible; + if (!menuVisible) { + updateOnTransition(); + } else if (isResumed()) { + refreshData(); + } + } + } + + /** Requests updates to the data to be shown. */ + private void refreshData() { + // Prevent unnecessary refresh. + if (mRefreshDataRequired) { + // Mark all entries in the contact info cache as out of date, so they will be looked up + // again once being shown. + mContactInfoCache.invalidate(); + mAdapter.setLoading(true); + + fetchCalls(); + mCallLogQueryHandler.fetchVoicemailStatus(); + mCallLogQueryHandler.fetchMissedCallsUnreadCount(); + updateOnTransition(); + mRefreshDataRequired = false; + } else { + // Refresh the display of the existing data to update the timestamp text descriptions. + mAdapter.notifyDataSetChanged(); + } + } + + /** + * Updates the voicemail notification state. + * + * <p>TODO: Move to CallLogActivity + */ + 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() + && mCallTypeFilter == Calls.VOICEMAIL_TYPE) { + CallLogNotificationsHelper.updateVoicemailNotifications(getActivity()); + } + } + + @Override + public void onEmptyViewActionButtonClicked() { + final Activity activity = getActivity(); + if (activity == null) { + return; + } + + if (!PermissionsUtil.hasPermission(activity, READ_CALL_LOG)) { + FragmentCompat.requestPermissions( + this, new String[] {READ_CALL_LOG}, READ_CALL_LOG_PERMISSION_REQUEST_CODE); + } else { + ((HostInterface) activity).showDialpad(); + } + } + + @Override + public void onRequestPermissionsResult( + int requestCode, String[] permissions, int[] grantResults) { + if (requestCode == READ_CALL_LOG_PERMISSION_REQUEST_CODE) { + if (grantResults.length >= 1 && PackageManager.PERMISSION_GRANTED == grantResults[0]) { + // Force a refresh of the data since we were missing the permission before this. + mRefreshDataRequired = true; + } + } + } + + /** 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); + } + + @Override + @CallSuper + public void onPageResume(@Nullable Activity activity) { + LogUtil.d("CallLogFragment.onPageResume", "frag: %s", this); + if (activity != null) { + ((HostInterface) activity) + .enableFloatingButton(mModalAlertManager == null || mModalAlertManager.isEmpty()); + } + } + + @Override + @CallSuper + public void onPagePause(@Nullable Activity activity) { + LogUtil.d("CallLogFragment.onPagePause", "frag: %s", this); + } + + @Override + public void onShowModalAlert(boolean show) { + LogUtil.d( + "CallLogFragment.onShowModalAlert", + "show: %b, fragment: %s, isVisible: %b", + show, + this, + getUserVisibleHint()); + getAdapter().notifyDataSetChanged(); + HostInterface hostInterface = (HostInterface) getActivity(); + if (show) { + mRecyclerView.setVisibility(View.GONE); + mModalAlertView.setVisibility(View.VISIBLE); + if (hostInterface != null && getUserVisibleHint()) { + hostInterface.enableFloatingButton(false); + } + } else { + mRecyclerView.setVisibility(View.VISIBLE); + mModalAlertView.setVisibility(View.GONE); + if (hostInterface != null && getUserVisibleHint()) { + hostInterface.enableFloatingButton(true); + } + } + } + + public interface HostInterface { + + void showDialpad(); + + void enableFloatingButton(boolean enabled); + } + + protected class CustomContentObserver extends ContentObserver { + + public CustomContentObserver() { + super(mHandler); + } + + @Override + public void onChange(boolean selfChange) { + mRefreshDataRequired = true; + } + } +} diff --git a/java/com/android/dialer/app/calllog/CallLogGroupBuilder.java b/java/com/android/dialer/app/calllog/CallLogGroupBuilder.java new file mode 100644 index 000000000..45ff3783d --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallLogGroupBuilder.java @@ -0,0 +1,274 @@ +/* + * 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.app.calllog; + +import android.database.Cursor; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import android.text.format.Time; +import com.android.contacts.common.util.DateUtils; +import com.android.dialer.compat.AppCompatConstants; +import com.android.dialer.phonenumbercache.CallLogQuery; +import com.android.dialer.phonenumberutil.PhoneNumberHelper; +import java.util.Objects; + +/** + * Groups together calls in the call log. The primary grouping attempts to group together calls to + * and from the same number into a single row on the call log. A secondary grouping assigns calls, + * grouped via the primary grouping, to "day groups". The day groups provide a means of identifying + * the calls which occurred "Today", "Yesterday", "Last week", or "Other". + * + * <p>This class is meant to be used in conjunction with {@link GroupingListAdapter}. + */ +public class CallLogGroupBuilder { + + /** + * Day grouping for call log entries used to represent no associated day group. Used primarily + * when retrieving the previous day group, but there is no previous day group (i.e. we are at the + * start of the list). + */ + public static final int DAY_GROUP_NONE = -1; + /** Day grouping for calls which occurred today. */ + public static final int DAY_GROUP_TODAY = 0; + /** Day grouping for calls which occurred yesterday. */ + public static final int DAY_GROUP_YESTERDAY = 1; + /** Day grouping for calls which occurred before last week. */ + public static final int DAY_GROUP_OTHER = 2; + /** Instance of the time object used for time calculations. */ + private static final Time TIME = new Time(); + /** The object on which the groups are created. */ + private final GroupCreator mGroupCreator; + + public CallLogGroupBuilder(GroupCreator groupCreator) { + mGroupCreator = groupCreator; + } + + /** + * Finds all groups of adjacent entries in the call log which should be grouped together and 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. + * + * <p>It assumes that the cursor will not change during its execution. + * + * @see GroupingListAdapter#addGroups(Cursor) + */ + public void addGroups(Cursor cursor) { + final int count = cursor.getCount(); + if (count == 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(); + cursor.moveToFirst(); + + // 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 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 = + (VERSION.SDK_INT >= VERSION_CODES.N) ? cursor.getString(CallLogQuery.POST_DIAL_DIGITS) : ""; + String groupViaNumbers = + (VERSION.SDK_INT >= VERSION_CODES.N) ? cursor.getString(CallLogQuery.VIA_NUMBER) : ""; + 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; + String numberViaNumbers; + int callType; + String accountComponentName; + String accountId; + + while (cursor.moveToNext()) { + // Obtain the values for the current call to group. + number = cursor.getString(CallLogQuery.NUMBER); + numberPostDialDigits = + (VERSION.SDK_INT >= VERSION_CODES.N) + ? cursor.getString(CallLogQuery.POST_DIAL_DIGITS) + : ""; + numberViaNumbers = + (VERSION.SDK_INT >= VERSION_CODES.N) ? cursor.getString(CallLogQuery.VIA_NUMBER) : ""; + 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 isSameViaNumbers = groupViaNumbers.equals(numberViaNumbers); + 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 + && isSameViaNumbers + && 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 finding a call that does not match. + groupSize++; + } else { + // 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. + groupSize = 1; + + // Update the group values to those of the current call. + groupNumber = number; + groupPostDialDigits = numberPostDialDigits; + groupViaNumbers = numberViaNumbers; + groupCallType = callType; + groupAccountComponentName = accountComponentName; + groupAccountId = accountId; + } + + // Save the day group associated with the current call. + 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); + } + + @VisibleForTesting + boolean equalNumbers(@Nullable String number1, @Nullable String number2) { + if (PhoneNumberHelper.isUriNumber(number1) || PhoneNumberHelper.isUriNumber(number2)) { + return compareSipAddresses(number1, number2); + } else { + return PhoneNumberUtils.compare(number1, number2); + } + } + + private boolean isSameAccount(String name1, String name2, String id1, String id2) { + return TextUtils.equals(name1, name2) && TextUtils.equals(id1, id2); + } + + @VisibleForTesting + boolean compareSipAddresses(@Nullable String number1, @Nullable String number2) { + if (number1 == null || number2 == null) { + return Objects.equals(number1, number2); + } + + int index1 = number1.indexOf('@'); + final String userinfo1; + final String rest1; + if (index1 != -1) { + userinfo1 = number1.substring(0, index1); + rest1 = number1.substring(index1); + } else { + userinfo1 = number1; + rest1 = ""; + } + + int index2 = number2.indexOf('@'); + final String userinfo2; + final String rest2; + if (index2 != -1) { + userinfo2 = number2.substring(0, index2); + rest2 = number2.substring(index2); + } else { + userinfo2 = number2; + rest2 = ""; + } + + return userinfo1.equals(userinfo2) && rest1.equalsIgnoreCase(rest2); + } + + /** + * Given a call date and the current date, determine which date group the call belongs in. + * + * @param date The call date. + * @param now The current date. + * @return The date group the call belongs in. + */ + private int getDayGroup(long date, long now) { + int days = DateUtils.getDayDifference(TIME, date, now); + + if (days == 0) { + return DAY_GROUP_TODAY; + } else if (days == 1) { + return DAY_GROUP_YESTERDAY; + } else { + 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; + } + + public interface GroupCreator { + + /** + * Defines the interface for adding a group to the call log. The primary group for a call log + * groups the calls together based on the number which was dialed. + * + * @param cursorPosition The starting position of the group in the cursor. + * @param size The size of the group. + */ + void addGroup(int cursorPosition, int size); + + /** + * Defines the interface for tracking the day group each call belongs to. Calls in a call group + * are assigned the same day group as the first call in the group. The day group assigns calls + * to the buckets: Today, Yesterday, Last week, and Other + * + * @param rowId The row Id of the current call. + * @param dayGroup The day group the call belongs in. + */ + void setDayGroup(long rowId, int dayGroup); + + /** Defines the interface for clearing the day groupings information on rebind/regroup. */ + void clearDayGroups(); + } +} diff --git a/java/com/android/dialer/app/calllog/CallLogListItemHelper.java b/java/com/android/dialer/app/calllog/CallLogListItemHelper.java new file mode 100644 index 000000000..ea2119c83 --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallLogListItemHelper.java @@ -0,0 +1,277 @@ +/* + * 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.app.calllog; + +import android.content.res.Resources; +import android.provider.CallLog.Calls; +import android.support.annotation.WorkerThread; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.util.Log; +import com.android.dialer.app.PhoneCallDetails; +import com.android.dialer.app.R; +import com.android.dialer.app.calllog.calllogcache.CallLogCache; +import com.android.dialer.common.Assert; +import com.android.dialer.compat.AppCompatConstants; + +/** Helper class to fill in the views of a call log entry. */ +/* package */ class CallLogListItemHelper { + + private static final String TAG = "CallLogListItemHelper"; + + /** Helper for populating the details of a phone call. */ + private final PhoneCallDetailsHelper mPhoneCallDetailsHelper; + /** Resources to look up strings. */ + private final Resources mResources; + + private final CallLogCache mCallLogCache; + + /** + * Creates a new helper instance. + * + * @param phoneCallDetailsHelper used to set the details of a phone call + * @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, + CallLogCache callLogCache) { + mPhoneCallDetailsHelper = phoneCallDetailsHelper; + mResources = resources; + mCallLogCache = callLogCache; + } + + /** + * Update phone call details. This is called before any drawing to avoid expensive operation on UI + * thread. + * + * @param details + */ + @WorkerThread + public void updatePhoneCallDetails(PhoneCallDetails details) { + Assert.isWorkerThread(); + details.callLocationAndDate = mPhoneCallDetailsHelper.getCallLocationAndDate(details); + details.callDescription = getCallDescription(details); + } + + /** + * Sets the name, label, and number for a contact. + * + * @param views the views to populate + * @param details the details of a phone call needed to fill in the data + */ + public void setPhoneCallDetails(CallLogListItemViewHolder views, PhoneCallDetails details) { + mPhoneCallDetailsHelper.setPhoneCallDetails(views.phoneCallDetailsViews, details); + + // Set the accessibility text for the contact badge + views.quickContactView.setContentDescription(getContactBadgeDescription(details)); + + // Set the primary action accessibility description + views.primaryActionView.setContentDescription(details.callDescription); + + // 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; + + views.updatePhoto(); + } + + /** + * Sets the accessibility descriptions for the action buttons in the action button ViewStub. + * + * @param views The views associated with the current call log entry. + */ + public void setActionContentDescriptions(CallLogListItemViewHolder views) { + if (views.nameOrNumber == null) { + Log.e(TAG, "setActionContentDescriptions; name or number is null."); + } + + // Calling expandTemplate with a null parameter will cause a NullPointerException. + // Although we don't expect a null name or number, it is best to protect against it. + CharSequence nameOrNumber = views.nameOrNumber == null ? "" : views.nameOrNumber; + + views.videoCallButtonView.setContentDescription( + TextUtils.expandTemplate( + mResources.getString(R.string.description_video_call_action), nameOrNumber)); + + views.createNewContactButtonView.setContentDescription( + TextUtils.expandTemplate( + mResources.getString(R.string.description_create_new_contact_action), nameOrNumber)); + + views.addToExistingContactButtonView.setContentDescription( + TextUtils.expandTemplate( + mResources.getString(R.string.description_add_to_existing_contact_action), + nameOrNumber)); + + views.detailsButtonView.setContentDescription( + TextUtils.expandTemplate( + mResources.getString(R.string.description_details_action), nameOrNumber)); + } + + /** + * Returns the accessibility description for the contact badge for a call log entry. + * + * @param details Details of call. + * @return Accessibility description. + */ + private CharSequence getContactBadgeDescription(PhoneCallDetails details) { + if (details.isSpam) { + return mResources.getString( + R.string.description_spam_contact_details, getNameOrNumber(details)); + } + return mResources.getString(R.string.description_contact_details, getNameOrNumber(details)); + } + + /** + * Returns the accessibility description of the "return call/call" action for a call log entry. + * Accessibility text is a combination of: {Voicemail Prefix}. {Number of Calls}. {Caller + * information} {Phone Account}. If most recent call is a voicemail, {Voicemail Prefix} is "New + * Voicemail.", otherwise "". + * + * <p>If more than one call for the caller, {Number of Calls} is: "{number of calls} calls.", + * otherwise "". + * + * <p>The {Caller Information} references the most recent call associated with the caller. For + * incoming calls: If missed call: Missed call from {Name/Number} {Call Type} {Call Time}. If + * answered call: Answered call from {Name/Number} {Call Type} {Call Time}. + * + * <p>For outgoing calls: If outgoing: Call to {Name/Number] {Call Type} {Call Time}. + * + * <p>Where: {Name/Number} is the name or number of the caller (as shown in call log). {Call type} + * is the contact phone number type (eg mobile) or location. {Call Time} is the time since the + * last call for the contact occurred. + * + * <p>The {Phone Account} refers to the account/SIM through which the call was placed or received + * in multi-SIM devices. + * + * <p>Examples: 3 calls. New Voicemail. Missed call from Joe Smith mobile 2 hours ago on SIM 1. + * + * <p>2 calls. Answered call from John Doe mobile 1 hour ago. + * + * @param context The application context. + * @param details Details of call. + * @return Return call action description. + */ + public CharSequence getCallDescription(PhoneCallDetails details) { + // Get the name or number of the caller. + final CharSequence nameOrNumber = getNameOrNumber(details); + + // Get the call type or location of the caller; null if not applicable + final CharSequence typeOrLocation = mPhoneCallDetailsHelper.getCallTypeOrLocation(details); + + // Get the time/date of the call + final CharSequence timeOfCall = mPhoneCallDetailsHelper.getCallDate(details); + + SpannableStringBuilder callDescription = new SpannableStringBuilder(); + + // Add number of calls if more than one. + if (details.callTypes.length > 1) { + callDescription.append( + mResources.getString(R.string.description_num_calls, details.callTypes.length)); + } + + // If call had video capabilities, add the "Video Call" string. + if ((details.features & Calls.FEATURES_VIDEO) == Calls.FEATURES_VIDEO) { + callDescription.append(mResources.getString(R.string.description_video_call)); + } + + String accountLabel = mCallLogCache.getAccountLabel(details.accountHandle); + CharSequence onAccountLabel = + PhoneCallDetails.createAccountLabelDescription(mResources, details.viaNumber, accountLabel); + + int stringID = getCallDescriptionStringID(details.callTypes, details.isRead); + callDescription.append( + TextUtils.expandTemplate( + mResources.getString(stringID), + nameOrNumber, + typeOrLocation == null ? "" : typeOrLocation, + timeOfCall, + onAccountLabel)); + + return callDescription; + } + + /** + * Determine the appropriate string ID to describe a call for accessibility purposes. + * + * @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, boolean isRead) { + int lastCallType = getLastCallType(callTypes); + int stringID; + + if (lastCallType == AppCompatConstants.CALLS_MISSED_TYPE) { + //Message: Missed call from <NameOrNumber>, <TypeOrLocation>, <TimeOfCall>, + //<PhoneAccount>. + stringID = R.string.description_incoming_missed_call; + } 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; + } + return stringID; + } + + /** + * Determine the call type for the most recent call. + * + * @param callTypes Call types to check. + * @return Call type. + */ + private int getLastCallType(int[] callTypes) { + if (callTypes.length > 0) { + return callTypes[0]; + } else { + return Calls.MISSED_TYPE; + } + } + + /** + * Return the name or number of the caller specified by the details. + * + * @param details Call details + * @return the name (if known) of the caller, otherwise the formatted number. + */ + private CharSequence getNameOrNumber(PhoneCallDetails details) { + final CharSequence recipient; + if (!TextUtils.isEmpty(details.getPreferredName())) { + recipient = details.getPreferredName(); + } else { + recipient = details.displayNumber + details.postDialDigits; + } + return recipient; + } +} diff --git a/java/com/android/dialer/app/calllog/CallLogListItemViewHolder.java b/java/com/android/dialer/app/calllog/CallLogListItemViewHolder.java new file mode 100644 index 000000000..6abd36078 --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallLogListItemViewHolder.java @@ -0,0 +1,966 @@ +/* + * 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.app.calllog; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.net.Uri; +import android.os.AsyncTask; +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.telephony.PhoneNumberUtils; +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.ViewStub; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.QuickContactBadge; +import android.widget.TextView; +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.PhoneNumberUtilsCompat; +import com.android.contacts.common.dialog.CallSubjectDialog; +import com.android.contacts.common.util.UriUtils; +import com.android.dialer.app.DialtactsActivity; +import com.android.dialer.app.R; +import com.android.dialer.app.calllog.calllogcache.CallLogCache; +import com.android.dialer.app.voicemail.VoicemailPlaybackLayout; +import com.android.dialer.app.voicemail.VoicemailPlaybackPresenter; +import com.android.dialer.blocking.BlockedNumbersMigrator; +import com.android.dialer.blocking.FilteredNumberCompat; +import com.android.dialer.blocking.FilteredNumbersUtil; +import com.android.dialer.callcomposer.CallComposerActivity; +import com.android.dialer.callcomposer.nano.CallComposerContact; +import com.android.dialer.common.ConfigProviderBindings; +import com.android.dialer.common.LogUtil; +import com.android.dialer.compat.CompatUtils; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.DialerImpression; +import com.android.dialer.logging.nano.ScreenEvent; +import com.android.dialer.phonenumbercache.ContactInfo; +import com.android.dialer.phonenumberutil.PhoneNumberHelper; +import com.android.dialer.util.CallUtil; +import com.android.dialer.util.DialerUtils; + +/** + * This is an object containing references to views contained by the call log list item. This + * improves performance by reducing the frequency with which we need to find views by IDs. + * + * <p>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, + MenuItem.OnMenuItemClickListener, + View.OnCreateContextMenuListener { + private static final String CONFIG_SHARE_VOICEMAIL_ALLOWED = "share_voicemail_allowed"; + + /** The root view of the call log list item */ + public final View rootView; + /** The quick contact badge for the contact. */ + public final QuickContactBadge quickContactView; + /** The primary action view of the entry. */ + public final View primaryActionView; + /** The details of the phone call. */ + public final PhoneCallDetailsViews phoneCallDetailsViews; + /** The text of the header for a day grouping. */ + public final TextView dayGroupHeader; + /** The view containing the details for the call log row, including the action buttons. */ + public final CardView callLogEntryView; + /** The actionable view which places a call to the number corresponding to the call log row. */ + public final ImageView primaryActionButtonView; + + private final Context mContext; + private final CallLogCache mCallLogCache; + private final CallLogListItemHelper mCallLogListItemHelper; + private final VoicemailPlaybackPresenter mVoicemailPlaybackPresenter; + private final OnClickListener mBlockReportListener; + private final int mPhotoSize; + /** Whether the data fields are populated by the worker thread, ready to be shown. */ + public boolean isLoaded; + /** The view containing call log item actions. Null until the ViewStub is inflated. */ + public View actionsView; + /** The button views below are assigned only when the action section is expanded. */ + public VoicemailPlaybackLayout voicemailPlaybackView; + + public View callButtonView; + public View videoCallButtonView; + public View createNewContactButtonView; + public View addToExistingContactButtonView; + public View sendMessageView; + public View blockReportView; + public View blockView; + public View unblockView; + public View reportNotSpamView; + public View detailsButtonView; + public View callWithNoteButtonView; + public View callComposeButtonView; + public View sendVoicemailButtonView; + public ImageView workIconView; + /** + * The row Id for the first call associated with the call log entry. Used as a key for the map + * used to track which call log entries have the action button section expanded. + */ + public long rowId; + /** + * The call Ids for the calls represented by the current call log entry. Used when the user + * deletes a call log entry. + */ + public long[] callIds; + /** + * The callable phone number for the current call log entry. Cached here as the call back intent + * is set only when the actions ViewStub is inflated. + */ + 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; + /** + * The phone number presentation for the current call log entry. Cached here as the call back + * intent is set only when the actions ViewStub is inflated. + */ + public int numberPresentation; + /** The type of the phone number (e.g. main, work, etc). */ + 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. + */ + public PhoneAccountHandle accountHandle; + /** + * If the call has an associated voicemail message, the URI of the voicemail message for playback. + * Cached here as the voicemail intent is only set when the actions ViewStub is inflated. + */ + public String voicemailUri; + /** + * The name or number associated with the call. Cached here for use when setting content + * descriptions on buttons in the actions ViewStub when it is inflated. + */ + 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; + /** The contact info for the contact displayed in this list item. */ + public volatile ContactInfo info; + /** Whether spam feature is enabled, which affects UI. */ + public boolean isSpamFeatureEnabled; + /** Whether the current log entry is a spam number or not. */ + public boolean isSpam; + + public boolean isCallComposerCapable; + + private View.OnClickListener mExpandCollapseListener; + private boolean mVoicemailPrimaryActionButtonClicked; + + public int dayGroupHeaderVisibility; + public CharSequence dayGroupHeaderText; + public boolean isAttachedToWindow; + + public AsyncTask<Void, Void, ?> asyncTask; + + private CallLogListItemViewHolder( + Context context, + OnClickListener blockReportListener, + View.OnClickListener expandCollapseListener, + CallLogCache callLogCache, + CallLogListItemHelper callLogListItemHelper, + VoicemailPlaybackPresenter voicemailPlaybackPresenter, + View rootView, + QuickContactBadge quickContactView, + View primaryActionView, + PhoneCallDetailsViews phoneCallDetailsViews, + CardView callLogEntryView, + TextView dayGroupHeader, + ImageView primaryActionButtonView) { + super(rootView); + + mContext = context; + mExpandCollapseListener = expandCollapseListener; + mCallLogCache = callLogCache; + mCallLogListItemHelper = callLogListItemHelper; + mVoicemailPlaybackPresenter = voicemailPlaybackPresenter; + mBlockReportListener = blockReportListener; + + this.rootView = rootView; + this.quickContactView = quickContactView; + this.primaryActionView = primaryActionView; + this.phoneCallDetailsViews = phoneCallDetailsViews; + this.callLogEntryView = callLogEntryView; + this.dayGroupHeader = dayGroupHeader; + this.primaryActionButtonView = primaryActionButtonView; + this.workIconView = (ImageView) rootView.findViewById(R.id.work_profile_icon); + mPhotoSize = mContext.getResources().getDimensionPixelSize(R.dimen.contact_photo_size); + + // Set text height to false on the TextViews so they don't have extra padding. + phoneCallDetailsViews.nameView.setElegantTextHeight(false); + phoneCallDetailsViews.callLocationAndDate.setElegantTextHeight(false); + + quickContactView.setOverlay(null); + if (CompatUtils.hasPrioritizedMimeType()) { + quickContactView.setPrioritizedMimeType(Phone.CONTENT_ITEM_TYPE); + } + primaryActionButtonView.setOnClickListener(this); + primaryActionView.setOnClickListener(mExpandCollapseListener); + primaryActionView.setOnCreateContextMenuListener(this); + } + + public static CallLogListItemViewHolder create( + View view, + Context context, + OnClickListener blockReportListener, + View.OnClickListener expandCollapseListener, + CallLogCache callLogCache, + CallLogListItemHelper callLogListItemHelper, + VoicemailPlaybackPresenter voicemailPlaybackPresenter) { + + return new CallLogListItemViewHolder( + context, + blockReportListener, + expandCollapseListener, + callLogCache, + callLogListItemHelper, + voicemailPlaybackPresenter, + 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)); + } + + public static CallLogListItemViewHolder createForTest(Context context) { + Resources resources = context.getResources(); + CallLogCache callLogCache = CallLogCache.getCallLogCache(context); + PhoneCallDetailsHelper phoneCallDetailsHelper = + new PhoneCallDetailsHelper(context, resources, callLogCache); + + CallLogListItemViewHolder viewHolder = + new CallLogListItemViewHolder( + context, + null, + null /* expandCollapseListener */, + callLogCache, + new CallLogListItemHelper(phoneCallDetailsHelper, resources, callLogCache), + null /* voicemailPlaybackPresenter */, + new View(context), + new QuickContactBadge(context), + new View(context), + PhoneCallDetailsViews.createForTest(context), + new CardView(context), + new TextView(context), + new ImageView(context)); + viewHolder.detailsButtonView = new TextView(context); + viewHolder.actionsView = new View(context); + viewHolder.voicemailPlaybackView = new VoicemailPlaybackLayout(context); + viewHolder.workIconView = new ImageButton(context); + return viewHolder; + } + + @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 (PhoneNumberHelper.canPlaceCallsTo(number, numberPresentation) + && !mCallLogCache.isVoicemailNumber(accountHandle, number) + && !PhoneNumberHelper.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); + } + + String e164Number = PhoneNumberUtils.formatNumberToE164(number, countryIso); + boolean isVoicemailNumber = mCallLogCache.isVoicemailNumber(accountHandle, number); + if (!isVoicemailNumber + && FilteredNumbersUtil.canBlockNumber(mContext, e164Number, number) + && FilteredNumberCompat.canAttemptBlockOperations(mContext)) { + boolean isBlocked = blockId != null; + if (isBlocked) { + menu.add( + ContextMenu.NONE, + R.id.context_menu_unblock, + ContextMenu.NONE, + R.string.call_log_action_unblock_number) + .setOnMenuItemClickListener(this); + } else { + if (isSpamFeatureEnabled) { + if (isSpam) { + menu.add( + ContextMenu.NONE, + R.id.context_menu_report_not_spam, + ContextMenu.NONE, + R.string.call_log_action_remove_spam) + .setOnMenuItemClickListener(this); + menu.add( + ContextMenu.NONE, + R.id.context_menu_block, + ContextMenu.NONE, + R.string.call_log_action_block_number) + .setOnMenuItemClickListener(this); + } else { + menu.add( + ContextMenu.NONE, + R.id.context_menu_block_report_spam, + ContextMenu.NONE, + R.string.call_log_action_block_report_number) + .setOnMenuItemClickListener(this); + } + } else { + menu.add( + ContextMenu.NONE, + R.id.context_menu_block, + ContextMenu.NONE, + R.string.call_log_action_block_number) + .setOnMenuItemClickListener(this); + } + } + } + + Logger.get(mContext).logScreenView(ScreenEvent.Type.CALL_LOG_CONTEXT_MENU, (Activity) mContext); + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + int resId = item.getItemId(); + 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; + } else if (resId == R.id.context_menu_block_report_spam) { + Logger.get(mContext) + .logImpression(DialerImpression.Type.CALL_LOG_CONTEXT_MENU_BLOCK_REPORT_SPAM); + maybeShowBlockNumberMigrationDialog( + new BlockedNumbersMigrator.Listener() { + @Override + public void onComplete() { + mBlockReportListener.onBlockReportSpam( + displayNumber, number, countryIso, callType, info.sourceType); + } + }); + } else if (resId == R.id.context_menu_block) { + Logger.get(mContext).logImpression(DialerImpression.Type.CALL_LOG_CONTEXT_MENU_BLOCK_NUMBER); + maybeShowBlockNumberMigrationDialog( + new BlockedNumbersMigrator.Listener() { + @Override + public void onComplete() { + mBlockReportListener.onBlock( + displayNumber, number, countryIso, callType, info.sourceType); + } + }); + } else if (resId == R.id.context_menu_unblock) { + Logger.get(mContext) + .logImpression(DialerImpression.Type.CALL_LOG_CONTEXT_MENU_UNBLOCK_NUMBER); + mBlockReportListener.onUnblock( + displayNumber, number, countryIso, callType, info.sourceType, isSpam, blockId); + } else if (resId == R.id.context_menu_report_not_spam) { + Logger.get(mContext) + .logImpression(DialerImpression.Type.CALL_LOG_CONTEXT_MENU_REPORT_AS_NOT_SPAM); + mBlockReportListener.onReportNotSpam( + displayNumber, number, countryIso, callType, info.sourceType); + } + 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. + */ + public void inflateActionViewStub() { + ViewStub stub = (ViewStub) rootView.findViewById(R.id.call_log_entry_actions_stub); + if (stub != null) { + actionsView = stub.inflate(); + + voicemailPlaybackView = + (VoicemailPlaybackLayout) actionsView.findViewById(R.id.voicemail_playback_layout); + voicemailPlaybackView.setViewHolder(this); + + callButtonView = actionsView.findViewById(R.id.call_action); + callButtonView.setOnClickListener(this); + + videoCallButtonView = actionsView.findViewById(R.id.video_call_action); + videoCallButtonView.setOnClickListener(this); + + createNewContactButtonView = actionsView.findViewById(R.id.create_new_contact_action); + createNewContactButtonView.setOnClickListener(this); + + addToExistingContactButtonView = + actionsView.findViewById(R.id.add_to_existing_contact_action); + addToExistingContactButtonView.setOnClickListener(this); + + sendMessageView = actionsView.findViewById(R.id.send_message_action); + sendMessageView.setOnClickListener(this); + + blockReportView = actionsView.findViewById(R.id.block_report_action); + blockReportView.setOnClickListener(this); + + blockView = actionsView.findViewById(R.id.block_action); + blockView.setOnClickListener(this); + + unblockView = actionsView.findViewById(R.id.unblock_action); + unblockView.setOnClickListener(this); + + reportNotSpamView = actionsView.findViewById(R.id.report_not_spam_action); + reportNotSpamView.setOnClickListener(this); + + detailsButtonView = actionsView.findViewById(R.id.details_action); + detailsButtonView.setOnClickListener(this); + + callWithNoteButtonView = actionsView.findViewById(R.id.call_with_note_action); + callWithNoteButtonView.setOnClickListener(this); + + callComposeButtonView = actionsView.findViewById(R.id.call_compose_action); + callComposeButtonView.setOnClickListener(this); + + sendVoicemailButtonView = actionsView.findViewById(R.id.share_voicemail); + sendVoicemailButtonView.setOnClickListener(this); + } + } + + private void updatePrimaryActionButton(boolean isExpanded) { + + if (nameOrNumber == null) { + LogUtil.e("CallLogListItemViewHolder.updatePrimaryActionButton", "name or number is null"); + } + + // Calling expandTemplate with a null parameter will cause a NullPointerException. + CharSequence validNameOrNumber = nameOrNumber == null ? "" : nameOrNumber; + + if (!TextUtils.isEmpty(voicemailUri)) { + // 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), validNameOrNumber)); + primaryActionButtonView.setVisibility(View.VISIBLE); + } else { + primaryActionButtonView.setVisibility(View.GONE); + } + } else { + // Treat as normal list item; show call button, if possible. + if (PhoneNumberHelper.canPlaceCallsTo(number, numberPresentation)) { + boolean isVoicemailNumber = 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 + postDialDigits)); + } + + primaryActionButtonView.setContentDescription( + TextUtils.expandTemplate( + mContext.getString(R.string.description_call_action), validNameOrNumber)); + primaryActionButtonView.setImageResource(R.drawable.ic_call_24dp); + primaryActionButtonView.setVisibility(View.VISIBLE); + } else { + primaryActionButtonView.setTag(null); + primaryActionButtonView.setVisibility(View.GONE); + } + } + } + + private static boolean isShareVoicemailAllowed(Context context) { + return ConfigProviderBindings.get(context).getBoolean(CONFIG_SHARE_VOICEMAIL_ALLOWED, true); + } + + /** + * Binds text titles, click handlers and intents to the voicemail, details and callback action + * buttons. + */ + private void bindActionButtons() { + boolean canPlaceCallToNumber = PhoneNumberHelper.canPlaceCallsTo(number, numberPresentation); + + if (isFullyUndialableVoicemail()) { + // Sometimes the voicemail server will report the message is from some non phone number + // source. If the number does not contains any dialable digit treat it as it is from a unknown + // number, remove all action buttons but still show the voicemail playback layout. + callButtonView.setVisibility(View.GONE); + videoCallButtonView.setVisibility(View.GONE); + detailsButtonView.setVisibility(View.GONE); + createNewContactButtonView.setVisibility(View.GONE); + addToExistingContactButtonView.setVisibility(View.GONE); + sendMessageView.setVisibility(View.GONE); + callWithNoteButtonView.setVisibility(View.GONE); + callComposeButtonView.setVisibility(View.GONE); + blockReportView.setVisibility(View.GONE); + blockView.setVisibility(View.GONE); + unblockView.setVisibility(View.GONE); + reportNotSpamView.setVisibility(View.GONE); + + if (isShareVoicemailAllowed(mContext)) { + sendVoicemailButtonView.setVisibility(View.VISIBLE); + } + voicemailPlaybackView.setVisibility(View.VISIBLE); + Uri uri = Uri.parse(voicemailUri); + mVoicemailPlaybackPresenter.setPlaybackView( + voicemailPlaybackView, rowId, uri, mVoicemailPrimaryActionButtonClicked); + mVoicemailPrimaryActionButtonClicked = false; + CallLogAsyncTaskUtil.markVoicemailAsRead(mContext, uri); + return; + } + + if (!TextUtils.isEmpty(voicemailUri) && canPlaceCallToNumber) { + callButtonView.setTag(IntentProvider.getReturnCallIntentProvider(number)); + ((TextView) callButtonView.findViewById(R.id.call_action_text)) + .setText( + TextUtils.expandTemplate( + mContext.getString(R.string.call_log_action_call), + nameOrNumber == null ? "" : 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 (shouldShowVideoCallActionButton(canPlaceCallToNumber)) { + videoCallButtonView.setTag(IntentProvider.getReturnVideoCallIntentProvider(number)); + videoCallButtonView.setVisibility(View.VISIBLE); + } else { + videoCallButtonView.setVisibility(View.GONE); + } + + // For voicemail calls, show the voicemail playback layout; hide otherwise. + if (callType == Calls.VOICEMAIL_TYPE + && mVoicemailPlaybackPresenter != null + && !TextUtils.isEmpty(voicemailUri)) { + voicemailPlaybackView.setVisibility(View.VISIBLE); + if (isShareVoicemailAllowed(mContext)) { + Logger.get(mContext).logImpression(DialerImpression.Type.VVM_SHARE_VISIBLE); + sendVoicemailButtonView.setVisibility(View.VISIBLE); + } + + Uri uri = Uri.parse(voicemailUri); + mVoicemailPlaybackPresenter.setPlaybackView( + voicemailPlaybackView, rowId, uri, mVoicemailPrimaryActionButtonClicked); + mVoicemailPrimaryActionButtonClicked = false; + CallLogAsyncTaskUtil.markVoicemailAsRead(mContext, uri); + } else { + voicemailPlaybackView.setVisibility(View.GONE); + sendVoicemailButtonView.setVisibility(View.GONE); + } + + if (callType == Calls.VOICEMAIL_TYPE) { + detailsButtonView.setVisibility(View.GONE); + } else { + detailsButtonView.setVisibility(View.VISIBLE); + detailsButtonView.setTag(IntentProvider.getCallDetailIntentProvider(rowId, callIds, null)); + } + + boolean isBlockedOrSpam = blockId != null || (isSpamFeatureEnabled && isSpam); + + if (!isBlockedOrSpam && info != null && UriUtils.isEncodedContactUri(info.lookupUri)) { + createNewContactButtonView.setTag( + IntentProvider.getAddContactIntentProvider( + info.lookupUri, info.name, info.number, info.type, true /* isNewContact */)); + createNewContactButtonView.setVisibility(View.VISIBLE); + + addToExistingContactButtonView.setTag( + IntentProvider.getAddContactIntentProvider( + info.lookupUri, info.name, info.number, info.type, false /* isNewContact */)); + addToExistingContactButtonView.setVisibility(View.VISIBLE); + } else { + createNewContactButtonView.setVisibility(View.GONE); + addToExistingContactButtonView.setVisibility(View.GONE); + } + + if (canPlaceCallToNumber && !isBlockedOrSpam) { + sendMessageView.setTag(IntentProvider.getSendSmsIntentProvider(number)); + sendMessageView.setVisibility(View.VISIBLE); + } else { + sendMessageView.setVisibility(View.GONE); + } + + mCallLogListItemHelper.setActionContentDescriptions(this); + + boolean supportsCallSubject = mCallLogCache.doesAccountSupportCallSubject(accountHandle); + boolean isVoicemailNumber = mCallLogCache.isVoicemailNumber(accountHandle, number); + callWithNoteButtonView.setVisibility( + supportsCallSubject && !isVoicemailNumber && info != null ? View.VISIBLE : View.GONE); + + callComposeButtonView.setVisibility(isCallComposerCapable ? View.VISIBLE : View.GONE); + + updateBlockReportActions(isVoicemailNumber); + } + + private boolean isFullyUndialableVoicemail() { + if (callType == Calls.VOICEMAIL_TYPE) { + if (!hasDialableChar(number)) { + return true; + } + } + return false; + } + + private static boolean hasDialableChar(CharSequence number) { + if (TextUtils.isEmpty(number)) { + return false; + } + for (char c : number.toString().toCharArray()) { + if (PhoneNumberUtils.isDialable(c)) { + return true; + } + } + return false; + } + + private boolean shouldShowVideoCallActionButton(boolean canPlaceCallToNumber) { + return canPlaceCallToNumber && (hasPlacedVideoCall() || canSupportVideoCall()); + } + + private boolean hasPlacedVideoCall() { + return phoneCallDetailsViews.callTypeIcons.isVideoShown(); + } + + private boolean canSupportVideoCall() { + return mCallLogCache.canRelyOnVideoPresence() + && info != null + && (info.carrierPresence & Phone.CARRIER_PRESENCE_VT_CAPABLE) != 0; + } + + /** + * Show or hide the action views, such as voicemail, details, and add contact. + * + * <p>If the action views have never been shown yet for this view, inflate the view stub. + */ + public void showActions(boolean show) { + showOrHideVoicemailTranscriptionView(show); + + if (show) { + if (!isLoaded) { + // b/31268128 for some unidentified reason showActions() can be called before the item is + // loaded, causing NPE on uninitialized fields. Just log and return here, showActions() will + // be called again once the item is loaded. + LogUtil.e( + "CallLogListItemViewHolder.showActions", + "called before item is loaded", + new Exception()); + return; + } + + // Inflate the view stub if necessary, and wire up the event handlers. + inflateActionViewStub(); + bindActionButtons(); + actionsView.setVisibility(View.VISIBLE); + actionsView.setAlpha(1.0f); + } else { + // When recycling a view, it is possible the actionsView ViewStub was previously + // inflated so we should hide it in this case. + if (actionsView != null) { + actionsView.setVisibility(View.GONE); + } + } + + updatePrimaryActionButton(show); + } + + public void showOrHideVoicemailTranscriptionView(boolean isExpanded) { + if (callType != Calls.VOICEMAIL_TYPE) { + return; + } + + final TextView view = phoneCallDetailsViews.voicemailTranscriptionView; + if (!isExpanded || TextUtils.isEmpty(view.getText())) { + view.setVisibility(View.GONE); + return; + } + view.setVisibility(View.VISIBLE); + } + + public void updatePhoto() { + quickContactView.assignContactUri(info.lookupUri); + + if (isSpamFeatureEnabled && isSpam) { + quickContactView.setImageDrawable(mContext.getDrawable(R.drawable.blocked_contact)); + return; + } + final boolean isVoicemail = mCallLogCache.isVoicemailNumber(accountHandle, number); + int contactType = ContactPhotoManager.TYPE_DEFAULT; + if (isVoicemail) { + contactType = ContactPhotoManager.TYPE_VOICEMAIL; + } else if (isBusiness) { + contactType = ContactPhotoManager.TYPE_BUSINESS; + } + + 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 (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, + info.photoId, + false /* darkTheme */, + true /* isCircular */, + request); + } + } + + @Override + public void onClick(View view) { + if (view.getId() == R.id.primary_action_button && !TextUtils.isEmpty(voicemailUri)) { + Logger.get(mContext).logImpression(DialerImpression.Type.VOICEMAIL_PLAY_AUDIO_DIRECTLY); + mVoicemailPrimaryActionButtonClicked = true; + mExpandCollapseListener.onClick(primaryActionView); + } else if (view.getId() == R.id.call_with_note_action) { + CallSubjectDialog.start( + (Activity) mContext, + info.photoId, + info.photoUri, + info.lookupUri, + (String) nameOrNumber /* top line of contact view in call subject dialog */, + isBusiness, + 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 */ + accountHandle); + } else if (view.getId() == R.id.block_report_action) { + Logger.get(mContext).logImpression(DialerImpression.Type.CALL_LOG_BLOCK_REPORT_SPAM); + maybeShowBlockNumberMigrationDialog( + new BlockedNumbersMigrator.Listener() { + @Override + public void onComplete() { + mBlockReportListener.onBlockReportSpam( + displayNumber, number, countryIso, callType, info.sourceType); + } + }); + } else if (view.getId() == R.id.block_action) { + Logger.get(mContext).logImpression(DialerImpression.Type.CALL_LOG_BLOCK_NUMBER); + maybeShowBlockNumberMigrationDialog( + new BlockedNumbersMigrator.Listener() { + @Override + public void onComplete() { + mBlockReportListener.onBlock( + displayNumber, number, countryIso, callType, info.sourceType); + } + }); + } else if (view.getId() == R.id.unblock_action) { + Logger.get(mContext).logImpression(DialerImpression.Type.CALL_LOG_UNBLOCK_NUMBER); + mBlockReportListener.onUnblock( + displayNumber, number, countryIso, callType, info.sourceType, isSpam, blockId); + } else if (view.getId() == R.id.report_not_spam_action) { + Logger.get(mContext).logImpression(DialerImpression.Type.CALL_LOG_REPORT_AS_NOT_SPAM); + mBlockReportListener.onReportNotSpam( + displayNumber, number, countryIso, callType, info.sourceType); + } else if (view.getId() == R.id.call_compose_action) { + LogUtil.i("CallLogListItemViewHolder.onClick", "share and call pressed"); + Logger.get(mContext).logImpression(DialerImpression.Type.CALL_LOG_SHARE_AND_CALL); + CallComposerContact contact = new CallComposerContact(); + contact.photoId = info.photoId; + contact.photoUri = info.photoUri == null ? null : info.photoUri.toString(); + contact.contactUri = info.lookupUri == null ? null : info.lookupUri.toString(); + contact.nameOrNumber = (String) nameOrNumber; + contact.isBusiness = isBusiness; + contact.number = number; + /* second line of contact view. */ + contact.displayNumber = TextUtils.isEmpty(info.name) ? null : displayNumber; + /* phone number type (e.g. mobile) in second line of contact view */ + contact.numberLabel = numberType; + Activity activity = (Activity) mContext; + activity.startActivityForResult( + CallComposerActivity.newIntent(activity, contact), + DialtactsActivity.ACTIVITY_REQUEST_CODE_CALL_COMPOSE); + } else if (view.getId() == R.id.share_voicemail) { + Logger.get(mContext).logImpression(DialerImpression.Type.VVM_SHARE_PRESSED); + mVoicemailPlaybackPresenter.shareVoicemail(); + } else { + logCallLogAction(view.getId()); + final IntentProvider intentProvider = (IntentProvider) view.getTag(); + if (intentProvider != null) { + final Intent intent = intentProvider.getIntent(mContext); + // See IntentProvider.getCallDetailIntentProvider() for why this may be null. + if (intent != null) { + DialerUtils.startActivityWithErrorToast(mContext, intent); + } + } + } + } + + private void logCallLogAction(int id) { + if (id == R.id.send_message_action) { + Logger.get(mContext).logImpression(DialerImpression.Type.CALL_LOG_SEND_MESSAGE); + } else if (id == R.id.add_to_existing_contact_action) { + Logger.get(mContext).logImpression(DialerImpression.Type.CALL_LOG_ADD_TO_CONTACT); + } else if (id == R.id.create_new_contact_action) { + Logger.get(mContext).logImpression(DialerImpression.Type.CALL_LOG_CREATE_NEW_CONTACT); + } + } + + private void maybeShowBlockNumberMigrationDialog(BlockedNumbersMigrator.Listener listener) { + if (!FilteredNumberCompat.maybeShowBlockNumberMigrationDialog( + mContext, ((Activity) mContext).getFragmentManager(), listener)) { + listener.onComplete(); + } + } + + private void updateBlockReportActions(boolean isVoicemailNumber) { + // Set block/spam actions. + blockReportView.setVisibility(View.GONE); + blockView.setVisibility(View.GONE); + unblockView.setVisibility(View.GONE); + reportNotSpamView.setVisibility(View.GONE); + String e164Number = PhoneNumberUtils.formatNumberToE164(number, countryIso); + if (isVoicemailNumber + || !FilteredNumbersUtil.canBlockNumber(mContext, e164Number, number) + || !FilteredNumberCompat.canAttemptBlockOperations(mContext)) { + return; + } + boolean isBlocked = blockId != null; + if (isBlocked) { + unblockView.setVisibility(View.VISIBLE); + } else { + if (isSpamFeatureEnabled) { + if (isSpam) { + blockView.setVisibility(View.VISIBLE); + reportNotSpamView.setVisibility(View.VISIBLE); + } else { + blockReportView.setVisibility(View.VISIBLE); + } + } else { + blockView.setVisibility(View.VISIBLE); + } + } + } + + public interface OnClickListener { + + void onBlockReportSpam( + String displayNumber, + String number, + String countryIso, + int callType, + int contactSourceType); + + void onBlock( + String displayNumber, + String number, + String countryIso, + int callType, + int contactSourceType); + + void onUnblock( + String displayNumber, + String number, + String countryIso, + int callType, + int contactSourceType, + boolean isSpam, + Integer blockId); + + void onReportNotSpam( + String displayNumber, + String number, + String countryIso, + int callType, + int contactSourceType); + } +} diff --git a/java/com/android/dialer/app/calllog/CallLogModalAlertManager.java b/java/com/android/dialer/app/calllog/CallLogModalAlertManager.java new file mode 100644 index 000000000..9de260a0a --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallLogModalAlertManager.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2017 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.app.calllog; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import com.android.dialer.app.R; +import com.android.dialer.app.alert.AlertManager; + +/** + * Alert manager controls modal view to show message in call log. When modal view is shown, regular + * call log will be hidden. + */ +public class CallLogModalAlertManager implements AlertManager { + + interface Listener { + void onShowModalAlert(boolean show); + } + + private final Listener listener; + private final ViewGroup parent; + private final ViewGroup container; + private final LayoutInflater inflater; + + public CallLogModalAlertManager(LayoutInflater inflater, ViewGroup parent, Listener listener) { + this.inflater = inflater; + this.parent = parent; + this.listener = listener; + container = (ViewGroup) parent.findViewById(R.id.modal_message_container); + } + + @Override + public View inflate(int layoutId) { + return inflater.inflate(layoutId, parent, false); + } + + @Override + public void add(View view) { + if (contains(view)) { + return; + } + container.addView(view); + listener.onShowModalAlert(true); + } + + @Override + public void clear() { + container.removeAllViews(); + listener.onShowModalAlert(false); + } + + public boolean isEmpty() { + return container.getChildCount() == 0; + } + + public boolean contains(View view) { + return container.indexOfChild(view) != -1; + } +} diff --git a/java/com/android/dialer/app/calllog/CallLogNotificationsHelper.java b/java/com/android/dialer/app/calllog/CallLogNotificationsHelper.java new file mode 100644 index 000000000..8f664d1a4 --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallLogNotificationsHelper.java @@ -0,0 +1,299 @@ +/* + * 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.app.calllog; + +import android.Manifest; +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build.VERSION_CODES; +import android.provider.CallLog.Calls; +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.dialer.app.R; +import com.android.dialer.phonenumbercache.ContactInfo; +import com.android.dialer.phonenumbercache.ContactInfoHelper; +import com.android.dialer.telecom.TelecomUtil; +import com.android.dialer.util.PermissionsUtil; +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; + private final Context mContext; + private final NewCallsQuery mNewCallsQuery; + private final ContactInfoHelper mContactInfoHelper; + private final String mCurrentCountryIso; + + CallLogNotificationsHelper( + Context context, + NewCallsQuery newCallsQuery, + ContactInfoHelper contactInfoHelper, + String countryIso) { + mContext = context; + mNewCallsQuery = newCallsQuery; + mContactInfoHelper = contactInfoHelper; + mCurrentCountryIso = countryIso; + } + + /** 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), + new ContactInfoHelper(context, countryIso), + countryIso); + } + return sInstance; + } + + /** Removes the missed call notifications. */ + public static void removeMissedCallNotifications(Context context) { + TelecomUtil.cancelMissedCallsNotification(context); + } + + /** Update the voice mail notifications. */ + public static void updateVoicemailNotifications(Context context) { + CallLogNotificationsService.updateVoicemailNotifications(context, null); + } + + /** Create a new instance of {@link NewCallsQuery}. */ + public static NewCallsQuery createNewCallsQuery( + Context context, ContentResolver contentResolver) { + + return new DefaultNewCallsQuery(context.getApplicationContext(), contentResolver); + } + + /** + * 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 ContactInfo getContactInfo( + @Nullable String number, int numberPresentation, @Nullable String countryIso) { + if (countryIso == null) { + countryIso = mCurrentCountryIso; + } + + number = (number == null) ? "" : number; + 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; + } + + /** 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 + List<NewCall> query(int type); + } + + /** 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; + } + } + + /** + * 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 + @TargetApi(VERSION_CODES.M) + 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)); + } + } +} diff --git a/java/com/android/dialer/app/calllog/CallLogNotificationsService.java b/java/com/android/dialer/app/calllog/CallLogNotificationsService.java new file mode 100644 index 000000000..820528126 --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallLogNotificationsService.java @@ -0,0 +1,203 @@ +/* + * 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.app.calllog; + +import android.app.IntentService; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import com.android.dialer.common.LogUtil; +import com.android.dialer.telecom.TelecomUtil; +import com.android.dialer.util.PermissionsUtil; +import me.leolin.shortcutbadger.ShortcutBadger; + +/** + * Provides operations for managing call-related notifications. + * + * <p>It handles the following actions: + * + * <ul> + * <li>Updating voicemail notifications + * <li>Marking new voicemails as old + * <li>Updating missed call notifications + * <li>Marking new missed calls as old + * <li>Calling back from a missed call + * <li>Sending an SMS from a missed call + * </ul> + */ +public class CallLogNotificationsService extends IntentService { + + /** Action to mark all the new voicemails as old. */ + public static final String ACTION_MARK_NEW_VOICEMAILS_AS_OLD = + "com.android.dialer.calllog.ACTION_MARK_NEW_VOICEMAILS_AS_OLD"; + /** + * Action to update voicemail notifications. + * + * <p>May include an optional extra {@link #EXTRA_NEW_VOICEMAIL_URI}. + */ + public static final String ACTION_UPDATE_VOICEMAIL_NOTIFICATIONS = + "com.android.dialer.calllog.UPDATE_VOICEMAIL_NOTIFICATIONS"; + /** + * 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() { + super("CallLogNotificationsService"); + } + + /** + * Updates notifications for any new voicemails. + * + * @param context a valid context. + * @param voicemailUri The uri pointing to the voicemail to update the notification for. If {@code + * null}, then notifications for all new voicemails will be updated. + */ + public static void updateVoicemailNotifications(Context context, Uri voicemailUri) { + if (!TelecomUtil.isDefaultDialer(context)) { + LogUtil.i( + "CallLogNotificationsService.updateVoicemailNotifications", + "not default dialer, ignoring voicemail notifications"); + return; + } + if (TelecomUtil.hasReadWriteVoicemailPermissions(context)) { + Intent serviceIntent = new Intent(context, CallLogNotificationsService.class); + serviceIntent.setAction(CallLogNotificationsService.ACTION_UPDATE_VOICEMAIL_NOTIFICATIONS); + // If voicemailUri is null, then notifications for all voicemails will be updated. + if (voicemailUri != null) { + serviceIntent.putExtra(CallLogNotificationsService.EXTRA_NEW_VOICEMAIL_URI, voicemailUri); + } + 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); + } + + public static void markNewVoicemailsAsOld(Context context) { + Intent serviceIntent = new Intent(context, CallLogNotificationsService.class); + serviceIntent.setAction(CallLogNotificationsService.ACTION_MARK_NEW_VOICEMAILS_AS_OLD); + context.startService(serviceIntent); + } + + public static boolean updateBadgeCount(Context context, int count) { + boolean success = ShortcutBadger.applyCount(context, count); + LogUtil.i( + "CallLogNotificationsService.updateBadgeCount", + "update badge count: %d success: %b", + count, + success); + return success; + } + + @Override + protected void onHandleIntent(Intent intent) { + if (intent == null) { + LogUtil.d("CallLogNotificationsService.onHandleIntent", "could not handle null intent"); + return; + } + + if (!PermissionsUtil.hasPermission(this, android.Manifest.permission.READ_CALL_LOG)) { + return; + } + + 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 = 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); + updateBadgeCount(this, count); + 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: + LogUtil.d("CallLogNotificationsService.onHandleIntent", "could not handle: " + intent); + break; + } + } +} diff --git a/java/com/android/dialer/app/calllog/CallLogReceiver.java b/java/com/android/dialer/app/calllog/CallLogReceiver.java new file mode 100644 index 000000000..a781b0887 --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallLogReceiver.java @@ -0,0 +1,77 @@ +/* + * 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.app.calllog; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.provider.VoicemailContract; +import com.android.dialer.app.voicemail.error.VoicemailStatusCorruptionHandler; +import com.android.dialer.app.voicemail.error.VoicemailStatusCorruptionHandler.Source; +import com.android.dialer.common.LogUtil; +import com.android.dialer.database.CallLogQueryHandler; + +/** + * Receiver for call log events. + * + * <p>It is currently used to handle {@link VoicemailContract#ACTION_NEW_VOICEMAIL} and {@link + * Intent#ACTION_BOOT_COMPLETED}. + */ +public class CallLogReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + if (VoicemailContract.ACTION_NEW_VOICEMAIL.equals(intent.getAction())) { + checkVoicemailStatus(context); + CallLogNotificationsService.updateVoicemailNotifications(context, intent.getData()); + } else if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { + CallLogNotificationsService.updateVoicemailNotifications(context, null); + } else { + LogUtil.w("CallLogReceiver.onReceive", "could not handle: " + intent); + } + } + + private static void checkVoicemailStatus(Context context) { + new CallLogQueryHandler( + context, + context.getContentResolver(), + new CallLogQueryHandler.Listener() { + @Override + public void onVoicemailStatusFetched(Cursor statusCursor) { + VoicemailStatusCorruptionHandler.maybeFixVoicemailStatus( + context, statusCursor, Source.Notification); + } + + @Override + public void onVoicemailUnreadCountFetched(Cursor cursor) { + // Do nothing + } + + @Override + public void onMissedCallsUnreadCountFetched(Cursor cursor) { + // Do nothing + } + + @Override + public boolean onCallsFetched(Cursor combinedCursor) { + return false; + } + }) + .fetchVoicemailStatus(); + } +} diff --git a/java/com/android/dialer/app/calllog/CallTypeHelper.java b/java/com/android/dialer/app/calllog/CallTypeHelper.java new file mode 100644 index 000000000..f3c27a1ac --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallTypeHelper.java @@ -0,0 +1,136 @@ +/* + * 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.app.calllog; + +import android.content.res.Resources; +import com.android.dialer.app.R; +import com.android.dialer.compat.AppCompatConstants; + +/** Helper class to perform operations related to call types. */ +public class CallTypeHelper { + + /** Name used to identify incoming calls. */ + private final CharSequence mIncomingName; + /** Name used to identify incoming calls which were transferred to another device. */ + private final CharSequence mIncomingPulledName; + /** Name used to identify outgoing calls. */ + private final CharSequence mOutgoingName; + /** Name used to identify outgoing calls which were transferred to another device. */ + private final CharSequence mOutgoingPulledName; + /** Name used to identify missed calls. */ + private final CharSequence mMissedName; + /** Name used to identify incoming video calls. */ + private final CharSequence mIncomingVideoName; + /** Name used to identify incoming video calls which were transferred to another device. */ + private final CharSequence mIncomingVideoPulledName; + /** Name used to identify outgoing video calls. */ + private final CharSequence mOutgoingVideoName; + /** Name used to identify outgoing video calls which were transferred to another device. */ + private final CharSequence mOutgoingVideoPulledName; + /** Name used to identify missed video calls. */ + 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; + /** Name used to identify calls which were answered on another device. */ + private final CharSequence mAnsweredElsewhereName; + + public CallTypeHelper(Resources resources) { + // Cache these values so that we do not need to look them up each time. + mIncomingName = resources.getString(R.string.type_incoming); + mIncomingPulledName = resources.getString(R.string.type_incoming_pulled); + mOutgoingName = resources.getString(R.string.type_outgoing); + mOutgoingPulledName = resources.getString(R.string.type_outgoing_pulled); + mMissedName = resources.getString(R.string.type_missed); + mIncomingVideoName = resources.getString(R.string.type_incoming_video); + mIncomingVideoPulledName = resources.getString(R.string.type_incoming_video_pulled); + mOutgoingVideoName = resources.getString(R.string.type_outgoing_video); + mOutgoingVideoPulledName = resources.getString(R.string.type_outgoing_video_pulled); + 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); + mAnsweredElsewhereName = resources.getString(R.string.type_answered_elsewhere); + } + + public static boolean isMissedCallType(int callType) { + return (callType != AppCompatConstants.CALLS_INCOMING_TYPE + && callType != AppCompatConstants.CALLS_OUTGOING_TYPE + && callType != AppCompatConstants.CALLS_VOICEMAIL_TYPE + && callType != AppCompatConstants.CALLS_ANSWERED_EXTERNALLY_TYPE); + } + + /** Returns the text used to represent the given call type. */ + public CharSequence getCallTypeText(int callType, boolean isVideoCall, boolean isPulledCall) { + switch (callType) { + case AppCompatConstants.CALLS_INCOMING_TYPE: + if (isVideoCall) { + if (isPulledCall) { + return mIncomingVideoPulledName; + } else { + return mIncomingVideoName; + } + } else { + if (isPulledCall) { + return mIncomingPulledName; + } else { + return mIncomingName; + } + } + + case AppCompatConstants.CALLS_OUTGOING_TYPE: + if (isVideoCall) { + if (isPulledCall) { + return mOutgoingVideoPulledName; + } else { + return mOutgoingVideoName; + } + } else { + if (isPulledCall) { + return mOutgoingPulledName; + } else { + return mOutgoingName; + } + } + + case AppCompatConstants.CALLS_MISSED_TYPE: + if (isVideoCall) { + return mMissedVideoName; + } else { + return mMissedName; + } + + case AppCompatConstants.CALLS_VOICEMAIL_TYPE: + return mVoicemailName; + + case AppCompatConstants.CALLS_REJECTED_TYPE: + return mRejectedName; + + case AppCompatConstants.CALLS_BLOCKED_TYPE: + return mBlockedName; + + case AppCompatConstants.CALLS_ANSWERED_EXTERNALLY_TYPE: + return mAnsweredElsewhereName; + + default: + return mMissedName; + } + } +} diff --git a/java/com/android/dialer/app/calllog/CallTypeIconsView.java b/java/com/android/dialer/app/calllog/CallTypeIconsView.java new file mode 100644 index 000000000..cd5c5460c --- /dev/null +++ b/java/com/android/dialer/app/calllog/CallTypeIconsView.java @@ -0,0 +1,221 @@ +/* + * 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.app.calllog; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.PorterDuff; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.View; +import com.android.contacts.common.util.BitmapUtil; +import com.android.dialer.app.R; +import com.android.dialer.compat.AppCompatConstants; +import java.util.ArrayList; +import java.util.List; + +/** + * View that draws one or more symbols for different types of calls (missed calls, outgoing etc). + * The symbols are set up horizontally. As this view doesn't create subviews, it is better suited + * for ListView-recycling that a regular LinearLayout using ImageViews. + */ +public class CallTypeIconsView extends View { + + private static Resources sResources; + private List<Integer> mCallTypes = new ArrayList<>(3); + private boolean mShowVideo = false; + private int mWidth; + private int mHeight; + + public CallTypeIconsView(Context context) { + this(context, null); + } + + public CallTypeIconsView(Context context, AttributeSet attrs) { + super(context, attrs); + if (sResources == null) { + sResources = new Resources(context); + } + } + + public void clear() { + mCallTypes.clear(); + mWidth = 0; + mHeight = 0; + invalidate(); + } + + public void add(int callType) { + mCallTypes.add(callType); + + final Drawable drawable = getCallTypeDrawable(callType); + mWidth += drawable.getIntrinsicWidth() + sResources.iconMargin; + mHeight = Math.max(mHeight, drawable.getIntrinsicHeight()); + invalidate(); + } + + /** + * Determines whether the video call icon will be shown. + * + * @param showVideo True where the video icon should be shown. + */ + public void setShowVideo(boolean showVideo) { + mShowVideo = showVideo; + if (showVideo) { + mWidth += sResources.videoCall.getIntrinsicWidth(); + mHeight = Math.max(mHeight, sResources.videoCall.getIntrinsicHeight()); + invalidate(); + } + } + + /** + * Determines if the video icon should be shown. + * + * @return True if the video icon should be shown. + */ + public boolean isVideoShown() { + return mShowVideo; + } + + public int getCount() { + return mCallTypes.size(); + } + + public int getCallType(int index) { + return mCallTypes.get(index); + } + + private Drawable getCallTypeDrawable(int callType) { + switch (callType) { + case AppCompatConstants.CALLS_INCOMING_TYPE: + case AppCompatConstants.CALLS_ANSWERED_EXTERNALLY_TYPE: + return sResources.incoming; + case AppCompatConstants.CALLS_OUTGOING_TYPE: + return sResources.outgoing; + case AppCompatConstants.CALLS_MISSED_TYPE: + return sResources.missed; + case AppCompatConstants.CALLS_VOICEMAIL_TYPE: + return sResources.voicemail; + case AppCompatConstants.CALLS_BLOCKED_TYPE: + return sResources.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 + // distinguish between rejected and missed calls). Instead of crashing, just + // assume that all unknown call types are missed calls. + return sResources.missed; + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + setMeasuredDimension(mWidth, mHeight); + } + + @Override + protected void onDraw(Canvas canvas) { + int left = 0; + for (Integer callType : mCallTypes) { + final Drawable drawable = getCallTypeDrawable(callType); + final int right = left + drawable.getIntrinsicWidth(); + drawable.setBounds(left, 0, right, drawable.getIntrinsicHeight()); + drawable.draw(canvas); + left = right + sResources.iconMargin; + } + + // If showing the video call icon, draw it scaled appropriately. + if (mShowVideo) { + final Drawable drawable = sResources.videoCall; + final int right = left + sResources.videoCall.getIntrinsicWidth(); + drawable.setBounds(left, 0, right, sResources.videoCall.getIntrinsicHeight()); + drawable.draw(canvas); + } + } + + private static class Resources { + + // Drawable representing an incoming answered call. + public final Drawable incoming; + + // Drawable respresenting an outgoing call. + public final Drawable outgoing; + + // Drawable representing an incoming missed call. + public final Drawable missed; + + // Drawable representing a voicemail. + public final Drawable voicemail; + + // Drawable representing a blocked call. + public final Drawable blocked; + + // Drawable repesenting a video call. + public final Drawable videoCall; + + /** The margin to use for icons. */ + public final int iconMargin; + + /** + * Configures the call icon drawables. A single white call arrow which points down and left is + * used as a basis for all of the call arrow icons, applying rotation and colors as needed. + * + * @param context The current context. + */ + public Resources(Context context) { + final android.content.res.Resources r = context.getResources(); + + incoming = r.getDrawable(R.drawable.ic_call_arrow); + incoming.setColorFilter(r.getColor(R.color.answered_call), PorterDuff.Mode.MULTIPLY); + + // Create a rotated instance of the call arrow for outgoing calls. + outgoing = BitmapUtil.getRotatedDrawable(r, R.drawable.ic_call_arrow, 180f); + outgoing.setColorFilter(r.getColor(R.color.answered_call), PorterDuff.Mode.MULTIPLY); + + // Need to make a copy of the arrow drawable, otherwise the same instance colored + // above will be recolored here. + missed = r.getDrawable(R.drawable.ic_call_arrow).mutate(); + missed.setColorFilter(r.getColor(R.color.missed_call), PorterDuff.Mode.MULTIPLY); + + voicemail = r.getDrawable(R.drawable.quantum_ic_voicemail_white_18); + voicemail.setColorFilter( + r.getColor(R.color.dialer_secondary_text_color), PorterDuff.Mode.MULTIPLY); + + blocked = getScaledBitmap(context, R.drawable.ic_block_24dp); + blocked.setColorFilter(r.getColor(R.color.blocked_call), PorterDuff.Mode.MULTIPLY); + + videoCall = getScaledBitmap(context, R.drawable.quantum_ic_videocam_white_24); + videoCall.setColorFilter( + r.getColor(R.color.dialer_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/java/com/android/dialer/app/calllog/ClearCallLogDialog.java b/java/com/android/dialer/app/calllog/ClearCallLogDialog.java new file mode 100644 index 000000000..0c9bd4b35 --- /dev/null +++ b/java/com/android/dialer/app/calllog/ClearCallLogDialog.java @@ -0,0 +1,98 @@ +/* + * 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.app.calllog; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.app.FragmentManager; +import android.app.ProgressDialog; +import android.content.ContentResolver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.os.AsyncTask; +import android.os.Bundle; +import android.provider.CallLog.Calls; +import com.android.dialer.app.R; +import com.android.dialer.phonenumbercache.CachedNumberLookupService; +import com.android.dialer.phonenumbercache.PhoneNumberCache; + +/** Dialog that clears the call log after confirming with the user */ +public class ClearCallLogDialog extends DialogFragment { + + /** Preferred way to show this dialog */ + public static void show(FragmentManager fragmentManager) { + ClearCallLogDialog dialog = new ClearCallLogDialog(); + dialog.show(fragmentManager, "deleteCallLog"); + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final ContentResolver resolver = getActivity().getContentResolver(); + final Context context = getActivity().getApplicationContext(); + final OnClickListener okListener = + new OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + final ProgressDialog progressDialog = + ProgressDialog.show( + getActivity(), getString(R.string.clearCallLogProgress_title), "", true, false); + progressDialog.setOwnerActivity(getActivity()); + final AsyncTask<Void, Void, Void> task = + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + resolver.delete(Calls.CONTENT_URI, null, null); + CachedNumberLookupService cachedNumberLookupService = + PhoneNumberCache.get(context).getCachedNumberLookupService(); + if (cachedNumberLookupService != null) { + cachedNumberLookupService.clearAllCacheEntries(context); + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + final Activity activity = progressDialog.getOwnerActivity(); + + if (activity == null || activity.isDestroyed() || activity.isFinishing()) { + return; + } + + if (progressDialog != null && progressDialog.isShowing()) { + progressDialog.dismiss(); + } + } + }; + // TODO: Once we have the API, we should configure this ProgressDialog + // to only show up after a certain time (e.g. 150ms) + progressDialog.show(); + task.execute(); + } + }; + return new AlertDialog.Builder(getActivity()) + .setTitle(R.string.clearCallLogConfirmation_title) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setMessage(R.string.clearCallLogConfirmation) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, okListener) + .setCancelable(true) + .create(); + } +} diff --git a/java/com/android/dialer/app/calllog/DefaultVoicemailNotifier.java b/java/com/android/dialer/app/calllog/DefaultVoicemailNotifier.java new file mode 100644 index 000000000..651a0ccb8 --- /dev/null +++ b/java/com/android/dialer/app/calllog/DefaultVoicemailNotifier.java @@ -0,0 +1,273 @@ +/* + * 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.app.calllog; + +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.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +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.ArrayMap; +import android.util.Log; +import com.android.contacts.common.compat.TelephonyManagerCompat; +import com.android.contacts.common.util.ContactDisplayUtils; +import com.android.dialer.app.DialtactsActivity; +import com.android.dialer.app.R; +import com.android.dialer.app.calllog.CallLogNotificationsHelper.NewCall; +import com.android.dialer.app.list.ListsFragment; +import com.android.dialer.blocking.FilteredNumbersUtil; +import com.android.dialer.telecom.TelecomUtil; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** Shows a voicemail notification in the status bar. */ +public class DefaultVoicemailNotifier { + + public static final String TAG = "VoicemailNotifier"; + + /** The tag used to identify notifications from this class. */ + private static final String NOTIFICATION_TAG = "DefaultVoicemailNotifier"; + /** The identifier of the notification of new voicemails. */ + private static final int NOTIFICATION_ID = 1; + + /** The singleton instance of {@link DefaultVoicemailNotifier}. */ + private static DefaultVoicemailNotifier sInstance; + + private final Context mContext; + + private DefaultVoicemailNotifier(Context context) { + mContext = context; + } + + /** Returns the singleton instance of the {@link DefaultVoicemailNotifier}. */ + public static DefaultVoicemailNotifier getInstance(Context context) { + if (sInstance == null) { + ContentResolver contentResolver = context.getContentResolver(); + sInstance = new DefaultVoicemailNotifier(context); + } + return sInstance; + } + + /** + * Updates the notification and notifies of the call with the given URI. + * + * <p>Clears the notification if there are no new voicemails, and notifies if the given URI + * corresponds to a new voicemail. + * + * <p>It is not safe to call this method from the main thread. + */ + 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 List<NewCall> newCalls = + CallLogNotificationsHelper.getInstance(mContext).getNewVoicemails(); + + if (newCalls == null) { + // Query failed, just return. + return; + } + + if (newCalls.isEmpty()) { + // No voicemails to notify about: clear the notification. + getNotificationManager().cancel(NOTIFICATION_TAG, NOTIFICATION_ID); + return; + } + + Resources resources = mContext.getResources(); + + // This represents a list of names to include in the notification. + String callers = null; + + // Maps each number into a name: if a number is in the map, it has already left a more + // recent voicemail. + final Map<String, String> names = new ArrayMap<>(); + + // Determine the call corresponding to the new voicemail we have to notify about. + NewCall callToNotify = null; + + // Iterate over the new voicemails to determine all the information above. + 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 = + 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)) { + callers = name; + } else { + callers = + resources.getString(R.string.notification_voicemail_callers_list, callers, name); + } + } + // Check if this is the new call we need to notify about. + if (newCallUri != null + && newCall.voicemailUri != null + && ContentUris.parseId(newCallUri) == ContentUris.parseId(newCall.voicemailUri)) { + callToNotify = newCall; + } + } + + // 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.size() == 1) { + transcription = newCalls.get(0).transcription; + } + + if (newCallUri != null && callToNotify == null) { + Log.e(TAG, "The new call could not be found in the call log: " + newCallUri); + } + + // Determine the title of the notification and the icon for it. + final String title = + resources.getQuantityString( + 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) + .setColor(resources.getColor(R.color.dialer_theme_color)) + .setSound(info.first) + .setDefaults(info.second) + .setDeleteIntent(createMarkNewVoicemailsAsOldIntent()) + .setAutoCancel(true); + + if (!TextUtils.isEmpty(transcription)) { + notificationBuilder.setStyle(new Notification.BigTextStyle().bigText(transcription)); + } + + // Determine the intent to fire when the notification is clicked on. + final Intent contentIntent; + // Open the call log. + contentIntent = DialtactsActivity.getShowTabIntent(mContext, ListsFragment.TAB_INDEX_VOICEMAIL); + contentIntent.putExtra(DialtactsActivity.EXTRA_CLEAR_NEW_VOICEMAILS, true); + notificationBuilder.setContentIntent( + PendingIntent.getActivity(mContext, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT)); + + // The text to show in the ticker, describing the new event. + if (callToNotify != null) { + CharSequence msg = + ContactDisplayUtils.getTtsSpannedPhoneNumber( + resources, + R.string.notification_new_voicemail_ticker, + names.get(callToNotify.number)); + notificationBuilder.setTicker(msg); + } + Log.i(TAG, "Creating voicemail notification"); + getNotificationManager().notify(NOTIFICATION_TAG, NOTIFICATION_ID, notificationBuilder.build()); + } + + /** + * Determines which ringtone Uri and Notification defaults to use when updating the notification + * for the given call. + */ + 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); + } + PhoneAccountHandle accountHandle; + 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); + } + + } 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)); + } + + private int getNotificationDefaults(PhoneAccountHandle accountHandle) { + if (VERSION.SDK_INT >= VERSION_CODES.N) { + return TelephonyManagerCompat.isVoicemailVibrationEnabled( + getTelephonyManager(), accountHandle) + ? Notification.DEFAULT_VIBRATE + : 0; + } + return Notification.DEFAULT_ALL; + } + + /** 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); + } + + private NotificationManager getNotificationManager() { + return (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); + } + + private TelephonyManager getTelephonyManager() { + return (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE); + } +} diff --git a/java/com/android/dialer/app/calllog/GroupingListAdapter.java b/java/com/android/dialer/app/calllog/GroupingListAdapter.java new file mode 100644 index 000000000..d1157206f --- /dev/null +++ b/java/com/android/dialer/app/calllog/GroupingListAdapter.java @@ -0,0 +1,153 @@ +/* + * 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.app.calllog; + +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.DataSetObserver; +import android.os.Handler; +import android.support.v7.widget.RecyclerView; +import android.util.SparseIntArray; + +/** + * 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. + * + * <p>There are two integers stored as metadata for every list item in the adapter. + */ +abstract class GroupingListAdapter extends RecyclerView.Adapter { + + protected ContentObserver mChangeObserver = + new ContentObserver(new Handler()) { + @Override + public boolean deliverSelfNotifications() { + return true; + } + + @Override + public void onChange(boolean selfChange) { + onContentChanged(); + } + }; + protected DataSetObserver mDataSetObserver = + new DataSetObserver() { + @Override + public void onChanged() { + notifyDataSetChanged(); + } + }; + private Cursor mCursor; + /** + * 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 SparseIntArray mGroupMetadata; + + private int mItemCount; + + public GroupingListAdapter() { + reset(); + } + + /** + * Finds all groups of adjacent items in the cursor and calls {@link #addGroup} for each of them. + */ + protected abstract void addGroups(Cursor cursor); + + protected abstract void onContentChanged(); + + public void changeCursor(Cursor cursor) { + if (cursor == mCursor) { + return; + } + + if (mCursor != null) { + mCursor.unregisterContentObserver(mChangeObserver); + mCursor.unregisterDataSetObserver(mDataSetObserver); + mCursor.close(); + } + + // Reset whenever the cursor is changed. + reset(); + mCursor = cursor; + + if (cursor != null) { + addGroups(mCursor); + + // Calculate the item count by subtracting group child counts from the cursor count. + mItemCount = mGroupMetadata.size(); + + cursor.registerContentObserver(mChangeObserver); + cursor.registerDataSetObserver(mDataSetObserver); + notifyDataSetChanged(); + } + } + + /** + * Records information about grouping in the list. Should be called by the overridden {@link + * #addGroups} method. + */ + 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); + } + } + + @Override + public int getItemCount() { + return mItemCount; + } + + /** + * Given the position of a list item, returns the size of the group of items corresponding to that + * position. + */ + public int getGroupSize(int listPosition) { + if (listPosition < 0 || listPosition >= mGroupMetadata.size()) { + return 0; + } + + return mGroupMetadata.valueAt(listPosition); + } + + /** + * Given the position of a list item, returns the the first item in the group of items + * corresponding to that position. + */ + public Object getItem(int listPosition) { + if (mCursor == null || listPosition < 0 || listPosition >= mGroupMetadata.size()) { + return null; + } + + int cursorPosition = mGroupMetadata.keyAt(listPosition); + if (mCursor.moveToPosition(cursorPosition)) { + return mCursor; + } else { + return null; + } + } + + private void reset() { + mItemCount = 0; + mGroupMetadata = new SparseIntArray(); + } +} diff --git a/java/com/android/dialer/app/calllog/IntentProvider.java b/java/com/android/dialer/app/calllog/IntentProvider.java new file mode 100644 index 000000000..879ac353d --- /dev/null +++ b/java/com/android/dialer/app/calllog/IntentProvider.java @@ -0,0 +1,198 @@ +/* + * 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.app.calllog; + +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.provider.ContactsContract; +import android.telecom.PhoneAccountHandle; +import com.android.contacts.common.model.Contact; +import com.android.contacts.common.model.ContactLoader; +import com.android.dialer.app.CallDetailActivity; +import com.android.dialer.callintent.CallIntentBuilder; +import com.android.dialer.callintent.nano.CallInitiationType; +import com.android.dialer.telecom.TelecomUtil; +import com.android.dialer.util.CallUtil; +import com.android.dialer.util.IntentUtil; +import java.util.ArrayList; + +/** + * Used to create an intent to attach to an action in the call log. + * + * <p>The intent is constructed lazily with the given information. + */ +public abstract class IntentProvider { + + private static final String TAG = IntentProvider.class.getSimpleName(); + + public static IntentProvider getReturnCallIntentProvider(final String number) { + return getReturnCallIntentProvider(number, null); + } + + public static IntentProvider getReturnCallIntentProvider( + final String number, final PhoneAccountHandle accountHandle) { + return new IntentProvider() { + @Override + public Intent getIntent(Context context) { + return new CallIntentBuilder(number, CallInitiationType.Type.CALL_LOG) + .setPhoneAccountHandle(accountHandle) + .build(); + } + }; + } + + public static IntentProvider getReturnVideoCallIntentProvider(final String number) { + return getReturnVideoCallIntentProvider(number, null); + } + + public static IntentProvider getReturnVideoCallIntentProvider( + final String number, final PhoneAccountHandle accountHandle) { + return new IntentProvider() { + @Override + public Intent getIntent(Context context) { + return new CallIntentBuilder(number, CallInitiationType.Type.CALL_LOG) + .setPhoneAccountHandle(accountHandle) + .setIsVideoCall(true) + .build(); + } + }; + } + + public static IntentProvider getReturnVoicemailCallIntentProvider() { + return new IntentProvider() { + @Override + public Intent getIntent(Context context) { + return new CallIntentBuilder(CallUtil.getVoicemailUri(), CallInitiationType.Type.CALL_LOG) + .build(); + } + }; + } + + public static IntentProvider getSendSmsIntentProvider(final String number) { + return new IntentProvider() { + @Override + public Intent getIntent(Context context) { + return IntentUtil.getSendSmsIntent(number); + } + }; + } + + /** + * Retrieves the call details intent provider for an entry in the call log. + * + * @param id The call ID of the first call in the call group. + * @param extraIds The call ID of the other calls grouped together with the call. + * @param voicemailUri If call log entry is for a voicemail, the voicemail URI. + * @return The call details intent provider. + */ + public static IntentProvider getCallDetailIntentProvider( + final long id, final long[] extraIds, final String voicemailUri) { + return new IntentProvider() { + @Override + public Intent getIntent(Context context) { + Intent intent = new Intent(context, CallDetailActivity.class); + // Check if the first item is a voicemail. + if (voicemailUri != null) { + intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI, Uri.parse(voicemailUri)); + } + + if (extraIds != null && extraIds.length > 0) { + intent.putExtra(CallDetailActivity.EXTRA_CALL_LOG_IDS, extraIds); + } else { + // If there is a single item, use the direct URI for it. + intent.setData(ContentUris.withAppendedId(TelecomUtil.getCallLogUri(context), id)); + } + return intent; + } + }; + } + + /** Retrieves an add contact intent for the given contact and phone call details. */ + public static IntentProvider getAddContactIntentProvider( + final Uri lookupUri, + final CharSequence name, + final CharSequence number, + final int numberType, + final boolean isNewContact) { + return new IntentProvider() { + @Override + public Intent getIntent(Context context) { + Contact contactToSave = null; + + if (lookupUri != null) { + contactToSave = ContactLoader.parseEncodedContactEntity(lookupUri); + } + + if (contactToSave != null) { + // Populate the intent with contact information stored in the lookup URI. + // Note: This code mirrors code in Contacts/QuickContactsActivity. + final Intent intent; + if (isNewContact) { + intent = IntentUtil.getNewContactIntent(); + } else { + intent = IntentUtil.getAddToExistingContactIntent(); + } + + ArrayList<ContentValues> values = contactToSave.getContentValues(); + // Only pre-fill the name field if the provided display name is an nickname + // or better (e.g. structured name, nickname) + if (contactToSave.getDisplayNameSource() + >= ContactsContract.DisplayNameSources.NICKNAME) { + intent.putExtra(ContactsContract.Intents.Insert.NAME, contactToSave.getDisplayName()); + } else if (contactToSave.getDisplayNameSource() + == ContactsContract.DisplayNameSources.ORGANIZATION) { + // This is probably an organization. Instead of copying the organization + // name into a name entry, copy it into the organization entry. This + // way we will still consider the contact an organization. + final ContentValues organization = new ContentValues(); + organization.put( + ContactsContract.CommonDataKinds.Organization.COMPANY, + contactToSave.getDisplayName()); + organization.put( + ContactsContract.Data.MIMETYPE, + ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE); + values.add(organization); + } + + // Last time used and times used are aggregated values from the usage stat + // table. They need to be removed from data values so the SQL table can insert + // properly + for (ContentValues value : values) { + value.remove(ContactsContract.Data.LAST_TIME_USED); + value.remove(ContactsContract.Data.TIMES_USED); + } + + intent.putExtra(ContactsContract.Intents.Insert.DATA, values); + + return intent; + } else { + // If no lookup uri is provided, rely on the available phone number and name. + if (isNewContact) { + return IntentUtil.getNewContactIntent(name, number, numberType); + } else { + return IntentUtil.getAddToExistingContactIntent(name, number, numberType); + } + } + } + }; + } + + public abstract Intent getIntent(Context context); +} diff --git a/java/com/android/dialer/app/calllog/MissedCallNotificationReceiver.java b/java/com/android/dialer/app/calllog/MissedCallNotificationReceiver.java new file mode 100644 index 000000000..3a202034e --- /dev/null +++ b/java/com/android/dialer/app/calllog/MissedCallNotificationReceiver.java @@ -0,0 +1,50 @@ +/* + * 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.app.calllog; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +/** + * 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/java/com/android/dialer/app/calllog/MissedCallNotifier.java b/java/com/android/dialer/app/calllog/MissedCallNotifier.java new file mode 100644 index 000000000..2fa3dae65 --- /dev/null +++ b/java/com/android/dialer/app/calllog/MissedCallNotifier.java @@ -0,0 +1,330 @@ +/* + * 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.app.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.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.support.v4.os.UserManagerCompat; +import android.text.BidiFormatter; +import android.text.TextDirectionHeuristics; +import android.text.TextUtils; +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; +import com.android.dialer.app.DialtactsActivity; +import com.android.dialer.app.R; +import com.android.dialer.app.calllog.CallLogNotificationsHelper.NewCall; +import com.android.dialer.app.contactinfo.ContactPhotoLoader; +import com.android.dialer.app.list.ListsFragment; +import com.android.dialer.callintent.CallIntentBuilder; +import com.android.dialer.callintent.nano.CallInitiationType; +import com.android.dialer.common.ConfigProviderBindings; +import com.android.dialer.common.LogUtil; +import com.android.dialer.phonenumbercache.ContactInfo; +import com.android.dialer.phonenumberutil.PhoneNumberHelper; +import com.android.dialer.util.DialerUtils; +import com.android.dialer.util.IntentUtil; +import java.util.List; + +/** Creates a notification for calls that the user missed (neither answered nor rejected). */ +public class 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; + + private static MissedCallNotifier sInstance; + private Context mContext; + private CallLogNotificationsHelper mCalllogNotificationsHelper; + + @VisibleForTesting + MissedCallNotifier(Context context, CallLogNotificationsHelper callLogNotificationsHelper) { + mContext = context; + mCalllogNotificationsHelper = callLogNotificationsHelper; + } + + /** Returns the singleton instance of the {@link MissedCallNotifier}. */ + public static MissedCallNotifier getInstance(Context context) { + if (sInstance == null) { + CallLogNotificationsHelper callLogNotificationsHelper = + CallLogNotificationsHelper.getInstance(context); + sInstance = new MissedCallNotifier(context, callLogNotificationsHelper); + } + return sInstance; + } + + /** + * Creates a missed call notification with a post call message if there are no existing missed + * calls. + */ + public void createPostCallMessageNotification(String number, String message) { + int count = CallLogNotificationsService.UNKNOWN_MISSED_CALL_COUNT; + if (ConfigProviderBindings.get(mContext).getBoolean("enable_call_compose", false)) { + updateMissedCallNotification(count, number, message); + } else { + updateMissedCallNotification(count, number, null); + } + } + + /** Creates a missed call notification. */ + public void updateMissedCallNotification(int count, String number) { + updateMissedCallNotification(count, number, null); + } + + private void updateMissedCallNotification( + int count, String number, @Nullable String postCallMessage) { + final int titleResId; + CharSequence expandedText; // The text in the notification's line 1 and 2. + + final List<NewCall> newCalls = mCalllogNotificationsHelper.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(); + String missedNumber = useCallLog ? newestCall.number : number; + + 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 = + mCalllogNotificationsHelper.getContactInfo( + missedNumber, + 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; + if (TextUtils.equals(contactInfo.name, contactInfo.formattedNumber) + || TextUtils.equals(contactInfo.name, contactInfo.number)) { + expandedText = + PhoneNumberUtilsCompat.createTtsSpannable( + BidiFormatter.getInstance() + .unicodeWrap(contactInfo.name, TextDirectionHeuristics.LTR)); + } else { + expandedText = contactInfo.name; + } + + if (!TextUtils.isEmpty(postCallMessage)) { + // Ex. "John Doe: Hey dude" + expandedText = + mContext.getString( + R.string.post_call_notification_message, expandedText, postCallMessage); + } + 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) + .setShowWhen(true) + .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) + .setShowWhen(true) + .setDefaults(Notification.DEFAULT_VIBRATE) + .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(missedNumber) + && !TextUtils.equals(missedNumber, mContext.getString(R.string.handle_restricted))) { + builder.addAction( + R.drawable.ic_phone_24dp, + mContext.getString(R.string.notification_missedCall_call_back), + createCallBackPendingIntent(missedNumber)); + + if (!PhoneNumberHelper.isUriNumber(missedNumber)) { + builder.addAction( + R.drawable.ic_message_24dp, + mContext.getString(R.string.notification_missedCall_message), + createSendSmsFromNotificationPendingIntent(missedNumber)); + } + } + } + + Notification notification = builder.build(); + configureLedOnNotification(notification); + + LogUtil.i("MissedCallNotifier.updateMissedCallNotification", "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) { + LogUtil.e( + "MissedCallNotifier.clearMissedCalls", + "contacts provider 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, CallInitiationType.Type.MISSED_CALL_NOTIFICATION) + .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 = + DialtactsActivity.getShowTabIntent(mContext, 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); + // Use FLAG_UPDATE_CURRENT to make sure any previous pending intent is updated with the new + // extra. + return PendingIntent.getService(mContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + 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); + // Use FLAG_UPDATE_CURRENT to make sure any previous pending intent is updated with the new + // extra. + return PendingIntent.getService(mContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + /** 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/java/com/android/dialer/app/calllog/PhoneAccountUtils.java b/java/com/android/dialer/app/calllog/PhoneAccountUtils.java new file mode 100644 index 000000000..c6d94d341 --- /dev/null +++ b/java/com/android/dialer/app/calllog/PhoneAccountUtils.java @@ -0,0 +1,104 @@ +/* + * 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.app.calllog; + +import android.content.ComponentName; +import android.content.Context; +import android.support.annotation.Nullable; +import android.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.text.TextUtils; +import com.android.dialer.telecom.TelecomUtil; +import java.util.ArrayList; +import java.util.List; + +/** Methods to help extract {@code PhoneAccount} information from database and Telecomm sources. */ +public class PhoneAccountUtils { + + /** Return a list of phone accounts that are subscription/SIM accounts. */ + public static List<PhoneAccountHandle> getSubscriptionPhoneAccounts(Context context) { + List<PhoneAccountHandle> subscriptionAccountHandles = new ArrayList<PhoneAccountHandle>(); + final List<PhoneAccountHandle> accountHandles = + TelecomUtil.getCallCapablePhoneAccounts(context); + for (PhoneAccountHandle accountHandle : accountHandles) { + PhoneAccount account = TelecomUtil.getPhoneAccount(context, accountHandle); + if (account.hasCapabilities(PhoneAccount.CAPABILITY_SIM_SUBSCRIPTION)) { + subscriptionAccountHandles.add(accountHandle); + } + } + return subscriptionAccountHandles; + } + + /** Compose PhoneAccount object from component name and account id. */ + @Nullable + public static PhoneAccountHandle getAccount( + @Nullable String componentString, @Nullable String accountId) { + if (TextUtils.isEmpty(componentString) || TextUtils.isEmpty(accountId)) { + return null; + } + final ComponentName componentName = ComponentName.unflattenFromString(componentString); + if (componentName == null) { + return null; + } + return new PhoneAccountHandle(componentName, accountId); + } + + /** Extract account label from PhoneAccount object. */ + @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(); + } + return null; + } + + /** Extract account color from PhoneAccount object. */ + 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. + return account == null ? PhoneAccount.NO_HIGHLIGHT_COLOR : account.getHighlightColor(); + } + + /** + * Determine whether a phone account supports call subjects. + * + * @return {@code true} if call subjects are supported, {@code false} otherwise. + */ + public static boolean getAccountSupportsCallSubject( + Context context, @Nullable PhoneAccountHandle accountHandle) { + final PhoneAccount account = TelecomUtil.getPhoneAccount(context, accountHandle); + + return account != null && account.hasCapabilities(PhoneAccount.CAPABILITY_CALL_SUBJECT); + } + + /** + * Retrieve the account metadata, but if the account does not exist or the device has only a + * single registered and enabled account, return null. + */ + @Nullable + private static PhoneAccount getAccountOrNull( + Context context, @Nullable PhoneAccountHandle accountHandle) { + if (TelecomUtil.getCallCapablePhoneAccounts(context).size() <= 1) { + return null; + } + return TelecomUtil.getPhoneAccount(context, accountHandle); + } +} diff --git a/java/com/android/dialer/app/calllog/PhoneCallDetailsHelper.java b/java/com/android/dialer/app/calllog/PhoneCallDetailsHelper.java new file mode 100644 index 000000000..b18270bb3 --- /dev/null +++ b/java/com/android/dialer/app/calllog/PhoneCallDetailsHelper.java @@ -0,0 +1,352 @@ +/* + * 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.app.calllog; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Typeface; +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; +import android.view.View; +import android.widget.TextView; +import com.android.dialer.app.PhoneCallDetails; +import com.android.dialer.app.R; +import com.android.dialer.app.calllog.calllogcache.CallLogCache; +import com.android.dialer.phonenumberutil.PhoneNumberHelper; +import com.android.dialer.util.DialerUtils; +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; + + private final Context mContext; + private final Resources mResources; + private final CallLogCache mCallLogCache; + /** Calendar used to construct dates */ + private final Calendar mCalendar; + /** The injected current time in milliseconds since the epoch. Used only by tests. */ + private Long mCurrentTimeMillisForTest; + + private CharSequence mPhoneTypeLabelForTest; + /** List of items to be concatenated together for accessibility descriptions */ + private ArrayList<CharSequence> mDescriptionItems = new ArrayList<>(); + + /** + * Creates a new instance of the helper. + * + * <p>Generally you should have a single instance of this helper in any context. + * + * @param resources used to look up strings + */ + public PhoneCallDetailsHelper(Context context, Resources resources, CallLogCache callLogCache) { + mContext = context; + mResources = resources; + mCallLogCache = callLogCache; + mCalendar = Calendar.getInstance(); + } + + /** Fills the call details views with content. */ + public void setPhoneCallDetails(PhoneCallDetailsViews views, PhoneCallDetails details) { + // Display up to a given number of icons. + views.callTypeIcons.clear(); + int count = details.callTypes.length; + boolean isVoicemail = false; + for (int index = 0; index < count && index < MAX_CALL_TYPE_ICONS; ++index) { + views.callTypeIcons.add(details.callTypes[index]); + if (index == 0) { + isVoicemail = details.callTypes[index] == Calls.VOICEMAIL_TYPE; + } + } + + // Show the video icon if the call had video enabled. + views.callTypeIcons.setShowVideo( + (details.features & Calls.FEATURES_VIDEO) == Calls.FEATURES_VIDEO); + views.callTypeIcons.requestLayout(); + views.callTypeIcons.setVisibility(View.VISIBLE); + + // Show the total call count only if there are more than the maximum number of icons. + final Integer callCount; + if (count > MAX_CALL_TYPE_ICONS) { + callCount = count; + } else { + callCount = null; + } + + // 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 = mCallLogCache.getAccountLabel(details.accountHandle); + if (!TextUtils.isEmpty(details.viaNumber)) { + if (!TextUtils.isEmpty(accountLabel)) { + accountLabel = + mResources.getString( + R.string.call_log_via_number_phone_account, accountLabel, details.viaNumber); + } else { + accountLabel = mResources.getString(R.string.call_log_via_number, details.viaNumber); + } + } + if (!TextUtils.isEmpty(accountLabel)) { + views.callAccountLabel.setVisibility(View.VISIBLE); + views.callAccountLabel.setText(accountLabel); + int color = mCallLogCache.getAccountColor(details.accountHandle); + if (color == PhoneAccount.NO_HIGHLIGHT_COLOR) { + int defaultColor = R.color.dialer_secondary_text_color; + views.callAccountLabel.setTextColor(mContext.getResources().getColor(defaultColor)); + } else { + views.callAccountLabel.setTextColor(color); + } + } else { + views.callAccountLabel.setVisibility(View.GONE); + } + + final CharSequence nameText; + final CharSequence displayNumber = details.displayNumber; + 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.getPreferredName(); + } + + views.nameView.setText(nameText); + + if (isVoicemail) { + views.voicemailTranscriptionView.setText( + TextUtils.isEmpty(details.transcription) ? null : details.transcription); + } + + // Bold if not read + Typeface typeface = details.isRead ? Typeface.SANS_SERIF : Typeface.DEFAULT_BOLD; + 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. 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. + */ + public CharSequence getCallLocationAndDate(PhoneCallDetails details) { + mDescriptionItems.clear(); + + 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); + } + } + + // The date of this call + mDescriptionItems.add(getCallDate(details)); + + // Create a comma separated list from the call type or location, and call date. + return DialerUtils.join(mDescriptionItems); + } + + /** + * 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). + */ + public CharSequence getCallTypeOrLocation(PhoneCallDetails details) { + if (details.isSpam) { + return mResources.getString(R.string.spam_number_call_log_label); + } else if (details.isBlocked) { + return mResources.getString(R.string.blocked_number_call_log_label); + } + + CharSequence numberFormattedLabel = null; + // 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()) + && !mCallLogCache.isVoicemailNumber(details.accountHandle, details.number)) { + + 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 = + mPhoneTypeLabelForTest != null + ? mPhoneTypeLabelForTest + : Phone.getTypeLabel(mResources, details.numberType, details.numberLabel); + } + } + + if (!TextUtils.isEmpty(details.namePrimary) && TextUtils.isEmpty(numberFormattedLabel)) { + numberFormattedLabel = details.displayNumber; + } + return numberFormattedLabel; + } + + public void setPhoneTypeLabelForTest(CharSequence phoneTypeLabel) { + this.mPhoneTypeLabelForTest = phoneTypeLabel; + } + + /** + * 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) { + 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. + * + * <p>If created today, DATE is 'Today' If created this year, DATE is 'MMM dd' Otherwise, DATE is + * 'MMM dd, yyyy' + * + * <p>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. */ + public void setCallDetailsHeader(TextView nameView, PhoneCallDetails details) { + final CharSequence nameText; + if (!TextUtils.isEmpty(details.namePrimary)) { + nameText = details.namePrimary; + } else if (!TextUtils.isEmpty(details.displayNumber)) { + nameText = details.displayNumber; + } else { + nameText = mResources.getString(R.string.unknown); + } + + nameView.setText(nameText); + } + + public void setCurrentTimeForTest(long currentTimeMillis) { + mCurrentTimeMillisForTest = currentTimeMillis; + } + + /** + * Returns the current time in milliseconds since the epoch. + * + * <p>It can be injected in tests using {@link #setCurrentTimeForTest(long)}. + */ + private long getCurrentTimeMillis() { + if (mCurrentTimeMillisForTest == null) { + return System.currentTimeMillis(); + } else { + return mCurrentTimeMillisForTest; + } + } + + /** 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 = details.callLocationAndDate; + final CharSequence text; + if (callCount != null) { + text = mResources.getString(R.string.call_log_item_count_and_date, callCount, dateText); + } else { + text = dateText; + } + + 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/java/com/android/dialer/app/calllog/PhoneCallDetailsViews.java b/java/com/android/dialer/app/calllog/PhoneCallDetailsViews.java new file mode 100644 index 000000000..476996826 --- /dev/null +++ b/java/com/android/dialer/app/calllog/PhoneCallDetailsViews.java @@ -0,0 +1,75 @@ +/* + * 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.app.calllog; + +import android.content.Context; +import android.view.View; +import android.widget.TextView; +import com.android.dialer.app.R; + +/** Encapsulates the views that are used to display the details of a phone call in the call log. */ +public final class PhoneCallDetailsViews { + + public final TextView nameView; + public final View callTypeView; + public final CallTypeIconsView callTypeIcons; + public final TextView callLocationAndDate; + public final TextView voicemailTranscriptionView; + public final TextView callAccountLabel; + + private PhoneCallDetailsViews( + TextView nameView, + View callTypeView, + CallTypeIconsView callTypeIcons, + TextView callLocationAndDate, + TextView voicemailTranscriptionView, + TextView callAccountLabel) { + this.nameView = nameView; + this.callTypeView = callTypeView; + this.callTypeIcons = callTypeIcons; + this.callLocationAndDate = callLocationAndDate; + this.voicemailTranscriptionView = voicemailTranscriptionView; + this.callAccountLabel = callAccountLabel; + } + + /** + * Create a new instance by extracting the elements from the given view. + * + * <p>The view should contain three text views with identifiers {@code R.id.name}, {@code + * R.id.date}, and {@code R.id.number}, and a linear layout with identifier {@code + * R.id.call_types}. + */ + public static PhoneCallDetailsViews fromView(View view) { + return new PhoneCallDetailsViews( + (TextView) view.findViewById(R.id.name), + view.findViewById(R.id.call_type), + (CallTypeIconsView) view.findViewById(R.id.call_type_icons), + (TextView) view.findViewById(R.id.call_location_and_date), + (TextView) view.findViewById(R.id.voicemail_transcription), + (TextView) view.findViewById(R.id.call_account_label)); + } + + public static PhoneCallDetailsViews createForTest(Context context) { + return new PhoneCallDetailsViews( + new TextView(context), + new View(context), + new CallTypeIconsView(context), + new TextView(context), + new TextView(context), + new TextView(context)); + } +} diff --git a/java/com/android/dialer/app/calllog/PhoneNumberDisplayUtil.java b/java/com/android/dialer/app/calllog/PhoneNumberDisplayUtil.java new file mode 100644 index 000000000..410d4cc37 --- /dev/null +++ b/java/com/android/dialer/app/calllog/PhoneNumberDisplayUtil.java @@ -0,0 +1,85 @@ +/* + * 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.app.calllog; + +import android.content.Context; +import android.provider.CallLog.Calls; +import android.text.BidiFormatter; +import android.text.TextDirectionHeuristics; +import android.text.TextUtils; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; +import com.android.dialer.app.R; +import com.android.dialer.phonenumberutil.PhoneNumberHelper; + +/** Helper for formatting and managing the display of phone numbers. */ +public class PhoneNumberDisplayUtil { + + /** Returns the string to display for the given phone number if there is no matching contact. */ + /* package */ + static CharSequence getDisplayName( + Context context, CharSequence number, int presentation, boolean isVoicemail) { + if (presentation == Calls.PRESENTATION_UNKNOWN) { + return context.getResources().getString(R.string.unknown); + } + if (presentation == Calls.PRESENTATION_RESTRICTED) { + return PhoneNumberHelper.getDisplayNameForRestrictedNumber(context); + } + if (presentation == Calls.PRESENTATION_PAYPHONE) { + return context.getResources().getString(R.string.payphone); + } + if (isVoicemail) { + return context.getResources().getString(R.string.voicemail); + } + if (PhoneNumberHelper.isLegacyUnknownNumbers(number)) { + return context.getResources().getString(R.string.unknown); + } + return ""; + } + + /** + * Returns the string to display for the given phone number. + * + * @param number the number to display + * @param formattedNumber the formatted number if available, may be null + */ + public static CharSequence getDisplayNumber( + Context context, + CharSequence number, + int presentation, + CharSequence formattedNumber, + CharSequence postDialDigits, + boolean isVoicemail) { + final CharSequence displayName = getDisplayName(context, number, presentation, isVoicemail); + if (!TextUtils.isEmpty(displayName)) { + return getTtsSpannableLtrNumber(displayName); + } + + if (!TextUtils.isEmpty(formattedNumber)) { + return getTtsSpannableLtrNumber(formattedNumber); + } else if (!TextUtils.isEmpty(number)) { + return getTtsSpannableLtrNumber(number.toString() + postDialDigits); + } else { + return context.getResources().getString(R.string.unknown); + } + } + + /** Returns number annotated as phone number in LTR direction. */ + public static CharSequence getTtsSpannableLtrNumber(CharSequence number) { + return PhoneNumberUtilsCompat.createTtsSpannable( + BidiFormatter.getInstance().unicodeWrap(number.toString(), TextDirectionHeuristics.LTR)); + } +} diff --git a/java/com/android/dialer/app/calllog/VisualVoicemailCallLogFragment.java b/java/com/android/dialer/app/calllog/VisualVoicemailCallLogFragment.java new file mode 100644 index 000000000..e539ceef6 --- /dev/null +++ b/java/com/android/dialer/app/calllog/VisualVoicemailCallLogFragment.java @@ -0,0 +1,132 @@ +/* + * 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.app.calllog; + +import android.app.Activity; +import android.database.ContentObserver; +import android.media.AudioManager; +import android.os.Bundle; +import android.provider.CallLog; +import android.provider.VoicemailContract; +import android.support.annotation.Nullable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import com.android.dialer.app.R; +import com.android.dialer.app.list.ListsFragment; +import com.android.dialer.app.voicemail.VoicemailAudioManager; +import com.android.dialer.app.voicemail.VoicemailErrorManager; +import com.android.dialer.app.voicemail.VoicemailPlaybackPresenter; +import com.android.dialer.common.LogUtil; + +public class VisualVoicemailCallLogFragment extends CallLogFragment { + + private final ContentObserver mVoicemailStatusObserver = new CustomContentObserver(); + private VoicemailPlaybackPresenter mVoicemailPlaybackPresenter; + + private VoicemailErrorManager mVoicemailAlertManager; + + @Override + public void onCreate(Bundle state) { + super.onCreate(state); + mCallTypeFilter = CallLog.Calls.VOICEMAIL_TYPE; + mVoicemailPlaybackPresenter = VoicemailPlaybackPresenter.getInstance(getActivity(), state); + getActivity() + .getContentResolver() + .registerContentObserver( + VoicemailContract.Status.CONTENT_URI, true, mVoicemailStatusObserver); + } + + @Override + protected VoicemailPlaybackPresenter getVoicemailPlaybackPresenter() { + return mVoicemailPlaybackPresenter; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + mVoicemailAlertManager = + new VoicemailErrorManager(getContext(), getAdapter().getAlertManager(), mModalAlertManager); + getActivity() + .getContentResolver() + .registerContentObserver( + VoicemailContract.Status.CONTENT_URI, + true, + mVoicemailAlertManager.getContentObserver()); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) { + View view = inflater.inflate(R.layout.call_log_fragment, container, false); + setupView(view); + return view; + } + + @Override + public void onResume() { + super.onResume(); + mVoicemailPlaybackPresenter.onResume(); + mVoicemailAlertManager.onResume(); + } + + @Override + public void onPause() { + mVoicemailPlaybackPresenter.onPause(); + mVoicemailAlertManager.onPause(); + super.onPause(); + } + + @Override + public void onDestroy() { + getActivity() + .getContentResolver() + .unregisterContentObserver(mVoicemailAlertManager.getContentObserver()); + 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(); + } + + @Override + public void onPageResume(@Nullable Activity activity) { + LogUtil.d("VisualVoicemailCallLogFragment.onPageResume", null); + super.onPageResume(activity); + if (activity != null) { + activity.setVolumeControlStream(VoicemailAudioManager.PLAYBACK_STREAM); + } + } + + @Override + public void onPagePause(@Nullable Activity activity) { + LogUtil.d("VisualVoicemailCallLogFragment.onPagePause", null); + super.onPagePause(activity); + if (activity != null) { + activity.setVolumeControlStream(AudioManager.USE_DEFAULT_STREAM_TYPE); + } + } +} diff --git a/java/com/android/dialer/app/calllog/VoicemailQueryHandler.java b/java/com/android/dialer/app/calllog/VoicemailQueryHandler.java new file mode 100644 index 000000000..d6d8354ec --- /dev/null +++ b/java/com/android/dialer/app/calllog/VoicemailQueryHandler.java @@ -0,0 +1,74 @@ +/* + * 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.app.calllog; + +import android.content.AsyncQueryHandler; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.provider.CallLog.Calls; +import android.util.Log; + +/** Handles asynchronous queries to the call log for voicemail. */ +public class VoicemailQueryHandler extends AsyncQueryHandler { + + private static final String TAG = "VoicemailQueryHandler"; + + /** The token for the query to mark all new voicemails as old. */ + private static final int UPDATE_MARK_VOICEMAILS_AS_OLD_TOKEN = 50; + + private Context mContext; + + public VoicemailQueryHandler(Context context, ContentResolver contentResolver) { + super(contentResolver); + mContext = context; + } + + /** Updates all new voicemails to mark them as old. */ + public void markNewVoicemailsAsOld() { + // Mark all "new" voicemails as not new anymore. + StringBuilder where = new StringBuilder(); + where.append(Calls.NEW); + where.append(" = 1 AND "); + where.append(Calls.TYPE); + where.append(" = ?"); + + ContentValues values = new ContentValues(1); + values.put(Calls.NEW, "0"); + + startUpdate( + UPDATE_MARK_VOICEMAILS_AS_OLD_TOKEN, + null, + Calls.CONTENT_URI_WITH_VOICEMAIL, + values, + where.toString(), + new String[] {Integer.toString(Calls.VOICEMAIL_TYPE)}); + } + + @Override + protected void onUpdateComplete(int token, Object cookie, int result) { + if (token == UPDATE_MARK_VOICEMAILS_AS_OLD_TOKEN) { + if (mContext != null) { + Intent serviceIntent = new Intent(mContext, CallLogNotificationsService.class); + serviceIntent.setAction(CallLogNotificationsService.ACTION_UPDATE_VOICEMAIL_NOTIFICATIONS); + mContext.startService(serviceIntent); + } else { + Log.w(TAG, "Unknown update completed: ignoring: " + token); + } + } + } +} diff --git a/java/com/android/dialer/app/calllog/calllogcache/CallLogCache.java b/java/com/android/dialer/app/calllog/calllogcache/CallLogCache.java new file mode 100644 index 000000000..7645a333e --- /dev/null +++ b/java/com/android/dialer/app/calllog/calllogcache/CallLogCache.java @@ -0,0 +1,105 @@ +/* + * 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.app.calllog.calllogcache; + +import android.content.Context; +import android.telecom.PhoneAccountHandle; +import com.android.dialer.app.calllog.CallLogAdapter; +import com.android.dialer.compat.CompatUtils; +import com.android.dialer.util.CallUtil; + +/** + * This is the base class for the CallLogCaches. + * + * <p>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. + * + * <p>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 mHasCheckedForVideoAvailability; + private int mVideoAvailability; + + 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() { + mHasCheckedForVideoAvailability = false; + mVideoAvailability = 0; + } + + /** + * 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); + + /** + * Returns {@code true} when the current sim supports video calls, regardless of the value in a + * contact's {@link android.provider.ContactsContract.CommonDataKinds.Phone#CARRIER_PRESENCE} + * column. + */ + public boolean isVideoEnabled() { + if (!mHasCheckedForVideoAvailability) { + mVideoAvailability = CallUtil.getVideoCallingAvailability(mContext); + mHasCheckedForVideoAvailability = true; + } + return (mVideoAvailability & CallUtil.VIDEO_CALLING_ENABLED) != 0; + } + + /** + * Returns {@code true} when the current sim supports checking video calling capabilities via the + * {@link android.provider.ContactsContract.CommonDataKinds.Phone#CARRIER_PRESENCE} column. + */ + public boolean canRelyOnVideoPresence() { + if (!mHasCheckedForVideoAvailability) { + mVideoAvailability = CallUtil.getVideoCallingAvailability(mContext); + mHasCheckedForVideoAvailability = true; + } + return (mVideoAvailability & CallUtil.VIDEO_CALLING_PRESENCE) != 0; + } + + /** 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/java/com/android/dialer/app/calllog/calllogcache/CallLogCacheLollipop.java b/java/com/android/dialer/app/calllog/calllogcache/CallLogCacheLollipop.java new file mode 100644 index 000000000..78aaa4193 --- /dev/null +++ b/java/com/android/dialer/app/calllog/calllogcache/CallLogCacheLollipop.java @@ -0,0 +1,74 @@ +/* + * 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.app.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). + * + * <p>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/java/com/android/dialer/app/calllog/calllogcache/CallLogCacheLollipopMr1.java b/java/com/android/dialer/app/calllog/calllogcache/CallLogCacheLollipopMr1.java new file mode 100644 index 000000000..c342b7e3b --- /dev/null +++ b/java/com/android/dialer/app/calllog/calllogcache/CallLogCacheLollipopMr1.java @@ -0,0 +1,116 @@ +/* + * 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.app.calllog.calllogcache; + +import android.content.Context; +import android.support.annotation.VisibleForTesting; +import android.telecom.PhoneAccountHandle; +import android.text.TextUtils; +import android.util.Pair; +import com.android.dialer.app.calllog.PhoneAccountUtils; +import com.android.dialer.phonenumberutil.PhoneNumberHelper; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * This is the CallLogCache for versions of dialer Lollipop Mr1 and above with support for multi-SIM + * devices. + * + * <p>This class should not be initialized directly and instead be acquired from {@link + * CallLogCache#getCallLogCache}. + */ +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. Access must be synchronzied + * as it's used in the background thread in CallLogAdapter. {@see CallLogAdapter#loadData} + */ + @VisibleForTesting + final Map<Pair<PhoneAccountHandle, CharSequence>, Boolean> mVoicemailQueryCache = + new ConcurrentHashMap<>(); + + private final Map<PhoneAccountHandle, String> mPhoneAccountLabelCache = new HashMap<>(); + private final Map<PhoneAccountHandle, Integer> mPhoneAccountColorCache = new HashMap<>(); + private final Map<PhoneAccountHandle, Boolean> mPhoneAccountCallWithNoteCache = new HashMap<>(); + + /* package */ CallLogCacheLollipopMr1(Context context) { + super(context); + } + + @Override + public void reset() { + mVoicemailQueryCache.clear(); + mPhoneAccountLabelCache.clear(); + mPhoneAccountColorCache.clear(); + mPhoneAccountCallWithNoteCache.clear(); + + super.reset(); + } + + @Override + public boolean isVoicemailNumber(PhoneAccountHandle accountHandle, CharSequence number) { + if (TextUtils.isEmpty(number)) { + return false; + } + + Pair<PhoneAccountHandle, CharSequence> key = new Pair<>(accountHandle, number); + Boolean value = mVoicemailQueryCache.get(key); + if (value != null) { + return value; + } + boolean isVoicemail = + PhoneNumberHelper.isVoicemailNumber(mContext, accountHandle, number.toString()); + mVoicemailQueryCache.put(key, isVoicemail); + return isVoicemail; + } + + @Override + public String getAccountLabel(PhoneAccountHandle accountHandle) { + if (mPhoneAccountLabelCache.containsKey(accountHandle)) { + return mPhoneAccountLabelCache.get(accountHandle); + } else { + String label = PhoneAccountUtils.getAccountLabel(mContext, accountHandle); + mPhoneAccountLabelCache.put(accountHandle, label); + return label; + } + } + + @Override + public int getAccountColor(PhoneAccountHandle accountHandle) { + if (mPhoneAccountColorCache.containsKey(accountHandle)) { + return mPhoneAccountColorCache.get(accountHandle); + } else { + Integer color = PhoneAccountUtils.getAccountColor(mContext, accountHandle); + mPhoneAccountColorCache.put(accountHandle, color); + return color; + } + } + + @Override + public boolean doesAccountSupportCallSubject(PhoneAccountHandle accountHandle) { + if (mPhoneAccountCallWithNoteCache.containsKey(accountHandle)) { + return mPhoneAccountCallWithNoteCache.get(accountHandle); + } else { + Boolean supportsCallWithNote = + PhoneAccountUtils.getAccountSupportsCallSubject(mContext, accountHandle); + mPhoneAccountCallWithNoteCache.put(accountHandle, supportsCallWithNote); + return supportsCallWithNote; + } + } +} |