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.commit(); 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.commit(); 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 (LOCAL_LOGV) { 332 Log.v(TAG, "onListItemClick: position=" + position + ", id=" + id); 333 } 334 335 if (position == 0) { 336 createNewMessage(); 337 } else if (v instanceof ConversationListItem) { 338 ConversationListItem headerView = (ConversationListItem) v; 339 ConversationListItemData ch = headerView.getConversationHeader(); 340 openThread(ch.getThreadId()); 341 } 342 } 343 344 private void createNewMessage() { 345 startActivity(ComposeMessageActivity.createIntent(this, 0)); 346 } 347 348 private void openThread(long threadId) { 349 startActivity(ComposeMessageActivity.createIntent(this, threadId)); 350 } 351 352 public static Intent createAddContactIntent(String address) { 353 // address must be a single recipient 354 Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT); 355 intent.setType(Contacts.CONTENT_ITEM_TYPE); 356 if (Mms.isEmailAddress(address)) { 357 intent.putExtra(ContactsContract.Intents.Insert.EMAIL, address); 358 } else { 359 intent.putExtra(ContactsContract.Intents.Insert.PHONE, address); 360 intent.putExtra(ContactsContract.Intents.Insert.PHONE_TYPE, 361 ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE); 362 } 363 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 364 365 return intent; 366 } 367 368 private final OnCreateContextMenuListener mConvListOnCreateContextMenuListener = 369 new OnCreateContextMenuListener() { 370 public void onCreateContextMenu(ContextMenu menu, View v, 371 ContextMenuInfo menuInfo) { 372 Cursor cursor = mListAdapter.getCursor(); 373 if (cursor == null || cursor.getPosition() < 0) { 374 return; 375 } 376 Conversation conv = Conversation.from(ConversationList.this, cursor); 377 ContactList recipients = conv.getRecipients(); 378 menu.setHeaderTitle(recipients.formatNames(",")); 379 380 AdapterView.AdapterContextMenuInfo info = 381 (AdapterView.AdapterContextMenuInfo) menuInfo; 382 if (info.position > 0) { 383 menu.add(0, MENU_VIEW, 0, R.string.menu_view); 384 385 // Only show if there's a single recipient 386 if (recipients.size() == 1) { 387 // do we have this recipient in contacts? 388 if (recipients.get(0).existsInDatabase()) { 389 menu.add(0, MENU_VIEW_CONTACT, 0, R.string.menu_view_contact); 390 } else { 391 menu.add(0, MENU_ADD_TO_CONTACTS, 0, R.string.menu_add_to_contacts); 392 } 393 } 394 menu.add(0, MENU_DELETE, 0, R.string.menu_delete); 395 } 396 } 397 }; 398 399 @Override 400 public boolean onContextItemSelected(MenuItem item) { 401 Cursor cursor = mListAdapter.getCursor(); 402 if (cursor != null && cursor.getPosition() >= 0) { 403 Conversation conv = Conversation.from(ConversationList.this, cursor); 404 long threadId = conv.getThreadId(); 405 switch (item.getItemId()) { 406 case MENU_DELETE: { 407 confirmDeleteThread(threadId, mQueryHandler); 408 break; 409 } 410 case MENU_VIEW: { 411 openThread(threadId); 412 break; 413 } 414 case MENU_VIEW_CONTACT: { 415 Contact contact = conv.getRecipients().get(0); 416 Intent intent = new Intent(Intent.ACTION_VIEW, contact.getUri()); 417 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 418 startActivity(intent); 419 break; 420 } 421 case MENU_ADD_TO_CONTACTS: { 422 String address = conv.getRecipients().get(0).getNumber(); 423 startActivity(createAddContactIntent(address)); 424 break; 425 } 426 default: 427 break; 428 } 429 } 430 return super.onContextItemSelected(item); 431 } 432 433 @Override 434 public void onConfigurationChanged(Configuration newConfig) { 435 // We override this method to avoid restarting the entire 436 // activity when the keyboard is opened (declared in 437 // AndroidManifest.xml). Because the only translatable text 438 // in this activity is "New Message", which has the full width 439 // of phone to work with, localization shouldn't be a problem: 440 // no abbreviated alternate words should be needed even in 441 // 'wide' languages like German or Russian. 442 443 super.onConfigurationChanged(newConfig); 444 if (DEBUG) Log.v(TAG, "onConfigurationChanged: " + newConfig); 445 } 446 447 /** 448 * Start the process of putting up a dialog to confirm deleting a thread, 449 * but first start a background query to see if any of the threads or thread 450 * contain locked messages so we'll know how detailed of a UI to display. 451 * @param threadId id of the thread to delete or -1 for all threads 452 * @param handler query handler to do the background locked query 453 */ 454 public static void confirmDeleteThread(long threadId, AsyncQueryHandler handler) { 455 Conversation.startQueryHaveLockedMessages(handler, threadId, 456 HAVE_LOCKED_MESSAGES_TOKEN); 457 } 458 459 /** 460 * Build and show the proper delete thread dialog. The UI is slightly different 461 * depending on whether there are locked messages in the thread(s) and whether we're 462 * deleting a single thread or all threads. 463 * @param listener gets called when the delete button is pressed 464 * @param deleteAll whether to show a single thread or all threads UI 465 * @param hasLockedMessages whether the thread(s) contain locked messages 466 * @param context used to load the various UI elements 467 */ 468 public static void confirmDeleteThreadDialog(final DeleteThreadListener listener, 469 boolean deleteAll, 470 boolean hasLockedMessages, 471 Context context) { 472 View contents = View.inflate(context, R.layout.delete_thread_dialog_view, null); 473 TextView msg = (TextView)contents.findViewById(R.id.message); 474 msg.setText(deleteAll 475 ? R.string.confirm_delete_all_conversations 476 : R.string.confirm_delete_conversation); 477 final CheckBox checkbox = (CheckBox)contents.findViewById(R.id.delete_locked); 478 if (!hasLockedMessages) { 479 checkbox.setVisibility(View.GONE); 480 } else { 481 listener.setDeleteLockedMessage(checkbox.isChecked()); 482 checkbox.setOnClickListener(new View.OnClickListener() { 483 public void onClick(View v) { 484 listener.setDeleteLockedMessage(checkbox.isChecked()); 485 } 486 }); 487 } 488 489 AlertDialog.Builder builder = new AlertDialog.Builder(context); 490 builder.setTitle(R.string.confirm_dialog_title) 491 .setIcon(android.R.drawable.ic_dialog_alert) 492 .setCancelable(true) 493 .setPositiveButton(R.string.delete, listener) 494 .setNegativeButton(R.string.no, null) 495 .setView(contents) 496 .show(); 497 } 498 499 private final OnKeyListener mThreadListKeyListener = new OnKeyListener() { 500 public boolean onKey(View v, int keyCode, KeyEvent event) { 501 if (event.getAction() == KeyEvent.ACTION_DOWN) { 502 switch (keyCode) { 503 case KeyEvent.KEYCODE_DEL: { 504 long id = getListView().getSelectedItemId(); 505 if (id > 0) { 506 confirmDeleteThread(id, mQueryHandler); 507 } 508 return true; 509 } 510 } 511 } 512 return false; 513 } 514 }; 515 516 public static class DeleteThreadListener implements OnClickListener { 517 private final long mThreadId; 518 private final AsyncQueryHandler mHandler; 519 private final Context mContext; 520 private boolean mDeleteLockedMessages; 521 522 public DeleteThreadListener(long threadId, AsyncQueryHandler handler, Context context) { 523 mThreadId = threadId; 524 mHandler = handler; 525 mContext = context; 526 } 527 528 public void setDeleteLockedMessage(boolean deleteLockedMessages) { 529 mDeleteLockedMessages = deleteLockedMessages; 530 } 531 532 public void onClick(DialogInterface dialog, final int whichButton) { 533 MessageUtils.handleReadReport(mContext, mThreadId, 534 PduHeaders.READ_STATUS__DELETED_WITHOUT_BEING_READ, new Runnable() { 535 public void run() { 536 int token = DELETE_CONVERSATION_TOKEN; 537 if (mThreadId == -1) { 538 Conversation.startDeleteAll(mHandler, token, mDeleteLockedMessages); 539 DraftCache.getInstance().refresh(); 540 } else { 541 Conversation.startDelete(mHandler, token, mDeleteLockedMessages, 542 mThreadId); 543 DraftCache.getInstance().setDraftState(mThreadId, false); 544 } 545 } 546 }); 547 } 548 } 549 550 private final class ThreadListQueryHandler extends AsyncQueryHandler { 551 public ThreadListQueryHandler(ContentResolver contentResolver) { 552 super(contentResolver); 553 } 554 555 @Override 556 protected void onQueryComplete(int token, Object cookie, Cursor cursor) { 557 switch (token) { 558 case THREAD_LIST_QUERY_TOKEN: 559 mListAdapter.changeCursor(cursor); 560 setTitle(mTitle); 561 setProgressBarIndeterminateVisibility(false); 562 563 if (mNeedToMarkAsSeen) { 564 mNeedToMarkAsSeen = false; 565 Conversation.markAllConversationsAsSeen(getApplicationContext()); 566 567 // Delete any obsolete threads. Obsolete threads are threads that aren't 568 // referenced by at least one message in the pdu or sms tables. 569 Conversation.asyncDeleteObsoleteThreads(mQueryHandler, 570 DELETE_OBSOLETE_THREADS_TOKEN); 571 } 572 break; 573 574 case HAVE_LOCKED_MESSAGES_TOKEN: 575 long threadId = (Long)cookie; 576 confirmDeleteThreadDialog(new DeleteThreadListener(threadId, mQueryHandler, 577 ConversationList.this), threadId == -1, 578 cursor != null && cursor.getCount() > 0, 579 ConversationList.this); 580 break; 581 582 default: 583 Log.e(TAG, "onQueryComplete called with unknown token " + token); 584 } 585 } 586 587 @Override 588 protected void onDeleteComplete(int token, Object cookie, int result) { 589 switch (token) { 590 case DELETE_CONVERSATION_TOKEN: 591 // Make sure the conversation cache reflects the threads in the DB. 592 Conversation.init(ConversationList.this); 593 594 // Update the notification for new messages since they 595 // may be deleted. 596 MessagingNotification.nonBlockingUpdateNewMessageIndicator(ConversationList.this, 597 false, false); 598 // Update the notification for failed messages since they 599 // may be deleted. 600 MessagingNotification.updateSendFailedNotification(ConversationList.this); 601 602 // Make sure the list reflects the delete 603 startAsyncQuery(); 604 break; 605 606 case DELETE_OBSOLETE_THREADS_TOKEN: 607 // Nothing to do here. 608 break; 609 } 610 } 611 } 612 613 private void log(String format, Object... args) { 614 String s = String.format(format, args); 615 Log.d(TAG, "[" + Thread.currentThread().getId() + "] " + s); 616 } 617 } 618