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