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