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 = "ConversationList"; 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 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 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 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 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 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 251 private void initListAdapter() { 252 mListAdapter = new ConversationListAdapter(this, null); 253 mListAdapter.setOnContentChangedListener(mContentChangedListener); 254 setListAdapter(mListAdapter); 255 getListView().setRecyclerListener(mListAdapter); 256 } 257 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 */ 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 */ 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 371 protected void onNewIntent(Intent intent) { 372 // Handle intents that occur after the activity has already been created. 373 startAsyncQuery(); 374 } 375 376 @Override 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 408 protected void onStop() { 409 super.onStop(); 410 411 DraftCache.getInstance().removeOnDraftChangedListener(this); 412 413 // Simply setting the choice mode causes the previous choice mode to finish and we exit 414 // multi-select mode (if we're in it) and remove all the selections. 415 getListView().setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL); 416 417 // Close the cursor in the ListAdapter if the activity stopped. 418 Cursor cursor = mListAdapter.getCursor(); 419 420 if (cursor != null && !cursor.isClosed()) { 421 cursor.close(); 422 } 423 424 mListAdapter.changeCursor(null); 425 } 426 427 @Override 428 public void onDraftChanged(final long threadId, final boolean hasDraft) { 429 // Run notifyDataSetChanged() on the main thread. 430 mQueryHandler.post(new Runnable() { 431 @Override 432 public void run() { 433 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 434 log("onDraftChanged: threadId=" + threadId + ", hasDraft=" + hasDraft); 435 } 436 mListAdapter.notifyDataSetChanged(); 437 } 438 }); 439 } 440 441 private void startAsyncQuery() { 442 try { 443 ((TextView)(getListView().getEmptyView())).setText(R.string.loading_conversations); 444 445 Conversation.startQueryForAll(mQueryHandler, THREAD_LIST_QUERY_TOKEN); 446 Conversation.startQuery(mQueryHandler, UNREAD_THREADS_QUERY_TOKEN, Threads.READ + "=0"); 447 } catch (SQLiteException e) { 448 SqliteWrapper.checkSQLiteException(this, e); 449 } 450 } 451 452 SearchView.OnQueryTextListener mQueryTextListener = new SearchView.OnQueryTextListener() { 453 @Override 454 public boolean onQueryTextSubmit(String query) { 455 Intent intent = new Intent(); 456 intent.setClass(ConversationList.this, SearchActivity.class); 457 intent.putExtra(SearchManager.QUERY, query); 458 startActivity(intent); 459 mSearchItem.collapseActionView(); 460 return true; 461 } 462 463 @Override 464 public boolean onQueryTextChange(String newText) { 465 return false; 466 } 467 }; 468 469 @Override 470 public boolean onCreateOptionsMenu(Menu menu) { 471 getMenuInflater().inflate(R.menu.conversation_list_menu, menu); 472 473 mSearchItem = menu.findItem(R.id.search); 474 mSearchView = (SearchView) mSearchItem.getActionView(); 475 476 mSearchView.setOnQueryTextListener(mQueryTextListener); 477 mSearchView.setQueryHint(getString(R.string.search_hint)); 478 mSearchView.setIconifiedByDefault(true); 479 SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE); 480 481 if (searchManager != null) { 482 SearchableInfo info = searchManager.getSearchableInfo(this.getComponentName()); 483 mSearchView.setSearchableInfo(info); 484 } 485 486 MenuItem cellBroadcastItem = menu.findItem(R.id.action_cell_broadcasts); 487 if (cellBroadcastItem != null) { 488 // Enable link to Cell broadcast activity depending on the value in config.xml. 489 boolean isCellBroadcastAppLinkEnabled = this.getResources().getBoolean( 490 com.android.internal.R.bool.config_cellBroadcastAppLinks); 491 try { 492 if (isCellBroadcastAppLinkEnabled) { 493 PackageManager pm = getPackageManager(); 494 if (pm.getApplicationEnabledSetting("com.android.cellbroadcastreceiver") 495 == PackageManager.COMPONENT_ENABLED_STATE_DISABLED) { 496 isCellBroadcastAppLinkEnabled = false; // CMAS app disabled 497 } 498 } 499 } catch (IllegalArgumentException ignored) { 500 isCellBroadcastAppLinkEnabled = false; // CMAS app not installed 501 } 502 if (!isCellBroadcastAppLinkEnabled) { 503 cellBroadcastItem.setVisible(false); 504 } 505 } 506 507 return true; 508 } 509 510 @Override 511 public boolean onPrepareOptionsMenu(Menu menu) { 512 MenuItem item = menu.findItem(R.id.action_delete_all); 513 if (item != null) { 514 item.setVisible((mListAdapter.getCount() > 0) && mIsSmsEnabled); 515 } 516 item = menu.findItem(R.id.action_compose_new); 517 if (item != null ){ 518 // Dim compose if SMS is disabled because it will not work (will show a toast) 519 item.getIcon().setAlpha(mIsSmsEnabled ? 255 : 127); 520 } 521 if (!LogTag.DEBUG_DUMP) { 522 item = menu.findItem(R.id.action_debug_dump); 523 if (item != null) { 524 item.setVisible(false); 525 } 526 } 527 return true; 528 } 529 530 @Override 531 public boolean onSearchRequested() { 532 if (mSearchItem != null) { 533 mSearchItem.expandActionView(); 534 } 535 return true; 536 } 537 538 @Override 539 public boolean onOptionsItemSelected(MenuItem item) { 540 switch(item.getItemId()) { 541 case R.id.action_compose_new: 542 if (mIsSmsEnabled) { 543 createNewMessage(); 544 } else { 545 // Display a toast letting the user know they can not compose. 546 if (mComposeDisabledToast == null) { 547 mComposeDisabledToast = Toast.makeText(this, 548 R.string.compose_disabled_toast, Toast.LENGTH_SHORT); 549 } 550 mComposeDisabledToast.show(); 551 } 552 break; 553 case R.id.action_delete_all: 554 // The invalid threadId of -1 means all threads here. 555 confirmDeleteThread(-1L, mQueryHandler); 556 break; 557 case R.id.action_settings: 558 Intent intent = new Intent(this, MessagingPreferenceActivity.class); 559 startActivityIfNeeded(intent, -1); 560 break; 561 case R.id.action_debug_dump: 562 LogTag.dumpInternalTables(this); 563 break; 564 case R.id.action_cell_broadcasts: 565 Intent cellBroadcastIntent = new Intent(Intent.ACTION_MAIN); 566 cellBroadcastIntent.setComponent(new ComponentName( 567 "com.android.cellbroadcastreceiver", 568 "com.android.cellbroadcastreceiver.CellBroadcastListActivity")); 569 cellBroadcastIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 570 try { 571 startActivity(cellBroadcastIntent); 572 } catch (ActivityNotFoundException ignored) { 573 Log.e(TAG, "ActivityNotFoundException for CellBroadcastListActivity"); 574 } 575 return true; 576 default: 577 return true; 578 } 579 return false; 580 } 581 582 @Override 583 protected void onListItemClick(ListView l, View v, int position, long id) { 584 // Note: don't read the thread id data from the ConversationListItem view passed in. 585 // It's unreliable to read the cached data stored in the view because the ListItem 586 // can be recycled, and the same view could be assigned to a different position 587 // if you click the list item fast enough. Instead, get the cursor at the position 588 // clicked and load the data from the cursor. 589 // (ConversationListAdapter extends CursorAdapter, so getItemAtPosition() should 590 // return the cursor object, which is moved to the position passed in) 591 Cursor cursor = (Cursor) getListView().getItemAtPosition(position); 592 Conversation conv = Conversation.from(this, cursor); 593 long tid = conv.getThreadId(); 594 595 if (LogTag.VERBOSE) { 596 Log.d(TAG, "onListItemClick: pos=" + position + ", view=" + v + ", tid=" + tid); 597 } 598 599 openThread(tid); 600 } 601 602 private void createNewMessage() { 603 startActivity(ComposeMessageActivity.createIntent(this, 0)); 604 } 605 606 private void openThread(long threadId) { 607 startActivity(ComposeMessageActivity.createIntent(this, threadId)); 608 } 609 610 public static Intent createAddContactIntent(String address) { 611 // address must be a single recipient 612 Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT); 613 intent.setType(Contacts.CONTENT_ITEM_TYPE); 614 if (Mms.isEmailAddress(address)) { 615 intent.putExtra(ContactsContract.Intents.Insert.EMAIL, address); 616 } else { 617 intent.putExtra(ContactsContract.Intents.Insert.PHONE, address); 618 intent.putExtra(ContactsContract.Intents.Insert.PHONE_TYPE, 619 ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE); 620 } 621 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 622 623 return intent; 624 } 625 626 private final OnCreateContextMenuListener mConvListOnCreateContextMenuListener = 627 new OnCreateContextMenuListener() { 628 @Override 629 public void onCreateContextMenu(ContextMenu menu, View v, 630 ContextMenuInfo menuInfo) { 631 Cursor cursor = mListAdapter.getCursor(); 632 if (cursor == null || cursor.getPosition() < 0) { 633 return; 634 } 635 Conversation conv = Conversation.from(ConversationList.this, cursor); 636 ContactList recipients = conv.getRecipients(); 637 menu.setHeaderTitle(recipients.formatNames(",")); 638 639 AdapterView.AdapterContextMenuInfo info = 640 (AdapterView.AdapterContextMenuInfo) menuInfo; 641 menu.add(0, MENU_VIEW, 0, R.string.menu_view); 642 643 // Only show if there's a single recipient 644 if (recipients.size() == 1) { 645 // do we have this recipient in contacts? 646 if (recipients.get(0).existsInDatabase()) { 647 menu.add(0, MENU_VIEW_CONTACT, 0, R.string.menu_view_contact); 648 } else { 649 menu.add(0, MENU_ADD_TO_CONTACTS, 0, R.string.menu_add_to_contacts); 650 } 651 } 652 if (mIsSmsEnabled) { 653 menu.add(0, MENU_DELETE, 0, R.string.menu_delete); 654 } 655 } 656 }; 657 658 @Override 659 public boolean onContextItemSelected(MenuItem item) { 660 Cursor cursor = mListAdapter.getCursor(); 661 if (cursor != null && cursor.getPosition() >= 0) { 662 Conversation conv = Conversation.from(ConversationList.this, cursor); 663 long threadId = conv.getThreadId(); 664 switch (item.getItemId()) { 665 case MENU_DELETE: { 666 confirmDeleteThread(threadId, mQueryHandler); 667 break; 668 } 669 case MENU_VIEW: { 670 openThread(threadId); 671 break; 672 } 673 case MENU_VIEW_CONTACT: { 674 Contact contact = conv.getRecipients().get(0); 675 Intent intent = new Intent(Intent.ACTION_VIEW, contact.getUri()); 676 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 677 startActivity(intent); 678 break; 679 } 680 case MENU_ADD_TO_CONTACTS: { 681 String address = conv.getRecipients().get(0).getNumber(); 682 startActivity(createAddContactIntent(address)); 683 break; 684 } 685 default: 686 break; 687 } 688 } 689 return super.onContextItemSelected(item); 690 } 691 692 @Override 693 public void onConfigurationChanged(Configuration newConfig) { 694 // We override this method to avoid restarting the entire 695 // activity when the keyboard is opened (declared in 696 // AndroidManifest.xml). Because the only translatable text 697 // in this activity is "New Message", which has the full width 698 // of phone to work with, localization shouldn't be a problem: 699 // no abbreviated alternate words should be needed even in 700 // 'wide' languages like German or Russian. 701 702 super.onConfigurationChanged(newConfig); 703 if (DEBUG) Log.v(TAG, "onConfigurationChanged: " + newConfig); 704 } 705 706 /** 707 * Start the process of putting up a dialog to confirm deleting a thread, 708 * but first start a background query to see if any of the threads or thread 709 * contain locked messages so we'll know how detailed of a UI to display. 710 * @param threadId id of the thread to delete or -1 for all threads 711 * @param handler query handler to do the background locked query 712 */ 713 public static void confirmDeleteThread(long threadId, AsyncQueryHandler handler) { 714 ArrayList<Long> threadIds = null; 715 if (threadId != -1) { 716 threadIds = new ArrayList<Long>(); 717 threadIds.add(threadId); 718 } 719 confirmDeleteThreads(threadIds, handler); 720 } 721 722 /** 723 * Start the process of putting up a dialog to confirm deleting threads, 724 * but first start a background query to see if any of the threads 725 * contain locked messages so we'll know how detailed of a UI to display. 726 * @param threadIds list of threadIds to delete or null for all threads 727 * @param handler query handler to do the background locked query 728 */ 729 public static void confirmDeleteThreads(Collection<Long> threadIds, AsyncQueryHandler handler) { 730 Conversation.startQueryHaveLockedMessages(handler, threadIds, 731 HAVE_LOCKED_MESSAGES_TOKEN); 732 } 733 734 /** 735 * Build and show the proper delete thread dialog. The UI is slightly different 736 * depending on whether there are locked messages in the thread(s) and whether we're 737 * deleting single/multiple threads or all threads. 738 * @param listener gets called when the delete button is pressed 739 * @param threadIds the thread IDs to be deleted (pass null for all threads) 740 * @param hasLockedMessages whether the thread(s) contain locked messages 741 * @param context used to load the various UI elements 742 */ 743 public static void confirmDeleteThreadDialog(final DeleteThreadListener listener, 744 Collection<Long> threadIds, 745 boolean hasLockedMessages, 746 Context context) { 747 View contents = View.inflate(context, R.layout.delete_thread_dialog_view, null); 748 TextView msg = (TextView)contents.findViewById(R.id.message); 749 750 if (threadIds == null) { 751 msg.setText(R.string.confirm_delete_all_conversations); 752 } else { 753 // Show the number of threads getting deleted in the confirmation dialog. 754 int cnt = threadIds.size(); 755 msg.setText(context.getResources().getQuantityString( 756 R.plurals.confirm_delete_conversation, cnt, cnt)); 757 } 758 759 final CheckBox checkbox = (CheckBox)contents.findViewById(R.id.delete_locked); 760 if (!hasLockedMessages) { 761 checkbox.setVisibility(View.GONE); 762 } else { 763 listener.setDeleteLockedMessage(checkbox.isChecked()); 764 checkbox.setOnClickListener(new View.OnClickListener() { 765 @Override 766 public void onClick(View v) { 767 listener.setDeleteLockedMessage(checkbox.isChecked()); 768 } 769 }); 770 } 771 772 AlertDialog.Builder builder = new AlertDialog.Builder(context); 773 builder.setTitle(R.string.confirm_dialog_title) 774 .setIconAttribute(android.R.attr.alertDialogIcon) 775 .setCancelable(true) 776 .setPositiveButton(R.string.delete, listener) 777 .setNegativeButton(R.string.no, null) 778 .setView(contents) 779 .show(); 780 } 781 782 private final OnKeyListener mThreadListKeyListener = new OnKeyListener() { 783 @Override 784 public boolean onKey(View v, int keyCode, KeyEvent event) { 785 if (event.getAction() == KeyEvent.ACTION_DOWN) { 786 switch (keyCode) { 787 case KeyEvent.KEYCODE_DEL: { 788 long id = getListView().getSelectedItemId(); 789 if (id > 0) { 790 confirmDeleteThread(id, mQueryHandler); 791 } 792 return true; 793 } 794 } 795 } 796 return false; 797 } 798 }; 799 800 public static class DeleteThreadListener implements OnClickListener { 801 private final Collection<Long> mThreadIds; 802 private final ConversationQueryHandler mHandler; 803 private final Context mContext; 804 private boolean mDeleteLockedMessages; 805 806 public DeleteThreadListener(Collection<Long> threadIds, ConversationQueryHandler handler, 807 Context context) { 808 mThreadIds = threadIds; 809 mHandler = handler; 810 mContext = context; 811 } 812 813 public void setDeleteLockedMessage(boolean deleteLockedMessages) { 814 mDeleteLockedMessages = deleteLockedMessages; 815 } 816 817 @Override 818 public void onClick(DialogInterface dialog, final int whichButton) { 819 MessageUtils.handleReadReport(mContext, mThreadIds, 820 PduHeaders.READ_STATUS__DELETED_WITHOUT_BEING_READ, new Runnable() { 821 @Override 822 public void run() { 823 int token = DELETE_CONVERSATION_TOKEN; 824 if (mThreadIds == null) { 825 Conversation.startDeleteAll(mHandler, token, mDeleteLockedMessages); 826 DraftCache.getInstance().refresh(); 827 } else { 828 Conversation.startDelete(mHandler, token, mDeleteLockedMessages, 829 mThreadIds); 830 } 831 } 832 }); 833 dialog.dismiss(); 834 } 835 } 836 837 private final Runnable mDeleteObsoleteThreadsRunnable = new Runnable() { 838 @Override 839 public void run() { 840 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 841 LogTag.debug("mDeleteObsoleteThreadsRunnable getSavingDraft(): " + 842 DraftCache.getInstance().getSavingDraft()); 843 } 844 if (DraftCache.getInstance().getSavingDraft()) { 845 // We're still saving a draft. Try again in a second. We don't want to delete 846 // any threads out from under the draft. 847 if (DEBUGCLEANUP) { 848 LogTag.debug("mDeleteObsoleteThreadsRunnable saving draft, trying again"); 849 } 850 mHandler.postDelayed(mDeleteObsoleteThreadsRunnable, 1000); 851 } else { 852 if (DEBUGCLEANUP) { 853 LogTag.debug("mDeleteObsoleteThreadsRunnable calling " + 854 "asyncDeleteObsoleteThreads"); 855 } 856 Conversation.asyncDeleteObsoleteThreads(mQueryHandler, 857 DELETE_OBSOLETE_THREADS_TOKEN); 858 } 859 } 860 }; 861 862 private final class ThreadListQueryHandler extends ConversationQueryHandler { 863 public ThreadListQueryHandler(ContentResolver contentResolver) { 864 super(contentResolver); 865 } 866 867 // Test code used for various scenarios where its desirable to insert a delay in 868 // responding to query complete. To use, uncomment out the block below and then 869 // comment out the @Override and onQueryComplete line. 870 // @Override 871 // protected void onQueryComplete(final int token, final Object cookie, final Cursor cursor) { 872 // mHandler.postDelayed(new Runnable() { 873 // public void run() { 874 // myonQueryComplete(token, cookie, cursor); 875 // } 876 // }, 2000); 877 // } 878 // 879 // protected void myonQueryComplete(int token, Object cookie, Cursor cursor) { 880 881 @Override 882 protected void onQueryComplete(int token, Object cookie, Cursor cursor) { 883 switch (token) { 884 case THREAD_LIST_QUERY_TOKEN: 885 mListAdapter.changeCursor(cursor); 886 887 if (mListAdapter.getCount() == 0) { 888 ((TextView)(getListView().getEmptyView())).setText(R.string.no_conversations); 889 } 890 891 if (mDoOnceAfterFirstQuery) { 892 mDoOnceAfterFirstQuery = false; 893 // Delay doing a couple of DB operations until we've initially queried the DB 894 // for the list of conversations to display. We don't want to slow down showing 895 // the initial UI. 896 897 // 1. Delete any obsolete threads. Obsolete threads are threads that aren't 898 // referenced by at least one message in the pdu or sms tables. 899 mHandler.post(mDeleteObsoleteThreadsRunnable); 900 901 // 2. Mark all the conversations as seen. 902 Conversation.markAllConversationsAsSeen(getApplicationContext()); 903 } 904 if (mSavedFirstVisiblePosition != AdapterView.INVALID_POSITION) { 905 // Restore the list to its previous position. 906 getListView().setSelectionFromTop(mSavedFirstVisiblePosition, 907 mSavedFirstItemOffset); 908 mSavedFirstVisiblePosition = AdapterView.INVALID_POSITION; 909 } 910 break; 911 912 case UNREAD_THREADS_QUERY_TOKEN: 913 int count = 0; 914 if (cursor != null) { 915 count = cursor.getCount(); 916 cursor.close(); 917 } 918 mUnreadConvCount.setText(count > 0 ? Integer.toString(count) : null); 919 break; 920 921 case HAVE_LOCKED_MESSAGES_TOKEN: 922 @SuppressWarnings("unchecked") 923 Collection<Long> threadIds = (Collection<Long>)cookie; 924 confirmDeleteThreadDialog(new DeleteThreadListener(threadIds, mQueryHandler, 925 ConversationList.this), threadIds, 926 cursor != null && cursor.getCount() > 0, 927 ConversationList.this); 928 if (cursor != null) { 929 cursor.close(); 930 } 931 break; 932 933 default: 934 Log.e(TAG, "onQueryComplete called with unknown token " + token); 935 } 936 } 937 938 @Override 939 protected void onDeleteComplete(int token, Object cookie, int result) { 940 super.onDeleteComplete(token, cookie, result); 941 switch (token) { 942 case DELETE_CONVERSATION_TOKEN: 943 long threadId = cookie != null ? (Long)cookie : -1; // default to all threads 944 945 if (threadId == -1) { 946 // Rebuild the contacts cache now that all threads and their associated unique 947 // recipients have been deleted. 948 Contact.init(ConversationList.this); 949 } else { 950 // Remove any recipients referenced by this single thread from the 951 // contacts cache. It's possible for two or more threads to reference 952 // the same contact. That's ok if we remove it. We'll recreate that contact 953 // when we init all Conversations below. 954 Conversation conv = Conversation.get(ConversationList.this, threadId, false); 955 if (conv != null) { 956 ContactList recipients = conv.getRecipients(); 957 for (Contact contact : recipients) { 958 contact.removeFromCache(); 959 } 960 } 961 } 962 // Make sure the conversation cache reflects the threads in the DB. 963 Conversation.init(ConversationList.this); 964 965 // Update the notification for new messages since they 966 // may be deleted. 967 MessagingNotification.nonBlockingUpdateNewMessageIndicator(ConversationList.this, 968 MessagingNotification.THREAD_NONE, false); 969 // Update the notification for failed messages since they 970 // may be deleted. 971 MessagingNotification.nonBlockingUpdateSendFailedNotification(ConversationList.this); 972 973 // Make sure the list reflects the delete 974 startAsyncQuery(); 975 976 MmsWidgetProvider.notifyDatasetChanged(getApplicationContext()); 977 break; 978 979 case DELETE_OBSOLETE_THREADS_TOKEN: 980 if (DEBUGCLEANUP) { 981 LogTag.debug("onQueryComplete finished DELETE_OBSOLETE_THREADS_TOKEN"); 982 } 983 break; 984 } 985 } 986 } 987 988 private class ModeCallback implements ListView.MultiChoiceModeListener { 989 private View mMultiSelectActionBarView; 990 private TextView mSelectedConvCount; 991 private HashSet<Long> mSelectedThreadIds; 992 993 @Override 994 public boolean onCreateActionMode(ActionMode mode, Menu menu) { 995 MenuInflater inflater = getMenuInflater(); 996 mSelectedThreadIds = new HashSet<Long>(); 997 inflater.inflate(R.menu.conversation_multi_select_menu, menu); 998 999 if (mMultiSelectActionBarView == null) { 1000 mMultiSelectActionBarView = LayoutInflater.from(ConversationList.this) 1001 .inflate(R.layout.conversation_list_multi_select_actionbar, null); 1002 1003 mSelectedConvCount = 1004 (TextView)mMultiSelectActionBarView.findViewById(R.id.selected_conv_count); 1005 } 1006 mode.setCustomView(mMultiSelectActionBarView); 1007 ((TextView)mMultiSelectActionBarView.findViewById(R.id.title)) 1008 .setText(R.string.select_conversations); 1009 return true; 1010 } 1011 1012 @Override 1013 public boolean onPrepareActionMode(ActionMode mode, Menu menu) { 1014 if (mMultiSelectActionBarView == null) { 1015 ViewGroup v = (ViewGroup)LayoutInflater.from(ConversationList.this) 1016 .inflate(R.layout.conversation_list_multi_select_actionbar, null); 1017 mode.setCustomView(v); 1018 1019 mSelectedConvCount = (TextView)v.findViewById(R.id.selected_conv_count); 1020 } 1021 return true; 1022 } 1023 1024 @Override 1025 public boolean onActionItemClicked(ActionMode mode, MenuItem item) { 1026 switch (item.getItemId()) { 1027 case R.id.delete: 1028 if (mSelectedThreadIds.size() > 0) { 1029 confirmDeleteThreads(mSelectedThreadIds, mQueryHandler); 1030 } 1031 mode.finish(); 1032 break; 1033 1034 default: 1035 break; 1036 } 1037 return true; 1038 } 1039 1040 @Override 1041 public void onDestroyActionMode(ActionMode mode) { 1042 ConversationListAdapter adapter = (ConversationListAdapter)getListView().getAdapter(); 1043 adapter.uncheckAll(); 1044 mSelectedThreadIds = null; 1045 } 1046 1047 @Override 1048 public void onItemCheckedStateChanged(ActionMode mode, 1049 int position, long id, boolean checked) { 1050 ListView listView = getListView(); 1051 final int checkedCount = listView.getCheckedItemCount(); 1052 mSelectedConvCount.setText(Integer.toString(checkedCount)); 1053 1054 Cursor cursor = (Cursor)listView.getItemAtPosition(position); 1055 Conversation conv = Conversation.from(ConversationList.this, cursor); 1056 conv.setIsChecked(checked); 1057 long threadId = conv.getThreadId(); 1058 1059 if (checked) { 1060 mSelectedThreadIds.add(threadId); 1061 } else { 1062 mSelectedThreadIds.remove(threadId); 1063 } 1064 } 1065 1066 } 1067 1068 private void log(String format, Object... args) { 1069 String s = String.format(format, args); 1070 Log.d(TAG, "[" + Thread.currentThread().getId() + "] " + s); 1071 } 1072 } 1073