ConversationList.java revision f168c442a50961a9f011a570c9de4040da111e29
1/* 2 * Copyright (C) 2008 Esmertec AG. 3 * Copyright (C) 2008 The Android Open Source Project 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18package com.android.mms.ui; 19 20import java.util.ArrayList; 21import java.util.Collection; 22import java.util.HashSet; 23 24import com.android.mms.LogTag; 25import com.android.mms.R; 26import com.android.mms.data.Contact; 27import com.android.mms.data.ContactList; 28import com.android.mms.data.Conversation; 29import com.android.mms.transaction.MessagingNotification; 30import com.android.mms.transaction.SmsRejectedReceiver; 31import com.android.mms.util.DraftCache; 32import com.android.mms.util.Recycler; 33import com.google.android.mms.pdu.PduHeaders; 34import android.database.sqlite.SqliteWrapper; 35 36import android.app.ActionBar; 37import android.app.AlertDialog; 38import android.app.ListActivity; 39import android.app.SearchManager; 40import android.app.SearchableInfo; 41import android.content.AsyncQueryHandler; 42import android.content.ContentResolver; 43import android.content.Context; 44import android.content.DialogInterface; 45import android.content.Intent; 46import android.content.SharedPreferences; 47import android.content.DialogInterface.OnClickListener; 48import android.content.res.Configuration; 49import android.database.Cursor; 50import android.database.sqlite.SQLiteException; 51import android.os.Bundle; 52import android.os.Handler; 53import android.preference.PreferenceManager; 54import android.provider.ContactsContract; 55import android.provider.ContactsContract.Contacts; 56import android.provider.Telephony.Mms; 57import android.provider.Telephony.Threads; 58import android.util.Log; 59import android.view.ActionMode; 60import android.view.ContextMenu; 61import android.view.Gravity; 62import android.view.KeyEvent; 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.Window; 70import android.view.ContextMenu.ContextMenuInfo; 71import android.view.View.OnCreateContextMenuListener; 72import android.view.View.OnKeyListener; 73import android.widget.AdapterView; 74import android.widget.CheckBox; 75import android.widget.ListView; 76import android.widget.SearchView; 77import android.widget.TextView; 78 79/** 80 * This activity provides a list view of existing conversations. 81 */ 82public class ConversationList extends ListActivity implements DraftCache.OnDraftChangedListener { 83 private static final String TAG = "ConversationList"; 84 private static final boolean DEBUG = false; 85 private static final boolean LOCAL_LOGV = DEBUG; 86 87 private static final int THREAD_LIST_QUERY_TOKEN = 1701; 88 private static final int UNREAD_THREADS_QUERY_TOKEN = 1702; 89 public static final int DELETE_CONVERSATION_TOKEN = 1801; 90 public static final int HAVE_LOCKED_MESSAGES_TOKEN = 1802; 91 private static final int DELETE_OBSOLETE_THREADS_TOKEN = 1803; 92 93 // IDs of the context menu items for the list of conversations. 94 public static final int MENU_DELETE = 0; 95 public static final int MENU_VIEW = 1; 96 public static final int MENU_VIEW_CONTACT = 2; 97 public static final int MENU_ADD_TO_CONTACTS = 3; 98 99 private ThreadListQueryHandler mQueryHandler; 100 private ConversationListAdapter mListAdapter; 101 private CharSequence mTitle; 102 private SharedPreferences mPrefs; 103 private Handler mHandler; 104 private boolean mNeedToMarkAsSeen; 105 private TextView mUnreadConvCount; 106 private MenuItem mSearchItem; 107 private SearchView mSearchView; 108 109 static private final String CHECKED_MESSAGE_LIMITS = "checked_message_limits"; 110 111 @Override 112 protected void onCreate(Bundle savedInstanceState) { 113 super.onCreate(savedInstanceState); 114 115 setContentView(R.layout.conversation_list_screen); 116 117 mQueryHandler = new ThreadListQueryHandler(getContentResolver()); 118 119 ListView listView = getListView(); 120 listView.setOnCreateContextMenuListener(mConvListOnCreateContextMenuListener); 121 listView.setOnKeyListener(mThreadListKeyListener); 122 listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL); 123 listView.setMultiChoiceModeListener(new ModeCallback()); 124 125 // Tell the list view which view to display when the list is empty 126 listView.setEmptyView(findViewById(R.id.empty)); 127 128 initListAdapter(); 129 130 setupActionBar(); 131 132 mTitle = getString(R.string.app_label); 133 134 mHandler = new Handler(); 135 mPrefs = PreferenceManager.getDefaultSharedPreferences(this); 136 boolean checkedMessageLimits = mPrefs.getBoolean(CHECKED_MESSAGE_LIMITS, false); 137 if (DEBUG) Log.v(TAG, "checkedMessageLimits: " + checkedMessageLimits); 138 if (!checkedMessageLimits || DEBUG) { 139 runOneTimeStorageLimitCheckForLegacyMessages(); 140 } 141 } 142 143 private void setupActionBar() { 144 ActionBar actionBar = getActionBar(); 145 146 ViewGroup v = (ViewGroup)LayoutInflater.from(this) 147 .inflate(R.layout.conversation_list_actionbar, null); 148 actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM, 149 ActionBar.DISPLAY_SHOW_CUSTOM); 150 actionBar.setCustomView(v, 151 new ActionBar.LayoutParams(ActionBar.LayoutParams.WRAP_CONTENT, 152 ActionBar.LayoutParams.WRAP_CONTENT, 153 Gravity.CENTER_VERTICAL | Gravity.RIGHT)); 154 155 mUnreadConvCount = (TextView)v.findViewById(R.id.unread_conv_count); 156 } 157 158 private final ConversationListAdapter.OnContentChangedListener mContentChangedListener = 159 new ConversationListAdapter.OnContentChangedListener() { 160 public void onContentChanged(ConversationListAdapter adapter) { 161 startAsyncQuery(); 162 } 163 }; 164 165 private void initListAdapter() { 166 mListAdapter = new ConversationListAdapter(this, null); 167 mListAdapter.setOnContentChangedListener(mContentChangedListener); 168 setListAdapter(mListAdapter); 169 getListView().setRecyclerListener(mListAdapter); 170 } 171 172 /** 173 * Checks to see if the number of MMS and SMS messages are under the limits for the 174 * recycler. If so, it will automatically turn on the recycler setting. If not, it 175 * will prompt the user with a message and point them to the setting to manually 176 * turn on the recycler. 177 */ 178 public synchronized void runOneTimeStorageLimitCheckForLegacyMessages() { 179 if (Recycler.isAutoDeleteEnabled(this)) { 180 if (DEBUG) Log.v(TAG, "recycler is already turned on"); 181 // The recycler is already turned on. We don't need to check anything or warn 182 // the user, just remember that we've made the check. 183 markCheckedMessageLimit(); 184 return; 185 } 186 new Thread(new Runnable() { 187 public void run() { 188 if (Recycler.checkForThreadsOverLimit(ConversationList.this)) { 189 if (DEBUG) Log.v(TAG, "checkForThreadsOverLimit TRUE"); 190 // Dang, one or more of the threads are over the limit. Show an activity 191 // that'll encourage the user to manually turn on the setting. Delay showing 192 // this activity until a couple of seconds after the conversation list appears. 193 mHandler.postDelayed(new Runnable() { 194 public void run() { 195 Intent intent = new Intent(ConversationList.this, 196 WarnOfStorageLimitsActivity.class); 197 startActivity(intent); 198 } 199 }, 2000); 200 } else { 201 if (DEBUG) Log.v(TAG, "checkForThreadsOverLimit silently turning on recycler"); 202 // No threads were over the limit. Turn on the recycler by default. 203 runOnUiThread(new Runnable() { 204 public void run() { 205 SharedPreferences.Editor editor = mPrefs.edit(); 206 editor.putBoolean(MessagingPreferenceActivity.AUTO_DELETE, true); 207 editor.apply(); 208 } 209 }); 210 } 211 // Remember that we don't have to do the check anymore when starting MMS. 212 runOnUiThread(new Runnable() { 213 public void run() { 214 markCheckedMessageLimit(); 215 } 216 }); 217 } 218 }).start(); 219 } 220 221 /** 222 * Mark in preferences that we've checked the user's message limits. Once checked, we'll 223 * never check them again, unless the user wipe-data or resets the device. 224 */ 225 private void markCheckedMessageLimit() { 226 if (DEBUG) Log.v(TAG, "markCheckedMessageLimit"); 227 SharedPreferences.Editor editor = mPrefs.edit(); 228 editor.putBoolean(CHECKED_MESSAGE_LIMITS, true); 229 editor.apply(); 230 } 231 232 @Override 233 protected void onNewIntent(Intent intent) { 234 // Handle intents that occur after the activity has already been created. 235 startAsyncQuery(); 236 } 237 238 @Override 239 protected void onStart() { 240 super.onStart(); 241 242 MessagingNotification.cancelNotification(getApplicationContext(), 243 SmsRejectedReceiver.SMS_REJECTED_NOTIFICATION_ID); 244 245 DraftCache.getInstance().addOnDraftChangedListener(this); 246 247 mNeedToMarkAsSeen = true; 248 249 startAsyncQuery(); 250 251 // We used to refresh the DraftCache here, but 252 // refreshing the DraftCache each time we go to the ConversationList seems overly 253 // aggressive. We already update the DraftCache when leaving CMA in onStop() and 254 // onNewIntent(), and when we delete threads or delete all in CMA or this activity. 255 // I hope we don't have to do such a heavy operation each time we enter here. 256 257 // we invalidate the contact cache here because we want to get updated presence 258 // and any contact changes. We don't invalidate the cache by observing presence and contact 259 // changes (since that's too untargeted), so as a tradeoff we do it here. 260 // If we're in the middle of the app initialization where we're loading the conversation 261 // threads, don't invalidate the cache because we're in the process of building it. 262 // TODO: think of a better way to invalidate cache more surgically or based on actual 263 // TODO: changes we care about 264 if (!Conversation.loadingThreads()) { 265 Contact.invalidateCache(); 266 } 267 } 268 269 @Override 270 protected void onStop() { 271 super.onStop(); 272 273 DraftCache.getInstance().removeOnDraftChangedListener(this); 274 275 // Simply setting the choice mode causes the previous choice mode to finish and we exit 276 // multi-select mode (if we're in it) and remove all the selections. 277 getListView().setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL); 278 279 mListAdapter.changeCursor(null); 280 } 281 282 public void onDraftChanged(final long threadId, final boolean hasDraft) { 283 // Run notifyDataSetChanged() on the main thread. 284 mQueryHandler.post(new Runnable() { 285 public void run() { 286 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 287 log("onDraftChanged: threadId=" + threadId + ", hasDraft=" + hasDraft); 288 } 289 mListAdapter.notifyDataSetChanged(); 290 } 291 }); 292 } 293 294 private void startAsyncQuery() { 295 try { 296 setTitle(getString(R.string.refreshing)); 297 ((TextView)(getListView().getEmptyView())).setText(R.string.loading_conversations); 298 299 Conversation.startQueryForAll(mQueryHandler, THREAD_LIST_QUERY_TOKEN); 300 Conversation.startQuery(mQueryHandler, UNREAD_THREADS_QUERY_TOKEN, Threads.READ + "=0"); 301 } catch (SQLiteException e) { 302 SqliteWrapper.checkSQLiteException(this, e); 303 } 304 } 305 306 SearchView.OnQueryTextListener mQueryTextListener = new SearchView.OnQueryTextListener() { 307 public boolean onQueryTextSubmit(String query) { 308 Intent intent = new Intent(); 309 intent.setClass(ConversationList.this, SearchActivity.class); 310 intent.putExtra(SearchManager.QUERY, query); 311 startActivity(intent); 312 mSearchItem.collapseActionView(); 313 return true; 314 } 315 316 public boolean onQueryTextChange(String newText) { 317 return false; 318 } 319 }; 320 321 @Override 322 public boolean onCreateOptionsMenu(Menu menu) { 323 getMenuInflater().inflate(R.menu.conversation_list_menu, menu); 324 325 mSearchItem = menu.findItem(R.id.search); 326 mSearchView = (SearchView) mSearchItem.getActionView(); 327 328 mSearchView.setOnQueryTextListener(mQueryTextListener); 329 mSearchView.setQueryHint(getString(R.string.search_hint)); 330 mSearchView.setIconifiedByDefault(true); 331 SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE); 332 333 if (searchManager != null) { 334 SearchableInfo info = searchManager.getSearchableInfo(this.getComponentName()); 335 mSearchView.setSearchableInfo(info); 336 } 337 338 return true; 339 } 340 341 @Override 342 public boolean onPrepareOptionsMenu(Menu menu) { 343 MenuItem item = menu.findItem(R.id.action_delete_all); 344 if (item != null) { 345 item.setVisible(mListAdapter.getCount() > 0); 346 } 347 if (!LogTag.DEBUG_DUMP) { 348 item = menu.findItem(R.id.action_debug_dump); 349 if (item != null) { 350 item.setVisible(false); 351 } 352 } 353 return true; 354 } 355 356 @Override 357 public boolean onSearchRequested() { 358 mSearchItem.expandActionView(); 359 return true; 360 } 361 362 @Override 363 public boolean onOptionsItemSelected(MenuItem item) { 364 switch(item.getItemId()) { 365 case R.id.action_compose_new: 366 createNewMessage(); 367 break; 368 case R.id.action_delete_all: 369 // The invalid threadId of -1 means all threads here. 370 confirmDeleteThread(-1L, mQueryHandler); 371 break; 372 case R.id.action_settings: 373 Intent intent = new Intent(this, MessagingPreferenceActivity.class); 374 startActivityIfNeeded(intent, -1); 375 break; 376 case R.id.action_debug_dump: 377 LogTag.dumpInternalTables(this); 378 break; 379 default: 380 return true; 381 } 382 return false; 383 } 384 385 @Override 386 protected void onListItemClick(ListView l, View v, int position, long id) { 387 // Note: don't read the thread id data from the ConversationListItem view passed in. 388 // It's unreliable to read the cached data stored in the view because the ListItem 389 // can be recycled, and the same view could be assigned to a different position 390 // if you click the list item fast enough. Instead, get the cursor at the position 391 // clicked and load the data from the cursor. 392 // (ConversationListAdapter extends CursorAdapter, so getItemAtPosition() should 393 // return the cursor object, which is moved to the position passed in) 394 Cursor cursor = (Cursor) getListView().getItemAtPosition(position); 395 Conversation conv = Conversation.from(this, cursor); 396 long tid = conv.getThreadId(); 397 398 if (LogTag.VERBOSE) { 399 Log.d(TAG, "onListItemClick: pos=" + position + ", view=" + v + ", tid=" + tid); 400 } 401 402 openThread(tid); 403 } 404 405 private void createNewMessage() { 406 startActivity(ComposeMessageActivity.createIntent(this, 0)); 407 } 408 409 private void openThread(long threadId) { 410 startActivity(ComposeMessageActivity.createIntent(this, threadId)); 411 } 412 413 public static Intent createAddContactIntent(String address) { 414 // address must be a single recipient 415 Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT); 416 intent.setType(Contacts.CONTENT_ITEM_TYPE); 417 if (Mms.isEmailAddress(address)) { 418 intent.putExtra(ContactsContract.Intents.Insert.EMAIL, address); 419 } else { 420 intent.putExtra(ContactsContract.Intents.Insert.PHONE, address); 421 intent.putExtra(ContactsContract.Intents.Insert.PHONE_TYPE, 422 ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE); 423 } 424 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 425 426 return intent; 427 } 428 429 private final OnCreateContextMenuListener mConvListOnCreateContextMenuListener = 430 new OnCreateContextMenuListener() { 431 public void onCreateContextMenu(ContextMenu menu, View v, 432 ContextMenuInfo menuInfo) { 433 Cursor cursor = mListAdapter.getCursor(); 434 if (cursor == null || cursor.getPosition() < 0) { 435 return; 436 } 437 Conversation conv = Conversation.from(ConversationList.this, cursor); 438 ContactList recipients = conv.getRecipients(); 439 menu.setHeaderTitle(recipients.formatNames(",")); 440 441 AdapterView.AdapterContextMenuInfo info = 442 (AdapterView.AdapterContextMenuInfo) menuInfo; 443 menu.add(0, MENU_VIEW, 0, R.string.menu_view); 444 445 // Only show if there's a single recipient 446 if (recipients.size() == 1) { 447 // do we have this recipient in contacts? 448 if (recipients.get(0).existsInDatabase()) { 449 menu.add(0, MENU_VIEW_CONTACT, 0, R.string.menu_view_contact); 450 } else { 451 menu.add(0, MENU_ADD_TO_CONTACTS, 0, R.string.menu_add_to_contacts); 452 } 453 } 454 menu.add(0, MENU_DELETE, 0, R.string.menu_delete); 455 } 456 }; 457 458 @Override 459 public boolean onContextItemSelected(MenuItem item) { 460 Cursor cursor = mListAdapter.getCursor(); 461 if (cursor != null && cursor.getPosition() >= 0) { 462 Conversation conv = Conversation.from(ConversationList.this, cursor); 463 long threadId = conv.getThreadId(); 464 switch (item.getItemId()) { 465 case MENU_DELETE: { 466 confirmDeleteThread(threadId, mQueryHandler); 467 break; 468 } 469 case MENU_VIEW: { 470 openThread(threadId); 471 break; 472 } 473 case MENU_VIEW_CONTACT: { 474 Contact contact = conv.getRecipients().get(0); 475 Intent intent = new Intent(Intent.ACTION_VIEW, contact.getUri()); 476 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 477 startActivity(intent); 478 break; 479 } 480 case MENU_ADD_TO_CONTACTS: { 481 String address = conv.getRecipients().get(0).getNumber(); 482 startActivity(createAddContactIntent(address)); 483 break; 484 } 485 default: 486 break; 487 } 488 } 489 return super.onContextItemSelected(item); 490 } 491 492 @Override 493 public void onConfigurationChanged(Configuration newConfig) { 494 // We override this method to avoid restarting the entire 495 // activity when the keyboard is opened (declared in 496 // AndroidManifest.xml). Because the only translatable text 497 // in this activity is "New Message", which has the full width 498 // of phone to work with, localization shouldn't be a problem: 499 // no abbreviated alternate words should be needed even in 500 // 'wide' languages like German or Russian. 501 502 super.onConfigurationChanged(newConfig); 503 if (DEBUG) Log.v(TAG, "onConfigurationChanged: " + newConfig); 504 } 505 506 /** 507 * Start the process of putting up a dialog to confirm deleting a thread, 508 * but first start a background query to see if any of the threads or thread 509 * contain locked messages so we'll know how detailed of a UI to display. 510 * @param threadId id of the thread to delete or -1 for all threads 511 * @param handler query handler to do the background locked query 512 */ 513 public static void confirmDeleteThread(long threadId, AsyncQueryHandler handler) { 514 ArrayList<Long> threadIds = null; 515 if (threadId != -1) { 516 threadIds = new ArrayList<Long>(); 517 threadIds.add(threadId); 518 } 519 confirmDeleteThreads(threadIds, handler); 520 } 521 522 /** 523 * Start the process of putting up a dialog to confirm deleting threads, 524 * but first start a background query to see if any of the threads 525 * contain locked messages so we'll know how detailed of a UI to display. 526 * @param threadIds list of threadIds to delete or null for all threads 527 * @param handler query handler to do the background locked query 528 */ 529 public static void confirmDeleteThreads(Collection<Long> threadIds, AsyncQueryHandler handler) { 530 Conversation.startQueryHaveLockedMessages(handler, threadIds, 531 HAVE_LOCKED_MESSAGES_TOKEN); 532 } 533 534 /** 535 * Build and show the proper delete thread dialog. The UI is slightly different 536 * depending on whether there are locked messages in the thread(s) and whether we're 537 * deleting single/multiple threads or all threads. 538 * @param listener gets called when the delete button is pressed 539 * @param deleteAll whether to show a single thread or all threads UI 540 * @param hasLockedMessages whether the thread(s) contain locked messages 541 * @param context used to load the various UI elements 542 */ 543 public static void confirmDeleteThreadDialog(final DeleteThreadListener listener, 544 Collection<Long> threadIds, 545 boolean hasLockedMessages, 546 Context context) { 547 View contents = View.inflate(context, R.layout.delete_thread_dialog_view, null); 548 TextView msg = (TextView)contents.findViewById(R.id.message); 549 550 if (threadIds == null) { 551 msg.setText(R.string.confirm_delete_all_conversations); 552 } else { 553 // Show the number of threads getting deleted in the confirmation dialog. 554 int cnt = threadIds.size(); 555 msg.setText(context.getResources().getQuantityString( 556 R.plurals.confirm_delete_conversation, cnt, cnt)); 557 } 558 559 final CheckBox checkbox = (CheckBox)contents.findViewById(R.id.delete_locked); 560 if (!hasLockedMessages) { 561 checkbox.setVisibility(View.GONE); 562 } else { 563 listener.setDeleteLockedMessage(checkbox.isChecked()); 564 checkbox.setOnClickListener(new View.OnClickListener() { 565 public void onClick(View v) { 566 listener.setDeleteLockedMessage(checkbox.isChecked()); 567 } 568 }); 569 } 570 571 AlertDialog.Builder builder = new AlertDialog.Builder(context); 572 builder.setTitle(R.string.confirm_dialog_title) 573 .setIconAttribute(android.R.attr.alertDialogIcon) 574 .setCancelable(true) 575 .setPositiveButton(R.string.delete, listener) 576 .setNegativeButton(R.string.no, null) 577 .setView(contents) 578 .show(); 579 } 580 581 private final OnKeyListener mThreadListKeyListener = new OnKeyListener() { 582 public boolean onKey(View v, int keyCode, KeyEvent event) { 583 if (event.getAction() == KeyEvent.ACTION_DOWN) { 584 switch (keyCode) { 585 case KeyEvent.KEYCODE_DEL: { 586 long id = getListView().getSelectedItemId(); 587 if (id > 0) { 588 confirmDeleteThread(id, mQueryHandler); 589 } 590 return true; 591 } 592 } 593 } 594 return false; 595 } 596 }; 597 598 public static class DeleteThreadListener implements OnClickListener { 599 private final Collection<Long> mThreadIds; 600 private final AsyncQueryHandler mHandler; 601 private final Context mContext; 602 private boolean mDeleteLockedMessages; 603 604 public DeleteThreadListener(Collection<Long> threadIds, AsyncQueryHandler handler, 605 Context context) { 606 mThreadIds = threadIds; 607 mHandler = handler; 608 mContext = context; 609 } 610 611 public void setDeleteLockedMessage(boolean deleteLockedMessages) { 612 mDeleteLockedMessages = deleteLockedMessages; 613 } 614 615 public void onClick(DialogInterface dialog, final int whichButton) { 616 MessageUtils.handleReadReport(mContext, mThreadIds, 617 PduHeaders.READ_STATUS__DELETED_WITHOUT_BEING_READ, new Runnable() { 618 public void run() { 619 int token = DELETE_CONVERSATION_TOKEN; 620 if (mThreadIds == null) { 621 Conversation.startDeleteAll(mHandler, token, mDeleteLockedMessages); 622 DraftCache.getInstance().refresh(); 623 } else { 624 for (long threadId : mThreadIds) { 625 Conversation.startDelete(mHandler, token, mDeleteLockedMessages, 626 threadId); 627 DraftCache.getInstance().setDraftState(threadId, false); 628 } 629 } 630 } 631 }); 632 dialog.dismiss(); 633 } 634 } 635 636 private final Runnable mDeleteObsoleteThreadsRunnable = new Runnable() { 637 public void run() { 638 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 639 LogTag.debug("mDeleteObsoleteThreadsRunnable getSavingDraft(): " + 640 DraftCache.getInstance().getSavingDraft()); 641 } 642 if (DraftCache.getInstance().getSavingDraft()) { 643 // We're still saving a draft. Try again in a second. We don't want to delete 644 // any threads out from under the draft. 645 mHandler.postDelayed(mDeleteObsoleteThreadsRunnable, 1000); 646 } else { 647 Conversation.asyncDeleteObsoleteThreads(mQueryHandler, 648 DELETE_OBSOLETE_THREADS_TOKEN); 649 } 650 } 651 }; 652 653 private final class ThreadListQueryHandler extends AsyncQueryHandler { 654 public ThreadListQueryHandler(ContentResolver contentResolver) { 655 super(contentResolver); 656 } 657 658 // Test code used for various scenarios where its desirable to insert a delay in 659 // responding to query complete. To use, uncomment out the block below and then 660 // comment out the @Override and onQueryComplete line. 661// @Override 662// protected void onQueryComplete(final int token, final Object cookie, final Cursor cursor) { 663// mHandler.postDelayed(new Runnable() { 664// public void run() { 665// myonQueryComplete(token, cookie, cursor); 666// } 667// }, 2000); 668// } 669// 670// protected void myonQueryComplete(int token, Object cookie, Cursor cursor) { 671 672 @Override 673 protected void onQueryComplete(int token, Object cookie, Cursor cursor) { 674 switch (token) { 675 case THREAD_LIST_QUERY_TOKEN: 676 mListAdapter.changeCursor(cursor); 677 setTitle(mTitle); 678 679 if (mListAdapter.getCount() == 0) { 680 ((TextView)(getListView().getEmptyView())).setText(R.string.no_conversations); 681 } 682 683 if (mNeedToMarkAsSeen) { 684 mNeedToMarkAsSeen = false; 685 Conversation.markAllConversationsAsSeen(getApplicationContext()); 686 687 // Delete any obsolete threads. Obsolete threads are threads that aren't 688 // referenced by at least one message in the pdu or sms tables. We only call 689 // this on the first query (because of mNeedToMarkAsSeen). 690 mHandler.post(mDeleteObsoleteThreadsRunnable); 691 } 692 break; 693 694 case UNREAD_THREADS_QUERY_TOKEN: 695 int count = cursor.getCount(); 696 mUnreadConvCount.setText(count > 0 ? Integer.toString(count) : null); 697 break; 698 699 case HAVE_LOCKED_MESSAGES_TOKEN: 700 Collection<Long> threadIds = (Collection<Long>)cookie; 701 confirmDeleteThreadDialog(new DeleteThreadListener(threadIds, mQueryHandler, 702 ConversationList.this), threadIds, 703 cursor != null && cursor.getCount() > 0, 704 ConversationList.this); 705 break; 706 707 default: 708 Log.e(TAG, "onQueryComplete called with unknown token " + token); 709 } 710 } 711 712 @Override 713 protected void onDeleteComplete(int token, Object cookie, int result) { 714 switch (token) { 715 case DELETE_CONVERSATION_TOKEN: 716 long threadId = cookie != null ? (Long)cookie : -1; // default to all threads 717 718 if (threadId == -1) { 719 // Rebuild the contacts cache now that all threads and their associated unique 720 // recipients have been deleted. 721 Contact.init(ConversationList.this); 722 } else { 723 // Remove any recipients referenced by this single thread from the 724 // contacts cache. It's possible for two or more threads to reference 725 // the same contact. That's ok if we remove it. We'll recreate that contact 726 // when we init all Conversations below. 727 Conversation conv = Conversation.get(ConversationList.this, threadId, false); 728 if (conv != null) { 729 ContactList recipients = conv.getRecipients(); 730 for (Contact contact : recipients) { 731 contact.removeFromCache(); 732 } 733 } 734 } 735 // Make sure the conversation cache reflects the threads in the DB. 736 Conversation.init(ConversationList.this); 737 738 // Update the notification for new messages since they 739 // may be deleted. 740 MessagingNotification.nonBlockingUpdateNewMessageIndicator(ConversationList.this, 741 false, false); 742 // Update the notification for failed messages since they 743 // may be deleted. 744 MessagingNotification.updateSendFailedNotification(ConversationList.this); 745 746 // Make sure the list reflects the delete 747 startAsyncQuery(); 748 break; 749 750 case DELETE_OBSOLETE_THREADS_TOKEN: 751 // Nothing to do here. 752 break; 753 } 754 } 755 } 756 757 private class ModeCallback implements ListView.MultiChoiceModeListener { 758 private View mMultiSelectActionBarView; 759 private TextView mSelectedConvCount; 760 private HashSet<Long> mSelectedThreadIds; 761 762 public boolean onCreateActionMode(ActionMode mode, Menu menu) { 763 MenuInflater inflater = getMenuInflater(); 764 mSelectedThreadIds = new HashSet<Long>(); 765 inflater.inflate(R.menu.conversation_multi_select_menu, menu); 766 767 if (mMultiSelectActionBarView == null) { 768 mMultiSelectActionBarView = (ViewGroup)LayoutInflater.from(ConversationList.this) 769 .inflate(R.layout.conversation_list_multi_select_actionbar, null); 770 771 mSelectedConvCount = 772 (TextView)mMultiSelectActionBarView.findViewById(R.id.selected_conv_count); 773 } 774 mode.setCustomView(mMultiSelectActionBarView); 775 ((TextView)mMultiSelectActionBarView.findViewById(R.id.title)) 776 .setText(R.string.select_conversations); 777 return true; 778 } 779 780 public boolean onPrepareActionMode(ActionMode mode, Menu menu) { 781 if (mMultiSelectActionBarView == null) { 782 ViewGroup v = (ViewGroup)LayoutInflater.from(ConversationList.this) 783 .inflate(R.layout.conversation_list_multi_select_actionbar, null); 784 mode.setCustomView(v); 785 786 mSelectedConvCount = (TextView)v.findViewById(R.id.selected_conv_count); 787 } 788 return true; 789 } 790 791 public boolean onActionItemClicked(ActionMode mode, MenuItem item) { 792 switch (item.getItemId()) { 793 case R.id.delete: 794 if (mSelectedThreadIds.size() > 0) { 795 confirmDeleteThreads(mSelectedThreadIds, mQueryHandler); 796 } 797 mode.finish(); 798 break; 799 800 default: 801 break; 802 } 803 return true; 804 } 805 806 public void onDestroyActionMode(ActionMode mode) { 807 ConversationListAdapter adapter = (ConversationListAdapter)getListView().getAdapter(); 808 adapter.uncheckAll(); 809 mSelectedThreadIds = null; 810 } 811 812 public void onItemCheckedStateChanged(ActionMode mode, 813 int position, long id, boolean checked) { 814 ListView listView = getListView(); 815 final int checkedCount = listView.getCheckedItemCount(); 816 mSelectedConvCount.setText(Integer.toString(checkedCount)); 817 818 Cursor cursor = (Cursor)listView.getItemAtPosition(position); 819 Conversation conv = Conversation.from(ConversationList.this, cursor); 820 conv.setIsChecked(checked); 821 long threadId = conv.getThreadId(); 822 823 if (checked) { 824 mSelectedThreadIds.add(threadId); 825 } else { 826 mSelectedThreadIds.remove(threadId); 827 } 828 } 829 830 } 831 832 private void log(String format, Object... args) { 833 String s = String.format(format, args); 834 Log.d(TAG, "[" + Thread.currentThread().getId() + "] " + s); 835 } 836} 837