Conversation.java revision e692b9dcf5742e7a3ef3a7e64b44bb9d08f05b57
1package com.android.mms.data; 2 3import java.util.HashSet; 4import java.util.Iterator; 5import java.util.Set; 6 7import android.content.AsyncQueryHandler; 8import android.content.ContentUris; 9import android.content.ContentValues; 10import android.content.Context; 11import android.database.Cursor; 12import android.net.Uri; 13import android.provider.Telephony.MmsSms; 14import android.provider.Telephony.Threads; 15import android.provider.Telephony.Sms.Conversations; 16import android.text.TextUtils; 17import android.util.Log; 18 19import com.android.mms.R; 20import com.android.mms.LogTag; 21import com.android.mms.transaction.MessagingNotification; 22import com.android.mms.ui.MessageUtils; 23import com.android.mms.util.DraftCache; 24 25/** 26 * An interface for finding information about conversations and/or creating new ones. 27 */ 28public class Conversation { 29 private static final String TAG = "Mms/conv"; 30 private static final boolean DEBUG = false; 31 32 private static final Uri sAllThreadsUri = 33 Threads.CONTENT_URI.buildUpon().appendQueryParameter("simple", "true").build(); 34 35 private static final String[] ALL_THREADS_PROJECTION = { 36 Threads._ID, Threads.DATE, Threads.MESSAGE_COUNT, Threads.RECIPIENT_IDS, 37 Threads.SNIPPET, Threads.SNIPPET_CHARSET, Threads.READ, Threads.ERROR, 38 Threads.HAS_ATTACHMENT 39 }; 40 private static final int ID = 0; 41 private static final int DATE = 1; 42 private static final int MESSAGE_COUNT = 2; 43 private static final int RECIPIENT_IDS = 3; 44 private static final int SNIPPET = 4; 45 private static final int SNIPPET_CS = 5; 46 private static final int READ = 6; 47 private static final int ERROR = 7; 48 private static final int HAS_ATTACHMENT = 8; 49 50 51 private final Context mContext; 52 53 // The thread ID of this conversation. Can be zero in the case of a 54 // new conversation where the recipient set is changing as the user 55 // types and we have not hit the database yet to create a thread. 56 private long mThreadId; 57 58 private ContactList mRecipients; // The current set of recipients. 59 private long mDate; // The last update time. 60 private int mMessageCount; // Number of messages. 61 private String mSnippet; // Text of the most recent message. 62 private boolean mHasUnreadMessages; // True if there are unread messages. 63 private boolean mHasAttachment; // True if any message has an attachment. 64 private boolean mHasError; // True if any message is in an error state. 65 66 private static ContentValues mReadContentValues; 67 private static boolean mLoadingThreads; 68 69 70 private Conversation(Context context) { 71 mContext = context; 72 mRecipients = new ContactList(); 73 mThreadId = 0; 74 } 75 76 private Conversation(Context context, long threadId, boolean allowQuery) { 77 mContext = context; 78 if (!loadFromThreadId(threadId, allowQuery)) { 79 mRecipients = new ContactList(); 80 mThreadId = 0; 81 } 82 } 83 84 private Conversation(Context context, Cursor cursor, boolean allowQuery) { 85 mContext = context; 86 fillFromCursor(context, this, cursor, allowQuery); 87 } 88 89 /** 90 * Create a new conversation with no recipients. {@link setRecipients} can 91 * be called as many times as you like; the conversation will not be 92 * created in the database until {@link ensureThreadId} is called. 93 */ 94 public static Conversation createNew(Context context) { 95 return new Conversation(context); 96 } 97 98 /** 99 * Find the conversation matching the provided thread ID. 100 */ 101 public static Conversation get(Context context, long threadId, boolean allowQuery) { 102 Conversation conv = Cache.get(threadId); 103 if (conv != null) 104 return conv; 105 106 conv = new Conversation(context, threadId, allowQuery); 107 try { 108 Cache.put(conv); 109 } catch (IllegalStateException e) { 110 LogTag.error("Tried to add duplicate Conversation to Cache"); 111 } 112 return conv; 113 } 114 115 /** 116 * Find the conversation matching the provided recipient set. 117 * When called with an empty recipient list, equivalent to {@link #createNew}. 118 */ 119 public static Conversation get(Context context, ContactList recipients, boolean allowQuery) { 120 // If there are no recipients in the list, make a new conversation. 121 if (recipients.size() < 1) { 122 return createNew(context); 123 } 124 125 Conversation conv = Cache.get(recipients); 126 if (conv != null) 127 return conv; 128 129 long threadId = getOrCreateThreadId(context, recipients); 130 conv = new Conversation(context, threadId, allowQuery); 131 conv.setRecipients(recipients); 132 133 try { 134 Cache.put(conv); 135 } catch (IllegalStateException e) { 136 LogTag.error("Tried to add duplicate Conversation to Cache"); 137 } 138 139 return conv; 140 } 141 142 /** 143 * Find the conversation matching in the specified Uri. Example 144 * forms: {@value content://mms-sms/conversations/3} or 145 * {@value sms:+12124797990}. 146 * When called with a null Uri, equivalent to {@link #createNew}. 147 */ 148 public static Conversation get(Context context, Uri uri, boolean allowQuery) { 149 if (uri == null) { 150 return createNew(context); 151 } 152 153 if (DEBUG) Log.v(TAG, "Conversation get URI: " + uri); 154 155 // Handle a conversation URI 156 if (uri.getPathSegments().size() >= 2) { 157 try { 158 long threadId = Long.parseLong(uri.getPathSegments().get(1)); 159 if (DEBUG) { 160 Log.v(TAG, "Conversation get threadId: " + threadId); 161 } 162 return get(context, threadId, allowQuery); 163 } catch (NumberFormatException exception) { 164 LogTag.error("Invalid URI: " + uri); 165 } 166 } 167 168 String recipient = uri.getSchemeSpecificPart(); 169 return get(context, ContactList.getByNumbers(recipient, 170 allowQuery /* don't block */, true /* replace number */), allowQuery); 171 } 172 173 /** 174 * Returns true if the recipient in the uri matches the recipient list in this 175 * conversation. 176 */ 177 public boolean sameRecipient(Uri uri) { 178 int size = mRecipients.size(); 179 if (size > 1) { 180 return false; 181 } 182 if (uri == null) { 183 return size == 0; 184 } 185 if (uri.getPathSegments().size() >= 2) { 186 return false; // it's a thread id for a conversation 187 } 188 String recipient = uri.getSchemeSpecificPart(); 189 ContactList incomingRecipient = ContactList.getByNumbers(recipient, 190 false /* don't block */, false /* don't replace number */); 191 return mRecipients.equals(incomingRecipient); 192 } 193 194 /** 195 * Returns a temporary Conversation (not representing one on disk) wrapping 196 * the contents of the provided cursor. The cursor should be the one 197 * returned to your AsyncQueryHandler passed in to {@link startQueryForAll}. 198 * The recipient list of this conversation can be empty if the results 199 * were not in cache. 200 */ 201 // TODO: check why can't load a cached Conversation object here. 202 public static Conversation from(Context context, Cursor cursor) { 203 return new Conversation(context, cursor, false); 204 } 205 206 private void buildReadContentValues() { 207 if (mReadContentValues == null) { 208 mReadContentValues = new ContentValues(1); 209 mReadContentValues.put("read", 1); 210 } 211 } 212 213 /** 214 * Marks all messages in this conversation as read and updates 215 * relevant notifications. This method returns immediately; 216 * work is dispatched to a background thread. 217 */ 218 public synchronized void markAsRead() { 219 // If we have no Uri to mark (as in the case of a conversation that 220 // has not yet made its way to disk), there's nothing to do. 221 final Uri threadUri = getUri(); 222 223 new Thread(new Runnable() { 224 public void run() { 225 if (threadUri != null) { 226 buildReadContentValues(); 227 mContext.getContentResolver().update(threadUri, mReadContentValues, 228 "read=0", null); 229 mHasUnreadMessages = false; 230 } 231 // Always update notifications regardless of the read state. 232 MessagingNotification.updateAllNotifications(mContext); 233 } 234 }).start(); 235 } 236 237 /** 238 * Returns a content:// URI referring to this conversation, 239 * or null if it does not exist on disk yet. 240 */ 241 public synchronized Uri getUri() { 242 if (mThreadId <= 0) 243 return null; 244 245 return ContentUris.withAppendedId(Threads.CONTENT_URI, mThreadId); 246 } 247 248 /** 249 * Return the Uri for all messages in the given thread ID. 250 * @deprecated 251 */ 252 public static Uri getUri(long threadId) { 253 // TODO: Callers using this should really just have a Conversation 254 // and call getUri() on it, but this guarantees no blocking. 255 return ContentUris.withAppendedId(Threads.CONTENT_URI, threadId); 256 } 257 258 /** 259 * Returns the thread ID of this conversation. Can be zero if 260 * {@link #ensureThreadId} has not been called yet. 261 */ 262 public synchronized long getThreadId() { 263 return mThreadId; 264 } 265 266 /** 267 * Guarantees that the conversation has been created in the database. 268 * This will make a blocking database call if it hasn't. 269 * 270 * @return The thread ID of this conversation in the database 271 */ 272 public synchronized long ensureThreadId() { 273 if (DEBUG) { 274 LogTag.debug("ensureThreadId before: " + mThreadId); 275 } 276 if (mThreadId <= 0) { 277 mThreadId = getOrCreateThreadId(mContext, mRecipients); 278 } 279 if (DEBUG) { 280 LogTag.debug("ensureThreadId after: " + mThreadId); 281 } 282 283 return mThreadId; 284 } 285 286 public synchronized void clearThreadId() { 287 // remove ourself from the cache 288 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 289 LogTag.debug("clearThreadId old threadId was: " + mThreadId + " now zero"); 290 } 291 Cache.remove(mThreadId); 292 293 mThreadId = 0; 294 } 295 296 /** 297 * Sets the list of recipients associated with this conversation. 298 * If called, {@link #ensureThreadId} must be called before the next 299 * operation that depends on this conversation existing in the 300 * database (e.g. storing a draft message to it). 301 */ 302 public synchronized void setRecipients(ContactList list) { 303 mRecipients = list; 304 305 // Invalidate thread ID because the recipient set has changed. 306 mThreadId = 0; 307 } 308 309 /** 310 * Returns the recipient set of this conversation. 311 */ 312 public synchronized ContactList getRecipients() { 313 return mRecipients; 314 } 315 316 /** 317 * Returns true if a draft message exists in this conversation. 318 */ 319 public synchronized boolean hasDraft() { 320 if (mThreadId <= 0) 321 return false; 322 323 return DraftCache.getInstance().hasDraft(mThreadId); 324 } 325 326 /** 327 * Sets whether or not this conversation has a draft message. 328 */ 329 public synchronized void setDraftState(boolean hasDraft) { 330 if (mThreadId <= 0) 331 return; 332 333 DraftCache.getInstance().setDraftState(mThreadId, hasDraft); 334 } 335 336 /** 337 * Returns the time of the last update to this conversation in milliseconds, 338 * on the {@link System#currentTimeMillis} timebase. 339 */ 340 public synchronized long getDate() { 341 return mDate; 342 } 343 344 /** 345 * Returns the number of messages in this conversation, excluding the draft 346 * (if it exists). 347 */ 348 public synchronized int getMessageCount() { 349 return mMessageCount; 350 } 351 352 /** 353 * Returns a snippet of text from the most recent message in the conversation. 354 */ 355 public synchronized String getSnippet() { 356 return mSnippet; 357 } 358 359 /** 360 * Returns true if there are any unread messages in the conversation. 361 */ 362 public synchronized boolean hasUnreadMessages() { 363 return mHasUnreadMessages; 364 } 365 366 /** 367 * Returns true if any messages in the conversation have attachments. 368 */ 369 public synchronized boolean hasAttachment() { 370 return mHasAttachment; 371 } 372 373 /** 374 * Returns true if any messages in the conversation are in an error state. 375 */ 376 public synchronized boolean hasError() { 377 return mHasError; 378 } 379 380 private static long getOrCreateThreadId(Context context, ContactList list) { 381 HashSet<String> recipients = new HashSet<String>(); 382 Contact cacheContact = null; 383 for (Contact c : list) { 384 cacheContact = Contact.get(c.getNumber(), false); 385 if (cacheContact != null) { 386 recipients.add(cacheContact.getNumber()); 387 } else { 388 recipients.add(c.getNumber()); 389 } 390 } 391 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 392 LogTag.debug("getOrCreateThreadId %s", recipients); 393 } 394 return Threads.getOrCreateThreadId(context, recipients); 395 } 396 397 /* 398 * The primary key of a conversation is its recipient set; override 399 * equals() and hashCode() to just pass through to the internal 400 * recipient sets. 401 */ 402 @Override 403 public synchronized boolean equals(Object obj) { 404 try { 405 Conversation other = (Conversation)obj; 406 return (mRecipients.equals(other.mRecipients)); 407 } catch (ClassCastException e) { 408 return false; 409 } 410 } 411 412 @Override 413 public synchronized int hashCode() { 414 return mRecipients.hashCode(); 415 } 416 417 @Override 418 public synchronized String toString() { 419 return String.format("[%s] (tid %d)", mRecipients.serialize(), mThreadId); 420 } 421 422 /** 423 * Remove any obsolete conversations sitting around on disk. 424 * @deprecated 425 */ 426 public static void cleanup(Context context) { 427 // TODO: Get rid of this awful hack. 428 context.getContentResolver().delete(Threads.OBSOLETE_THREADS_URI, null, null); 429 } 430 431 /** 432 * Start a query for all conversations in the database on the specified 433 * AsyncQueryHandler. 434 * 435 * @param handler An AsyncQueryHandler that will receive onQueryComplete 436 * upon completion of the query 437 * @param token The token that will be passed to onQueryComplete 438 */ 439 public static void startQueryForAll(AsyncQueryHandler handler, int token) { 440 handler.cancelOperation(token); 441 handler.startQuery(token, null, sAllThreadsUri, 442 ALL_THREADS_PROJECTION, null, null, Conversations.DEFAULT_SORT_ORDER); 443 } 444 445 /** 446 * Start a delete of the conversation with the specified thread ID. 447 * 448 * @param handler An AsyncQueryHandler that will receive onDeleteComplete 449 * upon completion of the conversation being deleted 450 * @param token The token that will be passed to onDeleteComplete 451 * @param deleteAll Delete the whole thread including locked messages 452 * @param threadId Thread ID of the conversation to be deleted 453 */ 454 public static void startDelete(AsyncQueryHandler handler, int token, boolean deleteAll, 455 long threadId) { 456 Uri uri = ContentUris.withAppendedId(Threads.CONTENT_URI, threadId); 457 String selection = deleteAll ? null : "locked=0"; 458 handler.startDelete(token, null, uri, selection, null); 459 } 460 461 /** 462 * Start deleting all conversations in the database. 463 * @param handler An AsyncQueryHandler that will receive onDeleteComplete 464 * upon completion of all conversations being deleted 465 * @param token The token that will be passed to onDeleteComplete 466 * @param deleteAll Delete the whole thread including locked messages 467 */ 468 public static void startDeleteAll(AsyncQueryHandler handler, int token, boolean deleteAll) { 469 String selection = deleteAll ? null : "locked=0"; 470 handler.startDelete(token, null, Threads.CONTENT_URI, selection, null); 471 } 472 473 /** 474 * Check for locked messages in all threads or a specified thread. 475 * @param handler An AsyncQueryHandler that will receive onQueryComplete 476 * upon completion of looking for locked messages 477 * @param threadId The threadId of the thread to search. -1 means all threads 478 * @param token The token that will be passed to onQueryComplete 479 */ 480 public static void startQueryHaveLockedMessages(AsyncQueryHandler handler, long threadId, 481 int token) { 482 handler.cancelOperation(token); 483 Uri uri = MmsSms.CONTENT_LOCKED_URI; 484 if (threadId != -1) { 485 uri = ContentUris.withAppendedId(uri, threadId); 486 } 487 handler.startQuery(token, new Long(threadId), uri, 488 ALL_THREADS_PROJECTION, null, null, Conversations.DEFAULT_SORT_ORDER); 489 } 490 491 /** 492 * Fill the specified conversation with the values from the specified 493 * cursor, possibly setting recipients to empty if {@value allowQuery} 494 * is false and the recipient IDs are not in cache. The cursor should 495 * be one made via {@link #startQueryForAll}. 496 */ 497 private static void fillFromCursor(Context context, Conversation conv, 498 Cursor c, boolean allowQuery) { 499 synchronized (conv) { 500 conv.mThreadId = c.getLong(ID); 501 conv.mDate = c.getLong(DATE); 502 conv.mMessageCount = c.getInt(MESSAGE_COUNT); 503 504 // Replace the snippet with a default value if it's empty. 505 String snippet = MessageUtils.extractEncStrFromCursor(c, SNIPPET, SNIPPET_CS); 506 if (TextUtils.isEmpty(snippet)) { 507 snippet = context.getString(R.string.no_subject_view); 508 } 509 conv.mSnippet = snippet; 510 511 conv.mHasUnreadMessages = (c.getInt(READ) == 0); 512 conv.mHasError = (c.getInt(ERROR) != 0); 513 conv.mHasAttachment = (c.getInt(HAS_ATTACHMENT) != 0); 514 } 515 // Fill in as much of the conversation as we can before doing the slow stuff of looking 516 // up the contacts associated with this conversation. 517 String recipientIds = c.getString(RECIPIENT_IDS); 518 ContactList recipients = ContactList.getByIds(recipientIds, allowQuery); 519 synchronized (conv) { 520 conv.mRecipients = recipients; 521 } 522 } 523 524 /** 525 * Private cache for the use of the various forms of Conversation.get. 526 */ 527 private static class Cache { 528 private static Cache sInstance = new Cache(); 529 static Cache getInstance() { return sInstance; } 530 private final HashSet<Conversation> mCache; 531 private Cache() { 532 mCache = new HashSet<Conversation>(10); 533 } 534 535 /** 536 * Return the conversation with the specified thread ID, or 537 * null if it's not in cache. 538 */ 539 static Conversation get(long threadId) { 540 synchronized (sInstance) { 541 if (DEBUG) { 542 LogTag.debug("Conversation get with threadId: " + threadId); 543 } 544 dumpCache(); 545 for (Conversation c : sInstance.mCache) { 546 if (DEBUG) { 547 LogTag.debug("Conversation get() threadId: " + threadId + 548 " c.getThreadId(): " + c.getThreadId()); 549 } 550 if (c.getThreadId() == threadId) { 551 return c; 552 } 553 } 554 } 555 return null; 556 } 557 558 /** 559 * Return the conversation with the specified recipient 560 * list, or null if it's not in cache. 561 */ 562 static Conversation get(ContactList list) { 563 synchronized (sInstance) { 564 if (DEBUG) { 565 LogTag.debug("Conversation get with ContactList: " + list); 566 dumpCache(); 567 } 568 for (Conversation c : sInstance.mCache) { 569 if (c.getRecipients().equals(list)) { 570 return c; 571 } 572 } 573 } 574 return null; 575 } 576 577 /** 578 * Put the specified conversation in the cache. The caller 579 * should not place an already-existing conversation in the 580 * cache, but rather update it in place. 581 */ 582 static void put(Conversation c) { 583 synchronized (sInstance) { 584 // We update cache entries in place so people with long- 585 // held references get updated. 586 if (DEBUG) { 587 LogTag.debug("Conversation c: " + c + " put with threadid: " + c.getThreadId() + 588 " c.hash: " + c.hashCode()); 589 dumpCache(); 590 } 591 592 if (sInstance.mCache.contains(c)) { 593 throw new IllegalStateException("cache already contains " + c + 594 " threadId: " + c.mThreadId); 595 } 596 sInstance.mCache.add(c); 597 } 598 } 599 600 static void remove(long threadId) { 601 if (DEBUG) { 602 LogTag.debug("remove threadid: " + threadId); 603 dumpCache(); 604 } 605 for (Conversation c : sInstance.mCache) { 606 if (c.getThreadId() == threadId) { 607 sInstance.mCache.remove(c); 608 return; 609 } 610 } 611 } 612 613 static void dumpCache() { 614 if (DEBUG) { 615 synchronized (sInstance) { 616 LogTag.debug("Conversation dumpCache: "); 617 for (Conversation c : sInstance.mCache) { 618 LogTag.debug(" c: " + c + " c.getThreadId(): " + c.getThreadId() + 619 " hash: " + c.hashCode()); 620 } 621 } 622 } 623 } 624 625 /** 626 * Remove all conversations from the cache that are not in 627 * the provided set of thread IDs. 628 */ 629 static void keepOnly(Set<Long> threads) { 630 synchronized (sInstance) { 631 Iterator<Conversation> iter = sInstance.mCache.iterator(); 632 while (iter.hasNext()) { 633 Conversation c = iter.next(); 634 if (!threads.contains(c.getThreadId())) { 635 iter.remove(); 636 } 637 } 638 } 639 } 640 } 641 642 /** 643 * Set up the conversation cache. To be called once at application 644 * startup time. 645 */ 646 public static void init(final Context context) { 647 new Thread(new Runnable() { 648 public void run() { 649 cacheAllThreads(context); 650 } 651 }).start(); 652 } 653 654 /** 655 * Are we in the process of loading and caching all the threads?. 656 */ 657 public static boolean loadingThreads() { 658 synchronized (Cache.getInstance()) { 659 return mLoadingThreads; 660 } 661 } 662 663 private static void cacheAllThreads(Context context) { 664 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 665 LogTag.debug("[Conversation] cacheAllThreads"); 666 } 667 synchronized (Cache.getInstance()) { 668 if (mLoadingThreads) { 669 return; 670 } 671 mLoadingThreads = true; 672 } 673 674 // Keep track of what threads are now on disk so we 675 // can discard anything removed from the cache. 676 HashSet<Long> threadsOnDisk = new HashSet<Long>(); 677 678 // Query for all conversations. 679 Cursor c = context.getContentResolver().query(sAllThreadsUri, 680 ALL_THREADS_PROJECTION, null, null, null); 681 try { 682 if (c != null) { 683 while (c.moveToNext()) { 684 long threadId = c.getLong(ID); 685 threadsOnDisk.add(threadId); 686 687 // Try to find this thread ID in the cache. 688 Conversation conv; 689 synchronized (Cache.getInstance()) { 690 conv = Cache.get(threadId); 691 } 692 693 if (conv == null) { 694 // Make a new Conversation and put it in 695 // the cache if necessary. 696 conv = new Conversation(context, c, true); 697 try { 698 synchronized (Cache.getInstance()) { 699 Cache.put(conv); 700 } 701 } catch (IllegalStateException e) { 702 LogTag.error("Tried to add duplicate Conversation to Cache"); 703 } 704 } else { 705 // Or update in place so people with references 706 // to conversations get updated too. 707 fillFromCursor(context, conv, c, true); 708 } 709 } 710 } 711 } finally { 712 if (c != null) { 713 c.close(); 714 } 715 synchronized (Cache.getInstance()) { 716 mLoadingThreads = false; 717 } 718 } 719 720 // Purge the cache of threads that no longer exist on disk. 721 Cache.keepOnly(threadsOnDisk); 722 } 723 724 private boolean loadFromThreadId(long threadId, boolean allowQuery) { 725 Cursor c = mContext.getContentResolver().query(sAllThreadsUri, ALL_THREADS_PROJECTION, 726 "_id=" + Long.toString(threadId), null, null); 727 try { 728 if (c.moveToFirst()) { 729 fillFromCursor(mContext, this, c, allowQuery); 730 } else { 731 LogTag.error("loadFromThreadId: Can't find thread ID " + threadId); 732 return false; 733 } 734 } finally { 735 c.close(); 736 } 737 return true; 738 } 739} 740