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