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