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