ConversationList.java revision e82e62f94d4ba4ac139faf055f40fbbc2b99b551
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.R; 21import com.android.mms.transaction.MessagingNotification; 22import com.android.mms.util.ContactNameCache; 23import com.google.android.mms.pdu.PduHeaders; 24import com.google.android.mms.util.SqliteWrapper; 25 26import android.app.AlertDialog; 27import android.app.ListActivity; 28import android.content.AsyncQueryHandler; 29import android.content.ContentResolver; 30import android.content.ContentUris; 31import android.content.Context; 32import android.content.DialogInterface; 33import android.content.Intent; 34import android.content.DialogInterface.OnClickListener; 35import android.content.res.Configuration; 36import android.database.ContentObserver; 37import android.database.Cursor; 38import android.database.sqlite.SQLiteException; 39import android.net.Uri; 40import android.os.Bundle; 41import android.os.Handler; 42import android.provider.Contacts; 43import android.provider.Telephony.Mms; 44import android.provider.Telephony.Sms; 45import android.provider.Telephony.Threads; 46import android.provider.Telephony.Sms.Conversations; 47import android.text.TextUtils; 48import android.util.Config; 49import android.util.Log; 50import android.view.ContextMenu; 51import android.view.KeyEvent; 52import android.view.LayoutInflater; 53import android.view.Menu; 54import android.view.MenuItem; 55import android.view.View; 56import android.view.Window; 57import android.view.ContextMenu.ContextMenuInfo; 58import android.view.View.OnCreateContextMenuListener; 59import android.view.View.OnKeyListener; 60import android.widget.AdapterView; 61import android.widget.ListView; 62 63import java.util.concurrent.ConcurrentHashMap; 64 65/** 66 * This activity provides a list view of existing conversations. 67 */ 68public class ConversationList extends ListActivity { 69 private static final String TAG = "ConversationList"; 70 private static final boolean DEBUG = false; 71 private static final boolean LOCAL_LOGV = Config.LOGV && DEBUG; 72 73 private static final int THREAD_LIST_QUERY_TOKEN = 1701; 74 private static final int SEARCH_TOKEN = 1702; 75 76 private static final int DELETE_CONVERSATION_TOKEN = 1801; 77 78 // IDs of the main menu items. 79 private static final int MENU_COMPOSE_NEW = 0; 80 private static final int MENU_SEARCH = 1; 81 private static final int MENU_UNDELIVERED_MESSAGES = 2; 82 private static final int MENU_DELETE_ALL = 3; 83 private static final int MENU_PREFERENCES = 4; 84 private static final int MENU_VIEW_BROADCAST_THREADS = 5; 85 86 // IDs of the context menu items for the list of conversations. 87 public static final int MENU_DELETE = 0; 88 private static final int MENU_VIEW = 1; 89 90 private Cursor mCursor; 91 private final Object mCursorLock = new Object(); 92 private ThreadListQueryHandler mQueryHandler; 93 private ConversationListAdapter mListAdapter; 94 private CharSequence mTitle; 95 private Uri mBaseUri; 96 private String mQuery; 97 private String[] mProjection; 98 private int mQueryToken; 99 private String mFilter; 100 private boolean mSearchFlag; 101 private CachingNameStore mCachingNameStore; 102 103 /** 104 * An interface that's passed down to ListAdapters to use 105 * for looking up the names of contact numbers. 106 */ 107 public static interface CachingNameStore { 108 // Returns comma-separated list of contact's display names 109 // given a semicolon-delimited string of canonical phone 110 // numbers. 111 public String getContactNames(String addresses); 112 } 113 114 @Override 115 protected void onCreate(Bundle savedInstanceState) { 116 super.onCreate(savedInstanceState); 117 118 requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); 119 setContentView(R.layout.conversation_list_screen); 120 121 mQueryHandler = new ThreadListQueryHandler(getContentResolver()); 122 123 ListView listView = getListView(); 124 LayoutInflater inflater = LayoutInflater.from(this); 125 ConversationHeaderView headerView = (ConversationHeaderView) 126 inflater.inflate(R.layout.conversation_header, listView, false); 127 headerView.bind(getString(R.string.new_message), 128 getString(R.string.create_new_message)); 129 listView.addHeaderView(headerView, null, true); 130 131 listView.setOnCreateContextMenuListener(mConvListOnCreateContextMenuListener); 132 listView.setOnKeyListener(mThreadListKeyListener); 133 134 mCachingNameStore = new CachingNameStoreImpl(this); 135 136 if (savedInstanceState != null) { 137 mBaseUri = (Uri) savedInstanceState.getParcelable("base_uri"); 138 mSearchFlag = savedInstanceState.getBoolean("search_flag"); 139 mFilter = savedInstanceState.getString("filter"); 140 mQueryToken = savedInstanceState.getInt("query_token"); 141 } 142 143 handleCreationIntent(getIntent()); 144 } 145 146 @Override 147 protected void onNewIntent(Intent intent) { 148 // Handle intents that occur after the activity has already been created. 149 handleCreationIntent(intent); 150 } 151 152 protected void handleCreationIntent(Intent intent) { 153 // Handle intents that occur upon creation of the activity. 154 initNormalQueryArgs(); 155 } 156 157 @Override 158 protected void onResume() { 159 super.onResume(); 160 161 if (mListAdapter != null) { 162 mListAdapter.initDraftCache(); // we might have a draft now 163 mListAdapter.registerObservers(); 164 } 165 166 getContentResolver().delete(Threads.OBSOLETE_THREADS_URI, null, null); 167 168 synchronized (mCursorLock) { 169 if (mCursor == null) { 170 startAsyncQuery(); 171 } else { 172 SqliteWrapper.requery(this, mCursor); 173 } 174 } 175 } 176 177 @Override 178 protected void onSaveInstanceState(Bundle outState) { 179 super.onSaveInstanceState(outState); 180 181 outState.putParcelable("base_uri", mBaseUri); 182 outState.putInt("query_token", mQueryToken); 183 outState.putBoolean("search_flag", mSearchFlag); 184 if (mSearchFlag) { 185 outState.putString("filter", mFilter); 186 } 187 } 188 189 @Override 190 protected void onPause() { 191 super.onPause(); 192 193 if (mListAdapter != null) { 194 mListAdapter.unregisterObservers(); 195 } 196 } 197 198 @Override 199 protected void onStop() { 200 super.onStop(); 201 202 synchronized (mCursorLock) { 203 if (mCursor != null) { 204 if (mListAdapter != null) { 205 mListAdapter.changeCursor(null); 206 } 207 mCursor.close(); 208 mCursor = null; 209 } 210 } 211 } 212 213 @Override 214 protected void onDestroy() { 215 super.onDestroy(); 216 217 if (mCursor != null) { 218 mCursor.close(); 219 } 220 } 221 222 private void initNormalQueryArgs() { 223 Uri.Builder builder = Threads.CONTENT_URI.buildUpon(); 224 builder.appendQueryParameter("simple", "true"); 225 mBaseUri = builder.build(); 226 mQuery = null; 227 mProjection = ConversationListAdapter.PROJECTION; 228 mQueryToken = THREAD_LIST_QUERY_TOKEN; 229 mTitle = getString(R.string.app_label); 230 } 231 232 private void startAsyncQuery() { 233 try { 234 synchronized (mCursorLock) { 235 setTitle(getString(R.string.refreshing)); 236 setProgressBarIndeterminateVisibility(true); 237 238 mQueryHandler.cancelOperation(THREAD_LIST_QUERY_TOKEN); 239 // FIXME: I have to pass the mQueryToken as cookie since the 240 // AsyncQueryHandler.onQueryComplete() method doesn't provide 241 // the same token as what I input here. 242 mQueryHandler.startQuery(0, mQueryToken, mBaseUri, mProjection, mQuery, null, 243 Conversations.DEFAULT_SORT_ORDER); 244 } 245 } catch (SQLiteException e) { 246 SqliteWrapper.checkSQLiteException(this, e); 247 } 248 } 249 250 @Override 251 public boolean onPrepareOptionsMenu(Menu menu) { 252 menu.clear(); 253 254 menu.add(0, MENU_COMPOSE_NEW, 0, R.string.menu_compose_new).setIcon( 255 R.drawable.ic_menu_compose); 256 // Removed search as part of b/1205708 257 //menu.add(0, MENU_SEARCH, 0, R.string.menu_search).setIcon( 258 // R.drawable.ic_menu_search).setAlphabeticShortcut(SearchManager.MENU_KEY); 259 if ((mCursor != null) && (mCursor.getCount() > 0) && !mSearchFlag) { 260 menu.add(0, MENU_DELETE_ALL, 0, R.string.menu_delete_all).setIcon( 261 android.R.drawable.ic_menu_delete); 262 } 263 264 // Check undelivered messages 265 Cursor mmsCursor = SqliteWrapper.query(this, getContentResolver(), 266 Mms.Outbox.CONTENT_URI, null, null, null, null); 267 Cursor smsCursor = SqliteWrapper.query(this, getContentResolver(), 268 Uri.withAppendedPath(Sms.CONTENT_URI, "undelivered"), 269 null, null, null, null); 270 if (((mmsCursor != null) && (mmsCursor.getCount() > 0)) || 271 ((smsCursor != null) && (smsCursor.getCount() > 0))) { 272 menu.add(0, MENU_UNDELIVERED_MESSAGES, 0, R.string.menu_undelivered_messages).setIcon( 273 R.drawable.ic_menu_undelivered); 274 } 275 if (mmsCursor != null) { 276 mmsCursor.close(); 277 } 278 if (smsCursor != null) { 279 smsCursor.close(); 280 } 281 282 menu.add(0, MENU_PREFERENCES, 0, R.string.menu_preferences).setIcon( 283 android.R.drawable.ic_menu_preferences); 284 285 return true; 286 } 287 288 @Override 289 public boolean onOptionsItemSelected(MenuItem item) { 290 switch(item.getItemId()) { 291 case MENU_COMPOSE_NEW: 292 createNewMessage(); 293 break; 294 case MENU_SEARCH: 295 onSearchRequested(); 296 break; 297 case MENU_DELETE_ALL: 298 confirmDeleteDialog(new DeleteThreadListener(-1L), true); 299 break; 300 case MENU_UNDELIVERED_MESSAGES: { 301 Intent intent = new Intent(this, UndeliveredMessagesActivity.class); 302 startActivityIfNeeded(intent, -1); 303 break; 304 } 305 case MENU_PREFERENCES: { 306 Intent intent = new Intent(this, MessagingPreferenceActivity.class); 307 startActivityIfNeeded(intent, -1); 308 break; 309 } 310 default: 311 return true; 312 } 313 return false; 314 } 315 316 @Override 317 protected void onListItemClick(ListView l, View v, int position, long id) { 318 if (LOCAL_LOGV) { 319 Log.v(TAG, "onListItemClick: position=" + position + ", id=" + id); 320 } 321 322 if (position == 0) { 323 createNewMessage(); 324 } else if (v instanceof ConversationHeaderView) { 325 ConversationHeaderView headerView = (ConversationHeaderView) v; 326 ConversationHeader ch = headerView.getConversationHeader(); 327 328 // TODO: The 'from' view of the ConversationHeader was 329 // repurposed to be the cached display value, rather than 330 // the old raw value, which openThread() wanted. But it 331 // turns out openThread() doesn't need it: 332 // ComposeMessageActivity will load it. That's not ideal, 333 // though, as it's an SQLite query. So fix this later to 334 // save some latency on starting ComposeMessageActivity. 335 String somethingDelimitedAddresses = null; 336 openThread(ch.getThreadId(), somethingDelimitedAddresses); 337 } 338 } 339 340 private void createNewMessage() { 341 Intent intent = new Intent(this, ComposeMessageActivity.class); 342 startActivity(intent); 343 } 344 345 private void openThread(long threadId, String address) { 346 Intent intent = new Intent(this, ComposeMessageActivity.class); 347 intent.putExtra("thread_id", threadId); 348 if (!TextUtils.isEmpty(address)) { 349 intent.putExtra("address", address); 350 } 351 startActivity(intent); 352 } 353 354 private final OnCreateContextMenuListener mConvListOnCreateContextMenuListener = 355 new OnCreateContextMenuListener() { 356 public void onCreateContextMenu(ContextMenu menu, View v, 357 ContextMenuInfo menuInfo) { 358 if ((mCursor != null) && (mCursor.getCount() > 0) && !mSearchFlag) { 359 String address = MessageUtils.getRecipientsByIds( 360 ConversationList.this, 361 mCursor.getString(ConversationListAdapter.COLUMN_RECIPIENTS_IDS)); 362 // The Recipient IDs column is separated with semicolons for some reason. 363 // We should fix this in the content provider rework. 364 CharSequence from = (ContactNameCache.getInstance().getContactName( 365 ConversationList.this, address)).replace(';', ','); 366 menu.setHeaderTitle(from); 367 368 AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo; 369 if (info.position > 0) { 370 menu.add(0, MENU_VIEW, 0, R.string.menu_view); 371 menu.add(0, MENU_DELETE, 0, R.string.menu_delete); 372 } 373 } 374 } 375 }; 376 377 @Override 378 public boolean onContextItemSelected(MenuItem item) { 379 long threadId = mCursor.getLong(ConversationListAdapter.COLUMN_ID); 380 switch (item.getItemId()) { 381 case MENU_DELETE: { 382 DeleteThreadListener l = new DeleteThreadListener(threadId); 383 confirmDeleteDialog(l, false); 384 break; 385 } 386 case MENU_VIEW: { 387 String address = null; 388 if (mListAdapter.isSimpleMode()) { 389 address = MessageUtils.getRecipientsByIds( 390 this, 391 mCursor.getString(ConversationListAdapter.COLUMN_RECIPIENTS_IDS)); 392 } else { 393 String msgType = mCursor.getString(ConversationListAdapter.COLUMN_MESSAGE_TYPE); 394 if (msgType.equals("sms")) { 395 address = mCursor.getString(ConversationListAdapter.COLUMN_SMS_ADDRESS); 396 } else { 397 address = MessageUtils.getAddressByThreadId(this, threadId); 398 } 399 } 400 openThread(threadId, address); 401 break; 402 } 403 default: 404 break; 405 } 406 407 return super.onContextItemSelected(item); 408 } 409 410 public void onConfigurationChanged(Configuration newConfig) { 411 // We override this method to avoid restarting the entire 412 // activity when the keyboard is opened (declared in 413 // AndroidManifest.xml). Because the only translatable text 414 // in this activity is "New Message", which has the full width 415 // of phone to work with, localization shouldn't be a problem: 416 // no abbreviated alternate words should be needed even in 417 // 'wide' languages like German or Russian. 418 419 super.onConfigurationChanged(newConfig); 420 if (DEBUG) Log.v(TAG, "onConfigurationChanged: " + newConfig); 421 } 422 423 private void confirmDeleteDialog(OnClickListener listener, boolean deleteAll) { 424 AlertDialog.Builder builder = new AlertDialog.Builder(this); 425 builder.setTitle(R.string.confirm_dialog_title); 426 builder.setIcon(android.R.drawable.ic_dialog_alert); 427 builder.setCancelable(true); 428 builder.setPositiveButton(R.string.yes, listener); 429 builder.setNegativeButton(R.string.no, null); 430 builder.setMessage(deleteAll 431 ? R.string.confirm_delete_all_conversations 432 : R.string.confirm_delete_conversation); 433 434 builder.show(); 435 } 436 437 private final OnKeyListener mThreadListKeyListener = new OnKeyListener() { 438 public boolean onKey(View v, int keyCode, KeyEvent event) { 439 if (event.getAction() == KeyEvent.ACTION_DOWN) { 440 switch (keyCode) { 441 case KeyEvent.KEYCODE_DEL: { 442 long id = getListView().getSelectedItemId(); 443 if (id > 0) { 444 DeleteThreadListener l = new DeleteThreadListener( 445 id); 446 confirmDeleteDialog(l, false); 447 } 448 return true; 449 } 450 case KeyEvent.KEYCODE_BACK: { 451 if (mSearchFlag) { 452 mSearchFlag = false; 453 initNormalQueryArgs(); 454 startAsyncQuery(); 455 456 return true; 457 } 458 break; 459 } 460 } 461 } 462 return false; 463 } 464 }; 465 466 private class DeleteThreadListener implements OnClickListener { 467 private final Uri mDeleteUri; 468 private final long mThreadId; 469 470 public DeleteThreadListener(long threadId) { 471 mThreadId = threadId; 472 473 if (threadId != -1) { 474 mDeleteUri = ContentUris.withAppendedId( 475 Threads.CONTENT_URI, threadId); 476 } else { 477 mDeleteUri = Threads.CONTENT_URI; 478 } 479 } 480 481 public void onClick(DialogInterface dialog, int whichButton) { 482 MessageUtils.handleReadReport(ConversationList.this, mThreadId, 483 PduHeaders.READ_STATUS__DELETED_WITHOUT_BEING_READ, new Runnable() { 484 public void run() { 485 mQueryHandler.startDelete(DELETE_CONVERSATION_TOKEN, 486 null, mDeleteUri, null, null); 487 } 488 }); 489 } 490 } 491 492 private final class ThreadListQueryHandler extends AsyncQueryHandler { 493 public ThreadListQueryHandler(ContentResolver contentResolver) { 494 super(contentResolver); 495 } 496 497 @Override 498 protected void onQueryComplete(int token, Object cookie, Cursor cursor) { 499 synchronized (mCursorLock) { 500 if (mCursor != null) { 501 mCursor.close(); 502 } 503 504 if (cursor != null) { 505 mCursor = cursor; 506 switch ((Integer) cookie) { 507 case THREAD_LIST_QUERY_TOKEN: 508 mListAdapter = new ConversationListAdapter( 509 ConversationList.this, 510 cursor, 511 true, // simple (non-search) 512 mListAdapter, 513 mCachingNameStore); 514 break; 515 case SEARCH_TOKEN: 516 mListAdapter = new ConversationListAdapter( 517 ConversationList.this, 518 cursor, 519 false, // non-simple (search) 520 mListAdapter, 521 mCachingNameStore); 522 break; 523 default: 524 Log.e(TAG, "Bad query token: " + token); 525 break; 526 } 527 528 ConversationList.this.setListAdapter(mListAdapter); 529 } else { 530 Log.e(TAG, "Cannot init the cursor for the thread list."); 531 finish(); 532 } 533 534 setTitle(mTitle); 535 setProgressBarIndeterminateVisibility(false); 536 } 537 } 538 539 @Override 540 protected void onDeleteComplete(int token, Object cookie, int result) { 541 switch (token) { 542 case DELETE_CONVERSATION_TOKEN: 543 // Update the notification for new messages since they 544 // may be deleted. 545 MessagingNotification.updateNewMessageIndicator(ConversationList.this); 546 // Update the notification for failed messages since they 547 // may be deleted. 548 MessagingNotification.updateSendFailedNotification(ConversationList.this); 549 break; 550 } 551 } 552 } 553 554 /** 555 * This implements the CachingNameStore interface defined above 556 * which we pass down to each newly-created ListAdapater, so they 557 * share a common, reused cached between activity resumes, not 558 * having to hit the Contacts providers all the time. 559 */ 560 private static final class CachingNameStoreImpl implements CachingNameStore { 561 private static final String TAG = "ConversationList/CachingNameStoreImpl"; 562 private final ConcurrentHashMap<String, String> mCachedNames = 563 new ConcurrentHashMap<String, String>(); 564 private final ContentObserver mPhonesObserver; 565 private final Context mContext; 566 567 public CachingNameStoreImpl(Context ctxt) { 568 mContext = ctxt; 569 mPhonesObserver = new ContentObserver(new Handler()) { 570 @Override 571 public void onChange(boolean selfUpdate) { 572 mCachedNames.clear(); 573 } 574 }; 575 ctxt.getContentResolver().registerContentObserver( 576 Contacts.Phones.CONTENT_URI, 577 true, mPhonesObserver); 578 } 579 580 // Returns comma-separated list of contact's display names 581 // given a semicolon-delimited string of canonical phone 582 // numbers, getting data either from cache or via a blocking 583 // call to a provider. 584 public String getContactNames(String addresses) { 585 String value = mCachedNames.get(addresses); 586 if (value != null) { 587 return value; 588 } 589 String[] values = addresses.split(";"); 590 if (values.length < 2) { 591 if (DEBUG) Log.v(TAG, "Looking up name: " + addresses); 592 ContactNameCache cache = ContactNameCache.getInstance(); 593 value = (cache.getContactName(mContext, addresses)).replace(';', ','); 594 } else { 595 int length = 0; 596 for (int i = 0; i < values.length; ++i) { 597 values[i] = getContactNames(values[i]); 598 length += values[i].length() + 2; // 2 for ", " 599 } 600 StringBuilder sb = new StringBuilder(length); 601 sb.append(values[0]); 602 for (int i = 1; i < values.length; ++i) { 603 sb.append(", "); 604 sb.append(values[i]); 605 } 606 value = sb.toString(); 607 } 608 mCachedNames.put(addresses, value); 609 return value; 610 } 611 612 } 613} 614