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