ConversationList.java revision 6be18bedb5b87dbbcdb54f37d5a0945bd0f71377
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 18package com.android.mms.ui; 19 20import com.android.mms.LogTag; 21import com.android.mms.R; 22import com.android.mms.data.Contact; 23import com.android.mms.data.ContactList; 24import com.android.mms.data.Conversation; 25import com.android.mms.transaction.MessagingNotification; 26import com.android.mms.transaction.SmsRejectedReceiver; 27import com.android.mms.util.DraftCache; 28import com.android.mms.util.Recycler; 29import com.android.mms.mms.pdu.PduHeaders; 30import com.android.mms.mms.util.SqliteWrapper; 31 32import android.app.AlertDialog; 33import android.app.ListActivity; 34import android.content.AsyncQueryHandler; 35import android.content.ContentResolver; 36import android.content.Context; 37import android.content.DialogInterface; 38import android.content.Intent; 39import android.content.SharedPreferences; 40import android.content.DialogInterface.OnClickListener; 41import android.content.res.Configuration; 42import android.database.Cursor; 43import android.database.sqlite.SQLiteException; 44import android.database.sqlite.SQLiteFullException; 45import android.os.Bundle; 46import android.os.Handler; 47import android.preference.PreferenceManager; 48import android.provider.ContactsContract; 49import android.provider.ContactsContract.Contacts; 50import com.android.mms.telephony.TelephonyProvider.Mms; 51import android.util.Log; 52import android.view.ContextMenu; 53import android.view.KeyEvent; 54import android.view.LayoutInflater; 55import android.view.Menu; 56import android.view.MenuItem; 57import android.view.View; 58import android.view.Window; 59import android.view.ContextMenu.ContextMenuInfo; 60import android.view.View.OnCreateContextMenuListener; 61import android.view.View.OnKeyListener; 62import android.widget.AdapterView; 63import android.widget.CheckBox; 64import android.widget.ListView; 65import android.widget.TextView; 66 67/** 68 * This activity provides a list view of existing conversations. 69 */ 70public 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 80 // IDs of the main menu items. 81 public static final int MENU_COMPOSE_NEW = 0; 82 public static final int MENU_SEARCH = 1; 83 public static final int MENU_DELETE_ALL = 3; 84 public static final int MENU_PREFERENCES = 4; 85 86 // IDs of the context menu items for the list of conversations. 87 public static final int MENU_DELETE = 0; 88 public static final int MENU_VIEW = 1; 89 public static final int MENU_VIEW_CONTACT = 2; 90 public static final int MENU_ADD_TO_CONTACTS = 3; 91 92 private ThreadListQueryHandler mQueryHandler; 93 private ConversationListAdapter mListAdapter; 94 private CharSequence mTitle; 95 private SharedPreferences mPrefs; 96 private Handler mHandler; 97 98 static private final String CHECKED_MESSAGE_LIMITS = "checked_message_limits"; 99 100 @Override 101 protected void onCreate(Bundle savedInstanceState) { 102 super.onCreate(savedInstanceState); 103 104 requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); 105 setContentView(R.layout.conversation_list_screen); 106 107 mQueryHandler = new ThreadListQueryHandler(getContentResolver()); 108 109 ListView listView = getListView(); 110 LayoutInflater inflater = LayoutInflater.from(this); 111 ConversationHeaderView headerView = (ConversationHeaderView) 112 inflater.inflate(R.layout.conversation_header, listView, false); 113 headerView.bind(getString(R.string.new_message), 114 getString(R.string.create_new_message)); 115 listView.addHeaderView(headerView, null, true); 116 117 listView.setOnCreateContextMenuListener(mConvListOnCreateContextMenuListener); 118 listView.setOnKeyListener(mThreadListKeyListener); 119 120 initListAdapter(); 121 122 mTitle = getString(R.string.app_label); 123 124 mHandler = new Handler(); 125 mPrefs = PreferenceManager.getDefaultSharedPreferences(this); 126 boolean checkedMessageLimits = mPrefs.getBoolean(CHECKED_MESSAGE_LIMITS, false); 127 if (DEBUG) Log.v(TAG, "checkedMessageLimits: " + checkedMessageLimits); 128 if (!checkedMessageLimits || DEBUG) { 129 runOneTimeStorageLimitCheckForLegacyMessages(); 130 } 131 } 132 133 private final ConversationListAdapter.OnContentChangedListener mContentChangedListener = 134 new ConversationListAdapter.OnContentChangedListener() { 135 public void onContentChanged(ConversationListAdapter adapter) { 136 startAsyncQuery(); 137 } 138 }; 139 140 private void initListAdapter() { 141 mListAdapter = new ConversationListAdapter(this, null); 142 mListAdapter.setOnContentChangedListener(mContentChangedListener); 143 setListAdapter(mListAdapter); 144 getListView().setRecyclerListener(mListAdapter); 145 } 146 147 /** 148 * Checks to see if the number of MMS and SMS messages are under the limits for the 149 * recycler. If so, it will automatically turn on the recycler setting. If not, it 150 * will prompt the user with a message and point them to the setting to manually 151 * turn on the recycler. 152 */ 153 public synchronized void runOneTimeStorageLimitCheckForLegacyMessages() { 154 if (Recycler.isAutoDeleteEnabled(this)) { 155 if (DEBUG) Log.v(TAG, "recycler is already turned on"); 156 // The recycler is already turned on. We don't need to check anything or warn 157 // the user, just remember that we've made the check. 158 markCheckedMessageLimit(); 159 return; 160 } 161 new Thread(new Runnable() { 162 public void run() { 163 if (Recycler.checkForThreadsOverLimit(ConversationList.this)) { 164 if (DEBUG) Log.v(TAG, "checkForThreadsOverLimit TRUE"); 165 // Dang, one or more of the threads are over the limit. Show an activity 166 // that'll encourage the user to manually turn on the setting. Delay showing 167 // this activity until a couple of seconds after the conversation list appears. 168 mHandler.postDelayed(new Runnable() { 169 public void run() { 170 Intent intent = new Intent(ConversationList.this, 171 WarnOfStorageLimitsActivity.class); 172 startActivity(intent); 173 } 174 }, 2000); 175 } else { 176 if (DEBUG) Log.v(TAG, "checkForThreadsOverLimit silently turning on recycler"); 177 // No threads were over the limit. Turn on the recycler by default. 178 runOnUiThread(new Runnable() { 179 public void run() { 180 SharedPreferences.Editor editor = mPrefs.edit(); 181 editor.putBoolean(MessagingPreferenceActivity.AUTO_DELETE, true); 182 editor.commit(); 183 } 184 }); 185 } 186 // Remember that we don't have to do the check anymore when starting MMS. 187 runOnUiThread(new Runnable() { 188 public void run() { 189 markCheckedMessageLimit(); 190 } 191 }); 192 } 193 }).start(); 194 } 195 196 /** 197 * Mark in preferences that we've checked the user's message limits. Once checked, we'll 198 * never check them again, unless the user wipe-data or resets the device. 199 */ 200 private void markCheckedMessageLimit() { 201 if (DEBUG) Log.v(TAG, "markCheckedMessageLimit"); 202 SharedPreferences.Editor editor = mPrefs.edit(); 203 editor.putBoolean(CHECKED_MESSAGE_LIMITS, true); 204 editor.commit(); 205 } 206 207 @Override 208 protected void onNewIntent(Intent intent) { 209 // Handle intents that occur after the activity has already been created. 210 privateOnStart(); 211 } 212 213 @Override 214 protected void onStart() { 215 super.onStart(); 216 217 MessagingNotification.cancelNotification(getApplicationContext(), 218 SmsRejectedReceiver.SMS_REJECTED_NOTIFICATION_ID); 219 220 try { 221 Conversation.cleanup(this); 222 } catch (SQLiteFullException e) { 223 Log.e(TAG, "ConversationList.onStart disk probably full - finishing: " + e); 224 finish(); 225 return; 226 } 227 228 DraftCache.getInstance().addOnDraftChangedListener(this); 229 230 // We used to refresh the DraftCache here, but 231 // refreshing the DraftCache each time we go to the ConversationList seems overly 232 // aggressive. We already update the DraftCache when leaving CMA in onStop() and 233 // onNewIntent(), and when we delete threads or delete all in CMA or this activity. 234 // I hope we don't have to do such a heavy operation each time we enter here. 235 236 privateOnStart(); 237 238 // we invalidate the contact cache here because we want to get updated presence 239 // and any contact changes. We don't invalidate the cache by observing presence and contact 240 // changes (since that's too untargeted), so as a tradeoff we do it here. 241 // If we're in the middle of the app initialization where we're loading the conversation 242 // threads, don't invalidate the cache because we're in the process of building it. 243 // TODO: think of a better way to invalidate cache more surgically or based on actual 244 // TODO: changes we care about 245 if (!Conversation.loadingThreads()) { 246 Contact.invalidateCache(); 247 } 248 } 249 250 protected void privateOnStart() { 251 startAsyncQuery(); 252 } 253 254 255 @Override 256 protected void onStop() { 257 super.onStop(); 258 259 DraftCache.getInstance().removeOnDraftChangedListener(this); 260 mListAdapter.changeCursor(null); 261 } 262 263 public void onDraftChanged(final long threadId, final boolean hasDraft) { 264 // Run notifyDataSetChanged() on the main thread. 265 mQueryHandler.post(new Runnable() { 266 public void run() { 267 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 268 log("onDraftChanged: threadId=" + threadId + ", hasDraft=" + hasDraft); 269 } 270 mListAdapter.notifyDataSetChanged(); 271 } 272 }); 273 } 274 275 private void startAsyncQuery() { 276 try { 277 setTitle(getString(R.string.refreshing)); 278 setProgressBarIndeterminateVisibility(true); 279 280 Conversation.startQueryForAll(mQueryHandler, THREAD_LIST_QUERY_TOKEN); 281 } catch (SQLiteException e) { 282 SqliteWrapper.checkSQLiteException(this, e); 283 } 284 } 285 286 @Override 287 public boolean onPrepareOptionsMenu(Menu menu) { 288 menu.clear(); 289 290 menu.add(0, MENU_COMPOSE_NEW, 0, R.string.menu_compose_new).setIcon( 291 com.android.internal.R.drawable.ic_menu_compose); 292 293 if (mListAdapter.getCount() > 0) { 294 menu.add(0, MENU_DELETE_ALL, 0, R.string.menu_delete_all).setIcon( 295 android.R.drawable.ic_menu_delete); 296 } 297 298 menu.add(0, MENU_SEARCH, 0, android.R.string.search_go). 299 setIcon(android.R.drawable.ic_menu_search). 300 setAlphabeticShortcut(android.app.SearchManager.MENU_KEY); 301 302 menu.add(0, MENU_PREFERENCES, 0, R.string.menu_preferences).setIcon( 303 android.R.drawable.ic_menu_preferences); 304 305 return true; 306 } 307 308 @Override 309 public boolean onSearchRequested() { 310 startSearch(null, false, null /*appData*/, false); 311 return true; 312 } 313 314 @Override 315 public boolean onOptionsItemSelected(MenuItem item) { 316 switch(item.getItemId()) { 317 case MENU_COMPOSE_NEW: 318 createNewMessage(); 319 break; 320 case MENU_SEARCH: 321 onSearchRequested(); 322 break; 323 case MENU_DELETE_ALL: 324 // The invalid threadId of -1 means all threads here. 325 confirmDeleteThread(-1L, mQueryHandler); 326 break; 327 case MENU_PREFERENCES: { 328 Intent intent = new Intent(this, MessagingPreferenceActivity.class); 329 startActivityIfNeeded(intent, -1); 330 break; 331 } 332 default: 333 return true; 334 } 335 return false; 336 } 337 338 @Override 339 protected void onListItemClick(ListView l, View v, int position, long id) { 340 if (LOCAL_LOGV) { 341 Log.v(TAG, "onListItemClick: position=" + position + ", id=" + id); 342 } 343 344 if (position == 0) { 345 createNewMessage(); 346 } else if (v instanceof ConversationHeaderView) { 347 ConversationHeaderView headerView = (ConversationHeaderView) v; 348 ConversationHeader ch = headerView.getConversationHeader(); 349 openThread(ch.getThreadId()); 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.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.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 } 557 } 558 559 private final class ThreadListQueryHandler extends AsyncQueryHandler { 560 public ThreadListQueryHandler(ContentResolver contentResolver) { 561 super(contentResolver); 562 } 563 564 @Override 565 protected void onQueryComplete(int token, Object cookie, Cursor cursor) { 566 switch (token) { 567 case THREAD_LIST_QUERY_TOKEN: 568 mListAdapter.changeCursor(cursor); 569 setTitle(mTitle); 570 setProgressBarIndeterminateVisibility(false); 571 break; 572 573 case HAVE_LOCKED_MESSAGES_TOKEN: 574 long threadId = (Long)cookie; 575 confirmDeleteThreadDialog(new DeleteThreadListener(threadId, mQueryHandler, 576 ConversationList.this), threadId == -1, 577 cursor != null && cursor.getCount() > 0, 578 ConversationList.this); 579 break; 580 581 default: 582 Log.e(TAG, "onQueryComplete called with unknown token " + token); 583 } 584 } 585 586 @Override 587 protected void onDeleteComplete(int token, Object cookie, int result) { 588 switch (token) { 589 case DELETE_CONVERSATION_TOKEN: 590 // Make sure the conversation cache reflects the threads in the DB. 591 Conversation.init(ConversationList.this); 592 593 // Update the notification for new messages since they 594 // may be deleted. 595 MessagingNotification.updateNewMessageIndicator(ConversationList.this); 596 // Update the notification for failed messages since they 597 // may be deleted. 598 MessagingNotification.updateSendFailedNotification(ConversationList.this); 599 600 // Make sure the list reflects the delete 601 startAsyncQuery(); 602 603 onContentChanged(); 604 break; 605 } 606 } 607 } 608 609 private void log(String format, Object... args) { 610 String s = String.format(format, args); 611 Log.d(TAG, "[" + Thread.currentThread().getId() + "] " + s); 612 } 613} 614