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