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