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