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 com.android.mms.LogTag; 21 import com.android.mms.R; 22 import com.android.mms.data.Contact; 23 import com.android.mms.data.ContactList; 24 import com.android.mms.data.Conversation; 25 import com.android.mms.transaction.MessagingNotification; 26 import com.android.mms.transaction.SmsRejectedReceiver; 27 import com.android.mms.util.DraftCache; 28 import com.android.mms.util.Recycler; 29 import com.google.android.mms.pdu.PduHeaders; 30 import android.database.sqlite.SqliteWrapper; 31 32 import android.app.AlertDialog; 33 import android.app.ListActivity; 34 import android.content.AsyncQueryHandler; 35 import android.content.ContentResolver; 36 import android.content.Context; 37 import android.content.DialogInterface; 38 import android.content.Intent; 39 import android.content.SharedPreferences; 40 import android.content.DialogInterface.OnClickListener; 41 import android.content.res.Configuration; 42 import android.database.Cursor; 43 import android.database.sqlite.SQLiteException; 44 import android.database.sqlite.SQLiteFullException; 45 import android.os.Bundle; 46 import android.os.Handler; 47 import android.preference.PreferenceManager; 48 import android.provider.ContactsContract; 49 import android.provider.ContactsContract.Contacts; 50 import android.provider.Telephony.Mms; 51 import android.util.Log; 52 import android.view.ContextMenu; 53 import android.view.KeyEvent; 54 import android.view.LayoutInflater; 55 import android.view.Menu; 56 import android.view.MenuItem; 57 import android.view.View; 58 import android.view.Window; 59 import android.view.ContextMenu.ContextMenuInfo; 60 import android.view.View.OnCreateContextMenuListener; 61 import android.view.View.OnKeyListener; 62 import android.widget.AdapterView; 63 import android.widget.CheckBox; 64 import android.widget.ListView; 65 import android.widget.TextView; 66 67 /** 68 * This activity provides a list view of existing conversations. 69 */ 70 public class ConversationList extends ListActivity 71 implements DraftCache.OnDraftChangedListener { 72 private static final String TAG = "ConversationList"; 73 private static final boolean DEBUG = false; 74 private static final boolean LOCAL_LOGV = DEBUG; 75 76 private static final int THREAD_LIST_QUERY_TOKEN = 1701; 77 public static final int DELETE_CONVERSATION_TOKEN = 1801; 78 public static final int HAVE_LOCKED_MESSAGES_TOKEN = 1802; 79 private static final int DELETE_OBSOLETE_THREADS_TOKEN = 1803; 80 81 // IDs of the main menu items. 82 public static final int MENU_COMPOSE_NEW = 0; 83 public static final int MENU_SEARCH = 1; 84 public static final int MENU_DELETE_ALL = 3; 85 public static final int MENU_PREFERENCES = 4; 86 87 // IDs of the context menu items for the list of conversations. 88 public static final int MENU_DELETE = 0; 89 public static final int MENU_VIEW = 1; 90 public static final int MENU_VIEW_CONTACT = 2; 91 public static final int MENU_ADD_TO_CONTACTS = 3; 92 93 private ThreadListQueryHandler mQueryHandler; 94 private ConversationListAdapter mListAdapter; 95 private CharSequence mTitle; 96 private SharedPreferences mPrefs; 97 private Handler mHandler; 98 private boolean mNeedToMarkAsSeen; 99 100 static private final String CHECKED_MESSAGE_LIMITS = "checked_message_limits"; 101 102 @Override 103 protected void onCreate(Bundle savedInstanceState) { 104 super.onCreate(savedInstanceState); 105 106 requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); 107 setContentView(R.layout.conversation_list_screen); 108 109 mQueryHandler = new ThreadListQueryHandler(getContentResolver()); 110 111 ListView listView = getListView(); 112 LayoutInflater inflater = LayoutInflater.from(this); 113 ConversationListItem headerView = (ConversationListItem) 114 inflater.inflate(R.layout.conversation_list_item, listView, false); 115 headerView.bind(getString(R.string.new_message), 116 getString(R.string.create_new_message)); 117 listView.addHeaderView(headerView, null, true); 118 119 listView.setOnCreateContextMenuListener(mConvListOnCreateContextMenuListener); 120 listView.setOnKeyListener(mThreadListKeyListener); 121 122 initListAdapter(); 123 124 mTitle = getString(R.string.app_label); 125 126 mHandler = new Handler(); 127 mPrefs = PreferenceManager.getDefaultSharedPreferences(this); 128 boolean checkedMessageLimits = mPrefs.getBoolean(CHECKED_MESSAGE_LIMITS, false); 129 if (DEBUG) Log.v(TAG, "checkedMessageLimits: " + checkedMessageLimits); 130 if (!checkedMessageLimits || DEBUG) { 131 runOneTimeStorageLimitCheckForLegacyMessages(); 132 } 133 } 134 135 private final ConversationListAdapter.OnContentChangedListener mContentChangedListener = 136 new ConversationListAdapter.OnContentChangedListener() { 137 public void onContentChanged(ConversationListAdapter adapter) { 138 startAsyncQuery(); 139 } 140 }; 141 142 private void initListAdapter() { 143 mListAdapter = new ConversationListAdapter(this, null); 144 mListAdapter.setOnContentChangedListener(mContentChangedListener); 145 setListAdapter(mListAdapter); 146 getListView().setRecyclerListener(mListAdapter); 147 } 148 149 /** 150 * Checks to see if the number of MMS and SMS messages are under the limits for the 151 * recycler. If so, it will automatically turn on the recycler setting. If not, it 152 * will prompt the user with a message and point them to the setting to manually 153 * turn on the recycler. 154 */ 155 public synchronized void runOneTimeStorageLimitCheckForLegacyMessages() { 156 if (Recycler.isAutoDeleteEnabled(this)) { 157 if (DEBUG) Log.v(TAG, "recycler is already turned on"); 158 // The recycler is already turned on. We don't need to check anything or warn 159 // the user, just remember that we've made the check. 160 markCheckedMessageLimit(); 161 return; 162 } 163 new Thread(new Runnable() { 164 public void run() { 165 if (Recycler.checkForThreadsOverLimit(ConversationList.this)) { 166 if (DEBUG) Log.v(TAG, "checkForThreadsOverLimit TRUE"); 167 // Dang, one or more of the threads are over the limit. Show an activity 168 // that'll encourage the user to manually turn on the setting. Delay showing 169 // this activity until a couple of seconds after the conversation list appears. 170 mHandler.postDelayed(new Runnable() { 171 public void run() { 172 Intent intent = new Intent(ConversationList.this, 173 WarnOfStorageLimitsActivity.class); 174 startActivity(intent); 175 } 176 }, 2000); 177 } else { 178 if (DEBUG) Log.v(TAG, "checkForThreadsOverLimit silently turning on recycler"); 179 // No threads were over the limit. Turn on the recycler by default. 180 runOnUiThread(new Runnable() { 181 public void run() { 182 SharedPreferences.Editor editor = mPrefs.edit(); 183 editor.putBoolean(MessagingPreferenceActivity.AUTO_DELETE, true); 184 editor.apply(); 185 } 186 }); 187 } 188 // Remember that we don't have to do the check anymore when starting MMS. 189 runOnUiThread(new Runnable() { 190 public void run() { 191 markCheckedMessageLimit(); 192 } 193 }); 194 } 195 }).start(); 196 } 197 198 /** 199 * Mark in preferences that we've checked the user's message limits. Once checked, we'll 200 * never check them again, unless the user wipe-data or resets the device. 201 */ 202 private void markCheckedMessageLimit() { 203 if (DEBUG) Log.v(TAG, "markCheckedMessageLimit"); 204 SharedPreferences.Editor editor = mPrefs.edit(); 205 editor.putBoolean(CHECKED_MESSAGE_LIMITS, true); 206 editor.apply(); 207 } 208 209 @Override 210 protected void onNewIntent(Intent intent) { 211 // Handle intents that occur after the activity has already been created. 212 startAsyncQuery(); 213 } 214 215 @Override 216 protected void onStart() { 217 super.onStart(); 218 219 MessagingNotification.cancelNotification(getApplicationContext(), 220 SmsRejectedReceiver.SMS_REJECTED_NOTIFICATION_ID); 221 222 DraftCache.getInstance().addOnDraftChangedListener(this); 223 224 mNeedToMarkAsSeen = true; 225 226 startAsyncQuery(); 227 228 // We used to refresh the DraftCache here, but 229 // refreshing the DraftCache each time we go to the ConversationList seems overly 230 // aggressive. We already update the DraftCache when leaving CMA in onStop() and 231 // onNewIntent(), and when we delete threads or delete all in CMA or this activity. 232 // I hope we don't have to do such a heavy operation each time we enter here. 233 234 // we invalidate the contact cache here because we want to get updated presence 235 // and any contact changes. We don't invalidate the cache by observing presence and contact 236 // changes (since that's too untargeted), so as a tradeoff we do it here. 237 // If we're in the middle of the app initialization where we're loading the conversation 238 // threads, don't invalidate the cache because we're in the process of building it. 239 // TODO: think of a better way to invalidate cache more surgically or based on actual 240 // TODO: changes we care about 241 if (!Conversation.loadingThreads()) { 242 Contact.invalidateCache(); 243 } 244 } 245 246 @Override 247 protected void onStop() { 248 super.onStop(); 249 250 DraftCache.getInstance().removeOnDraftChangedListener(this); 251 mListAdapter.changeCursor(null); 252 } 253 254 public void onDraftChanged(final long threadId, final boolean hasDraft) { 255 // Run notifyDataSetChanged() on the main thread. 256 mQueryHandler.post(new Runnable() { 257 public void run() { 258 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 259 log("onDraftChanged: threadId=" + threadId + ", hasDraft=" + hasDraft); 260 } 261 mListAdapter.notifyDataSetChanged(); 262 } 263 }); 264 } 265 266 private void startAsyncQuery() { 267 try { 268 setTitle(getString(R.string.refreshing)); 269 setProgressBarIndeterminateVisibility(true); 270 271 Conversation.startQueryForAll(mQueryHandler, THREAD_LIST_QUERY_TOKEN); 272 } catch (SQLiteException e) { 273 SqliteWrapper.checkSQLiteException(this, e); 274 } 275 } 276 277 @Override 278 public boolean onPrepareOptionsMenu(Menu menu) { 279 menu.clear(); 280 281 menu.add(0, MENU_COMPOSE_NEW, 0, R.string.menu_compose_new).setIcon( 282 com.android.internal.R.drawable.ic_menu_compose); 283 284 if (mListAdapter.getCount() > 0) { 285 menu.add(0, MENU_DELETE_ALL, 0, R.string.menu_delete_all).setIcon( 286 android.R.drawable.ic_menu_delete); 287 } 288 289 menu.add(0, MENU_SEARCH, 0, android.R.string.search_go). 290 setIcon(android.R.drawable.ic_menu_search). 291 setAlphabeticShortcut(android.app.SearchManager.MENU_KEY); 292 293 menu.add(0, MENU_PREFERENCES, 0, R.string.menu_preferences).setIcon( 294 android.R.drawable.ic_menu_preferences); 295 296 return true; 297 } 298 299 @Override 300 public boolean onSearchRequested() { 301 startSearch(null, false, null /*appData*/, false); 302 return true; 303 } 304 305 @Override 306 public boolean onOptionsItemSelected(MenuItem item) { 307 switch(item.getItemId()) { 308 case MENU_COMPOSE_NEW: 309 createNewMessage(); 310 break; 311 case MENU_SEARCH: 312 onSearchRequested(); 313 break; 314 case MENU_DELETE_ALL: 315 // The invalid threadId of -1 means all threads here. 316 confirmDeleteThread(-1L, mQueryHandler); 317 break; 318 case MENU_PREFERENCES: { 319 Intent intent = new Intent(this, MessagingPreferenceActivity.class); 320 startActivityIfNeeded(intent, -1); 321 break; 322 } 323 default: 324 return true; 325 } 326 return false; 327 } 328 329 @Override 330 protected void onListItemClick(ListView l, View v, int position, long id) { 331 if (position == 0) { 332 createNewMessage(); 333 } else { 334 // Note: don't read the thread id data from the ConversationListItem view passed in. 335 // It's unreliable to read the cached data stored in the view because the ListItem 336 // can be recycled, and the same view could be assigned to a different position 337 // if you click the list item fast enough. Instead, get the cursor at the position 338 // clicked and load the data from the cursor. 339 // (ConversationListAdapter extends CursorAdapter, so getItemAtPosition() should 340 // return the cursor object, which is moved to the position passed in) 341 Cursor cursor = (Cursor) getListView().getItemAtPosition(position); 342 Conversation conv = Conversation.from(this, cursor); 343 long tid = conv.getThreadId(); 344 345 if (LogTag.VERBOSE) { 346 Log.d(TAG, "onListItemClick: pos=" + position + ", view=" + v + ", tid=" + tid); 347 } 348 349 openThread(tid); 350 } 351 } 352 353 private void createNewMessage() { 354 startActivity(ComposeMessageActivity.createIntent(this, 0)); 355 } 356 357 private void openThread(long threadId) { 358 startActivity(ComposeMessageActivity.createIntent(this, threadId)); 359 } 360 361 public static Intent createAddContactIntent(String address) { 362 // address must be a single recipient 363 Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT); 364 intent.setType(Contacts.CONTENT_ITEM_TYPE); 365 if (Mms.isEmailAddress(address)) { 366 intent.putExtra(ContactsContract.Intents.Insert.EMAIL, address); 367 } else { 368 intent.putExtra(ContactsContract.Intents.Insert.PHONE, address); 369 intent.putExtra(ContactsContract.Intents.Insert.PHONE_TYPE, 370 ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE); 371 } 372 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 373 374 return intent; 375 } 376 377 private final OnCreateContextMenuListener mConvListOnCreateContextMenuListener = 378 new OnCreateContextMenuListener() { 379 public void onCreateContextMenu(ContextMenu menu, View v, 380 ContextMenuInfo menuInfo) { 381 Cursor cursor = mListAdapter.getCursor(); 382 if (cursor == null || cursor.getPosition() < 0) { 383 return; 384 } 385 Conversation conv = Conversation.from(ConversationList.this, cursor); 386 ContactList recipients = conv.getRecipients(); 387 menu.setHeaderTitle(recipients.formatNames(",")); 388 389 AdapterView.AdapterContextMenuInfo info = 390 (AdapterView.AdapterContextMenuInfo) menuInfo; 391 if (info.position > 0) { 392 menu.add(0, MENU_VIEW, 0, R.string.menu_view); 393 394 // Only show if there's a single recipient 395 if (recipients.size() == 1) { 396 // do we have this recipient in contacts? 397 if (recipients.get(0).existsInDatabase()) { 398 menu.add(0, MENU_VIEW_CONTACT, 0, R.string.menu_view_contact); 399 } else { 400 menu.add(0, MENU_ADD_TO_CONTACTS, 0, R.string.menu_add_to_contacts); 401 } 402 } 403 menu.add(0, MENU_DELETE, 0, R.string.menu_delete); 404 } 405 } 406 }; 407 408 @Override 409 public boolean onContextItemSelected(MenuItem item) { 410 Cursor cursor = mListAdapter.getCursor(); 411 if (cursor != null && cursor.getPosition() >= 0) { 412 Conversation conv = Conversation.from(ConversationList.this, cursor); 413 long threadId = conv.getThreadId(); 414 switch (item.getItemId()) { 415 case MENU_DELETE: { 416 confirmDeleteThread(threadId, mQueryHandler); 417 break; 418 } 419 case MENU_VIEW: { 420 openThread(threadId); 421 break; 422 } 423 case MENU_VIEW_CONTACT: { 424 Contact contact = conv.getRecipients().get(0); 425 Intent intent = new Intent(Intent.ACTION_VIEW, contact.getUri()); 426 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 427 startActivity(intent); 428 break; 429 } 430 case MENU_ADD_TO_CONTACTS: { 431 String address = conv.getRecipients().get(0).getNumber(); 432 startActivity(createAddContactIntent(address)); 433 break; 434 } 435 default: 436 break; 437 } 438 } 439 return super.onContextItemSelected(item); 440 } 441 442 @Override 443 public void onConfigurationChanged(Configuration newConfig) { 444 // We override this method to avoid restarting the entire 445 // activity when the keyboard is opened (declared in 446 // AndroidManifest.xml). Because the only translatable text 447 // in this activity is "New Message", which has the full width 448 // of phone to work with, localization shouldn't be a problem: 449 // no abbreviated alternate words should be needed even in 450 // 'wide' languages like German or Russian. 451 452 super.onConfigurationChanged(newConfig); 453 if (DEBUG) Log.v(TAG, "onConfigurationChanged: " + newConfig); 454 } 455 456 /** 457 * Start the process of putting up a dialog to confirm deleting a thread, 458 * but first start a background query to see if any of the threads or thread 459 * contain locked messages so we'll know how detailed of a UI to display. 460 * @param threadId id of the thread to delete or -1 for all threads 461 * @param handler query handler to do the background locked query 462 */ 463 public static void confirmDeleteThread(long threadId, AsyncQueryHandler handler) { 464 Conversation.startQueryHaveLockedMessages(handler, threadId, 465 HAVE_LOCKED_MESSAGES_TOKEN); 466 } 467 468 /** 469 * Build and show the proper delete thread dialog. The UI is slightly different 470 * depending on whether there are locked messages in the thread(s) and whether we're 471 * deleting a single thread or all threads. 472 * @param listener gets called when the delete button is pressed 473 * @param deleteAll whether to show a single thread or all threads UI 474 * @param hasLockedMessages whether the thread(s) contain locked messages 475 * @param context used to load the various UI elements 476 */ 477 public static void confirmDeleteThreadDialog(final DeleteThreadListener listener, 478 boolean deleteAll, 479 boolean hasLockedMessages, 480 Context context) { 481 View contents = View.inflate(context, R.layout.delete_thread_dialog_view, null); 482 TextView msg = (TextView)contents.findViewById(R.id.message); 483 msg.setText(deleteAll 484 ? R.string.confirm_delete_all_conversations 485 : R.string.confirm_delete_conversation); 486 final CheckBox checkbox = (CheckBox)contents.findViewById(R.id.delete_locked); 487 if (!hasLockedMessages) { 488 checkbox.setVisibility(View.GONE); 489 } else { 490 listener.setDeleteLockedMessage(checkbox.isChecked()); 491 checkbox.setOnClickListener(new View.OnClickListener() { 492 public void onClick(View v) { 493 listener.setDeleteLockedMessage(checkbox.isChecked()); 494 } 495 }); 496 } 497 498 AlertDialog.Builder builder = new AlertDialog.Builder(context); 499 builder.setTitle(R.string.confirm_dialog_title) 500 .setIcon(android.R.drawable.ic_dialog_alert) 501 .setCancelable(true) 502 .setPositiveButton(R.string.delete, listener) 503 .setNegativeButton(R.string.no, null) 504 .setView(contents) 505 .show(); 506 } 507 508 private final OnKeyListener mThreadListKeyListener = new OnKeyListener() { 509 public boolean onKey(View v, int keyCode, KeyEvent event) { 510 if (event.getAction() == KeyEvent.ACTION_DOWN) { 511 switch (keyCode) { 512 case KeyEvent.KEYCODE_DEL: { 513 long id = getListView().getSelectedItemId(); 514 if (id > 0) { 515 confirmDeleteThread(id, mQueryHandler); 516 } 517 return true; 518 } 519 } 520 } 521 return false; 522 } 523 }; 524 525 public static class DeleteThreadListener implements OnClickListener { 526 private final long mThreadId; 527 private final AsyncQueryHandler mHandler; 528 private final Context mContext; 529 private boolean mDeleteLockedMessages; 530 531 public DeleteThreadListener(long threadId, AsyncQueryHandler handler, Context context) { 532 mThreadId = threadId; 533 mHandler = handler; 534 mContext = context; 535 } 536 537 public void setDeleteLockedMessage(boolean deleteLockedMessages) { 538 mDeleteLockedMessages = deleteLockedMessages; 539 } 540 541 public void onClick(DialogInterface dialog, final int whichButton) { 542 MessageUtils.handleReadReport(mContext, mThreadId, 543 PduHeaders.READ_STATUS__DELETED_WITHOUT_BEING_READ, new Runnable() { 544 public void run() { 545 int token = DELETE_CONVERSATION_TOKEN; 546 if (mThreadId == -1) { 547 Conversation.startDeleteAll(mHandler, token, mDeleteLockedMessages); 548 DraftCache.getInstance().refresh(); 549 } else { 550 Conversation.startDelete(mHandler, token, mDeleteLockedMessages, 551 mThreadId); 552 DraftCache.getInstance().setDraftState(mThreadId, false); 553 } 554 } 555 }); 556 dialog.dismiss(); 557 } 558 } 559 560 private final class ThreadListQueryHandler extends AsyncQueryHandler { 561 public ThreadListQueryHandler(ContentResolver contentResolver) { 562 super(contentResolver); 563 } 564 565 @Override 566 protected void onQueryComplete(int token, Object cookie, Cursor cursor) { 567 switch (token) { 568 case THREAD_LIST_QUERY_TOKEN: 569 mListAdapter.changeCursor(cursor); 570 setTitle(mTitle); 571 setProgressBarIndeterminateVisibility(false); 572 573 if (mNeedToMarkAsSeen) { 574 mNeedToMarkAsSeen = false; 575 Conversation.markAllConversationsAsSeen(getApplicationContext()); 576 577 // Delete any obsolete threads. Obsolete threads are threads that aren't 578 // referenced by at least one message in the pdu or sms tables. 579 Conversation.asyncDeleteObsoleteThreads(mQueryHandler, 580 DELETE_OBSOLETE_THREADS_TOKEN); 581 } 582 break; 583 584 case HAVE_LOCKED_MESSAGES_TOKEN: 585 long threadId = (Long)cookie; 586 confirmDeleteThreadDialog(new DeleteThreadListener(threadId, mQueryHandler, 587 ConversationList.this), threadId == -1, 588 cursor != null && cursor.getCount() > 0, 589 ConversationList.this); 590 break; 591 592 default: 593 Log.e(TAG, "onQueryComplete called with unknown token " + token); 594 } 595 } 596 597 @Override 598 protected void onDeleteComplete(int token, Object cookie, int result) { 599 switch (token) { 600 case DELETE_CONVERSATION_TOKEN: 601 // Make sure the conversation cache reflects the threads in the DB. 602 Conversation.init(ConversationList.this); 603 604 // Update the notification for new messages since they 605 // may be deleted. 606 MessagingNotification.nonBlockingUpdateNewMessageIndicator(ConversationList.this, 607 false, false); 608 // Update the notification for failed messages since they 609 // may be deleted. 610 MessagingNotification.updateSendFailedNotification(ConversationList.this); 611 612 // Make sure the list reflects the delete 613 startAsyncQuery(); 614 break; 615 616 case DELETE_OBSOLETE_THREADS_TOKEN: 617 // Nothing to do here. 618 break; 619 } 620 } 621 } 622 623 private void log(String format, Object... args) { 624 String s = String.format(format, args); 625 Log.d(TAG, "[" + Thread.currentThread().getId() + "] " + s); 626 } 627 } 628