Conversation.java revision 71bbae69cee0da902032743d0702e283cfe31504
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 /** 235 * Sets the list of recipients associated with this conversation. 236 * If called, {@link ensureThreadId} must be called before the next 237 * operation that depends on this conversation existing in the 238 * database (e.g. storing a draft message to it). 239 */ 240 public synchronized void setRecipients(ContactList list) { 241 mRecipients = list; 242 243 // Invalidate thread ID because the recipient set has changed. 244 mThreadId = 0; 245 } 246 247 /** 248 * Returns the recipient set of this conversation. 249 */ 250 public synchronized ContactList getRecipients() { 251 return mRecipients; 252 } 253 254 /** 255 * Returns true if a draft message exists in this conversation. 256 */ 257 public synchronized boolean hasDraft() { 258 if (mThreadId <= 0) 259 return false; 260 261 return DraftCache.getInstance().hasDraft(mThreadId); 262 } 263 264 /** 265 * Sets whether or not this conversation has a draft message. 266 */ 267 public synchronized void setDraftState(boolean hasDraft) { 268 if (mThreadId <= 0) 269 return; 270 271 DraftCache.getInstance().setDraftState(mThreadId, hasDraft); 272 } 273 274 /** 275 * Returns the time of the last update to this conversation in milliseconds, 276 * on the {@link System.currentTimeMillis} timebase. 277 */ 278 public synchronized long getDate() { 279 return mDate; 280 } 281 282 /** 283 * Returns the number of messages in this conversation, excluding the draft 284 * (if it exists). 285 */ 286 public synchronized int getMessageCount() { 287 return mMessageCount; 288 } 289 290 /** 291 * Returns a snippet of text from the most recent message in the conversation. 292 */ 293 public synchronized String getSnippet() { 294 return mSnippet; 295 } 296 297 /** 298 * Returns true if there are any unread messages in the conversation. 299 */ 300 public synchronized boolean hasUnreadMessages() { 301 return mHasUnreadMessages; 302 } 303 304 /** 305 * Returns true if any messages in the conversation have attachments. 306 */ 307 public synchronized boolean hasAttachment() { 308 return mHasAttachment; 309 } 310 311 /** 312 * Returns true if any messages in the conversation are in an error state. 313 */ 314 public synchronized boolean hasError() { 315 return mHasError; 316 } 317 318 private static long getOrCreateThreadId(Context context, ContactList list) { 319 HashSet<String> recipients = new HashSet<String>(); 320 for (Contact c : list) { 321 recipients.add(c.getNumber()); 322 } 323 return Threads.getOrCreateThreadId(context, recipients); 324 } 325 326 /* 327 * The primary key of a conversation is its recipient set; override 328 * equals() and hashCode() to just pass through to the internal 329 * recipient sets. 330 */ 331 @Override 332 public synchronized boolean equals(Object obj) { 333 try { 334 Conversation other = (Conversation)obj; 335 return (mRecipients.equals(other.mRecipients)); 336 } catch (ClassCastException e) { 337 return false; 338 } 339 } 340 341 @Override 342 public synchronized int hashCode() { 343 return mRecipients.hashCode(); 344 } 345 346 @Override 347 public synchronized String toString() { 348 return String.format("[%s] (tid %d)", mRecipients.serialize(), mThreadId); 349 } 350 351 /** 352 * Remove any obsolete conversations sitting around on disk. 353 * @deprecated 354 */ 355 public static void cleanup(Context context) { 356 // TODO: Get rid of this awful hack. 357 context.getContentResolver().delete(Threads.OBSOLETE_THREADS_URI, null, null); 358 } 359 360 /** 361 * Start a query for all conversations in the database on the specified 362 * AsyncQueryHandler. 363 * 364 * @param handler An AsyncQueryHandler that will receive onQueryComplete 365 * upon completion of the query 366 * @param token The token that will be passed to onQueryComplete 367 */ 368 public static void startQueryForAll(AsyncQueryHandler handler, int token) { 369 handler.cancelOperation(token); 370 handler.startQuery(token, null, sAllThreadsUri, 371 ALL_THREADS_PROJECTION, null, null, Conversations.DEFAULT_SORT_ORDER); 372 } 373 374 /** 375 * Start a delete of the conversation with the specified thread ID. 376 * 377 * @param handler An AsyncQueryHandler that will receive onDeleteComplete 378 * upon completion of the conversation being deleted 379 * @param token The token that will be passed to onDeleteComplete 380 * @param threadId Thread ID of the conversation to be deleted 381 */ 382 public static void startDelete(AsyncQueryHandler handler, int token, long threadId) { 383 Uri uri = ContentUris.withAppendedId(Threads.CONTENT_URI, threadId); 384 handler.startDelete(token, null, uri, null, null); 385 } 386 387 /** 388 * Start deleting all conversations in the database. 389 * @param handler An AsyncQueryHandler that will receive onDeleteComplete 390 * upon completion of all conversations being deleted 391 * @param token The token that will be passed to onDeleteComplete 392 */ 393 public static void startDeleteAll(AsyncQueryHandler handler, int token) { 394 handler.startDelete(token, null, Threads.CONTENT_URI, null, null); 395 } 396 397 /** 398 * Fill the specified conversation with the values from the specified 399 * cursor, possibly setting recipients to empty if {@value allowQuery} 400 * is false and the recipient IDs are not in cache. The cursor should 401 * be one made via {@link startQueryForAll}. 402 */ 403 private static void fillFromCursor(Context context, Conversation conv, 404 Cursor c, boolean allowQuery) { 405 synchronized (conv) { 406 conv.mThreadId = c.getInt(ID); 407 conv.mDate = c.getLong(DATE); 408 conv.mMessageCount = c.getInt(MESSAGE_COUNT); 409 410 // Replace the snippet with a default value if it's empty. 411 String snippet = MessageUtils.extractEncStrFromCursor(c, SNIPPET, SNIPPET_CS); 412 if (TextUtils.isEmpty(snippet)) { 413 snippet = context.getString(R.string.no_subject_view); 414 } 415 conv.mSnippet = snippet; 416 417 conv.mHasUnreadMessages = (c.getInt(READ) == 0); 418 conv.mHasError = (c.getInt(ERROR) != 0); 419 conv.mHasAttachment = (c.getInt(HAS_ATTACHMENT) != 0); 420 421 String recipientIds = c.getString(RECIPIENT_IDS); 422 conv.mRecipients = ContactList.getByIds(recipientIds, allowQuery); 423 } 424 } 425 426 /** 427 * Private cache for the use of the various forms of Conversation.get. 428 */ 429 private static class Cache { 430 private static Cache sInstance = new Cache(); 431 static Cache getInstance() { return sInstance; } 432 private final HashSet<Conversation> mCache; 433 private Cache() { 434 mCache = new HashSet<Conversation>(10); 435 } 436 437 /** 438 * Return the conversation with the specified thread ID, or 439 * null if it's not in cache. 440 */ 441 static Conversation get(long threadId) { 442 synchronized (sInstance) { 443 for (Conversation c : sInstance.mCache) { 444 if (c.getThreadId() == threadId) { 445 return c; 446 } 447 } 448 } 449 return null; 450 } 451 452 /** 453 * Return the conversation with the specified recipient 454 * list, or null if it's not in cache. 455 */ 456 static Conversation get(ContactList list) { 457 synchronized (sInstance) { 458 for (Conversation c : sInstance.mCache) { 459 if (c.getRecipients().equals(list)) { 460 return c; 461 } 462 } 463 } 464 return null; 465 } 466 467 /** 468 * Put the specified conversation in the cache. The caller 469 * should not place an already-existing conversation in the 470 * cache, but rather update it in place. 471 */ 472 static void put(Conversation c) { 473 synchronized (sInstance) { 474 // We update cache entries in place so people with long- 475 // held references get updated. 476 if (sInstance.mCache.contains(c)) { 477 throw new IllegalStateException("cache already contains" + c); 478 } 479 sInstance.mCache.add(c); 480 } 481 } 482 483 /** 484 * Remove all conversations from the cache that are not in 485 * the provided set of thread IDs. 486 */ 487 static void keepOnly(Set<Long> threads) { 488 synchronized (sInstance) { 489 Iterator<Conversation> iter = sInstance.mCache.iterator(); 490 while (iter.hasNext()) { 491 Conversation c = iter.next(); 492 if (!threads.contains(c.getThreadId())) { 493 iter.remove(); 494 } 495 } 496 } 497 } 498 } 499 500 /** 501 * Set up the conversation cache. To be called once at application 502 * startup time. 503 */ 504 public static void init(final Context context) { 505 new Thread(new Runnable() { 506 public void run() { 507 cacheAllThreads(context); 508 } 509 }).start(); 510 } 511 512 private static void cacheAllThreads(Context context) { 513 synchronized (Cache.getInstance()) { 514 // Keep track of what threads are now on disk so we 515 // can discard anything removed from the cache. 516 HashSet<Long> threadsOnDisk = new HashSet<Long>(); 517 518 // Query for all conversations. 519 Cursor c = context.getContentResolver().query(sAllThreadsUri, 520 ALL_THREADS_PROJECTION, null, null, null); 521 try { 522 while (c.moveToNext()) { 523 long threadId = c.getLong(ID); 524 threadsOnDisk.add(threadId); 525 526 // Try to find this thread ID in the cache. 527 Conversation conv = Cache.get(threadId); 528 if (conv == null) { 529 // Make a new Conversation and put it in 530 // the cache if necessary. 531 conv = new Conversation(context, c, true); 532 Cache.put(conv); 533 } else { 534 // Or update in place so people with references 535 // to conversations get updated too. 536 fillFromCursor(context, conv, c, true); 537 } 538 } 539 } finally { 540 c.close(); 541 } 542 543 // Purge the cache of threads that no longer exist on disk. 544 Cache.keepOnly(threadsOnDisk); 545 } 546 } 547 548 private void loadFromThreadId(long threadId) { 549 Cursor c = mContext.getContentResolver().query(sAllThreadsUri, ALL_THREADS_PROJECTION, 550 "_id=" + Long.toString(threadId), null, null); 551 try { 552 if (c.moveToFirst()) { 553 fillFromCursor(mContext, this, c, true); 554 } else { 555 throw new IllegalArgumentException("Can't find thread ID " + threadId); 556 } 557 } finally { 558 c.close(); 559 } 560 } 561} 562