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