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