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