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