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