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