CallLogFragment.java revision 3d4e92d0be6d128cfcaa37bdd4f4d9b9639d3eab
1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.contacts.calllog;
18
19import com.android.common.widget.GroupingListAdapter;
20import com.android.contacts.CallDetailActivity;
21import com.android.contacts.ContactPhotoManager;
22import com.android.contacts.ContactsUtils;
23import com.android.contacts.PhoneCallDetails;
24import com.android.contacts.PhoneCallDetailsHelper;
25import com.android.contacts.R;
26import com.android.contacts.activities.DialtactsActivity;
27import com.android.contacts.activities.DialtactsActivity.ViewPagerVisibilityListener;
28import com.android.contacts.calllog.VoicemailStatusHelper.StatusMessage;
29import com.android.contacts.util.ExpirableCache;
30import com.android.internal.telephony.CallerInfo;
31import com.google.common.annotations.VisibleForTesting;
32
33import android.app.ListFragment;
34import android.content.ContentUris;
35import android.content.Context;
36import android.content.Intent;
37import android.content.res.Resources;
38import android.database.CharArrayBuffer;
39import android.database.Cursor;
40import android.graphics.drawable.Drawable;
41import android.net.Uri;
42import android.os.Bundle;
43import android.os.Handler;
44import android.os.Message;
45import android.provider.CallLog;
46import android.provider.CallLog.Calls;
47import android.provider.ContactsContract.CommonDataKinds.SipAddress;
48import android.provider.ContactsContract.Contacts;
49import android.provider.ContactsContract.Data;
50import android.provider.ContactsContract.PhoneLookup;
51import android.telephony.PhoneNumberUtils;
52import android.telephony.TelephonyManager;
53import android.text.TextUtils;
54import android.util.Log;
55import android.view.LayoutInflater;
56import android.view.Menu;
57import android.view.MenuInflater;
58import android.view.MenuItem;
59import android.view.View;
60import android.view.ViewGroup;
61import android.view.ViewTreeObserver;
62import android.widget.ListView;
63import android.widget.QuickContactBadge;
64import android.widget.TextView;
65
66import java.util.LinkedList;
67import java.util.List;
68
69
70/**
71 * Displays a list of call log entries.
72 */
73public class CallLogFragment extends ListFragment implements ViewPagerVisibilityListener {
74    private static final String TAG = "CallLogFragment";
75
76    /** The size of the cache of contact info. */
77    private static final int CONTACT_INFO_CACHE_SIZE = 100;
78
79    /** The query for the call log table. */
80    public static final class CallLogQuery {
81        // If you alter this, you must also alter the method that inserts a fake row to the headers
82        // in the CallLogQueryHandler class called createHeaderCursorFor().
83        public static final String[] _PROJECTION = new String[] {
84                Calls._ID,
85                Calls.NUMBER,
86                Calls.DATE,
87                Calls.DURATION,
88                Calls.TYPE,
89                Calls.COUNTRY_ISO,
90                Calls.VOICEMAIL_URI,
91        };
92
93        public static final int ID = 0;
94        public static final int NUMBER = 1;
95        public static final int DATE = 2;
96        public static final int DURATION = 3;
97        public static final int CALL_TYPE = 4;
98        public static final int COUNTRY_ISO = 5;
99        public static final int VOICEMAIL_URI = 6;
100
101        /**
102         * The name of the synthetic "section" column.
103         * <p>
104         * This column identifies whether a row is a header or an actual item, and whether it is
105         * part of the new or old calls.
106         */
107        public static final String SECTION_NAME = "section";
108        /** The index of the "section" column in the projection. */
109        public static final int SECTION = 7;
110        /** The value of the "section" column for the header of the new section. */
111        public static final int SECTION_NEW_HEADER = 0;
112        /** The value of the "section" column for the items of the new section. */
113        public static final int SECTION_NEW_ITEM = 1;
114        /** The value of the "section" column for the header of the old section. */
115        public static final int SECTION_OLD_HEADER = 2;
116        /** The value of the "section" column for the items of the old section. */
117        public static final int SECTION_OLD_ITEM = 3;
118    }
119
120    /** The query to use for the phones table */
121    private static final class PhoneQuery {
122        public static final String[] _PROJECTION = new String[] {
123                PhoneLookup._ID,
124                PhoneLookup.DISPLAY_NAME,
125                PhoneLookup.TYPE,
126                PhoneLookup.LABEL,
127                PhoneLookup.NUMBER,
128                PhoneLookup.NORMALIZED_NUMBER,
129                PhoneLookup.PHOTO_THUMBNAIL_URI,
130                PhoneLookup.LOOKUP_KEY};
131
132        public static final int PERSON_ID = 0;
133        public static final int NAME = 1;
134        public static final int PHONE_TYPE = 2;
135        public static final int LABEL = 3;
136        public static final int MATCHED_NUMBER = 4;
137        public static final int NORMALIZED_NUMBER = 5;
138        public static final int THUMBNAIL_URI = 6;
139        public static final int LOOKUP_KEY = 7;
140    }
141
142    private static final class OptionsMenuItems {
143        public static final int DELETE_ALL = 1;
144    }
145
146    private CallLogAdapter mAdapter;
147    private CallLogQueryHandler mCallLogQueryHandler;
148    private String mVoiceMailNumber;
149    private String mCurrentCountryIso;
150    private boolean mScrollToTop;
151
152    private boolean mShowOptionsMenu;
153
154    private VoicemailStatusHelper mVoicemailStatusHelper;
155    private View mStatusMessageView;
156    private TextView mStatusMessageText;
157    private TextView mStatusMessageAction;
158
159    public static final class ContactInfo {
160        public long personId;
161        public String name;
162        public int type;
163        public String label;
164        public String number;
165        public String formattedNumber;
166        public String normalizedNumber;
167        public Uri thumbnailUri;
168        public String lookupKey;
169
170        public static ContactInfo EMPTY = new ContactInfo();
171    }
172
173    public static final class CallerInfoQuery {
174        public String number;
175        public int position;
176        public String name;
177        public int numberType;
178        public String numberLabel;
179        public Uri thumbnailUri;
180        public String lookupKey;
181    }
182
183    /** Encapsulates the information needed to call a number from the call log. */
184    private static final class NumberAndType {
185        private final String mNumber;
186        private final long mRowId;
187        private final int mCallType;
188        private final String mVoicemailUri;
189
190        public NumberAndType(String number, long rowId, int callType, String voicemailUri) {
191            mNumber = number;
192            mRowId = rowId;
193            mCallType = callType;
194            mVoicemailUri = voicemailUri;
195        }
196
197        public Intent getIntent(Context context) {
198            switch (mCallType) {
199                case CallLog.Calls.VOICEMAIL_TYPE:
200                    Intent intent = new Intent(context, CallDetailActivity.class);
201                    intent.setData(ContentUris.withAppendedId(
202                            Calls.CONTENT_URI_WITH_VOICEMAIL, mRowId));
203                    intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI,
204                            Uri.parse(mVoicemailUri));
205                    intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK, true);
206                    return intent;
207                case CallLog.Calls.INCOMING_TYPE:
208                case CallLog.Calls.OUTGOING_TYPE:
209                case CallLog.Calls.MISSED_TYPE:
210                default: {
211                    // Here, "number" can either be a PSTN phone number or a
212                    // SIP address.  So turn it into either a tel: URI or a
213                    // sip: URI, as appropriate.
214                    Uri uri;
215                    if (PhoneNumberUtils.isUriNumber(mNumber)) {
216                        uri = Uri.fromParts("sip", mNumber, null);
217                    } else {
218                        uri = Uri.fromParts("tel", mNumber, null);
219                    }
220                    return new Intent(Intent.ACTION_CALL_PRIVILEGED, uri);
221                }
222            }
223        }
224    }
225
226    /** Adapter class to fill in data for the Call Log */
227    public final class CallLogAdapter extends GroupingListAdapter
228            implements Runnable, ViewTreeObserver.OnPreDrawListener, View.OnClickListener {
229        /** The time in millis to delay starting the thread processing requests. */
230        private static final int START_PROCESSING_REQUESTS_DELAY_MILLIS = 1000;
231
232        /**
233         * A cache of the contact details for the phone numbers in the call log.
234         * <p>
235         * The content of the cache is expired (but not purged) whenever the application comes to
236         * the foreground.
237         */
238        private ExpirableCache<String, ContactInfo> mContactInfoCache;
239
240        /**
241         * List of requests to update contact details.
242         * <p>
243         * The requests are added when displaying the contacts and are processed by a background
244         * thread.
245         */
246        private final LinkedList<CallerInfoQuery> mRequests;
247
248        private volatile boolean mDone;
249        private boolean mLoading = true;
250        private ViewTreeObserver.OnPreDrawListener mPreDrawListener;
251        private static final int REDRAW = 1;
252        private static final int START_THREAD = 2;
253        private boolean mFirst;
254        private Thread mCallerIdThread;
255
256        /** Instance of helper class for managing views. */
257        private final CallLogListItemHelper mCallLogViewsHelper;
258
259        /**
260         * Reusable char array buffers.
261         */
262        private CharArrayBuffer mBuffer1 = new CharArrayBuffer(128);
263        private CharArrayBuffer mBuffer2 = new CharArrayBuffer(128);
264        /** Helper to set up contact photos. */
265        private final ContactPhotoManager mContactPhotoManager;
266
267        /** Can be set to true by tests to disable processing of requests. */
268        private volatile boolean mRequestProcessingDisabled = false;
269
270        @Override
271        public void onClick(View view) {
272            NumberAndType numberAndType = (NumberAndType) view.getTag();
273            if (numberAndType != null) {
274                startActivity(numberAndType.getIntent(CallLogFragment.this.getActivity()));
275            }
276        }
277
278        @Override
279        public boolean onPreDraw() {
280            if (mFirst) {
281                mHandler.sendEmptyMessageDelayed(START_THREAD,
282                        START_PROCESSING_REQUESTS_DELAY_MILLIS);
283                mFirst = false;
284            }
285            return true;
286        }
287
288        private Handler mHandler = new Handler() {
289            @Override
290            public void handleMessage(Message msg) {
291                switch (msg.what) {
292                    case REDRAW:
293                        notifyDataSetChanged();
294                        break;
295                    case START_THREAD:
296                        startRequestProcessing();
297                        break;
298                }
299            }
300        };
301
302        public CallLogAdapter() {
303            super(getActivity());
304
305            mContactInfoCache = ExpirableCache.create(CONTACT_INFO_CACHE_SIZE);
306            mRequests = new LinkedList<CallerInfoQuery>();
307            mPreDrawListener = null;
308
309            Resources resources = getResources();
310            CallTypeHelper callTypeHelper = new CallTypeHelper(resources,
311                    resources.getDrawable(R.drawable.ic_call_incoming_holo_dark),
312                    resources.getDrawable(R.drawable.ic_call_outgoing_holo_dark),
313                    resources.getDrawable(R.drawable.ic_call_missed_holo_dark),
314                    resources.getDrawable(R.drawable.ic_call_voicemail_holo_dark));
315            Drawable callDrawable = resources.getDrawable(R.drawable.ic_dial_action_call);
316            Drawable playDrawable = resources.getDrawable(
317                    R.drawable.ic_call_log_list_action_play);
318
319            mContactPhotoManager = ContactPhotoManager.getInstance(getActivity());
320            PhoneNumberHelper phoneNumberHelper =
321                    new PhoneNumberHelper(getResources(), mVoiceMailNumber);
322            PhoneCallDetailsHelper phoneCallDetailsHelper = new PhoneCallDetailsHelper(
323                    getActivity(), resources, callTypeHelper, phoneNumberHelper );
324            mCallLogViewsHelper = new CallLogListItemHelper(phoneCallDetailsHelper,
325                    phoneNumberHelper, callDrawable, playDrawable);
326        }
327
328        /**
329         * Requery on background thread when {@link Cursor} changes.
330         */
331        @Override
332        protected void onContentChanged() {
333            // Start async requery
334            startQuery();
335        }
336
337        void setLoading(boolean loading) {
338            mLoading = loading;
339        }
340
341        @Override
342        public boolean isEmpty() {
343            if (mLoading) {
344                // We don't want the empty state to show when loading.
345                return false;
346            } else {
347                return super.isEmpty();
348            }
349        }
350
351        public ContactInfo getContactInfo(String number) {
352            return mContactInfoCache.getPossiblyExpired(number);
353        }
354
355        public void startRequestProcessing() {
356            if (mRequestProcessingDisabled) {
357                return;
358            }
359
360            mDone = false;
361            mCallerIdThread = new Thread(this);
362            mCallerIdThread.setPriority(Thread.MIN_PRIORITY);
363            mCallerIdThread.start();
364        }
365
366        /**
367         * Stops the background thread that processes updates and cancels any pending requests to
368         * start it.
369         * <p>
370         * Should be called from the main thread to prevent a race condition between the request to
371         * start the thread being processed and stopping the thread.
372         */
373        public void stopRequestProcessing() {
374            // Remove any pending requests to start the processing thread.
375            mHandler.removeMessages(START_THREAD);
376            mDone = true;
377            if (mCallerIdThread != null) mCallerIdThread.interrupt();
378        }
379
380        public void invalidateCache() {
381            mContactInfoCache.expireAll();
382        }
383
384        private void enqueueRequest(String number, boolean immediate, int position, String name,
385                int numberType, String numberLabel, Uri thumbnailUri, String lookupKey) {
386            CallerInfoQuery ciq = new CallerInfoQuery();
387            ciq.number = number;
388            ciq.position = position;
389            ciq.name = name;
390            ciq.numberType = numberType;
391            ciq.numberLabel = numberLabel;
392            ciq.thumbnailUri = thumbnailUri;
393            ciq.lookupKey = lookupKey;
394            synchronized (mRequests) {
395                mRequests.add(ciq);
396                mRequests.notifyAll();
397            }
398            if (mFirst && immediate) {
399                startRequestProcessing();
400                mFirst = false;
401            }
402        }
403
404        private boolean queryContactInfo(CallerInfoQuery ciq) {
405            // First check if there was a prior request for the same number
406            // that was already satisfied
407            ContactInfo info = mContactInfoCache.get(ciq.number);
408            boolean needNotify = false;
409            if (info != null && info != ContactInfo.EMPTY) {
410                return true;
411            } else {
412                // Ok, do a fresh Contacts lookup for ciq.number.
413                boolean infoUpdated = false;
414
415                if (PhoneNumberUtils.isUriNumber(ciq.number)) {
416                    // This "number" is really a SIP address.
417
418                    // TODO: This code is duplicated from the
419                    // CallerInfoAsyncQuery class.  To avoid that, could the
420                    // code here just use CallerInfoAsyncQuery, rather than
421                    // manually running ContentResolver.query() itself?
422
423                    // We look up SIP addresses directly in the Data table:
424                    Uri contactRef = Data.CONTENT_URI;
425
426                    // Note Data.DATA1 and SipAddress.SIP_ADDRESS are equivalent.
427                    //
428                    // Also note we use "upper(data1)" in the WHERE clause, and
429                    // uppercase the incoming SIP address, in order to do a
430                    // case-insensitive match.
431                    //
432                    // TODO: May also need to normalize by adding "sip:" as a
433                    // prefix, if we start storing SIP addresses that way in the
434                    // database.
435                    String selection = "upper(" + Data.DATA1 + ")=?"
436                            + " AND "
437                            + Data.MIMETYPE + "='" + SipAddress.CONTENT_ITEM_TYPE + "'";
438                    String[] selectionArgs = new String[] { ciq.number.toUpperCase() };
439
440                    Cursor dataTableCursor =
441                            getActivity().getContentResolver().query(
442                                    contactRef,
443                                    null,  // projection
444                                    selection,  // selection
445                                    selectionArgs,  // selectionArgs
446                                    null);  // sortOrder
447
448                    if (dataTableCursor != null) {
449                        if (dataTableCursor.moveToFirst()) {
450                            info = new ContactInfo();
451
452                            // TODO: we could slightly speed this up using an
453                            // explicit projection (and thus not have to do
454                            // those getColumnIndex() calls) but the benefit is
455                            // very minimal.
456
457                            // Note the Data.CONTACT_ID column here is
458                            // equivalent to the PERSON_ID_COLUMN_INDEX column
459                            // we use with "phonesCursor" below.
460                            info.personId = dataTableCursor.getLong(
461                                    dataTableCursor.getColumnIndex(Data.CONTACT_ID));
462                            info.name = dataTableCursor.getString(
463                                    dataTableCursor.getColumnIndex(Data.DISPLAY_NAME));
464                            // "type" and "label" are currently unused for SIP addresses
465                            info.type = SipAddress.TYPE_OTHER;
466                            info.label = null;
467
468                            // And "number" is the SIP address.
469                            // Note Data.DATA1 and SipAddress.SIP_ADDRESS are equivalent.
470                            info.number = dataTableCursor.getString(
471                                    dataTableCursor.getColumnIndex(Data.DATA1));
472                            info.normalizedNumber = null;  // meaningless for SIP addresses
473                            final String thumbnailUriString = dataTableCursor.getString(
474                                    dataTableCursor.getColumnIndex(Data.PHOTO_THUMBNAIL_URI));
475                            info.thumbnailUri = thumbnailUriString == null
476                                    ? null
477                                    : Uri.parse(thumbnailUriString);
478                            info.lookupKey = dataTableCursor.getString(
479                                    dataTableCursor.getColumnIndex(Data.LOOKUP_KEY));
480
481                            infoUpdated = true;
482                        }
483                        dataTableCursor.close();
484                    }
485                } else {
486                    // "number" is a regular phone number, so use the
487                    // PhoneLookup table:
488                    Cursor phonesCursor =
489                            getActivity().getContentResolver().query(
490                                Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI,
491                                        Uri.encode(ciq.number)),
492                                        PhoneQuery._PROJECTION, null, null, null);
493                    if (phonesCursor != null) {
494                        if (phonesCursor.moveToFirst()) {
495                            info = new ContactInfo();
496                            info.personId = phonesCursor.getLong(PhoneQuery.PERSON_ID);
497                            info.name = phonesCursor.getString(PhoneQuery.NAME);
498                            info.type = phonesCursor.getInt(PhoneQuery.PHONE_TYPE);
499                            info.label = phonesCursor.getString(PhoneQuery.LABEL);
500                            info.number = phonesCursor
501                                    .getString(PhoneQuery.MATCHED_NUMBER);
502                            info.normalizedNumber = phonesCursor
503                                    .getString(PhoneQuery.NORMALIZED_NUMBER);
504                            final String thumbnailUriString = phonesCursor.getString(
505                                    PhoneQuery.THUMBNAIL_URI);
506                            info.thumbnailUri = thumbnailUriString == null
507                                    ? null
508                                    : Uri.parse(thumbnailUriString);
509                            info.lookupKey = phonesCursor.getString(PhoneQuery.LOOKUP_KEY);
510
511                            infoUpdated = true;
512                        }
513                        phonesCursor.close();
514                    }
515                }
516
517                if (infoUpdated) {
518                    // New incoming phone number invalidates our formatted
519                    // cache. Any cache fills happen only on the GUI thread.
520                    info.formattedNumber = null;
521
522                    mContactInfoCache.put(ciq.number, info);
523
524                    // Inform list to update this item, if in view
525                    needNotify = true;
526                }
527            }
528            return needNotify;
529        }
530
531        /*
532         * Handles requests for contact name and number type
533         * @see java.lang.Runnable#run()
534         */
535        @Override
536        public void run() {
537            boolean needNotify = false;
538            while (!mDone) {
539                CallerInfoQuery ciq = null;
540                synchronized (mRequests) {
541                    if (!mRequests.isEmpty()) {
542                        ciq = mRequests.removeFirst();
543                    } else {
544                        if (needNotify) {
545                            needNotify = false;
546                            mHandler.sendEmptyMessage(REDRAW);
547                        }
548                        try {
549                            mRequests.wait(1000);
550                        } catch (InterruptedException ie) {
551                            // Ignore and continue processing requests
552                            Thread.currentThread().interrupt();
553                        }
554                    }
555                }
556                if (!mDone && ciq != null && queryContactInfo(ciq)) {
557                    needNotify = true;
558                }
559            }
560        }
561
562        @Override
563        protected void addGroups(Cursor cursor) {
564            int count = cursor.getCount();
565            if (count == 0) {
566                return;
567            }
568
569            int groupItemCount = 1;
570
571            CharArrayBuffer currentValue = mBuffer1;
572            CharArrayBuffer value = mBuffer2;
573            cursor.moveToFirst();
574            cursor.copyStringToBuffer(CallLogQuery.NUMBER, currentValue);
575            int currentCallType = cursor.getInt(CallLogQuery.CALL_TYPE);
576            for (int i = 1; i < count; i++) {
577                cursor.moveToNext();
578                cursor.copyStringToBuffer(CallLogQuery.NUMBER, value);
579                boolean sameNumber = equalPhoneNumbers(value, currentValue);
580
581                // Group adjacent calls with the same number. Make an exception
582                // for the latest item if it was a missed call.  We don't want
583                // a missed call to be hidden inside a group.
584                if (sameNumber && currentCallType != Calls.MISSED_TYPE
585                        && !isSectionHeader(cursor)) {
586                    groupItemCount++;
587                } else {
588                    if (groupItemCount > 1) {
589                        addGroup(i - groupItemCount, groupItemCount, false);
590                    }
591
592                    groupItemCount = 1;
593
594                    // Swap buffers
595                    CharArrayBuffer temp = currentValue;
596                    currentValue = value;
597                    value = temp;
598
599                    // If we have just examined a row following a missed call, make
600                    // sure that it is grouped with subsequent calls from the same number
601                    // even if it was also missed.
602                    if (sameNumber && currentCallType == Calls.MISSED_TYPE) {
603                        currentCallType = 0;       // "not a missed call"
604                    } else {
605                        currentCallType = cursor.getInt(CallLogQuery.CALL_TYPE);
606                    }
607                }
608            }
609            if (groupItemCount > 1) {
610                addGroup(count - groupItemCount, groupItemCount, false);
611            }
612        }
613
614        private boolean isSectionHeader(Cursor cursor) {
615            int section = cursor.getInt(CallLogQuery.SECTION);
616            return section == CallLogQuery.SECTION_NEW_HEADER
617                    || section == CallLogQuery.SECTION_OLD_HEADER;
618        }
619
620        private boolean isNewSection(Cursor cursor) {
621            int section = cursor.getInt(CallLogQuery.SECTION);
622            return section == CallLogQuery.SECTION_NEW_ITEM
623                    || section == CallLogQuery.SECTION_NEW_HEADER;
624        }
625
626        protected boolean equalPhoneNumbers(CharArrayBuffer buffer1, CharArrayBuffer buffer2) {
627
628            // TODO add PhoneNumberUtils.compare(CharSequence, CharSequence) to avoid
629            // string allocation
630            return PhoneNumberUtils.compare(new String(buffer1.data, 0, buffer1.sizeCopied),
631                    new String(buffer2.data, 0, buffer2.sizeCopied));
632        }
633
634
635        @VisibleForTesting
636        @Override
637        public View newStandAloneView(Context context, ViewGroup parent) {
638            LayoutInflater inflater =
639                    (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
640            View view = inflater.inflate(R.layout.call_log_list_item, parent, false);
641            findAndCacheViews(view);
642            return view;
643        }
644
645        @VisibleForTesting
646        @Override
647        public void bindStandAloneView(View view, Context context, Cursor cursor) {
648            bindView(view, cursor, 1);
649        }
650
651        @VisibleForTesting
652        @Override
653        public View newChildView(Context context, ViewGroup parent) {
654            LayoutInflater inflater =
655                    (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
656            View view = inflater.inflate(R.layout.call_log_list_child_item, parent, false);
657            findAndCacheViews(view);
658            return view;
659        }
660
661        @VisibleForTesting
662        @Override
663        public void bindChildView(View view, Context context, Cursor cursor) {
664            bindView(view, cursor, 1);
665        }
666
667        @VisibleForTesting
668        @Override
669        public View newGroupView(Context context, ViewGroup parent) {
670            LayoutInflater inflater =
671                    (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
672            View view = inflater.inflate(R.layout.call_log_list_group_item, parent, false);
673            findAndCacheViews(view);
674            return view;
675        }
676
677        @VisibleForTesting
678        @Override
679        public void bindGroupView(View view, Context context, Cursor cursor, int groupSize,
680                boolean expanded) {
681            bindView(view, cursor, groupSize);
682        }
683
684        private void findAndCacheViews(View view) {
685            // Get the views to bind to.
686            CallLogListItemViews views = CallLogListItemViews.fromView(view);
687            if (views.callView != null) {
688                views.callView.setOnClickListener(this);
689            }
690            view.setTag(views);
691        }
692
693        /**
694         * Binds the views in the entry to the data in the call log.
695         *
696         * @param view the view corresponding to this entry
697         * @param c the cursor pointing to the entry in the call log
698         * @param count the number of entries in the current item, greater than 1 if it is a group
699         */
700        private void bindView(View view, Cursor c, int count) {
701            final CallLogListItemViews views = (CallLogListItemViews) view.getTag();
702            final int section = c.getInt(CallLogQuery.SECTION);
703
704            if (views.standAloneItemView != null) {
705                // This is stand-alone item: it might, however, be a header: check the value of the
706                // section column in the cursor.
707                if (section == CallLogQuery.SECTION_NEW_HEADER
708                        || section == CallLogQuery.SECTION_OLD_HEADER) {
709                    views.standAloneItemView.setVisibility(View.GONE);
710                    views.standAloneHeaderView.setVisibility(View.VISIBLE);
711                    views.standAloneHeaderTextView.setText(
712                            section == CallLogQuery.SECTION_NEW_HEADER
713                                    ? R.string.call_log_new_header
714                                    : R.string.call_log_old_header);
715                    // Nothing else to set up for a header.
716                    return;
717                }
718                // Default case: an item in the call log.
719                views.standAloneItemView.setVisibility(View.VISIBLE);
720                views.standAloneHeaderView.setVisibility(View.GONE);
721            }
722
723            final String number = c.getString(CallLogQuery.NUMBER);
724            final long date = c.getLong(CallLogQuery.DATE);
725            final long duration = c.getLong(CallLogQuery.DURATION);
726            final String formattedNumber;
727            final String countryIso = c.getString(CallLogQuery.COUNTRY_ISO);
728            // Store away the number so we can call it directly if you click on the call icon
729            if (views.callView != null && !TextUtils.isEmpty(number)) {
730                int callType = c.getInt(CallLogQuery.CALL_TYPE);
731                String voicemailUri = c.getString(CallLogQuery.VOICEMAIL_URI);
732                long rowId = c.getLong(CallLogQuery.ID);
733                views.callView.setTag(new NumberAndType(number, rowId, callType, voicemailUri));
734            }
735
736            // Lookup contacts with this number
737            ExpirableCache.CachedValue<ContactInfo> cachedInfo =
738                    mContactInfoCache.getCachedValue(number);
739            ContactInfo info = cachedInfo == null ? null : cachedInfo.getValue();
740            if (cachedInfo == null) {
741                // Mark it as empty and queue up a request to find the name
742                // The db request should happen on a non-UI thread
743                info = ContactInfo.EMPTY;
744                // Format the cached call_log phone number
745                formattedNumber = formatPhoneNumber(number, null, countryIso);
746                mContactInfoCache.put(number, info);
747                Log.d(TAG, "Contact info missing: " + number);
748                // Request the contact details immediately since they are currently missing.
749                enqueueRequest(number, true, c.getPosition(), "", 0, "", null, "");
750            } else if (info != ContactInfo.EMPTY) { // Has been queried
751                if (cachedInfo.isExpired()) {
752                    Log.d(TAG, "Contact info expired: " + number);
753                    // Put it back in the cache, therefore marking it as not expired, so that other
754                    // entries with the same number will not re-request it.
755                    mContactInfoCache.put(number, info);
756                    // The contact info is no longer up to date, we should request it. However, we
757                    // do not need to request them immediately.
758                    enqueueRequest(number, false, c.getPosition(), info.name, info.type, info.label,
759                            info.thumbnailUri, info.lookupKey);
760                }
761
762                // Format and cache phone number for found contact
763                if (info.formattedNumber == null) {
764                    info.formattedNumber =
765                            formatPhoneNumber(info.number, info.normalizedNumber, countryIso);
766                }
767                formattedNumber = info.formattedNumber;
768            } else {
769                // Format the cached call_log phone number
770                formattedNumber = formatPhoneNumber(number, null, countryIso);
771            }
772
773            final long personId = info.personId;
774            final String name = info.name;
775            final int ntype = info.type;
776            final String label = info.label;
777            final Uri thumbnailUri = info.thumbnailUri;
778            final String lookupKey = info.lookupKey;
779            // Assumes the call back feature is on most of the
780            // time. For private and unknown numbers: hide it.
781            if (views.callView != null) {
782                views.callView.setVisibility(View.VISIBLE);
783            }
784
785            final int[] callTypes = getCallTypes(c, count);
786            final PhoneCallDetails details;
787            if (TextUtils.isEmpty(name)) {
788                details = new PhoneCallDetails(number, formattedNumber, callTypes, date, duration);
789            } else {
790                details = new PhoneCallDetails(number, formattedNumber, callTypes, date, duration,
791                        name, ntype, label, personId, thumbnailUri);
792            }
793
794            final boolean isNew = isNewSection(c);
795            // Use icons for old items, but text for new ones.
796            final boolean useIcons = !isNew;
797            // New items also use the highlighted version of the text.
798            final boolean isHighlighted = isNew;
799            mCallLogViewsHelper.setPhoneCallDetails(views, details, useIcons, isHighlighted);
800            if (views.photoView != null) {
801                bindQuickContact(views.photoView, thumbnailUri, personId, lookupKey);
802            }
803
804
805            // Listen for the first draw
806            if (mPreDrawListener == null) {
807                mFirst = true;
808                mPreDrawListener = this;
809                view.getViewTreeObserver().addOnPreDrawListener(this);
810            }
811        }
812
813        /**
814         * Returns the call types for the given number of items in the cursor.
815         * <p>
816         * It uses the next {@code count} rows in the cursor to extract the types.
817         * <p>
818         * It position in the cursor is unchanged by this function.
819         */
820        private int[] getCallTypes(Cursor cursor, int count) {
821            int position = cursor.getPosition();
822            int[] callTypes = new int[count];
823            for (int index = 0; index < count; ++index) {
824                callTypes[index] = cursor.getInt(CallLogQuery.CALL_TYPE);
825                cursor.moveToNext();
826            }
827            cursor.moveToPosition(position);
828            return callTypes;
829        }
830
831        private void bindQuickContact(QuickContactBadge view, Uri thumbnailUri, long contactId,
832                String lookupKey) {
833            view.assignContactUri(getContactUri(contactId, lookupKey));
834            mContactPhotoManager.loadPhoto(view, thumbnailUri);
835        }
836
837        private Uri getContactUri(long contactId, String lookupKey) {
838            return Contacts.getLookupUri(contactId, lookupKey);
839        }
840
841        /**
842         * Sets whether processing of requests for contact details should be enabled.
843         * <p>
844         * This method should be called in tests to disable such processing of requests when not
845         * needed.
846         */
847        public void disableRequestProcessingForTest() {
848            mRequestProcessingDisabled = true;
849        }
850
851        public void injectContactInfoForTest(String number, ContactInfo contactInfo) {
852            mContactInfoCache.put(number, contactInfo);
853        }
854    }
855
856    @Override
857    public void onCreate(Bundle state) {
858        super.onCreate(state);
859
860        mVoiceMailNumber = ((TelephonyManager) getActivity().getSystemService(
861                Context.TELEPHONY_SERVICE)).getVoiceMailNumber();
862        mCallLogQueryHandler = new CallLogQueryHandler(this);
863
864        mCurrentCountryIso = ContactsUtils.getCurrentCountryIso(getActivity());
865
866        setHasOptionsMenu(true);
867    }
868
869    /** Called by the CallLogQueryHandler when the list of calls has been fetched or updated. */
870    public void onCallsFetched(Cursor cursor) {
871        if (getActivity() == null || getActivity().isFinishing()) {
872            return;
873        }
874        Log.d(TAG, "updating adapter");
875        mAdapter.setLoading(false);
876        mAdapter.changeCursor(cursor);
877        if (mScrollToTop) {
878            final ListView listView = getListView();
879            if (listView.getFirstVisiblePosition() > 5) {
880                listView.setSelection(5);
881            }
882            listView.smoothScrollToPosition(0);
883            mScrollToTop = false;
884        }
885    }
886
887    @Override
888    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) {
889        View view = inflater.inflate(R.layout.call_log_fragment, container, false);
890        mVoicemailStatusHelper = new VoicemailStatusHelperImpl(getActivity().getContentResolver());
891        mStatusMessageView = view.findViewById(R.id.voicemail_status);
892        mStatusMessageText = (TextView) view.findViewById(R.id.voicemail_status_message);
893        mStatusMessageAction = (TextView) view.findViewById(R.id.voicemail_status_action);
894        return view;
895    }
896
897    @Override
898    public void onViewCreated(View view, Bundle savedInstanceState) {
899        super.onViewCreated(view, savedInstanceState);
900        mAdapter = new CallLogAdapter();
901        setListAdapter(mAdapter);
902    }
903
904    @Override
905    public void onStart() {
906        mScrollToTop = true;
907        super.onStart();
908    }
909
910    @Override
911    public void onResume() {
912        // Mark all entries in the contact info cache as out of date, so they will be looked up
913        // again once being shown.
914        if (mAdapter != null) {
915            mAdapter.invalidateCache();
916        }
917
918        startQuery();
919        resetNewCallsFlag();
920        updateVoicemailStatusMessage();
921        super.onResume();
922
923        mAdapter.mPreDrawListener = null; // Let it restart the thread after next draw
924    }
925
926    private void updateVoicemailStatusMessage() {
927        // TODO: make call to mVoicemailStatusHelper asynchronously.
928        List<StatusMessage> messages = mVoicemailStatusHelper.getStatusMessages();
929        if (messages.size() == 0) {
930            mStatusMessageView.setVisibility(View.GONE);
931        } else {
932            mStatusMessageView.setVisibility(View.VISIBLE);
933            // TODO: Change the code to show all messages. For now just pick the first message.
934            final StatusMessage message = messages.get(0);
935            if (message.statusMessageId != -1) {
936                mStatusMessageText.setText(message.statusMessageId);
937            }
938            if (message.actionMessageId != -1) {
939                mStatusMessageAction.setText(message.actionMessageId);
940            }
941            if (message.actionUri != null) {
942                mStatusMessageAction.setClickable(true);
943                mStatusMessageAction.setOnClickListener(new View.OnClickListener() {
944                    @Override
945                    public void onClick(View v) {
946                        getActivity().startActivity(
947                                new Intent(Intent.ACTION_VIEW, message.actionUri));
948                    }
949                });
950            } else {
951                mStatusMessageAction.setClickable(false);
952            }
953        }
954    }
955
956    @Override
957    public void onPause() {
958        super.onPause();
959
960        // Kill the requests thread
961        mAdapter.stopRequestProcessing();
962    }
963
964    @Override
965    public void onDestroy() {
966        super.onDestroy();
967        mAdapter.stopRequestProcessing();
968        mAdapter.changeCursor(null);
969    }
970
971    /**
972     * Format the given phone number
973     *
974     * @param number the number to be formatted.
975     * @param normalizedNumber the normalized number of the given number.
976     * @param countryIso the ISO 3166-1 two letters country code, the country's
977     *        convention will be used to format the number if the normalized
978     *        phone is null.
979     *
980     * @return the formatted number, or the given number if it was formatted.
981     */
982    private String formatPhoneNumber(String number, String normalizedNumber, String countryIso) {
983        if (TextUtils.isEmpty(number)) {
984            return "";
985        }
986        // If "number" is really a SIP address, don't try to do any formatting at all.
987        if (PhoneNumberUtils.isUriNumber(number)) {
988            return number;
989        }
990        if (TextUtils.isEmpty(countryIso)) {
991            countryIso = mCurrentCountryIso;
992        }
993        return PhoneNumberUtils.formatNumber(number, normalizedNumber, countryIso);
994    }
995
996    private void resetNewCallsFlag() {
997        mCallLogQueryHandler.updateMissedCalls();
998    }
999
1000    private void startQuery() {
1001        mAdapter.setLoading(true);
1002        mCallLogQueryHandler.fetchCalls();
1003    }
1004
1005    @Override
1006    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
1007        super.onCreateOptionsMenu(menu, inflater);
1008        inflater.inflate(R.menu.call_log_options, menu);
1009    }
1010
1011    @Override
1012    public void onPrepareOptionsMenu(Menu menu) {
1013        menu.findItem(R.id.delete_all).setVisible(mShowOptionsMenu);
1014        final MenuItem callSettingsMenuItem = menu.findItem(R.id.menu_call_settings_call_log);
1015        if (mShowOptionsMenu) {
1016            callSettingsMenuItem.setVisible(true);
1017            callSettingsMenuItem.setIntent(DialtactsActivity.getCallSettingsIntent());
1018        } else {
1019            callSettingsMenuItem.setVisible(false);
1020        }
1021    }
1022
1023    @Override
1024    public boolean onOptionsItemSelected(MenuItem item) {
1025        switch (item.getItemId()) {
1026            case OptionsMenuItems.DELETE_ALL: {
1027                ClearCallLogDialog.show(getFragmentManager());
1028                return true;
1029            }
1030        }
1031        return super.onOptionsItemSelected(item);
1032    }
1033
1034    /*
1035     * Get the number from the Contacts, if available, since sometimes
1036     * the number provided by caller id may not be formatted properly
1037     * depending on the carrier (roaming) in use at the time of the
1038     * incoming call.
1039     * Logic : If the caller-id number starts with a "+", use it
1040     *         Else if the number in the contacts starts with a "+", use that one
1041     *         Else if the number in the contacts is longer, use that one
1042     */
1043    private String getBetterNumberFromContacts(String number) {
1044        String matchingNumber = null;
1045        // Look in the cache first. If it's not found then query the Phones db
1046        ContactInfo ci = mAdapter.mContactInfoCache.getPossiblyExpired(number);
1047        if (ci != null && ci != ContactInfo.EMPTY) {
1048            matchingNumber = ci.number;
1049        } else {
1050            try {
1051                Cursor phonesCursor = getActivity().getContentResolver().query(
1052                        Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, number),
1053                        PhoneQuery._PROJECTION, null, null, null);
1054                if (phonesCursor != null) {
1055                    if (phonesCursor.moveToFirst()) {
1056                        matchingNumber = phonesCursor.getString(PhoneQuery.MATCHED_NUMBER);
1057                    }
1058                    phonesCursor.close();
1059                }
1060            } catch (Exception e) {
1061                // Use the number from the call log
1062            }
1063        }
1064        if (!TextUtils.isEmpty(matchingNumber) &&
1065                (matchingNumber.startsWith("+")
1066                        || matchingNumber.length() > number.length())) {
1067            number = matchingNumber;
1068        }
1069        return number;
1070    }
1071
1072    public void callSelectedEntry() {
1073        int position = getListView().getSelectedItemPosition();
1074        if (position < 0) {
1075            // In touch mode you may often not have something selected, so
1076            // just call the first entry to make sure that [send] [send] calls the
1077            // most recent entry.
1078            position = 0;
1079        }
1080        final Cursor cursor = (Cursor)mAdapter.getItem(position);
1081        if (cursor != null) {
1082            String number = cursor.getString(CallLogQuery.NUMBER);
1083            if (TextUtils.isEmpty(number)
1084                    || number.equals(CallerInfo.UNKNOWN_NUMBER)
1085                    || number.equals(CallerInfo.PRIVATE_NUMBER)
1086                    || number.equals(CallerInfo.PAYPHONE_NUMBER)) {
1087                // This number can't be called, do nothing
1088                return;
1089            }
1090            Intent intent;
1091            // If "number" is really a SIP address, construct a sip: URI.
1092            if (PhoneNumberUtils.isUriNumber(number)) {
1093                intent = new Intent(Intent.ACTION_CALL_PRIVILEGED,
1094                                    Uri.fromParts("sip", number, null));
1095            } else {
1096                // We're calling a regular PSTN phone number.
1097                // Construct a tel: URI, but do some other possible cleanup first.
1098                int callType = cursor.getInt(CallLogQuery.CALL_TYPE);
1099                if (!number.startsWith("+") &&
1100                       (callType == Calls.INCOMING_TYPE
1101                                || callType == Calls.MISSED_TYPE)) {
1102                    // If the caller-id matches a contact with a better qualified number, use it
1103                    number = getBetterNumberFromContacts(number);
1104                }
1105                intent = new Intent(Intent.ACTION_CALL_PRIVILEGED,
1106                                    Uri.fromParts("tel", number, null));
1107            }
1108            intent.setFlags(
1109                    Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
1110            startActivity(intent);
1111        }
1112    }
1113
1114    @Override
1115    public void onListItemClick(ListView l, View v, int position, long id) {
1116        Intent intent = new Intent(getActivity(), CallDetailActivity.class);
1117        Cursor cursor = (Cursor) mAdapter.getItem(position);
1118        if (mAdapter.isGroupHeader(position)) {
1119            // We want to restore the position in the cursor at the end.
1120            int currentPosition = cursor.getPosition();
1121            int groupSize = mAdapter.getGroupSize(position);
1122            long[] ids = new long[groupSize];
1123            // Copy the ids of the rows in the group.
1124            for (int index = 0; index < groupSize; ++index) {
1125                ids[index] = cursor.getLong(CallLogQuery.ID);
1126                cursor.moveToNext();
1127            }
1128            intent.putExtra(CallDetailActivity.EXTRA_CALL_LOG_IDS, ids);
1129            cursor.moveToPosition(currentPosition);
1130        } else {
1131            // If there is a single item, use the direct URI for it.
1132            intent.setData(ContentUris.withAppendedId(Calls.CONTENT_URI_WITH_VOICEMAIL, id));
1133            String voicemailUri = cursor.getString(CallLogQuery.VOICEMAIL_URI);
1134            if (voicemailUri != null) {
1135                intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_URI, Uri.parse(voicemailUri));
1136            }
1137            intent.putExtra(CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK, false);
1138        }
1139        startActivity(intent);
1140    }
1141
1142    @VisibleForTesting
1143    public CallLogAdapter getAdapter() {
1144        return mAdapter;
1145    }
1146
1147    @VisibleForTesting
1148    public String getVoiceMailNumber() {
1149        return mVoiceMailNumber;
1150    }
1151
1152    @Override
1153    public void onVisibilityChanged(boolean visible) {
1154        mShowOptionsMenu = visible;
1155    }
1156}
1157