diff options
Diffstat (limited to 'src/com/android/dialer/calllog')
22 files changed, 3876 insertions, 0 deletions
diff --git a/src/com/android/dialer/calllog/CallDetailHistoryAdapter.java b/src/com/android/dialer/calllog/CallDetailHistoryAdapter.java new file mode 100644 index 000000000..38dc72722 --- /dev/null +++ b/src/com/android/dialer/calllog/CallDetailHistoryAdapter.java @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.content.Context; +import android.provider.CallLog.Calls; +import android.text.format.DateUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; + +import com.android.dialer.PhoneCallDetails; +import com.android.contacts.R; + +/** + * Adapter for a ListView containing history items from the details of a call. + */ +public class CallDetailHistoryAdapter extends BaseAdapter { + /** The top element is a blank header, which is hidden under the rest of the UI. */ + private static final int VIEW_TYPE_HEADER = 0; + /** Each history item shows the detail of a call. */ + private static final int VIEW_TYPE_HISTORY_ITEM = 1; + + private final Context mContext; + private final LayoutInflater mLayoutInflater; + private final CallTypeHelper mCallTypeHelper; + private final PhoneCallDetails[] mPhoneCallDetails; + /** Whether the voicemail controls are shown. */ + private final boolean mShowVoicemail; + /** Whether the call and SMS controls are shown. */ + private final boolean mShowCallAndSms; + /** The controls that are shown on top of the history list. */ + private final View mControls; + /** The listener to changes of focus of the header. */ + private View.OnFocusChangeListener mHeaderFocusChangeListener = + new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View v, boolean hasFocus) { + // When the header is focused, focus the controls above it instead. + if (hasFocus) { + mControls.requestFocus(); + } + } + }; + + public CallDetailHistoryAdapter(Context context, LayoutInflater layoutInflater, + CallTypeHelper callTypeHelper, PhoneCallDetails[] phoneCallDetails, + boolean showVoicemail, boolean showCallAndSms, View controls) { + mContext = context; + mLayoutInflater = layoutInflater; + mCallTypeHelper = callTypeHelper; + mPhoneCallDetails = phoneCallDetails; + mShowVoicemail = showVoicemail; + mShowCallAndSms = showCallAndSms; + mControls = controls; + } + + @Override + public boolean isEnabled(int position) { + // None of history will be clickable. + return false; + } + + @Override + public int getCount() { + return mPhoneCallDetails.length + 1; + } + + @Override + public Object getItem(int position) { + if (position == 0) { + return null; + } + return mPhoneCallDetails[position - 1]; + } + + @Override + public long getItemId(int position) { + if (position == 0) { + return -1; + } + return position - 1; + } + + @Override + public int getViewTypeCount() { + return 2; + } + + @Override + public int getItemViewType(int position) { + if (position == 0) { + return VIEW_TYPE_HEADER; + } + return VIEW_TYPE_HISTORY_ITEM; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (position == 0) { + final View header = convertView == null + ? mLayoutInflater.inflate(R.layout.call_detail_history_header, parent, false) + : convertView; + // Voicemail controls are only shown in the main UI if there is a voicemail. + View voicemailContainer = header.findViewById(R.id.header_voicemail_container); + voicemailContainer.setVisibility(mShowVoicemail ? View.VISIBLE : View.GONE); + // Call and SMS controls are only shown in the main UI if there is a known number. + View callAndSmsContainer = header.findViewById(R.id.header_call_and_sms_container); + callAndSmsContainer.setVisibility(mShowCallAndSms ? View.VISIBLE : View.GONE); + header.setFocusable(true); + header.setOnFocusChangeListener(mHeaderFocusChangeListener); + return header; + } + + // Make sure we have a valid convertView to start with + final View result = convertView == null + ? mLayoutInflater.inflate(R.layout.call_detail_history_item, parent, false) + : convertView; + + PhoneCallDetails details = mPhoneCallDetails[position - 1]; + 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]; + callTypeIconView.clear(); + callTypeIconView.add(callType); + callTypeTextView.setText(mCallTypeHelper.getCallTypeText(callType)); + // Set the date. + CharSequence dateValue = DateUtils.formatDateRange(mContext, details.date, details.date, + DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_DATE | + DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_SHOW_YEAR); + dateView.setText(dateValue); + // Set the duration + if (callType == Calls.MISSED_TYPE || callType == Calls.VOICEMAIL_TYPE) { + durationView.setVisibility(View.GONE); + } else { + durationView.setVisibility(View.VISIBLE); + durationView.setText(formatDuration(details.duration)); + } + + return result; + } + + private String 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); + } +} diff --git a/src/com/android/dialer/calllog/CallLogAdapter.java b/src/com/android/dialer/calllog/CallLogAdapter.java new file mode 100644 index 000000000..217f59765 --- /dev/null +++ b/src/com/android/dialer/calllog/CallLogAdapter.java @@ -0,0 +1,802 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.content.ContentValues; +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.os.Handler; +import android.os.Message; +import android.provider.CallLog.Calls; +import android.provider.ContactsContract.PhoneLookup; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; + +import com.android.common.widget.GroupingListAdapter; +import com.android.contacts.ContactPhotoManager; +import com.android.dialer.PhoneCallDetails; +import com.android.dialer.PhoneCallDetailsHelper; +import com.android.contacts.R; +import com.android.dialer.util.ExpirableCache; +import com.android.contacts.util.UriUtils; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Objects; + +import java.util.LinkedList; + +/** + * Adapter class to fill in data for the Call Log. + */ +/*package*/ class CallLogAdapter extends GroupingListAdapter + implements ViewTreeObserver.OnPreDrawListener, CallLogGroupBuilder.GroupCreator { + /** Interface used to initiate a refresh of the content. */ + public interface CallFetcher { + public void fetchCalls(); + } + + /** + * Stores a phone number of a call with the country code where it originally occurred. + * <p> + * Note the country does not necessarily specifies the country of the phone number itself, but + * it is the country in which the user was in when the call was placed or received. + */ + private static final class NumberWithCountryIso { + public final String number; + public final String countryIso; + + public NumberWithCountryIso(String number, String countryIso) { + this.number = number; + this.countryIso = countryIso; + } + + @Override + public boolean equals(Object o) { + if (o == null) return false; + if (!(o instanceof NumberWithCountryIso)) return false; + NumberWithCountryIso other = (NumberWithCountryIso) o; + return TextUtils.equals(number, other.number) + && TextUtils.equals(countryIso, other.countryIso); + } + + @Override + public int hashCode() { + return (number == null ? 0 : number.hashCode()) + ^ (countryIso == null ? 0 : countryIso.hashCode()); + } + } + + /** The time in millis to delay starting the thread processing requests. */ + private static final int START_PROCESSING_REQUESTS_DELAY_MILLIS = 1000; + + /** The size of the cache of contact info. */ + private static final int CONTACT_INFO_CACHE_SIZE = 100; + + private final Context mContext; + private final ContactInfoHelper mContactInfoHelper; + private final CallFetcher mCallFetcher; + private ViewTreeObserver mViewTreeObserver = null; + + /** + * A cache of the contact details for the phone numbers in the call log. + * <p> + * The content of the cache is expired (but not purged) whenever the application comes to + * the foreground. + * <p> + * The key is number with the country in which the call was placed or received. + */ + private ExpirableCache<NumberWithCountryIso, ContactInfo> mContactInfoCache; + + /** + * A request for contact details for the given number. + */ + private static final class ContactInfoRequest { + /** The number to look-up. */ + public final String number; + /** The country in which a call to or from this number was placed or received. */ + public final String countryIso; + /** The cached contact information stored in the call log. */ + public final ContactInfo callLogInfo; + + public ContactInfoRequest(String number, String countryIso, ContactInfo callLogInfo) { + this.number = number; + this.countryIso = countryIso; + this.callLogInfo = callLogInfo; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (!(obj instanceof ContactInfoRequest)) return false; + + ContactInfoRequest other = (ContactInfoRequest) obj; + + if (!TextUtils.equals(number, other.number)) return false; + if (!TextUtils.equals(countryIso, other.countryIso)) return false; + if (!Objects.equal(callLogInfo, other.callLogInfo)) return false; + + return true; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((callLogInfo == null) ? 0 : callLogInfo.hashCode()); + result = prime * result + ((countryIso == null) ? 0 : countryIso.hashCode()); + result = prime * result + ((number == null) ? 0 : number.hashCode()); + return result; + } + } + + /** + * List of requests to update contact details. + * <p> + * Each request is made of a phone number to look up, and the contact info currently stored in + * the call log for this number. + * <p> + * The requests are added when displaying the contacts and are processed by a background + * thread. + */ + private final LinkedList<ContactInfoRequest> mRequests; + + private boolean mLoading = true; + private static final int REDRAW = 1; + private static final int START_THREAD = 2; + + private QueryThread mCallerIdThread; + + /** Instance of helper class for managing views. */ + private final CallLogListItemHelper mCallLogViewsHelper; + + /** Helper to set up contact photos. */ + private final ContactPhotoManager mContactPhotoManager; + /** Helper to parse and process phone numbers. */ + private PhoneNumberHelper mPhoneNumberHelper; + /** Helper to group call log entries. */ + private final CallLogGroupBuilder mCallLogGroupBuilder; + + /** Can be set to true by tests to disable processing of requests. */ + private volatile boolean mRequestProcessingDisabled = false; + + /** Listener for the primary action in the list, opens the call details. */ + private final View.OnClickListener mPrimaryActionListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + IntentProvider intentProvider = (IntentProvider) view.getTag(); + if (intentProvider != null) { + mContext.startActivity(intentProvider.getIntent(mContext)); + } + } + }; + /** Listener for the secondary action in the list, either call or play. */ + private final View.OnClickListener mSecondaryActionListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + IntentProvider intentProvider = (IntentProvider) view.getTag(); + if (intentProvider != null) { + mContext.startActivity(intentProvider.getIntent(mContext)); + } + } + }; + + @Override + public boolean onPreDraw() { + // We only wanted to listen for the first draw (and this is it). + unregisterPreDrawListener(); + + // Only schedule a thread-creation message if the thread hasn't been + // created yet. This is purely an optimization, to queue fewer messages. + if (mCallerIdThread == null) { + mHandler.sendEmptyMessageDelayed(START_THREAD, START_PROCESSING_REQUESTS_DELAY_MILLIS); + } + + return true; + } + + private Handler mHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case REDRAW: + notifyDataSetChanged(); + break; + case START_THREAD: + startRequestProcessing(); + break; + } + } + }; + + CallLogAdapter(Context context, CallFetcher callFetcher, + ContactInfoHelper contactInfoHelper) { + super(context); + + mContext = context; + mCallFetcher = callFetcher; + mContactInfoHelper = contactInfoHelper; + + mContactInfoCache = ExpirableCache.create(CONTACT_INFO_CACHE_SIZE); + mRequests = new LinkedList<ContactInfoRequest>(); + + Resources resources = mContext.getResources(); + CallTypeHelper callTypeHelper = new CallTypeHelper(resources); + + mContactPhotoManager = ContactPhotoManager.getInstance(mContext); + mPhoneNumberHelper = new PhoneNumberHelper(resources); + PhoneCallDetailsHelper phoneCallDetailsHelper = new PhoneCallDetailsHelper( + resources, callTypeHelper, mPhoneNumberHelper); + mCallLogViewsHelper = + new CallLogListItemHelper( + phoneCallDetailsHelper, mPhoneNumberHelper, resources); + mCallLogGroupBuilder = new CallLogGroupBuilder(this); + } + + /** + * Requery on background thread when {@link Cursor} changes. + */ + @Override + protected void onContentChanged() { + mCallFetcher.fetchCalls(); + } + + void setLoading(boolean loading) { + mLoading = loading; + } + + @Override + public boolean isEmpty() { + if (mLoading) { + // We don't want the empty state to show when loading. + return false; + } else { + return super.isEmpty(); + } + } + + /** + * Starts a background thread to process contact-lookup requests, unless one + * has already been started. + */ + private synchronized void startRequestProcessing() { + // For unit-testing. + if (mRequestProcessingDisabled) return; + + // Idempotence... if a thread is already started, don't start another. + if (mCallerIdThread != null) return; + + mCallerIdThread = new QueryThread(); + mCallerIdThread.setPriority(Thread.MIN_PRIORITY); + mCallerIdThread.start(); + } + + /** + * Stops the background thread that processes updates and cancels any + * pending requests to start it. + */ + public synchronized void stopRequestProcessing() { + // Remove any pending requests to start the processing thread. + mHandler.removeMessages(START_THREAD); + if (mCallerIdThread != null) { + // Stop the thread; we are finished with it. + mCallerIdThread.stopProcessing(); + mCallerIdThread.interrupt(); + mCallerIdThread = null; + } + } + + /** + * Stop receiving onPreDraw() notifications. + */ + private void unregisterPreDrawListener() { + if (mViewTreeObserver != null && mViewTreeObserver.isAlive()) { + mViewTreeObserver.removeOnPreDrawListener(this); + } + mViewTreeObserver = null; + } + + public void invalidateCache() { + mContactInfoCache.expireAll(); + + // Restart the request-processing thread after the next draw. + stopRequestProcessing(); + unregisterPreDrawListener(); + } + + /** + * Enqueues a request to look up the contact details for the given phone number. + * <p> + * It also provides the current contact info stored in the call log for this number. + * <p> + * If the {@code immediate} parameter is true, it will start immediately the thread that looks + * up the contact information (if it has not been already started). Otherwise, it will be + * started with a delay. See {@link #START_PROCESSING_REQUESTS_DELAY_MILLIS}. + */ + @VisibleForTesting + void enqueueRequest(String number, String countryIso, ContactInfo callLogInfo, + boolean immediate) { + ContactInfoRequest request = new ContactInfoRequest(number, countryIso, callLogInfo); + synchronized (mRequests) { + if (!mRequests.contains(request)) { + mRequests.add(request); + mRequests.notifyAll(); + } + } + if (immediate) startRequestProcessing(); + } + + /** + * Queries the appropriate content provider for the contact associated with the number. + * <p> + * Upon completion it also updates the cache in the call log, if it is different from + * {@code callLogInfo}. + * <p> + * The number might be either a SIP address or a phone number. + * <p> + * It returns true if it updated the content of the cache and we should therefore tell the + * view to update its content. + */ + private boolean queryContactInfo(String number, String countryIso, ContactInfo callLogInfo) { + final ContactInfo info = mContactInfoHelper.lookupNumber(number, countryIso); + + if (info == null) { + // The lookup failed, just return without requesting to update the view. + return false; + } + + // Check the existing entry in the cache: only if it has changed we should update the + // view. + NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); + ContactInfo existingInfo = mContactInfoCache.getPossiblyExpired(numberCountryIso); + boolean updated = (existingInfo != ContactInfo.EMPTY) && !info.equals(existingInfo); + + // Store the data in the cache so that the UI thread can use to display it. Store it + // even if it has not changed so that it is marked as not expired. + mContactInfoCache.put(numberCountryIso, info); + // Update the call log even if the cache it is up-to-date: it is possible that the cache + // contains the value from a different call log entry. + updateCallLogContactInfoCache(number, countryIso, info, callLogInfo); + return updated; + } + + /* + * Handles requests for contact name and number type. + */ + private class QueryThread extends Thread { + private volatile boolean mDone = false; + + public QueryThread() { + super("CallLogAdapter.QueryThread"); + } + + public void stopProcessing() { + mDone = true; + } + + @Override + public void run() { + boolean needRedraw = false; + while (true) { + // Check if thread is finished, and if so return immediately. + if (mDone) return; + + // Obtain next request, if any is available. + // Keep synchronized section small. + ContactInfoRequest req = null; + synchronized (mRequests) { + if (!mRequests.isEmpty()) { + req = mRequests.removeFirst(); + } + } + + if (req != null) { + // Process the request. If the lookup succeeds, schedule a + // redraw. + needRedraw |= queryContactInfo(req.number, req.countryIso, req.callLogInfo); + } else { + // Throttle redraw rate by only sending them when there are + // more requests. + if (needRedraw) { + needRedraw = false; + mHandler.sendEmptyMessage(REDRAW); + } + + // Wait until another request is available, or until this + // thread is no longer needed (as indicated by being + // interrupted). + try { + synchronized (mRequests) { + mRequests.wait(1000); + } + } catch (InterruptedException ie) { + // Ignore, and attempt to continue processing requests. + } + } + } + } + } + + @Override + protected void addGroups(Cursor cursor) { + mCallLogGroupBuilder.addGroups(cursor); + } + + @Override + protected View newStandAloneView(Context context, ViewGroup parent) { + LayoutInflater inflater = + (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(R.layout.call_log_list_item, parent, false); + findAndCacheViews(view); + return view; + } + + @Override + protected void bindStandAloneView(View view, Context context, Cursor cursor) { + bindView(view, cursor, 1); + } + + @Override + protected View newChildView(Context context, ViewGroup parent) { + LayoutInflater inflater = + (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(R.layout.call_log_list_item, parent, false); + findAndCacheViews(view); + return view; + } + + @Override + protected void bindChildView(View view, Context context, Cursor cursor) { + bindView(view, cursor, 1); + } + + @Override + protected View newGroupView(Context context, ViewGroup parent) { + LayoutInflater inflater = + (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(R.layout.call_log_list_item, parent, false); + findAndCacheViews(view); + return view; + } + + @Override + protected void bindGroupView(View view, Context context, Cursor cursor, int groupSize, + boolean expanded) { + bindView(view, cursor, groupSize); + } + + private void findAndCacheViews(View view) { + // Get the views to bind to. + CallLogListItemViews views = CallLogListItemViews.fromView(view); + views.primaryActionView.setOnClickListener(mPrimaryActionListener); + views.secondaryActionView.setOnClickListener(mSecondaryActionListener); + view.setTag(views); + } + + /** + * Binds the views in the entry to the data in the call log. + * + * @param view the view corresponding to this entry + * @param c the cursor pointing to the entry in the call log + * @param count the number of entries in the current item, greater than 1 if it is a group + */ + private void bindView(View view, Cursor c, int count) { + final CallLogListItemViews views = (CallLogListItemViews) view.getTag(); + final int section = c.getInt(CallLogQuery.SECTION); + + // This might be a header: check the value of the section column in the cursor. + if (section == CallLogQuery.SECTION_NEW_HEADER + || section == CallLogQuery.SECTION_OLD_HEADER) { + views.primaryActionView.setVisibility(View.GONE); + views.bottomDivider.setVisibility(View.GONE); + views.listHeaderTextView.setVisibility(View.VISIBLE); + views.listHeaderTextView.setText( + section == CallLogQuery.SECTION_NEW_HEADER + ? R.string.call_log_new_header + : R.string.call_log_old_header); + // Nothing else to set up for a header. + return; + } + // Default case: an item in the call log. + views.primaryActionView.setVisibility(View.VISIBLE); + views.bottomDivider.setVisibility(isLastOfSection(c) ? View.GONE : View.VISIBLE); + views.listHeaderTextView.setVisibility(View.GONE); + + final String number = c.getString(CallLogQuery.NUMBER); + final long date = c.getLong(CallLogQuery.DATE); + final long duration = c.getLong(CallLogQuery.DURATION); + final int callType = c.getInt(CallLogQuery.CALL_TYPE); + final String countryIso = c.getString(CallLogQuery.COUNTRY_ISO); + + final ContactInfo cachedContactInfo = getContactInfoFromCallLog(c); + + views.primaryActionView.setTag( + IntentProvider.getCallDetailIntentProvider( + this, c.getPosition(), c.getLong(CallLogQuery.ID), count)); + // Store away the voicemail information so we can play it directly. + if (callType == Calls.VOICEMAIL_TYPE) { + String voicemailUri = c.getString(CallLogQuery.VOICEMAIL_URI); + final long rowId = c.getLong(CallLogQuery.ID); + views.secondaryActionView.setTag( + IntentProvider.getPlayVoicemailIntentProvider(rowId, voicemailUri)); + } else if (!TextUtils.isEmpty(number)) { + // Store away the number so we can call it directly if you click on the call icon. + views.secondaryActionView.setTag( + IntentProvider.getReturnCallIntentProvider(number)); + } else { + // No action enabled. + views.secondaryActionView.setTag(null); + } + + // Lookup contacts with this number + NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); + ExpirableCache.CachedValue<ContactInfo> cachedInfo = + mContactInfoCache.getCachedValue(numberCountryIso); + ContactInfo info = cachedInfo == null ? null : cachedInfo.getValue(); + if (!mPhoneNumberHelper.canPlaceCallsTo(number) + || mPhoneNumberHelper.isVoicemailNumber(number)) { + // If this is a number that cannot be dialed, there is no point in looking up a contact + // for it. + info = ContactInfo.EMPTY; + } else if (cachedInfo == null) { + mContactInfoCache.put(numberCountryIso, ContactInfo.EMPTY); + // Use the cached contact info from the call log. + info = cachedContactInfo; + // The db request should happen on a non-UI thread. + // Request the contact details immediately since they are currently missing. + enqueueRequest(number, countryIso, cachedContactInfo, true); + // We will format the phone number when we make the background request. + } else { + if (cachedInfo.isExpired()) { + // The contact info is no longer up to date, we should request it. However, we + // do not need to request them immediately. + enqueueRequest(number, countryIso, cachedContactInfo, false); + } else if (!callLogInfoMatches(cachedContactInfo, info)) { + // The call log information does not match the one we have, look it up again. + // We could simply update the call log directly, but that needs to be done in a + // background thread, so it is easier to simply request a new lookup, which will, as + // a side-effect, update the call log. + enqueueRequest(number, countryIso, cachedContactInfo, false); + } + + if (info == ContactInfo.EMPTY) { + // Use the cached contact info from the call log. + info = cachedContactInfo; + } + } + + final Uri lookupUri = info.lookupUri; + final String name = info.name; + final int ntype = info.type; + final String label = info.label; + final long photoId = info.photoId; + CharSequence formattedNumber = info.formattedNumber; + final int[] callTypes = getCallTypes(c, count); + final String geocode = c.getString(CallLogQuery.GEOCODED_LOCATION); + final PhoneCallDetails details; + if (TextUtils.isEmpty(name)) { + details = new PhoneCallDetails(number, formattedNumber, countryIso, geocode, + callTypes, date, duration); + } else { + // We do not pass a photo id since we do not need the high-res picture. + details = new PhoneCallDetails(number, formattedNumber, countryIso, geocode, + callTypes, date, duration, name, ntype, label, lookupUri, null); + } + + final boolean isNew = c.getInt(CallLogQuery.IS_READ) == 0; + // New items also use the highlighted version of the text. + final boolean isHighlighted = isNew; + mCallLogViewsHelper.setPhoneCallDetails(views, details, isHighlighted); + setPhoto(views, photoId, lookupUri); + + // Listen for the first draw + if (mViewTreeObserver == null) { + mViewTreeObserver = view.getViewTreeObserver(); + mViewTreeObserver.addOnPreDrawListener(this); + } + } + + /** Returns true if this is the last item of a section. */ + private boolean isLastOfSection(Cursor c) { + if (c.isLast()) return true; + final int section = c.getInt(CallLogQuery.SECTION); + if (!c.moveToNext()) return true; + final int nextSection = c.getInt(CallLogQuery.SECTION); + c.moveToPrevious(); + return section != nextSection; + } + + /** Checks whether the contact info from the call log matches the one from the contacts db. */ + private boolean callLogInfoMatches(ContactInfo callLogInfo, ContactInfo info) { + // The call log only contains a subset of the fields in the contacts db. + // Only check those. + return TextUtils.equals(callLogInfo.name, info.name) + && callLogInfo.type == info.type + && TextUtils.equals(callLogInfo.label, info.label); + } + + /** Stores the updated contact info in the call log if it is different from the current one. */ + private void updateCallLogContactInfoCache(String number, String countryIso, + ContactInfo updatedInfo, ContactInfo callLogInfo) { + final ContentValues values = new ContentValues(); + boolean needsUpdate = false; + + if (callLogInfo != null) { + if (!TextUtils.equals(updatedInfo.name, callLogInfo.name)) { + values.put(Calls.CACHED_NAME, updatedInfo.name); + needsUpdate = true; + } + + if (updatedInfo.type != callLogInfo.type) { + values.put(Calls.CACHED_NUMBER_TYPE, updatedInfo.type); + needsUpdate = true; + } + + if (!TextUtils.equals(updatedInfo.label, callLogInfo.label)) { + values.put(Calls.CACHED_NUMBER_LABEL, updatedInfo.label); + needsUpdate = true; + } + if (!UriUtils.areEqual(updatedInfo.lookupUri, callLogInfo.lookupUri)) { + values.put(Calls.CACHED_LOOKUP_URI, UriUtils.uriToString(updatedInfo.lookupUri)); + needsUpdate = true; + } + if (!TextUtils.equals(updatedInfo.normalizedNumber, callLogInfo.normalizedNumber)) { + values.put(Calls.CACHED_NORMALIZED_NUMBER, updatedInfo.normalizedNumber); + needsUpdate = true; + } + if (!TextUtils.equals(updatedInfo.number, callLogInfo.number)) { + values.put(Calls.CACHED_MATCHED_NUMBER, updatedInfo.number); + needsUpdate = true; + } + if (updatedInfo.photoId != callLogInfo.photoId) { + values.put(Calls.CACHED_PHOTO_ID, updatedInfo.photoId); + needsUpdate = true; + } + if (!TextUtils.equals(updatedInfo.formattedNumber, callLogInfo.formattedNumber)) { + values.put(Calls.CACHED_FORMATTED_NUMBER, updatedInfo.formattedNumber); + needsUpdate = true; + } + } else { + // No previous values, store all of them. + values.put(Calls.CACHED_NAME, updatedInfo.name); + values.put(Calls.CACHED_NUMBER_TYPE, updatedInfo.type); + values.put(Calls.CACHED_NUMBER_LABEL, updatedInfo.label); + values.put(Calls.CACHED_LOOKUP_URI, UriUtils.uriToString(updatedInfo.lookupUri)); + values.put(Calls.CACHED_MATCHED_NUMBER, updatedInfo.number); + values.put(Calls.CACHED_NORMALIZED_NUMBER, updatedInfo.normalizedNumber); + values.put(Calls.CACHED_PHOTO_ID, updatedInfo.photoId); + values.put(Calls.CACHED_FORMATTED_NUMBER, updatedInfo.formattedNumber); + needsUpdate = true; + } + + if (!needsUpdate) return; + + if (countryIso == null) { + mContext.getContentResolver().update(Calls.CONTENT_URI_WITH_VOICEMAIL, values, + Calls.NUMBER + " = ? AND " + Calls.COUNTRY_ISO + " IS NULL", + new String[]{ number }); + } else { + mContext.getContentResolver().update(Calls.CONTENT_URI_WITH_VOICEMAIL, values, + Calls.NUMBER + " = ? AND " + Calls.COUNTRY_ISO + " = ?", + new String[]{ number, countryIso }); + } + } + + /** Returns the contact information as stored in the call log. */ + private ContactInfo getContactInfoFromCallLog(Cursor c) { + ContactInfo info = new ContactInfo(); + info.lookupUri = UriUtils.parseUriOrNull(c.getString(CallLogQuery.CACHED_LOOKUP_URI)); + info.name = c.getString(CallLogQuery.CACHED_NAME); + info.type = c.getInt(CallLogQuery.CACHED_NUMBER_TYPE); + info.label = c.getString(CallLogQuery.CACHED_NUMBER_LABEL); + String matchedNumber = c.getString(CallLogQuery.CACHED_MATCHED_NUMBER); + info.number = matchedNumber == null ? c.getString(CallLogQuery.NUMBER) : matchedNumber; + info.normalizedNumber = c.getString(CallLogQuery.CACHED_NORMALIZED_NUMBER); + info.photoId = c.getLong(CallLogQuery.CACHED_PHOTO_ID); + info.photoUri = null; // We do not cache the photo URI. + info.formattedNumber = c.getString(CallLogQuery.CACHED_FORMATTED_NUMBER); + return info; + } + + /** + * 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 int[] getCallTypes(Cursor cursor, int count) { + int position = cursor.getPosition(); + int[] callTypes = new int[count]; + for (int index = 0; index < count; ++index) { + callTypes[index] = cursor.getInt(CallLogQuery.CALL_TYPE); + cursor.moveToNext(); + } + cursor.moveToPosition(position); + return callTypes; + } + + private void setPhoto(CallLogListItemViews views, long photoId, Uri contactUri) { + views.quickContactView.assignContactUri(contactUri); + mContactPhotoManager.loadThumbnail(views.quickContactView, photoId, true); + } + + /** + * Sets whether processing of requests for contact details should be enabled. + * <p> + * This method should be called in tests to disable such processing of requests when not + * needed. + */ + @VisibleForTesting + void disableRequestProcessingForTest() { + mRequestProcessingDisabled = true; + } + + @VisibleForTesting + void injectContactInfoForTest(String number, String countryIso, ContactInfo contactInfo) { + NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); + mContactInfoCache.put(numberCountryIso, contactInfo); + } + + @Override + public void addGroup(int cursorPosition, int size, boolean expanded) { + super.addGroup(cursorPosition, size, expanded); + } + + /* + * Get the number from the Contacts, if available, since sometimes + * the number provided by caller id may not be formatted properly + * depending on the carrier (roaming) in use at the time of the + * incoming call. + * Logic : If the caller-id number starts with a "+", use it + * Else if the number in the contacts starts with a "+", use that one + * Else if the number in the contacts is longer, use that one + */ + public String getBetterNumberFromContacts(String number, String countryIso) { + String matchingNumber = null; + // Look in the cache first. If it's not found then query the Phones db + NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); + ContactInfo ci = mContactInfoCache.getPossiblyExpired(numberCountryIso); + if (ci != null && ci != ContactInfo.EMPTY) { + matchingNumber = ci.number; + } else { + try { + Cursor phonesCursor = mContext.getContentResolver().query( + Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, number), + PhoneQuery._PROJECTION, null, null, null); + if (phonesCursor != null) { + if (phonesCursor.moveToFirst()) { + matchingNumber = phonesCursor.getString(PhoneQuery.MATCHED_NUMBER); + } + phonesCursor.close(); + } + } catch (Exception e) { + // Use the number from the call log + } + } + if (!TextUtils.isEmpty(matchingNumber) && + (matchingNumber.startsWith("+") + || matchingNumber.length() > number.length())) { + number = matchingNumber; + } + return number; + } +} diff --git a/src/com/android/dialer/calllog/CallLogFragment.java b/src/com/android/dialer/calllog/CallLogFragment.java new file mode 100644 index 000000000..4b3113403 --- /dev/null +++ b/src/com/android/dialer/calllog/CallLogFragment.java @@ -0,0 +1,549 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.app.Activity; +import android.app.KeyguardManager; +import android.app.ListFragment; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.ContentObserver; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.RemoteException; +import android.os.ServiceManager; +import android.preference.PreferenceManager; +import android.provider.CallLog; +import android.provider.CallLog.Calls; +import android.provider.ContactsContract; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ListView; +import android.widget.TextView; + +import com.android.common.io.MoreCloseables; +import com.android.contacts.ContactsUtils; +import com.android.contacts.R; +import com.android.contacts.util.Constants; +import com.android.contacts.util.EmptyLoader; +import com.android.dialer.voicemail.VoicemailStatusHelper; +import com.android.dialer.voicemail.VoicemailStatusHelper.StatusMessage; +import com.android.dialer.voicemail.VoicemailStatusHelperImpl; +import com.android.internal.telephony.CallerInfo; +import com.android.internal.telephony.ITelephony; +import com.google.common.annotations.VisibleForTesting; + +import java.util.List; + +/** + * Displays a list of call log entries. + */ +public class CallLogFragment extends ListFragment + implements CallLogQueryHandler.Listener, CallLogAdapter.CallFetcher { + private static final String TAG = "CallLogFragment"; + + /** + * ID of the empty loader to defer other fragments. + */ + private static final int EMPTY_LOADER_ID = 0; + + private static final String PREF_CALL_LOG_FILTER_LAST_CALL_TYPE = "CallLogFragment_last_filter"; + + private CallLogAdapter mAdapter; + private CallLogQueryHandler mCallLogQueryHandler; + private boolean mScrollToTop; + + /** Whether there is at least one voicemail source installed. */ + private boolean mVoicemailSourcesAvailable = false; + /** Whether we are currently filtering over voicemail. */ + private boolean mShowingVoicemailOnly = false; + + private VoicemailStatusHelper mVoicemailStatusHelper; + private View mStatusMessageView; + private TextView mStatusMessageText; + private TextView mStatusMessageAction; + private TextView mFilterStatusView; + private KeyguardManager mKeyguardManager; + + private boolean mEmptyLoaderRunning; + private boolean mCallLogFetched; + private boolean mVoicemailStatusFetched; + + private final Handler mHandler = new Handler(); + + private class CustomContentObserver extends ContentObserver { + public CustomContentObserver() { + super(mHandler); + } + @Override + public void onChange(boolean selfChange) { + mRefreshDataRequired = true; + } + } + + // See issue 6363009 + private final ContentObserver mCallLogObserver = new CustomContentObserver(); + private final ContentObserver mContactsObserver = new CustomContentObserver(); + private boolean mRefreshDataRequired = true; + + // Exactly same variable is in Fragment as a package private. + private boolean mMenuVisible = true; + + // Default to all calls. + private int mCallTypeFilter = CallLogQueryHandler.CALL_TYPE_ALL; + + @Override + public void onCreate(Bundle state) { + super.onCreate(state); + + mCallLogQueryHandler = new CallLogQueryHandler(getActivity().getContentResolver(), this); + mKeyguardManager = + (KeyguardManager) getActivity().getSystemService(Context.KEYGUARD_SERVICE); + getActivity().getContentResolver().registerContentObserver( + CallLog.CONTENT_URI, true, mCallLogObserver); + getActivity().getContentResolver().registerContentObserver( + ContactsContract.Contacts.CONTENT_URI, true, mContactsObserver); + setHasOptionsMenu(true); + + // Load the last filter used. + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); + mCallTypeFilter = prefs.getInt(PREF_CALL_LOG_FILTER_LAST_CALL_TYPE, + CallLogQueryHandler.CALL_TYPE_ALL); + } + + /** Called by the CallLogQueryHandler when the list of calls has been fetched or updated. */ + @Override + public void onCallsFetched(Cursor cursor) { + if (getActivity() == null || getActivity().isFinishing()) { + return; + } + mAdapter.setLoading(false); + mAdapter.changeCursor(cursor); + // This will update the state of the "Clear call log" menu item. + getActivity().invalidateOptionsMenu(); + if (mScrollToTop) { + final ListView listView = getListView(); + // 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 (listView.getFirstVisiblePosition() > 5) { + listView.setSelection(5); + } + // 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; + } + listView.smoothScrollToPosition(0); + } + }); + + mScrollToTop = false; + } + mCallLogFetched = true; + destroyEmptyLoaderIfAllDataFetched(); + } + + /** + * Called by {@link CallLogQueryHandler} after a successful query to voicemail status provider. + */ + @Override + public void onVoicemailStatusFetched(Cursor statusCursor) { + if (getActivity() == null || getActivity().isFinishing()) { + return; + } + updateVoicemailStatusMessage(statusCursor); + + int activeSources = mVoicemailStatusHelper.getNumberActivityVoicemailSources(statusCursor); + setVoicemailSourcesAvailable(activeSources != 0); + MoreCloseables.closeQuietly(statusCursor); + mVoicemailStatusFetched = true; + destroyEmptyLoaderIfAllDataFetched(); + } + + private void destroyEmptyLoaderIfAllDataFetched() { + if (mCallLogFetched && mVoicemailStatusFetched && mEmptyLoaderRunning) { + mEmptyLoaderRunning = false; + getLoaderManager().destroyLoader(EMPTY_LOADER_ID); + } + } + + /** Sets whether there are any voicemail sources available in the platform. */ + private void setVoicemailSourcesAvailable(boolean voicemailSourcesAvailable) { + if (mVoicemailSourcesAvailable == voicemailSourcesAvailable) return; + mVoicemailSourcesAvailable = voicemailSourcesAvailable; + + Activity activity = getActivity(); + if (activity != null) { + // This is so that the options menu content is updated. + activity.invalidateOptionsMenu(); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) { + View view = inflater.inflate(R.layout.call_log_fragment, container, false); + mVoicemailStatusHelper = new VoicemailStatusHelperImpl(); + mStatusMessageView = view.findViewById(R.id.voicemail_status); + mStatusMessageText = (TextView) view.findViewById(R.id.voicemail_status_message); + mStatusMessageAction = (TextView) view.findViewById(R.id.voicemail_status_action); + mFilterStatusView = (TextView) view.findViewById(R.id.filter_status); + return view; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + String currentCountryIso = ContactsUtils.getCurrentCountryIso(getActivity()); + mAdapter = new CallLogAdapter(getActivity(), this, + new ContactInfoHelper(getActivity(), currentCountryIso)); + setListAdapter(mAdapter); + getListView().setItemsCanFocus(true); + + updateFilterHeader(); + } + + /** + * Based on the new intent, decide whether the list should be configured + * to scroll up to display the first item. + */ + public void configureScreenFromIntent(Intent newIntent) { + // Typically, when switching to the call-log we want to show the user + // the same section of the list that they were most recently looking + // at. However, under some circumstances, we want to automatically + // scroll to the top of the list to present the newest call items. + // For example, immediately after a call is finished, we want to + // display information about that call. + mScrollToTop = Calls.CONTENT_TYPE.equals(newIntent.getType()); + } + + @Override + public void onStart() { + // Start the empty loader now to defer other fragments. We destroy it when both calllog + // and the voicemail status are fetched. + getLoaderManager().initLoader(EMPTY_LOADER_ID, null, + new EmptyLoader.Callback(getActivity())); + mEmptyLoaderRunning = true; + super.onStart(); + } + + @Override + public void onResume() { + super.onResume(); + refreshData(); + } + + private void updateVoicemailStatusMessage(Cursor statusCursor) { + List<StatusMessage> messages = mVoicemailStatusHelper.getStatusMessages(statusCursor); + if (messages.size() == 0) { + mStatusMessageView.setVisibility(View.GONE); + } else { + mStatusMessageView.setVisibility(View.VISIBLE); + // TODO: Change the code to show all messages. For now just pick the first message. + final StatusMessage message = messages.get(0); + if (message.showInCallLog()) { + mStatusMessageText.setText(message.callLogMessageId); + } + if (message.actionMessageId != -1) { + mStatusMessageAction.setText(message.actionMessageId); + } + if (message.actionUri != null) { + mStatusMessageAction.setVisibility(View.VISIBLE); + mStatusMessageAction.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + getActivity().startActivity( + new Intent(Intent.ACTION_VIEW, message.actionUri)); + } + }); + } else { + mStatusMessageAction.setVisibility(View.GONE); + } + } + } + + @Override + public void onPause() { + super.onPause(); + // Kill the requests thread + mAdapter.stopRequestProcessing(); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); + prefs.edit() + .putInt(PREF_CALL_LOG_FILTER_LAST_CALL_TYPE, mCallTypeFilter) + .apply(); + } + + @Override + public void onStop() { + super.onStop(); + updateOnExit(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mAdapter.stopRequestProcessing(); + mAdapter.changeCursor(null); + getActivity().getContentResolver().unregisterContentObserver(mCallLogObserver); + getActivity().getContentResolver().unregisterContentObserver(mContactsObserver); + } + + @Override + public void fetchCalls() { + mCallLogQueryHandler.fetchCalls(mCallTypeFilter); + } + + public void startCallsQuery() { + mAdapter.setLoading(true); + mCallLogQueryHandler.fetchCalls(mCallTypeFilter); + if (mShowingVoicemailOnly) { + mShowingVoicemailOnly = false; + getActivity().invalidateOptionsMenu(); + } + } + + private void startVoicemailStatusQuery() { + mCallLogQueryHandler.fetchVoicemailStatus(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.call_log_options, menu); + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + final MenuItem itemDeleteAll = menu.findItem(R.id.delete_all); + // Check if all the menu items are inflated correctly. As a shortcut, we assume all + // menu items are ready if the first item is non-null. + if (itemDeleteAll != null) { + itemDeleteAll.setEnabled(mAdapter != null && !mAdapter.isEmpty()); + menu.findItem(R.id.show_voicemails_only).setVisible(mVoicemailSourcesAvailable); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.delete_all: + ClearCallLogDialog.show(getFragmentManager()); + return true; + + case R.id.show_outgoing_only: + mCallLogQueryHandler.fetchCalls(Calls.OUTGOING_TYPE); + mCallTypeFilter = Calls.OUTGOING_TYPE; + updateFilterHeader(); + return true; + + case R.id.show_incoming_only: + mCallLogQueryHandler.fetchCalls(Calls.INCOMING_TYPE); + mCallTypeFilter = Calls.INCOMING_TYPE; + updateFilterHeader(); + return true; + + case R.id.show_missed_only: + mCallLogQueryHandler.fetchCalls(Calls.MISSED_TYPE); + mCallTypeFilter = Calls.MISSED_TYPE; + updateFilterHeader(); + return true; + + case R.id.show_voicemails_only: + mCallLogQueryHandler.fetchCalls(Calls.VOICEMAIL_TYPE); + mCallTypeFilter = Calls.VOICEMAIL_TYPE; + updateFilterHeader(); + mShowingVoicemailOnly = true; + return true; + + case R.id.show_all_calls: + mCallLogQueryHandler.fetchCalls(CallLogQueryHandler.CALL_TYPE_ALL); + mCallTypeFilter = CallLogQueryHandler.CALL_TYPE_ALL; + updateFilterHeader(); + mShowingVoicemailOnly = false; + return true; + + default: + return false; + } + } + + private void updateFilterHeader() { + switch (mCallTypeFilter) { + case CallLogQueryHandler.CALL_TYPE_ALL: + mFilterStatusView.setVisibility(View.GONE); + break; + case Calls.INCOMING_TYPE: + showFilterStatus(R.string.call_log_incoming_header); + break; + case Calls.OUTGOING_TYPE: + showFilterStatus(R.string.call_log_outgoing_header); + break; + case Calls.MISSED_TYPE: + showFilterStatus(R.string.call_log_missed_header); + break; + case Calls.VOICEMAIL_TYPE: + showFilterStatus(R.string.call_log_voicemail_header); + break; + } + } + + private void showFilterStatus(int resId) { + mFilterStatusView.setText(resId); + mFilterStatusView.setVisibility(View.VISIBLE); + } + + public void callSelectedEntry() { + int position = getListView().getSelectedItemPosition(); + if (position < 0) { + // In touch mode you may often not have something selected, so + // just call the first entry to make sure that [send] [send] calls the + // most recent entry. + position = 0; + } + final Cursor cursor = (Cursor)mAdapter.getItem(position); + if (cursor != null) { + String number = cursor.getString(CallLogQuery.NUMBER); + if (TextUtils.isEmpty(number) + || number.equals(CallerInfo.UNKNOWN_NUMBER) + || number.equals(CallerInfo.PRIVATE_NUMBER) + || number.equals(CallerInfo.PAYPHONE_NUMBER)) { + // This number can't be called, do nothing + return; + } + Intent intent; + // If "number" is really a SIP address, construct a sip: URI. + if (PhoneNumberUtils.isUriNumber(number)) { + intent = ContactsUtils.getCallIntent( + Uri.fromParts(Constants.SCHEME_SIP, number, null)); + } else { + // We're calling a regular PSTN phone number. + // Construct a tel: URI, but do some other possible cleanup first. + int callType = cursor.getInt(CallLogQuery.CALL_TYPE); + if (!number.startsWith("+") && + (callType == Calls.INCOMING_TYPE + || callType == Calls.MISSED_TYPE)) { + // If the caller-id matches a contact with a better qualified number, use it + String countryIso = cursor.getString(CallLogQuery.COUNTRY_ISO); + number = mAdapter.getBetterNumberFromContacts(number, countryIso); + } + intent = ContactsUtils.getCallIntent( + Uri.fromParts(Constants.SCHEME_TEL, number, null)); + } + intent.setFlags( + Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + startActivity(intent); + } + } + + @VisibleForTesting + CallLogAdapter getAdapter() { + return mAdapter; + } + + @Override + public void setMenuVisibility(boolean menuVisible) { + super.setMenuVisibility(menuVisible); + if (mMenuVisible != menuVisible) { + mMenuVisible = menuVisible; + if (!menuVisible) { + updateOnExit(); + } 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. + mAdapter.invalidateCache(); + startCallsQuery(); + startVoicemailStatusQuery(); + updateOnEntry(); + mRefreshDataRequired = false; + } + } + + /** Removes the missed call notifications. */ + private void removeMissedCallNotifications() { + try { + ITelephony telephony = + ITelephony.Stub.asInterface(ServiceManager.getService("phone")); + if (telephony != null) { + telephony.cancelMissedCallsNotification(); + } else { + Log.w(TAG, "Telephony service is null, can't call " + + "cancelMissedCallsNotification"); + } + } catch (RemoteException e) { + Log.e(TAG, "Failed to clear missed calls notification due to remote exception"); + } + } + + /** Updates call data and notification state while leaving the call log tab. */ + private void updateOnExit() { + updateOnTransition(false); + } + + /** Updates call data and notification state while entering the call log tab. */ + private void updateOnEntry() { + updateOnTransition(true); + } + + private void updateOnTransition(boolean onEntry) { + // We don't want to update any call data when keyguard is on because the user has likely not + // seen the new calls yet. + // This might be called before onCreate() and thus we need to check null explicitly. + if (mKeyguardManager != null && !mKeyguardManager.inKeyguardRestrictedInputMode()) { + // On either of the transitions we reset the new flag and update the notifications. + // While exiting we additionally consume all missed calls (by marking them as read). + // This will ensure that they no more appear in the "new" section when we return back. + mCallLogQueryHandler.markNewCallsAsOld(); + if (!onEntry) { + mCallLogQueryHandler.markMissedCallsAsRead(); + } + removeMissedCallNotifications(); + updateVoicemailNotifications(); + } + } + + private void updateVoicemailNotifications() { + Intent serviceIntent = new Intent(getActivity(), CallLogNotificationsService.class); + serviceIntent.setAction(CallLogNotificationsService.ACTION_UPDATE_NOTIFICATIONS); + getActivity().startService(serviceIntent); + } +} diff --git a/src/com/android/dialer/calllog/CallLogGroupBuilder.java b/src/com/android/dialer/calllog/CallLogGroupBuilder.java new file mode 100644 index 000000000..bf472bd7a --- /dev/null +++ b/src/com/android/dialer/calllog/CallLogGroupBuilder.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.database.Cursor; +import android.provider.CallLog.Calls; +import android.telephony.PhoneNumberUtils; + +import com.android.common.widget.GroupingListAdapter; +import com.google.common.annotations.VisibleForTesting; + +/** + * Groups together calls in the call log. + * <p> + * This class is meant to be used in conjunction with {@link GroupingListAdapter}. + */ +public class CallLogGroupBuilder { + public interface GroupCreator { + public void addGroup(int cursorPosition, int size, boolean expanded); + } + + /** 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, boolean)} 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; + } + + int currentGroupSize = 1; + cursor.moveToFirst(); + // The number of the first entry in the group. + String firstNumber = cursor.getString(CallLogQuery.NUMBER); + // This is the type of the first call in the group. + int firstCallType = cursor.getInt(CallLogQuery.CALL_TYPE); + while (cursor.moveToNext()) { + // The number of the current row in the cursor. + final String currentNumber = cursor.getString(CallLogQuery.NUMBER); + final int callType = cursor.getInt(CallLogQuery.CALL_TYPE); + final boolean sameNumber = equalNumbers(firstNumber, currentNumber); + final boolean shouldGroup; + + if (CallLogQuery.isSectionHeader(cursor)) { + // Cannot group headers. + shouldGroup = false; + } else if (!sameNumber) { + // Should only group with calls from the same number. + shouldGroup = false; + } else if (firstCallType == Calls.VOICEMAIL_TYPE) { + // never group voicemail. + shouldGroup = false; + } else { + // Incoming, outgoing, and missed calls group together. + shouldGroup = (callType == Calls.INCOMING_TYPE || callType == Calls.OUTGOING_TYPE || + callType == Calls.MISSED_TYPE); + } + + if (shouldGroup) { + // Increment the size of the group to include the current call, but do not create + // the group until we find a call that does not match. + currentGroupSize++; + } else { + // Create a group for the previous set of calls, excluding the current one, but do + // not create a group for a single call. + if (currentGroupSize > 1) { + addGroup(cursor.getPosition() - currentGroupSize, currentGroupSize); + } + // Start a new group; it will include at least the current call. + currentGroupSize = 1; + // The current entry is now the first in the group. + firstNumber = currentNumber; + firstCallType = callType; + } + } + // If the last set of calls at the end of the call log was itself a group, create it now. + if (currentGroupSize > 1) { + addGroup(count - currentGroupSize, currentGroupSize); + } + } + + /** + * Creates a group of items in the cursor. + * <p> + * The group is always unexpanded. + * + * @see CallLogAdapter#addGroup(int, int, boolean) + */ + private void addGroup(int cursorPosition, int size) { + mGroupCreator.addGroup(cursorPosition, size, false); + } + + @VisibleForTesting + boolean equalNumbers(String number1, String number2) { + if (PhoneNumberUtils.isUriNumber(number1) || PhoneNumberUtils.isUriNumber(number2)) { + return compareSipAddresses(number1, number2); + } else { + return PhoneNumberUtils.compare(number1, number2); + } + } + + @VisibleForTesting + boolean compareSipAddresses(String number1, String number2) { + if (number1 == null || number2 == null) return 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); + } +} diff --git a/src/com/android/dialer/calllog/CallLogListItemHelper.java b/src/com/android/dialer/calllog/CallLogListItemHelper.java new file mode 100644 index 000000000..7862a5679 --- /dev/null +++ b/src/com/android/dialer/calllog/CallLogListItemHelper.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.content.res.Resources; +import android.provider.CallLog.Calls; +import android.text.TextUtils; +import android.view.View; + +import com.android.dialer.PhoneCallDetails; +import com.android.dialer.PhoneCallDetailsHelper; +import com.android.contacts.R; + +/** + * Helper class to fill in the views of a call log entry. + */ +/*package*/ class CallLogListItemHelper { + /** Helper for populating the details of a phone call. */ + private final PhoneCallDetailsHelper mPhoneCallDetailsHelper; + /** Helper for handling phone numbers. */ + private final PhoneNumberHelper mPhoneNumberHelper; + /** Resources to look up strings. */ + private final Resources mResources; + + /** + * Creates a new helper instance. + * + * @param phoneCallDetailsHelper used to set the details of a phone call + * @param phoneNumberHelper used to process phone number + */ + public CallLogListItemHelper(PhoneCallDetailsHelper phoneCallDetailsHelper, + PhoneNumberHelper phoneNumberHelper, Resources resources) { + mPhoneCallDetailsHelper = phoneCallDetailsHelper; + mPhoneNumberHelper = phoneNumberHelper; + mResources = resources; + } + + /** + * 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 + * @param isHighlighted whether to use the highlight text for the call + */ + public void setPhoneCallDetails(CallLogListItemViews views, PhoneCallDetails details, + boolean isHighlighted) { + mPhoneCallDetailsHelper.setPhoneCallDetails(views.phoneCallDetailsViews, details, + isHighlighted); + boolean canCall = mPhoneNumberHelper.canPlaceCallsTo(details.number); + boolean canPlay = details.callTypes[0] == Calls.VOICEMAIL_TYPE; + + if (canPlay) { + // Playback action takes preference. + configurePlaySecondaryAction(views, isHighlighted); + views.dividerView.setVisibility(View.VISIBLE); + } else if (canCall) { + // Call is the secondary action. + configureCallSecondaryAction(views, details); + views.dividerView.setVisibility(View.VISIBLE); + } else { + // No action available. + views.secondaryActionView.setVisibility(View.GONE); + views.dividerView.setVisibility(View.GONE); + } + } + + /** Sets the secondary action to correspond to the call button. */ + private void configureCallSecondaryAction(CallLogListItemViews views, + PhoneCallDetails details) { + views.secondaryActionView.setVisibility(View.VISIBLE); + views.secondaryActionView.setImageResource(R.drawable.ic_ab_dialer_holo_dark); + views.secondaryActionView.setContentDescription(getCallActionDescription(details)); + } + + /** Returns the description used by the call action for this phone call. */ + private CharSequence getCallActionDescription(PhoneCallDetails details) { + final CharSequence recipient; + if (!TextUtils.isEmpty(details.name)) { + recipient = details.name; + } else { + recipient = mPhoneNumberHelper.getDisplayNumber( + details.number, details.formattedNumber); + } + return mResources.getString(R.string.description_call, recipient); + } + + /** Sets the secondary action to correspond to the play button. */ + private void configurePlaySecondaryAction(CallLogListItemViews views, boolean isHighlighted) { + views.secondaryActionView.setVisibility(View.VISIBLE); + views.secondaryActionView.setImageResource( + isHighlighted ? R.drawable.ic_play_active_holo_dark : R.drawable.ic_play_holo_dark); + views.secondaryActionView.setContentDescription( + mResources.getString(R.string.description_call_log_play_button)); + } +} diff --git a/src/com/android/dialer/calllog/CallLogListItemView.java b/src/com/android/dialer/calllog/CallLogListItemView.java new file mode 100644 index 000000000..113b02a5b --- /dev/null +++ b/src/com/android/dialer/calllog/CallLogListItemView.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.LinearLayout; + +/** + * An entry in the call log. + */ +public class CallLogListItemView extends LinearLayout { + public CallLogListItemView(Context context) { + super(context); + } + + public CallLogListItemView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public CallLogListItemView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + public void requestLayout() { + // We will assume that once measured this will not need to resize + // itself, so there is no need to pass the layout request to the parent + // view (ListView). + forceLayout(); + } +} diff --git a/src/com/android/dialer/calllog/CallLogListItemViews.java b/src/com/android/dialer/calllog/CallLogListItemViews.java new file mode 100644 index 000000000..5b860efcb --- /dev/null +++ b/src/com/android/dialer/calllog/CallLogListItemViews.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.content.Context; +import android.view.View; +import android.widget.ImageView; +import android.widget.QuickContactBadge; +import android.widget.TextView; + +import com.android.dialer.PhoneCallDetailsViews; +import com.android.contacts.R; +import com.android.contacts.test.NeededForTesting; + +/** + * Simple value object containing the various views within a call log entry. + */ +public final class CallLogListItemViews { + /** The quick contact badge for the contact. */ + public final QuickContactBadge quickContactView; + /** The primary action view of the entry. */ + public final View primaryActionView; + /** The secondary action button on the entry. */ + public final ImageView secondaryActionView; + /** The divider between the primary and secondary actions. */ + public final View dividerView; + /** The details of the phone call. */ + public final PhoneCallDetailsViews phoneCallDetailsViews; + /** The text of the header of a section. */ + public final TextView listHeaderTextView; + /** The divider to be shown below items. */ + public final View bottomDivider; + + private CallLogListItemViews(QuickContactBadge quickContactView, View primaryActionView, + ImageView secondaryActionView, View dividerView, + PhoneCallDetailsViews phoneCallDetailsViews, + TextView listHeaderTextView, View bottomDivider) { + this.quickContactView = quickContactView; + this.primaryActionView = primaryActionView; + this.secondaryActionView = secondaryActionView; + this.dividerView = dividerView; + this.phoneCallDetailsViews = phoneCallDetailsViews; + this.listHeaderTextView = listHeaderTextView; + this.bottomDivider = bottomDivider; + } + + public static CallLogListItemViews fromView(View view) { + return new CallLogListItemViews( + (QuickContactBadge) view.findViewById(R.id.quick_contact_photo), + view.findViewById(R.id.primary_action_view), + (ImageView) view.findViewById(R.id.secondary_action_icon), + view.findViewById(R.id.divider), + PhoneCallDetailsViews.fromView(view), + (TextView) view.findViewById(R.id.call_log_header), + view.findViewById(R.id.call_log_divider)); + } + + @NeededForTesting + public static CallLogListItemViews createForTest(Context context) { + return new CallLogListItemViews( + new QuickContactBadge(context), + new View(context), + new ImageView(context), + new View(context), + PhoneCallDetailsViews.createForTest(context), + new TextView(context), + new View(context)); + } +} diff --git a/src/com/android/dialer/calllog/CallLogNotificationsService.java b/src/com/android/dialer/calllog/CallLogNotificationsService.java new file mode 100644 index 000000000..3270963cc --- /dev/null +++ b/src/com/android/dialer/calllog/CallLogNotificationsService.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.app.IntentService; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; + +/** + * Provides operations for managing notifications. + * <p> + * It handles the following actions: + * <ul> + * <li>{@link #ACTION_MARK_NEW_VOICEMAILS_AS_OLD}: marks all the new voicemails in the call log as + * old; this is called when a notification is dismissed.</li> + * <li>{@link #ACTION_UPDATE_NOTIFICATIONS}: updates the content of the new items notification; it + * may include an optional extra {@link #EXTRA_NEW_VOICEMAIL_URI}, containing the URI of the new + * voicemail that has triggered this update (if any).</li> + * </ul> + */ +public class CallLogNotificationsService extends IntentService { + private static final String TAG = "CallLogNotificationsService"; + + /** 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 the notifications. + * <p> + * May include an optional extra {@link #EXTRA_NEW_VOICEMAIL_URI}. + */ + public static final String ACTION_UPDATE_NOTIFICATIONS = + "com.android.dialer.calllog.UPDATE_NOTIFICATIONS"; + + /** + * Extra to included with {@link #ACTION_UPDATE_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"; + + private CallLogQueryHandler mCallLogQueryHandler; + + public CallLogNotificationsService() { + super("CallLogNotificationsService"); + } + + @Override + public void onCreate() { + super.onCreate(); + mCallLogQueryHandler = new CallLogQueryHandler(getContentResolver(), null /*listener*/); + } + + @Override + protected void onHandleIntent(Intent intent) { + if (ACTION_MARK_NEW_VOICEMAILS_AS_OLD.equals(intent.getAction())) { + mCallLogQueryHandler.markNewVoicemailsAsOld(); + } else if (ACTION_UPDATE_NOTIFICATIONS.equals(intent.getAction())) { + Uri voicemailUri = (Uri) intent.getParcelableExtra(EXTRA_NEW_VOICEMAIL_URI); + DefaultVoicemailNotifier.getInstance(this).updateNotification(voicemailUri); + } else { + Log.d(TAG, "onHandleIntent: could not handle: " + intent); + } + } +} diff --git a/src/com/android/dialer/calllog/CallLogQuery.java b/src/com/android/dialer/calllog/CallLogQuery.java new file mode 100644 index 000000000..5f7b27b93 --- /dev/null +++ b/src/com/android/dialer/calllog/CallLogQuery.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.database.Cursor; +import android.provider.CallLog.Calls; + +/** + * The query for the call log table. + */ +public final class CallLogQuery { + // If you alter this, you must also alter the method that inserts a fake row to the headers + // in the CallLogQueryHandler class called createHeaderCursorFor(). + public static final String[] _PROJECTION = new String[] { + Calls._ID, // 0 + Calls.NUMBER, // 1 + Calls.DATE, // 2 + Calls.DURATION, // 3 + Calls.TYPE, // 4 + Calls.COUNTRY_ISO, // 5 + Calls.VOICEMAIL_URI, // 6 + Calls.GEOCODED_LOCATION, // 7 + Calls.CACHED_NAME, // 8 + Calls.CACHED_NUMBER_TYPE, // 9 + Calls.CACHED_NUMBER_LABEL, // 10 + Calls.CACHED_LOOKUP_URI, // 11 + Calls.CACHED_MATCHED_NUMBER, // 12 + Calls.CACHED_NORMALIZED_NUMBER, // 13 + Calls.CACHED_PHOTO_ID, // 14 + Calls.CACHED_FORMATTED_NUMBER, // 15 + Calls.IS_READ, // 16 + }; + + public static final int ID = 0; + public static final int NUMBER = 1; + public static final int DATE = 2; + public static final int DURATION = 3; + public static final int CALL_TYPE = 4; + public static final int COUNTRY_ISO = 5; + public static final int VOICEMAIL_URI = 6; + public static final int GEOCODED_LOCATION = 7; + public static final int CACHED_NAME = 8; + public static final int CACHED_NUMBER_TYPE = 9; + public static final int CACHED_NUMBER_LABEL = 10; + public static final int CACHED_LOOKUP_URI = 11; + public static final int CACHED_MATCHED_NUMBER = 12; + public static final int CACHED_NORMALIZED_NUMBER = 13; + public static final int CACHED_PHOTO_ID = 14; + public static final int CACHED_FORMATTED_NUMBER = 15; + public static final int IS_READ = 16; + /** The index of the synthetic "section" column in the extended projection. */ + public static final int SECTION = 17; + + /** + * The name of the synthetic "section" column. + * <p> + * This column identifies whether a row is a header or an actual item, and whether it is + * part of the new or old calls. + */ + public static final String SECTION_NAME = "section"; + /** The value of the "section" column for the header of the new section. */ + public static final int SECTION_NEW_HEADER = 0; + /** The value of the "section" column for the items of the new section. */ + public static final int SECTION_NEW_ITEM = 1; + /** The value of the "section" column for the header of the old section. */ + public static final int SECTION_OLD_HEADER = 2; + /** The value of the "section" column for the items of the old section. */ + public static final int SECTION_OLD_ITEM = 3; + + /** The call log projection including the section name. */ + public static final String[] EXTENDED_PROJECTION; + static { + EXTENDED_PROJECTION = new String[_PROJECTION.length + 1]; + System.arraycopy(_PROJECTION, 0, EXTENDED_PROJECTION, 0, _PROJECTION.length); + EXTENDED_PROJECTION[_PROJECTION.length] = SECTION_NAME; + } + + public static boolean isSectionHeader(Cursor cursor) { + int section = cursor.getInt(CallLogQuery.SECTION); + return section == CallLogQuery.SECTION_NEW_HEADER + || section == CallLogQuery.SECTION_OLD_HEADER; + } + + public static boolean isNewSection(Cursor cursor) { + int section = cursor.getInt(CallLogQuery.SECTION); + return section == CallLogQuery.SECTION_NEW_ITEM + || section == CallLogQuery.SECTION_NEW_HEADER; + } +} diff --git a/src/com/android/dialer/calllog/CallLogQueryHandler.java b/src/com/android/dialer/calllog/CallLogQueryHandler.java new file mode 100644 index 000000000..2e67e5a01 --- /dev/null +++ b/src/com/android/dialer/calllog/CallLogQueryHandler.java @@ -0,0 +1,364 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.content.AsyncQueryHandler; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MergeCursor; +import android.database.sqlite.SQLiteDatabaseCorruptException; +import android.database.sqlite.SQLiteDiskIOException; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteFullException; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.provider.CallLog.Calls; +import android.provider.VoicemailContract.Status; +import android.util.Log; + +import com.android.common.io.MoreCloseables; +import com.android.dialer.voicemail.VoicemailStatusHelperImpl; +import com.google.common.collect.Lists; + +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import javax.annotation.concurrent.GuardedBy; + +/** Handles asynchronous queries to the call log. */ +/*package*/ class CallLogQueryHandler extends AsyncQueryHandler { + private static final String[] EMPTY_STRING_ARRAY = new String[0]; + + private static final String TAG = "CallLogQueryHandler"; + private static final int NUM_LOGS_TO_DISPLAY = 1000; + + /** The token for the query to fetch the new entries from the call log. */ + private static final int QUERY_NEW_CALLS_TOKEN = 53; + /** The token for the query to fetch the old entries from the call log. */ + private static final int QUERY_OLD_CALLS_TOKEN = 54; + /** The token for the query to mark all missed calls as old after seeing the call log. */ + private static final int UPDATE_MARK_AS_OLD_TOKEN = 55; + /** The token for the query to mark all new voicemails as old. */ + private static final int UPDATE_MARK_VOICEMAILS_AS_OLD_TOKEN = 56; + /** The token for the query to mark all missed calls as read after seeing the call log. */ + private static final int UPDATE_MARK_MISSED_CALL_AS_READ_TOKEN = 57; + /** The token for the query to fetch voicemail status messages. */ + private static final int QUERY_VOICEMAIL_STATUS_TOKEN = 58; + + /** + * Call type similar to Calls.INCOMING_TYPE used to specify all types instead of one particular + * type. + */ + public static final int CALL_TYPE_ALL = -1; + + /** + * The time window from the current time within which an unread entry will be added to the new + * section. + */ + private static final long NEW_SECTION_TIME_WINDOW = TimeUnit.DAYS.toMillis(7); + + private final WeakReference<Listener> mListener; + + /** The cursor containing the new calls, or null if they have not yet been fetched. */ + @GuardedBy("this") private Cursor mNewCallsCursor; + /** The cursor containing the old calls, or null if they have not yet been fetched. */ + @GuardedBy("this") private Cursor mOldCallsCursor; + /** + * The identifier of the latest calls request. + * <p> + * A request for the list of calls requires two queries and hence the two cursor + * {@link #mNewCallsCursor} and {@link #mOldCallsCursor} above, corresponding to + * {@link #QUERY_NEW_CALLS_TOKEN} and {@link #QUERY_OLD_CALLS_TOKEN}. + * <p> + * When a new request is about to be started, existing cursors are closed. However, it is + * possible that one of the queries completes after the new request has started. This means that + * we might merge two cursors that do not correspond to the same request. Moreover, this may + * lead to a resource leak if the same query completes and we override the cursor without + * closing it first. + * <p> + * To make sure we only join two cursors from the same request, we use this variable to store + * the request id of the latest request and make sure we only process cursors corresponding to + * the this request. + */ + @GuardedBy("this") private int mCallsRequestId; + + /** + * Simple handler that wraps background calls to catch + * {@link SQLiteException}, such as when the disk is full. + */ + protected class CatchingWorkerHandler extends AsyncQueryHandler.WorkerHandler { + public CatchingWorkerHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + try { + // Perform same query while catching any exceptions + super.handleMessage(msg); + } catch (SQLiteDiskIOException e) { + Log.w(TAG, "Exception on background worker thread", e); + } catch (SQLiteFullException e) { + Log.w(TAG, "Exception on background worker thread", e); + } catch (SQLiteDatabaseCorruptException e) { + Log.w(TAG, "Exception on background worker thread", e); + } + } + } + + @Override + protected Handler createHandler(Looper looper) { + // Provide our special handler that catches exceptions + return new CatchingWorkerHandler(looper); + } + + public CallLogQueryHandler(ContentResolver contentResolver, Listener listener) { + super(contentResolver); + mListener = new WeakReference<Listener>(listener); + } + + /** Creates a cursor that contains a single row and maps the section to the given value. */ + private Cursor createHeaderCursorFor(int section) { + MatrixCursor matrixCursor = + new MatrixCursor(CallLogQuery.EXTENDED_PROJECTION); + // The values in this row correspond to default values for _PROJECTION from CallLogQuery + // plus the section value. + matrixCursor.addRow(new Object[]{ + 0L, "", 0L, 0L, 0, "", "", "", null, 0, null, null, null, null, 0L, null, 0, + section + }); + return matrixCursor; + } + + /** Returns a cursor for the old calls header. */ + private Cursor createOldCallsHeaderCursor() { + return createHeaderCursorFor(CallLogQuery.SECTION_OLD_HEADER); + } + + /** Returns a cursor for the new calls header. */ + private Cursor createNewCallsHeaderCursor() { + return createHeaderCursorFor(CallLogQuery.SECTION_NEW_HEADER); + } + + /** + * Fetches the list of calls from the call log for a given type. + * <p> + * It will asynchronously update the content of the list view when the fetch completes. + */ + public void fetchCalls(int callType) { + cancelFetch(); + int requestId = newCallsRequest(); + fetchCalls(QUERY_NEW_CALLS_TOKEN, requestId, true /*isNew*/, callType); + fetchCalls(QUERY_OLD_CALLS_TOKEN, requestId, false /*isNew*/, callType); + } + + public void fetchVoicemailStatus() { + startQuery(QUERY_VOICEMAIL_STATUS_TOKEN, null, Status.CONTENT_URI, + VoicemailStatusHelperImpl.PROJECTION, null, null, null); + } + + /** Fetches the list of calls in the call log, either the new one or the old ones. */ + private void fetchCalls(int token, int requestId, boolean isNew, int callType) { + // We need to check for NULL explicitly otherwise entries with where READ is NULL + // may not match either the query or its negation. + // We consider the calls that are not yet consumed (i.e. IS_READ = 0) as "new". + String selection = String.format("%s IS NOT NULL AND %s = 0 AND %s > ?", + Calls.IS_READ, Calls.IS_READ, Calls.DATE); + List<String> selectionArgs = Lists.newArrayList( + Long.toString(System.currentTimeMillis() - NEW_SECTION_TIME_WINDOW)); + if (!isNew) { + // Negate the query. + selection = String.format("NOT (%s)", selection); + } + if (callType > CALL_TYPE_ALL) { + // Add a clause to fetch only items of type voicemail. + selection = String.format("(%s) AND (%s = ?)", selection, Calls.TYPE); + selectionArgs.add(Integer.toString(callType)); + } + Uri uri = Calls.CONTENT_URI_WITH_VOICEMAIL.buildUpon() + .appendQueryParameter(Calls.LIMIT_PARAM_KEY, Integer.toString(NUM_LOGS_TO_DISPLAY)) + .build(); + startQuery(token, requestId, uri, + CallLogQuery._PROJECTION, selection, selectionArgs.toArray(EMPTY_STRING_ARRAY), + Calls.DEFAULT_SORT_ORDER); + } + + /** Cancel any pending fetch request. */ + private void cancelFetch() { + cancelOperation(QUERY_NEW_CALLS_TOKEN); + cancelOperation(QUERY_OLD_CALLS_TOKEN); + } + + /** Updates all new calls to mark them as old. */ + public void markNewCallsAsOld() { + // Mark all "new" calls as not new anymore. + StringBuilder where = new StringBuilder(); + where.append(Calls.NEW); + where.append(" = 1"); + + ContentValues values = new ContentValues(1); + values.put(Calls.NEW, "0"); + + startUpdate(UPDATE_MARK_AS_OLD_TOKEN, null, Calls.CONTENT_URI_WITH_VOICEMAIL, + values, where.toString(), null); + } + + /** 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) }); + } + + /** Updates all missed calls to mark them as read. */ + public void markMissedCallsAsRead() { + // Mark all "new" calls as not new anymore. + StringBuilder where = new StringBuilder(); + where.append(Calls.IS_READ).append(" = 0"); + where.append(" AND "); + where.append(Calls.TYPE).append(" = ").append(Calls.MISSED_TYPE); + + ContentValues values = new ContentValues(1); + values.put(Calls.IS_READ, "1"); + + startUpdate(UPDATE_MARK_MISSED_CALL_AS_READ_TOKEN, null, Calls.CONTENT_URI, values, + where.toString(), null); + } + + /** + * Start a new request and return its id. The request id will be used as the cookie for the + * background request. + * <p> + * Closes any open cursor that has not yet been sent to the requester. + */ + private synchronized int newCallsRequest() { + MoreCloseables.closeQuietly(mNewCallsCursor); + MoreCloseables.closeQuietly(mOldCallsCursor); + mNewCallsCursor = null; + mOldCallsCursor = null; + return ++mCallsRequestId; + } + + @Override + protected synchronized void onQueryComplete(int token, Object cookie, Cursor cursor) { + if (token == QUERY_NEW_CALLS_TOKEN) { + int requestId = ((Integer) cookie).intValue(); + if (requestId != mCallsRequestId) { + // Ignore this query since it does not correspond to the latest request. + return; + } + + // Store the returned cursor. + MoreCloseables.closeQuietly(mNewCallsCursor); + mNewCallsCursor = new ExtendedCursor( + cursor, CallLogQuery.SECTION_NAME, CallLogQuery.SECTION_NEW_ITEM); + } else if (token == QUERY_OLD_CALLS_TOKEN) { + int requestId = ((Integer) cookie).intValue(); + if (requestId != mCallsRequestId) { + // Ignore this query since it does not correspond to the latest request. + return; + } + + // Store the returned cursor. + MoreCloseables.closeQuietly(mOldCallsCursor); + mOldCallsCursor = new ExtendedCursor( + cursor, CallLogQuery.SECTION_NAME, CallLogQuery.SECTION_OLD_ITEM); + } else if (token == QUERY_VOICEMAIL_STATUS_TOKEN) { + updateVoicemailStatus(cursor); + return; + } else { + Log.w(TAG, "Unknown query completed: ignoring: " + token); + return; + } + + if (mNewCallsCursor != null && mOldCallsCursor != null) { + updateAdapterData(createMergedCursor()); + } + } + + /** Creates the merged cursor representing the data to show in the call log. */ + @GuardedBy("this") + private Cursor createMergedCursor() { + try { + final boolean hasNewCalls = mNewCallsCursor.getCount() != 0; + final boolean hasOldCalls = mOldCallsCursor.getCount() != 0; + + if (!hasNewCalls) { + // Return only the old calls, without the header. + MoreCloseables.closeQuietly(mNewCallsCursor); + return mOldCallsCursor; + } + + if (!hasOldCalls) { + // Return only the new calls. + MoreCloseables.closeQuietly(mOldCallsCursor); + return new MergeCursor( + new Cursor[]{ createNewCallsHeaderCursor(), mNewCallsCursor }); + } + + return new MergeCursor(new Cursor[]{ + createNewCallsHeaderCursor(), mNewCallsCursor, + createOldCallsHeaderCursor(), mOldCallsCursor}); + } finally { + // Any cursor still open is now owned, directly or indirectly, by the caller. + mNewCallsCursor = null; + mOldCallsCursor = null; + } + } + + /** + * Updates the adapter in the call log fragment to show the new cursor data. + */ + private void updateAdapterData(Cursor combinedCursor) { + final Listener listener = mListener.get(); + if (listener != null) { + listener.onCallsFetched(combinedCursor); + } + } + + private void updateVoicemailStatus(Cursor statusCursor) { + final Listener listener = mListener.get(); + if (listener != null) { + listener.onVoicemailStatusFetched(statusCursor); + } + } + + /** Listener to completion of various queries. */ + public interface Listener { + /** Called when {@link CallLogQueryHandler#fetchVoicemailStatus()} completes. */ + void onVoicemailStatusFetched(Cursor statusCursor); + + /** + * Called when {@link CallLogQueryHandler#fetchCalls(int)}complete. + */ + void onCallsFetched(Cursor combinedCursor); + } +} diff --git a/src/com/android/dialer/calllog/CallLogReceiver.java b/src/com/android/dialer/calllog/CallLogReceiver.java new file mode 100644 index 000000000..97d2951c1 --- /dev/null +++ b/src/com/android/dialer/calllog/CallLogReceiver.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.dialer.calllog; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.provider.VoicemailContract; +import android.util.Log; + +/** + * 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 { + private static final String TAG = "CallLogReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + if (VoicemailContract.ACTION_NEW_VOICEMAIL.equals(intent.getAction())) { + Intent serviceIntent = new Intent(context, CallLogNotificationsService.class); + serviceIntent.setAction(CallLogNotificationsService.ACTION_UPDATE_NOTIFICATIONS); + serviceIntent.putExtra( + CallLogNotificationsService.EXTRA_NEW_VOICEMAIL_URI, intent.getData()); + context.startService(serviceIntent); + } else if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { + Intent serviceIntent = new Intent(context, CallLogNotificationsService.class); + serviceIntent.setAction(CallLogNotificationsService.ACTION_UPDATE_NOTIFICATIONS); + context.startService(serviceIntent); + } else { + Log.w(TAG, "onReceive: could not handle: " + intent); + } + } +} diff --git a/src/com/android/dialer/calllog/CallTypeHelper.java b/src/com/android/dialer/calllog/CallTypeHelper.java new file mode 100644 index 000000000..255258e01 --- /dev/null +++ b/src/com/android/dialer/calllog/CallTypeHelper.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.content.res.Resources; +import android.provider.CallLog.Calls; + +import com.android.contacts.R; + +/** + * 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 outgoing calls. */ + private final CharSequence mOutgoingName; + /** Name used to identify missed calls. */ + private final CharSequence mMissedName; + /** Name used to identify voicemail calls. */ + private final CharSequence mVoicemailName; + /** Color used to identify new missed calls. */ + private final int mNewMissedColor; + /** Color used to identify new voicemail calls. */ + private final int mNewVoicemailColor; + + 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); + mOutgoingName = resources.getString(R.string.type_outgoing); + mMissedName = resources.getString(R.string.type_missed); + mVoicemailName = resources.getString(R.string.type_voicemail); + mNewMissedColor = resources.getColor(R.color.call_log_missed_call_highlight_color); + mNewVoicemailColor = resources.getColor(R.color.call_log_voicemail_highlight_color); + } + + /** Returns the text used to represent the given call type. */ + public CharSequence getCallTypeText(int callType) { + switch (callType) { + case Calls.INCOMING_TYPE: + return mIncomingName; + + case Calls.OUTGOING_TYPE: + return mOutgoingName; + + case Calls.MISSED_TYPE: + return mMissedName; + + case Calls.VOICEMAIL_TYPE: + return mVoicemailName; + + default: + throw new IllegalArgumentException("invalid call type: " + callType); + } + } + + /** Returns the color used to highlight the given call type, null if not highlight is needed. */ + public Integer getHighlightedColor(int callType) { + switch (callType) { + case Calls.INCOMING_TYPE: + // New incoming calls are not highlighted. + return null; + + case Calls.OUTGOING_TYPE: + // New outgoing calls are not highlighted. + return null; + + case Calls.MISSED_TYPE: + return mNewMissedColor; + + case Calls.VOICEMAIL_TYPE: + return mNewVoicemailColor; + + default: + throw new IllegalArgumentException("invalid call type: " + callType); + } + } +} diff --git a/src/com/android/dialer/calllog/CallTypeIconsView.java b/src/com/android/dialer/calllog/CallTypeIconsView.java new file mode 100644 index 000000000..e26d5a1bb --- /dev/null +++ b/src/com/android/dialer/calllog/CallTypeIconsView.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.provider.CallLog.Calls; +import android.util.AttributeSet; +import android.view.View; + +import com.android.contacts.R; +import com.android.contacts.test.NeededForTesting; +import com.google.common.collect.Lists; + +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 List<Integer> mCallTypes = Lists.newArrayListWithCapacity(3); + private Resources mResources; + private int mWidth; + private int mHeight; + + public CallTypeIconsView(Context context) { + this(context, null); + } + + public CallTypeIconsView(Context context, AttributeSet attrs) { + super(context, attrs); + mResources = 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() + mResources.iconMargin; + mHeight = Math.max(mHeight, drawable.getIntrinsicHeight()); + invalidate(); + } + + @NeededForTesting + public int getCount() { + return mCallTypes.size(); + } + + @NeededForTesting + public int getCallType(int index) { + return mCallTypes.get(index); + } + + private Drawable getCallTypeDrawable(int callType) { + switch (callType) { + case Calls.INCOMING_TYPE: + return mResources.incoming; + case Calls.OUTGOING_TYPE: + return mResources.outgoing; + case Calls.MISSED_TYPE: + return mResources.missed; + case Calls.VOICEMAIL_TYPE: + return mResources.voicemail; + default: + throw new IllegalArgumentException("invalid call type: " + callType); + } + } + + @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 + mResources.iconMargin; + } + } + + private static class Resources { + public final Drawable incoming; + public final Drawable outgoing; + public final Drawable missed; + public final Drawable voicemail; + public final int iconMargin; + + public Resources(Context context) { + final android.content.res.Resources r = context.getResources(); + incoming = r.getDrawable(R.drawable.ic_call_incoming_holo_dark); + outgoing = r.getDrawable(R.drawable.ic_call_outgoing_holo_dark); + missed = r.getDrawable(R.drawable.ic_call_missed_holo_dark); + voicemail = r.getDrawable(R.drawable.ic_call_voicemail_holo_dark); + iconMargin = r.getDimensionPixelSize(R.dimen.call_log_icon_margin); + } + } +} diff --git a/src/com/android/dialer/calllog/ClearCallLogDialog.java b/src/com/android/dialer/calllog/ClearCallLogDialog.java new file mode 100644 index 000000000..e91c08fa8 --- /dev/null +++ b/src/com/android/dialer/calllog/ClearCallLogDialog.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.dialer.calllog; + +import android.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.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.os.AsyncTask; +import android.os.Bundle; +import android.provider.CallLog.Calls; + +import com.android.contacts.R; + +/** + * 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 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); + final AsyncTask<Void, Void, Void> task = new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + resolver.delete(Calls.CONTENT_URI, null, null); + return null; + } + @Override + protected void onPostExecute(Void result) { + 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/src/com/android/dialer/calllog/ContactInfo.java b/src/com/android/dialer/calllog/ContactInfo.java new file mode 100644 index 000000000..b48adef0f --- /dev/null +++ b/src/com/android/dialer/calllog/ContactInfo.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.net.Uri; +import android.text.TextUtils; + +import com.android.contacts.util.UriUtils; + +/** + * Information for a contact as needed by the Call Log. + */ +public final class ContactInfo { + public Uri lookupUri; + public String name; + public int type; + public String label; + public String number; + public String formattedNumber; + public String normalizedNumber; + /** The photo for the contact, if available. */ + public long photoId; + /** The high-res photo for the contact, if available. */ + public Uri photoUri; + + public static ContactInfo EMPTY = new ContactInfo(); + + @Override + public int hashCode() { + // Uses only name and contactUri to determine hashcode. + // This should be sufficient to have a reasonable distribution of hash codes. + // Moreover, there should be no two people with the same lookupUri. + final int prime = 31; + int result = 1; + result = prime * result + ((lookupUri == null) ? 0 : lookupUri.hashCode()); + result = prime * result + ((name == null) ? 0 : name.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + ContactInfo other = (ContactInfo) obj; + if (!UriUtils.areEqual(lookupUri, other.lookupUri)) return false; + if (!TextUtils.equals(name, other.name)) return false; + if (type != other.type) return false; + if (!TextUtils.equals(label, other.label)) return false; + if (!TextUtils.equals(number, other.number)) return false; + if (!TextUtils.equals(formattedNumber, other.formattedNumber)) return false; + if (!TextUtils.equals(normalizedNumber, other.normalizedNumber)) return false; + if (photoId != other.photoId) return false; + if (!UriUtils.areEqual(photoUri, other.photoUri)) return false; + return true; + } +} diff --git a/src/com/android/dialer/calllog/ContactInfoHelper.java b/src/com/android/dialer/calllog/ContactInfoHelper.java new file mode 100644 index 000000000..b6f0662f2 --- /dev/null +++ b/src/com/android/dialer/calllog/ContactInfoHelper.java @@ -0,0 +1,215 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.PhoneLookup; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; + +import com.android.contacts.util.UriUtils; + +/** + * Utility class to look up the contact information for a given number. + */ +public class ContactInfoHelper { + private final Context mContext; + private final String mCurrentCountryIso; + + public ContactInfoHelper(Context context, String currentCountryIso) { + mContext = context; + mCurrentCountryIso = currentCountryIso; + } + + /** + * Returns the contact information for the given number. + * <p> + * If the number does not match any contact, returns a contact info containing only the number + * and the formatted number. + * <p> + * If an error occurs during the lookup, it returns null. + * + * @param number the number to look up + * @param countryIso the country associated with this number + */ + public ContactInfo lookupNumber(String number, String countryIso) { + final ContactInfo info; + + // Determine the contact info. + if (PhoneNumberUtils.isUriNumber(number)) { + // This "number" is really a SIP address. + ContactInfo sipInfo = queryContactInfoForSipAddress(number); + if (sipInfo == null || sipInfo == ContactInfo.EMPTY) { + // Check whether the "username" part of the SIP address is + // actually the phone number of a contact. + String username = PhoneNumberUtils.getUsernameFromUriNumber(number); + if (PhoneNumberUtils.isGlobalPhoneNumber(username)) { + sipInfo = queryContactInfoForPhoneNumber(username, countryIso); + } + } + info = sipInfo; + } else { + // Look for a contact that has the given phone number. + ContactInfo phoneInfo = queryContactInfoForPhoneNumber(number, countryIso); + + if (phoneInfo == null || phoneInfo == ContactInfo.EMPTY) { + // Check whether the phone number has been saved as an "Internet call" number. + phoneInfo = queryContactInfoForSipAddress(number); + } + info = phoneInfo; + } + + final ContactInfo updatedInfo; + if (info == null) { + // The lookup failed. + updatedInfo = null; + } else { + // If we did not find a matching contact, generate an empty contact info for the number. + if (info == ContactInfo.EMPTY) { + // Did not find a matching contact. + updatedInfo = new ContactInfo(); + updatedInfo.number = number; + updatedInfo.formattedNumber = formatPhoneNumber(number, null, countryIso); + } else { + updatedInfo = info; + } + } + return updatedInfo; + } + + /** + * Looks up a contact using the given URI. + * <p> + * It returns null if an error occurs, {@link ContactInfo#EMPTY} if no matching contact is + * found, or the {@link ContactInfo} for the given contact. + * <p> + * The {@link ContactInfo#formattedNumber} field is always set to {@code null} in the returned + * value. + */ + private ContactInfo lookupContactFromUri(Uri uri) { + final ContactInfo info; + Cursor phonesCursor = + mContext.getContentResolver().query( + uri, PhoneQuery._PROJECTION, null, null, null); + + if (phonesCursor != null) { + try { + if (phonesCursor.moveToFirst()) { + info = new ContactInfo(); + long contactId = phonesCursor.getLong(PhoneQuery.PERSON_ID); + String lookupKey = phonesCursor.getString(PhoneQuery.LOOKUP_KEY); + info.lookupUri = Contacts.getLookupUri(contactId, lookupKey); + info.name = phonesCursor.getString(PhoneQuery.NAME); + info.type = phonesCursor.getInt(PhoneQuery.PHONE_TYPE); + info.label = phonesCursor.getString(PhoneQuery.LABEL); + info.number = phonesCursor.getString(PhoneQuery.MATCHED_NUMBER); + info.normalizedNumber = phonesCursor.getString(PhoneQuery.NORMALIZED_NUMBER); + info.photoId = phonesCursor.getLong(PhoneQuery.PHOTO_ID); + info.photoUri = + UriUtils.parseUriOrNull(phonesCursor.getString(PhoneQuery.PHOTO_URI)); + info.formattedNumber = null; + } else { + info = ContactInfo.EMPTY; + } + } finally { + phonesCursor.close(); + } + } else { + // Failed to fetch the data, ignore this request. + info = null; + } + return info; + } + + /** + * Determines the contact information for the given SIP address. + * <p> + * It returns the contact info if found. + * <p> + * If no contact corresponds to the given SIP address, returns {@link ContactInfo#EMPTY}. + * <p> + * If the lookup fails for some other reason, it returns null. + */ + private ContactInfo queryContactInfoForSipAddress(String sipAddress) { + final ContactInfo info; + + // "contactNumber" is a SIP address, so use the PhoneLookup table with the SIP parameter. + Uri.Builder uriBuilder = PhoneLookup.CONTENT_FILTER_URI.buildUpon(); + uriBuilder.appendPath(Uri.encode(sipAddress)); + uriBuilder.appendQueryParameter(PhoneLookup.QUERY_PARAMETER_SIP_ADDRESS, "1"); + return lookupContactFromUri(uriBuilder.build()); + } + + /** + * Determines the contact information for the given phone number. + * <p> + * It returns the contact info if found. + * <p> + * If no contact corresponds to the given phone number, returns {@link ContactInfo#EMPTY}. + * <p> + * If the lookup fails for some other reason, it returns null. + */ + private ContactInfo queryContactInfoForPhoneNumber(String number, String countryIso) { + String contactNumber = number; + if (!TextUtils.isEmpty(countryIso)) { + // Normalize the number: this is needed because the PhoneLookup query below does not + // accept a country code as an input. + String numberE164 = PhoneNumberUtils.formatNumberToE164(number, countryIso); + if (!TextUtils.isEmpty(numberE164)) { + // Only use it if the number could be formatted to E164. + contactNumber = numberE164; + } + } + + // The "contactNumber" is a regular phone number, so use the PhoneLookup table. + Uri uri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(contactNumber)); + ContactInfo info = lookupContactFromUri(uri); + if (info != null && info != ContactInfo.EMPTY) { + info.formattedNumber = formatPhoneNumber(number, null, countryIso); + } + return info; + } + + /** + * Format the given phone number + * + * @param number the number to be formatted. + * @param normalizedNumber the normalized number of the given number. + * @param countryIso the ISO 3166-1 two letters country code, the country's + * convention will be used to format the number if the normalized + * phone is null. + * + * @return the formatted number, or the given number if it was formatted. + */ + private String formatPhoneNumber(String number, String normalizedNumber, + String countryIso) { + if (TextUtils.isEmpty(number)) { + return ""; + } + // If "number" is really a SIP address, don't try to do any formatting at all. + if (PhoneNumberUtils.isUriNumber(number)) { + return number; + } + if (TextUtils.isEmpty(countryIso)) { + countryIso = mCurrentCountryIso; + } + return PhoneNumberUtils.formatNumber(number, normalizedNumber, countryIso); + } +} diff --git a/src/com/android/dialer/calllog/DefaultVoicemailNotifier.java b/src/com/android/dialer/calllog/DefaultVoicemailNotifier.java new file mode 100644 index 000000000..0f6fe3b08 --- /dev/null +++ b/src/com/android/dialer/calllog/DefaultVoicemailNotifier.java @@ -0,0 +1,340 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.dialer.calllog; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.provider.CallLog.Calls; +import android.provider.ContactsContract.PhoneLookup; +import android.text.TextUtils; +import android.util.Log; + +import com.android.common.io.MoreCloseables; +import com.android.dialer.CallDetailActivity; +import com.android.contacts.R; +import com.google.common.collect.Maps; + +import java.util.Map; + +/** + * Implementation of {@link VoicemailNotifier} that shows a notification in the + * status bar. + */ +public class DefaultVoicemailNotifier implements VoicemailNotifier { + public static final String TAG = "DefaultVoicemailNotifier"; + + /** 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 final NotificationManager mNotificationManager; + private final NewCallsQuery mNewCallsQuery; + private final NameLookupQuery mNameLookupQuery; + private final PhoneNumberHelper mPhoneNumberHelper; + + /** Returns the singleton instance of the {@link DefaultVoicemailNotifier}. */ + public static synchronized DefaultVoicemailNotifier getInstance(Context context) { + if (sInstance == null) { + NotificationManager notificationManager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + ContentResolver contentResolver = context.getContentResolver(); + sInstance = new DefaultVoicemailNotifier(context, notificationManager, + createNewCallsQuery(contentResolver), + createNameLookupQuery(contentResolver), + createPhoneNumberHelper(context)); + } + return sInstance; + } + + private DefaultVoicemailNotifier(Context context, + NotificationManager notificationManager, NewCallsQuery newCallsQuery, + NameLookupQuery nameLookupQuery, PhoneNumberHelper phoneNumberHelper) { + mContext = context; + mNotificationManager = notificationManager; + mNewCallsQuery = newCallsQuery; + mNameLookupQuery = nameLookupQuery; + mPhoneNumberHelper = phoneNumberHelper; + } + + /** Updates the notification and notifies of the call with the given URI. */ + @Override + public void updateNotification(Uri newCallUri) { + // Lookup the list of new voicemails to include in the notification. + // TODO: Move this into a service, to avoid holding the receiver up. + final NewCall[] newCalls = mNewCallsQuery.query(); + + if (newCalls == null) { + // Query failed, just return. + return; + } + + if (newCalls.length == 0) { + // No voicemails to notify about: clear the notification. + clearNotification(); + 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 = Maps.newHashMap(); + + // 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. + for (NewCall newCall : newCalls) { + // Check if we already know the name associated with this number. + String name = names.get(newCall.number); + if (name == null) { + // Look it up in the database. + name = mNameLookupQuery.query(newCall.number); + // If we cannot lookup the contact, use the number instead. + if (name == null) { + name = mPhoneNumberHelper.getDisplayNumber(newCall.number, "").toString(); + if (TextUtils.isEmpty(name)) { + name = newCall.number; + } + } + 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 && newCallUri.equals(newCall.voicemailUri)) { + callToNotify = newCall; + } + } + + 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.length, newCalls.length); + // TODO: Use the photo of contact if all calls are from the same person. + final int icon = android.R.drawable.stat_notify_voicemail; + + Notification.Builder notificationBuilder = new Notification.Builder(mContext) + .setSmallIcon(icon) + .setContentTitle(title) + .setContentText(callers) + .setDefaults(callToNotify != null ? Notification.DEFAULT_ALL : 0) + .setDeleteIntent(createMarkNewVoicemailsAsOldIntent()) + .setAutoCancel(true); + + // Determine the intent to fire when the notification is clicked on. + final Intent contentIntent; + if (newCalls.length == 1) { + // Open the voicemail directly. + contentIntent = new Intent(mContext, CallDetailActivity.class); + contentIntent.setData(newCalls[0].callsUri); + contentIntent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI, + newCalls[0].voicemailUri); + Intent playIntent = new Intent(mContext, CallDetailActivity.class); + playIntent.setData(newCalls[0].callsUri); + playIntent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI, + newCalls[0].voicemailUri); + playIntent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK, true); + playIntent.putExtra(CallDetailActivity.EXTRA_FROM_NOTIFICATION, true); + notificationBuilder.addAction(R.drawable.ic_play_holo_dark, + resources.getString(R.string.notification_action_voicemail_play), + PendingIntent.getActivity(mContext, 0, playIntent, 0)); + } else { + // Open the call log. + contentIntent = new Intent(Intent.ACTION_VIEW, Calls.CONTENT_URI); + } + notificationBuilder.setContentIntent( + PendingIntent.getActivity(mContext, 0, contentIntent, 0)); + + // The text to show in the ticker, describing the new event. + if (callToNotify != null) { + notificationBuilder.setTicker(resources.getString( + R.string.notification_new_voicemail_ticker, names.get(callToNotify.number))); + } + + mNotificationManager.notify(NOTIFICATION_TAG, NOTIFICATION_ID, notificationBuilder.build()); + } + + /** Creates a pending intent that marks all new voicemails as old. */ + private PendingIntent createMarkNewVoicemailsAsOldIntent() { + Intent intent = new Intent(mContext, CallLogNotificationsService.class); + intent.setAction(CallLogNotificationsService.ACTION_MARK_NEW_VOICEMAILS_AS_OLD); + return PendingIntent.getService(mContext, 0, intent, 0); + } + + @Override + public void clearNotification() { + mNotificationManager.cancel(NOTIFICATION_TAG, NOTIFICATION_ID); + } + + /** Information about a new voicemail. */ + private static final class NewCall { + public final Uri callsUri; + public final Uri voicemailUri; + public final String number; + + public NewCall(Uri callsUri, Uri voicemailUri, String number) { + this.callsUri = callsUri; + this.voicemailUri = voicemailUri; + this.number = number; + } + } + + /** Allows determining the new calls for which a notification should be generated. */ + public interface NewCallsQuery { + /** + * Returns the new calls for which a notification should be generated. + */ + public NewCall[] query(); + } + + /** Create a new instance of {@link NewCallsQuery}. */ + public static NewCallsQuery createNewCallsQuery(ContentResolver contentResolver) { + return new DefaultNewCallsQuery(contentResolver); + } + + /** + * Default implementation of {@link NewCallsQuery} that looks up the list of new calls to + * notify about in the call log. + */ + private static final class DefaultNewCallsQuery implements NewCallsQuery { + private static final String[] PROJECTION = { + Calls._ID, Calls.NUMBER, Calls.VOICEMAIL_URI + }; + 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 final ContentResolver mContentResolver; + + private DefaultNewCallsQuery(ContentResolver contentResolver) { + mContentResolver = contentResolver; + } + + @Override + public NewCall[] query() { + final String selection = String.format("%s = 1 AND %s = ?", Calls.NEW, Calls.TYPE); + final String[] selectionArgs = new String[]{ Integer.toString(Calls.VOICEMAIL_TYPE) }; + Cursor cursor = null; + try { + cursor = mContentResolver.query(Calls.CONTENT_URI_WITH_VOICEMAIL, PROJECTION, + selection, selectionArgs, Calls.DEFAULT_SORT_ORDER); + if (cursor == null) { + return null; + } + NewCall[] newCalls = new NewCall[cursor.getCount()]; + while (cursor.moveToNext()) { + newCalls[cursor.getPosition()] = createNewCallsFromCursor(cursor); + } + return newCalls; + } finally { + MoreCloseables.closeQuietly(cursor); + } + } + + /** 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)); + } + } + + /** Allows determining the name associated with a given phone number. */ + public interface NameLookupQuery { + /** + * Returns the name associated with the given number in the contacts database, or null if + * the number does not correspond to any of the contacts. + * <p> + * If there are multiple contacts with the same phone number, it will return the name of one + * of the matching contacts. + */ + public String query(String number); + } + + /** Create a new instance of {@link NameLookupQuery}. */ + public static NameLookupQuery createNameLookupQuery(ContentResolver contentResolver) { + return new DefaultNameLookupQuery(contentResolver); + } + + /** + * Default implementation of {@link NameLookupQuery} that looks up the name of a contact in the + * contacts database. + */ + private static final class DefaultNameLookupQuery implements NameLookupQuery { + private static final String[] PROJECTION = { PhoneLookup.DISPLAY_NAME }; + private static final int DISPLAY_NAME_COLUMN_INDEX = 0; + + private final ContentResolver mContentResolver; + + private DefaultNameLookupQuery(ContentResolver contentResolver) { + mContentResolver = contentResolver; + } + + @Override + public String query(String number) { + Cursor cursor = null; + try { + cursor = mContentResolver.query( + Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)), + PROJECTION, null, null, null); + if (cursor == null || !cursor.moveToFirst()) return null; + return cursor.getString(DISPLAY_NAME_COLUMN_INDEX); + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + } + + /** + * Create a new PhoneNumberHelper. + * <p> + * This will cause some Disk I/O, at least the first time it is created, so it should not be + * called from the main thread. + */ + public static PhoneNumberHelper createPhoneNumberHelper(Context context) { + return new PhoneNumberHelper(context.getResources()); + } +} diff --git a/src/com/android/dialer/calllog/ExtendedCursor.java b/src/com/android/dialer/calllog/ExtendedCursor.java new file mode 100644 index 000000000..3e55aabe8 --- /dev/null +++ b/src/com/android/dialer/calllog/ExtendedCursor.java @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.database.AbstractCursor; +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.DataSetObserver; + +import com.android.common.io.MoreCloseables; + +/** + * Wraps a cursor to add an additional column with the same value for all rows. + * <p> + * The number of rows in the cursor and the set of columns is determined by the cursor being + * wrapped. + */ +public class ExtendedCursor extends AbstractCursor { + /** The cursor to wrap. */ + private final Cursor mCursor; + /** The name of the additional column. */ + private final String mColumnName; + /** The value to be assigned to the additional column. */ + private final Object mValue; + + /** + * Creates a new cursor which extends the given cursor by adding a column with a constant value. + * + * @param cursor the cursor to extend + * @param columnName the name of the additional column + * @param value the value to be assigned to the additional column + */ + public ExtendedCursor(Cursor cursor, String columnName, Object value) { + mCursor = cursor; + mColumnName = columnName; + mValue = value; + } + + @Override + public int getCount() { + return mCursor.getCount(); + } + + @Override + public String[] getColumnNames() { + String[] columnNames = mCursor.getColumnNames(); + int length = columnNames.length; + String[] extendedColumnNames = new String[length + 1]; + System.arraycopy(columnNames, 0, extendedColumnNames, 0, length); + extendedColumnNames[length] = mColumnName; + return extendedColumnNames; + } + + @Override + public String getString(int column) { + if (column == mCursor.getColumnCount()) { + return (String) mValue; + } + return mCursor.getString(column); + } + + @Override + public short getShort(int column) { + if (column == mCursor.getColumnCount()) { + return (Short) mValue; + } + return mCursor.getShort(column); + } + + @Override + public int getInt(int column) { + if (column == mCursor.getColumnCount()) { + return (Integer) mValue; + } + return mCursor.getInt(column); + } + + @Override + public long getLong(int column) { + if (column == mCursor.getColumnCount()) { + return (Long) mValue; + } + return mCursor.getLong(column); + } + + @Override + public float getFloat(int column) { + if (column == mCursor.getColumnCount()) { + return (Float) mValue; + } + return mCursor.getFloat(column); + } + + @Override + public double getDouble(int column) { + if (column == mCursor.getColumnCount()) { + return (Double) mValue; + } + return mCursor.getDouble(column); + } + + @Override + public boolean isNull(int column) { + if (column == mCursor.getColumnCount()) { + return mValue == null; + } + return mCursor.isNull(column); + } + + @Override + public boolean onMove(int oldPosition, int newPosition) { + return mCursor.moveToPosition(newPosition); + } + + @Override + public void close() { + MoreCloseables.closeQuietly(mCursor); + super.close(); + } + + @Override + public void registerContentObserver(ContentObserver observer) { + mCursor.registerContentObserver(observer); + } + + @Override + public void unregisterContentObserver(ContentObserver observer) { + mCursor.unregisterContentObserver(observer); + } + + @Override + public void registerDataSetObserver(DataSetObserver observer) { + mCursor.registerDataSetObserver(observer); + } + + @Override + public void unregisterDataSetObserver(DataSetObserver observer) { + mCursor.unregisterDataSetObserver(observer); + } +} diff --git a/src/com/android/dialer/calllog/IntentProvider.java b/src/com/android/dialer/calllog/IntentProvider.java new file mode 100644 index 000000000..f43dc5104 --- /dev/null +++ b/src/com/android/dialer/calllog/IntentProvider.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.provider.CallLog.Calls; + +import com.android.dialer.CallDetailActivity; +import com.android.contacts.ContactsUtils; + +/** + * 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 { + public abstract Intent getIntent(Context context); + + public static IntentProvider getReturnCallIntentProvider(final String number) { + return new IntentProvider() { + @Override + public Intent getIntent(Context context) { + return ContactsUtils.getCallIntent(number); + } + }; + } + + public static IntentProvider getPlayVoicemailIntentProvider(final long rowId, + final String voicemailUri) { + return new IntentProvider() { + @Override + public Intent getIntent(Context context) { + Intent intent = new Intent(context, CallDetailActivity.class); + intent.setData(ContentUris.withAppendedId( + Calls.CONTENT_URI_WITH_VOICEMAIL, rowId)); + if (voicemailUri != null) { + intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI, + Uri.parse(voicemailUri)); + } + intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK, true); + return intent; + } + }; + } + + public static IntentProvider getCallDetailIntentProvider( + final CallLogAdapter adapter, final int position, final long id, final int groupSize) { + return new IntentProvider() { + @Override + public Intent getIntent(Context context) { + Cursor cursor = adapter.getCursor(); + cursor.moveToPosition(position); + if (CallLogQuery.isSectionHeader(cursor)) { + // Do nothing when a header is clicked. + return null; + } + Intent intent = new Intent(context, CallDetailActivity.class); + // Check if the first item is a voicemail. + String voicemailUri = cursor.getString(CallLogQuery.VOICEMAIL_URI); + if (voicemailUri != null) { + intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI, + Uri.parse(voicemailUri)); + } + intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK, false); + + if (groupSize > 1) { + // We want to restore the position in the cursor at the end. + 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(); + } + intent.putExtra(CallDetailActivity.EXTRA_CALL_LOG_IDS, ids); + } else { + // If there is a single item, use the direct URI for it. + intent.setData(ContentUris.withAppendedId( + Calls.CONTENT_URI_WITH_VOICEMAIL, id)); + } + return intent; + } + }; + } +} diff --git a/src/com/android/dialer/calllog/PhoneNumberHelper.java b/src/com/android/dialer/calllog/PhoneNumberHelper.java new file mode 100644 index 000000000..70505eebc --- /dev/null +++ b/src/com/android/dialer/calllog/PhoneNumberHelper.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.content.res.Resources; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; + +import com.android.contacts.R; +import com.android.internal.telephony.CallerInfo; + +/** + * Helper for formatting and managing phone numbers. + */ +public class PhoneNumberHelper { + private final Resources mResources; + + public PhoneNumberHelper(Resources resources) { + mResources = resources; + } + + /** Returns true if it is possible to place a call to the given number. */ + public boolean canPlaceCallsTo(CharSequence number) { + return !(TextUtils.isEmpty(number) + || number.equals(CallerInfo.UNKNOWN_NUMBER) + || number.equals(CallerInfo.PRIVATE_NUMBER) + || number.equals(CallerInfo.PAYPHONE_NUMBER)); + } + + /** Returns true if it is possible to send an SMS to the given number. */ + public boolean canSendSmsTo(CharSequence number) { + return canPlaceCallsTo(number) && !isVoicemailNumber(number) && !isSipNumber(number); + } + + /** + * 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 CharSequence getDisplayNumber(CharSequence number, CharSequence formattedNumber) { + if (TextUtils.isEmpty(number)) { + return ""; + } + if (number.equals(CallerInfo.UNKNOWN_NUMBER)) { + return mResources.getString(R.string.unknown); + } + if (number.equals(CallerInfo.PRIVATE_NUMBER)) { + return mResources.getString(R.string.private_num); + } + if (number.equals(CallerInfo.PAYPHONE_NUMBER)) { + return mResources.getString(R.string.payphone); + } + if (isVoicemailNumber(number)) { + return mResources.getString(R.string.voicemail); + } + if (TextUtils.isEmpty(formattedNumber)) { + return number; + } else { + return formattedNumber; + } + } + + /** + * 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 boolean isVoicemailNumber(CharSequence number) { + return PhoneNumberUtils.isVoiceMailNumber(number.toString()); + } + + /** + * Returns true if the given number is a SIP address. + * To be able to mock-out this, it is not a static method. + */ + public boolean isSipNumber(CharSequence number) { + return PhoneNumberUtils.isUriNumber(number.toString()); + } +} diff --git a/src/com/android/dialer/calllog/PhoneQuery.java b/src/com/android/dialer/calllog/PhoneQuery.java new file mode 100644 index 000000000..719052204 --- /dev/null +++ b/src/com/android/dialer/calllog/PhoneQuery.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.calllog; + +import android.provider.ContactsContract.PhoneLookup; + +/** + * The query to look up the {@link ContactInfo} for a given number in the Call Log. + */ +final class PhoneQuery { + public static final String[] _PROJECTION = new String[] { + PhoneLookup._ID, + PhoneLookup.DISPLAY_NAME, + PhoneLookup.TYPE, + PhoneLookup.LABEL, + PhoneLookup.NUMBER, + PhoneLookup.NORMALIZED_NUMBER, + PhoneLookup.PHOTO_ID, + PhoneLookup.LOOKUP_KEY, + PhoneLookup.PHOTO_URI}; + + public static final int PERSON_ID = 0; + public static final int NAME = 1; + public static final int PHONE_TYPE = 2; + public static final int LABEL = 3; + public static final int MATCHED_NUMBER = 4; + public static final int NORMALIZED_NUMBER = 5; + public static final int PHOTO_ID = 6; + public static final int LOOKUP_KEY = 7; + public static final int PHOTO_URI = 8; +} diff --git a/src/com/android/dialer/calllog/VoicemailNotifier.java b/src/com/android/dialer/calllog/VoicemailNotifier.java new file mode 100644 index 000000000..d433cf7f6 --- /dev/null +++ b/src/com/android/dialer/calllog/VoicemailNotifier.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.dialer.calllog; + +import android.net.Uri; + +/** + * Handles notifications for voicemails. + */ +public interface VoicemailNotifier { + /** + * Updates the notification and clears it if there are no new voicemails. + * <p> + * If the given URI corresponds to a new voicemail, also notifies about it. + * <p> + * It is not safe to call this method from the main thread. + * + * @param newCallUri URI of the new call, may be null + */ + public void updateNotification(Uri newCallUri); + + /** Clears the new voicemail notification. */ + public void clearNotification(); +} |