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