Conversation.java revision fd36e337347b5f9a945806961d61f1c0b8b3514e
1package com.android.mms.data; 2 3import java.util.ArrayList; 4import java.util.Collection; 5import java.util.HashSet; 6import java.util.Iterator; 7import java.util.Set; 8 9import android.app.Activity; 10import android.content.AsyncQueryHandler; 11import android.content.ContentResolver; 12import android.content.ContentUris; 13import android.content.ContentValues; 14import android.content.Context; 15import android.database.Cursor; 16import android.net.Uri; 17import android.os.AsyncTask; 18import android.provider.BaseColumns; 19import android.provider.Telephony.Mms; 20import android.provider.Telephony.MmsSms; 21import android.provider.Telephony.Sms; 22import android.provider.Telephony.Threads; 23import android.provider.Telephony.Sms.Conversations; 24import android.provider.Telephony.ThreadsColumns; 25import android.telephony.PhoneNumberUtils; 26import android.text.TextUtils; 27import android.util.Log; 28 29import com.android.mms.LogTag; 30import com.android.mms.R; 31import com.android.mms.transaction.MessagingNotification; 32import com.android.mms.ui.MessageUtils; 33import com.android.mms.util.DraftCache; 34import com.google.android.mms.util.PduCache; 35 36/** 37 * An interface for finding information about conversations and/or creating new ones. 38 */ 39public class Conversation { 40 private static final String TAG = "Mms/conv"; 41 private static final boolean DEBUG = false; 42 43 public static final Uri sAllThreadsUri = 44 Threads.CONTENT_URI.buildUpon().appendQueryParameter("simple", "true").build(); 45 46 public static final String[] ALL_THREADS_PROJECTION = { 47 Threads._ID, Threads.DATE, Threads.MESSAGE_COUNT, Threads.RECIPIENT_IDS, 48 Threads.SNIPPET, Threads.SNIPPET_CHARSET, Threads.READ, Threads.ERROR, 49 Threads.HAS_ATTACHMENT 50 }; 51 52 public static final String[] UNREAD_PROJECTION = { 53 Threads._ID, 54 Threads.READ 55 }; 56 57 private static final String UNREAD_SELECTION = "(read=0 OR seen=0)"; 58 59 private static final String[] SEEN_PROJECTION = new String[] { 60 "seen" 61 }; 62 63 private static final int ID = 0; 64 private static final int DATE = 1; 65 private static final int MESSAGE_COUNT = 2; 66 private static final int RECIPIENT_IDS = 3; 67 private static final int SNIPPET = 4; 68 private static final int SNIPPET_CS = 5; 69 private static final int READ = 6; 70 private static final int ERROR = 7; 71 private static final int HAS_ATTACHMENT = 8; 72 73 74 private final Context mContext; 75 76 // The thread ID of this conversation. Can be zero in the case of a 77 // new conversation where the recipient set is changing as the user 78 // types and we have not hit the database yet to create a thread. 79 private long mThreadId; 80 81 private ContactList mRecipients; // The current set of recipients. 82 private long mDate; // The last update time. 83 private int mMessageCount; // Number of messages. 84 private String mSnippet; // Text of the most recent message. 85 private boolean mHasUnreadMessages; // True if there are unread messages. 86 private boolean mHasAttachment; // True if any message has an attachment. 87 private boolean mHasError; // True if any message is in an error state. 88 private boolean mIsChecked; // True if user has selected the conversation for a 89 // multi-operation such as delete. 90 91 private static ContentValues mReadContentValues; 92 private static boolean mLoadingThreads; 93 private boolean mMarkAsReadBlocked; 94 private boolean mMarkAsReadWaiting; 95 96 private Conversation(Context context) { 97 mContext = context; 98 mRecipients = new ContactList(); 99 mThreadId = 0; 100 } 101 102 private Conversation(Context context, long threadId, boolean allowQuery) { 103 if (DEBUG) { 104 Log.v(TAG, "Conversation constructor threadId: " + threadId); 105 } 106 mContext = context; 107 if (!loadFromThreadId(threadId, allowQuery)) { 108 mRecipients = new ContactList(); 109 mThreadId = 0; 110 } 111 } 112 113 private Conversation(Context context, Cursor cursor, boolean allowQuery) { 114 if (DEBUG) { 115 Log.v(TAG, "Conversation constructor cursor, allowQuery: " + allowQuery); 116 } 117 mContext = context; 118 fillFromCursor(context, this, cursor, allowQuery); 119 } 120 121 /** 122 * Create a new conversation with no recipients. {@link #setRecipients} can 123 * be called as many times as you like; the conversation will not be 124 * created in the database until {@link #ensureThreadId} is called. 125 */ 126 public static Conversation createNew(Context context) { 127 return new Conversation(context); 128 } 129 130 /** 131 * Find the conversation matching the provided thread ID. 132 */ 133 public static Conversation get(Context context, long threadId, boolean allowQuery) { 134 if (DEBUG) { 135 Log.v(TAG, "Conversation get by threadId: " + threadId); 136 } 137 Conversation conv = Cache.get(threadId); 138 if (conv != null) 139 return conv; 140 141 conv = new Conversation(context, threadId, allowQuery); 142 try { 143 Cache.put(conv); 144 } catch (IllegalStateException e) { 145 LogTag.error("Tried to add duplicate Conversation to Cache (from threadId): " + conv); 146 if (!Cache.replace(conv)) { 147 LogTag.error("get by threadId cache.replace failed on " + conv); 148 } 149 } 150 return conv; 151 } 152 153 /** 154 * Find the conversation matching the provided recipient set. 155 * When called with an empty recipient list, equivalent to {@link #createNew}. 156 */ 157 public static Conversation get(Context context, ContactList recipients, boolean allowQuery) { 158 if (DEBUG) { 159 Log.v(TAG, "Conversation get by recipients: " + recipients.serialize()); 160 } 161 // If there are no recipients in the list, make a new conversation. 162 if (recipients.size() < 1) { 163 return createNew(context); 164 } 165 166 Conversation conv = Cache.get(recipients); 167 if (conv != null) 168 return conv; 169 170 long threadId = getOrCreateThreadId(context, recipients); 171 conv = new Conversation(context, threadId, allowQuery); 172 Log.d(TAG, "Conversation.get: created new conversation " + /*conv.toString()*/ "xxxxxxx"); 173 174 if (!conv.getRecipients().equals(recipients)) { 175 LogTag.error(TAG, "Conversation.get: new conv's recipients don't match input recpients " 176 + /*recipients*/ "xxxxxxx"); 177 } 178 179 try { 180 Cache.put(conv); 181 } catch (IllegalStateException e) { 182 LogTag.error("Tried to add duplicate Conversation to Cache (from recipients): " + conv); 183 if (!Cache.replace(conv)) { 184 LogTag.error("get by recipients cache.replace failed on " + conv); 185 } 186 } 187 188 return conv; 189 } 190 191 /** 192 * Find the conversation matching in the specified Uri. Example 193 * forms: {@value content://mms-sms/conversations/3} or 194 * {@value sms:+12124797990}. 195 * When called with a null Uri, equivalent to {@link #createNew}. 196 */ 197 public static Conversation get(Context context, Uri uri, boolean allowQuery) { 198 if (DEBUG) { 199 Log.v(TAG, "Conversation get by uri: " + uri); 200 } 201 if (uri == null) { 202 return createNew(context); 203 } 204 205 if (DEBUG) Log.v(TAG, "Conversation get URI: " + uri); 206 207 // Handle a conversation URI 208 if (uri.getPathSegments().size() >= 2) { 209 try { 210 long threadId = Long.parseLong(uri.getPathSegments().get(1)); 211 if (DEBUG) { 212 Log.v(TAG, "Conversation get threadId: " + threadId); 213 } 214 return get(context, threadId, allowQuery); 215 } catch (NumberFormatException exception) { 216 LogTag.error("Invalid URI: " + uri); 217 } 218 } 219 220 String recipients = getRecipients(uri).replace(',', ';'); 221 return get(context, ContactList.getByNumbers(recipients, 222 allowQuery /* don't block */, true /* replace number */), allowQuery); 223 } 224 225 /** 226 * Returns true if the recipient in the uri matches the recipient list in this 227 * conversation. 228 */ 229 public boolean sameRecipient(Uri uri, Context context) { 230 int size = mRecipients.size(); 231 if (size > 1) { 232 return false; 233 } 234 if (uri == null) { 235 return size == 0; 236 } 237 ContactList incomingRecipient = null; 238 if (uri.getPathSegments().size() >= 2) { 239 // it's a thread id for a conversation 240 Conversation otherConv = get(context, uri, false); 241 if (otherConv == null) { 242 return false; 243 } 244 incomingRecipient = otherConv.mRecipients; 245 } else { 246 String recipient = getRecipients(uri); 247 incomingRecipient = ContactList.getByNumbers(recipient, 248 false /* don't block */, false /* don't replace number */); 249 } 250 if (DEBUG) Log.v(TAG, "sameRecipient incomingRecipient: " + incomingRecipient + 251 " mRecipients: " + mRecipients); 252 return mRecipients.equals(incomingRecipient); 253 } 254 255 /** 256 * Returns a temporary Conversation (not representing one on disk) wrapping 257 * the contents of the provided cursor. The cursor should be the one 258 * returned to your AsyncQueryHandler passed in to {@link #startQueryForAll}. 259 * The recipient list of this conversation can be empty if the results 260 * were not in cache. 261 */ 262 public static Conversation from(Context context, Cursor cursor) { 263 // First look in the cache for the Conversation and return that one. That way, all the 264 // people that are looking at the cached copy will get updated when fillFromCursor() is 265 // called with this cursor. 266 long threadId = cursor.getLong(ID); 267 if (threadId > 0) { 268 Conversation conv = Cache.get(threadId); 269 if (conv != null) { 270 fillFromCursor(context, conv, cursor, false); // update the existing conv in-place 271 return conv; 272 } 273 } 274 Conversation conv = new Conversation(context, cursor, false); 275 try { 276 Cache.put(conv); 277 } catch (IllegalStateException e) { 278 LogTag.error(TAG, "Tried to add duplicate Conversation to Cache (from cursor): " + 279 conv); 280 if (!Cache.replace(conv)) { 281 LogTag.error("Converations.from cache.replace failed on " + conv); 282 } 283 } 284 return conv; 285 } 286 287 private void buildReadContentValues() { 288 if (mReadContentValues == null) { 289 mReadContentValues = new ContentValues(2); 290 mReadContentValues.put("read", 1); 291 mReadContentValues.put("seen", 1); 292 } 293 } 294 295 /** 296 * Marks all messages in this conversation as read and updates 297 * relevant notifications. This method returns immediately; 298 * work is dispatched to a background thread. This function should 299 * always be called from the UI thread. 300 */ 301 public void markAsRead() { 302 if (mMarkAsReadWaiting) { 303 // We've already been asked to mark everything as read, but we're blocked. 304 return; 305 } 306 if (mMarkAsReadBlocked) { 307 // We're blocked so record the fact that we want to mark the messages as read 308 // when we get unblocked. 309 mMarkAsReadWaiting = true; 310 return; 311 } 312 final Uri threadUri = getUri(); 313 314 new AsyncTask<Void, Void, Void>() { 315 protected Void doInBackground(Void... none) { 316 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 317 LogTag.debug("markAsRead"); 318 } 319 // If we have no Uri to mark (as in the case of a conversation that 320 // has not yet made its way to disk), there's nothing to do. 321 if (threadUri != null) { 322 buildReadContentValues(); 323 324 // Check the read flag first. It's much faster to do a query than 325 // to do an update. Timing this function show it's about 10x faster to 326 // do the query compared to the update, even when there's nothing to 327 // update. 328 boolean needUpdate = true; 329 330 Cursor c = mContext.getContentResolver().query(threadUri, 331 UNREAD_PROJECTION, UNREAD_SELECTION, null, null); 332 if (c != null) { 333 try { 334 needUpdate = c.getCount() > 0; 335 } finally { 336 c.close(); 337 } 338 } 339 340 if (needUpdate) { 341 LogTag.debug("markAsRead: update read/seen for thread uri: " + 342 threadUri); 343 mContext.getContentResolver().update(threadUri, mReadContentValues, 344 UNREAD_SELECTION, null); 345 } 346 setHasUnreadMessages(false); 347 } 348 // Always update notifications regardless of the read state. 349 MessagingNotification.blockingUpdateAllNotifications(mContext); 350 351 return null; 352 } 353 }.execute(); 354 } 355 356 /** 357 * Call this with false to prevent marking messages as read. The code calls this so 358 * the DB queries in markAsRead don't slow down the main query for messages. Once we've 359 * queried for all the messages (see ComposeMessageActivity.onQueryComplete), then we 360 * can mark messages as read. Only call this function on the UI thread. 361 */ 362 public void blockMarkAsRead(boolean block) { 363 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 364 LogTag.debug("blockMarkAsRead: " + block); 365 } 366 367 if (block != mMarkAsReadBlocked) { 368 mMarkAsReadBlocked = block; 369 if (!mMarkAsReadBlocked) { 370 if (mMarkAsReadWaiting) { 371 mMarkAsReadWaiting = false; 372 markAsRead(); 373 } 374 } 375 } 376 } 377 378 /** 379 * Returns a content:// URI referring to this conversation, 380 * or null if it does not exist on disk yet. 381 */ 382 public synchronized Uri getUri() { 383 if (mThreadId <= 0) 384 return null; 385 386 return ContentUris.withAppendedId(Threads.CONTENT_URI, mThreadId); 387 } 388 389 /** 390 * Return the Uri for all messages in the given thread ID. 391 * @deprecated 392 */ 393 public static Uri getUri(long threadId) { 394 // TODO: Callers using this should really just have a Conversation 395 // and call getUri() on it, but this guarantees no blocking. 396 return ContentUris.withAppendedId(Threads.CONTENT_URI, threadId); 397 } 398 399 /** 400 * Returns the thread ID of this conversation. Can be zero if 401 * {@link #ensureThreadId} has not been called yet. 402 */ 403 public synchronized long getThreadId() { 404 return mThreadId; 405 } 406 407 /** 408 * Guarantees that the conversation has been created in the database. 409 * This will make a blocking database call if it hasn't. 410 * 411 * @return The thread ID of this conversation in the database 412 */ 413 public synchronized long ensureThreadId() { 414 if (DEBUG) { 415 LogTag.debug("ensureThreadId before: " + mThreadId); 416 } 417 if (mThreadId <= 0) { 418 mThreadId = getOrCreateThreadId(mContext, mRecipients); 419 } 420 if (DEBUG) { 421 LogTag.debug("ensureThreadId after: " + mThreadId); 422 } 423 424 return mThreadId; 425 } 426 427 public synchronized void clearThreadId() { 428 // remove ourself from the cache 429 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 430 LogTag.debug("clearThreadId old threadId was: " + mThreadId + " now zero"); 431 } 432 Cache.remove(mThreadId); 433 434 mThreadId = 0; 435 } 436 437 /** 438 * Sets the list of recipients associated with this conversation. 439 * If called, {@link #ensureThreadId} must be called before the next 440 * operation that depends on this conversation existing in the 441 * database (e.g. storing a draft message to it). 442 */ 443 public synchronized void setRecipients(ContactList list) { 444 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 445 Log.d(TAG, "setRecipients before: " + this.toString()); 446 } 447 mRecipients = list; 448 449 // Invalidate thread ID because the recipient set has changed. 450 mThreadId = 0; 451 452 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 453 Log.d(TAG, "setRecipients after: " + this.toString()); 454 } 455} 456 457 /** 458 * Returns the recipient set of this conversation. 459 */ 460 public synchronized ContactList getRecipients() { 461 return mRecipients; 462 } 463 464 /** 465 * Returns true if a draft message exists in this conversation. 466 */ 467 public synchronized boolean hasDraft() { 468 if (mThreadId <= 0) 469 return false; 470 471 return DraftCache.getInstance().hasDraft(mThreadId); 472 } 473 474 /** 475 * Sets whether or not this conversation has a draft message. 476 */ 477 public synchronized void setDraftState(boolean hasDraft) { 478 if (mThreadId <= 0) 479 return; 480 481 DraftCache.getInstance().setDraftState(mThreadId, hasDraft); 482 } 483 484 /** 485 * Returns the time of the last update to this conversation in milliseconds, 486 * on the {@link System#currentTimeMillis} timebase. 487 */ 488 public synchronized long getDate() { 489 return mDate; 490 } 491 492 /** 493 * Returns the number of messages in this conversation, excluding the draft 494 * (if it exists). 495 */ 496 public synchronized int getMessageCount() { 497 return mMessageCount; 498 } 499 /** 500 * Set the number of messages in this conversation, excluding the draft 501 * (if it exists). 502 */ 503 public synchronized void setMessageCount(int cnt) { 504 mMessageCount = cnt; 505 } 506 507 /** 508 * Returns a snippet of text from the most recent message in the conversation. 509 */ 510 public synchronized String getSnippet() { 511 return mSnippet; 512 } 513 514 /** 515 * Returns true if there are any unread messages in the conversation. 516 */ 517 public boolean hasUnreadMessages() { 518 synchronized (this) { 519 return mHasUnreadMessages; 520 } 521 } 522 523 private void setHasUnreadMessages(boolean flag) { 524 synchronized (this) { 525 mHasUnreadMessages = flag; 526 } 527 } 528 529 /** 530 * Returns true if any messages in the conversation have attachments. 531 */ 532 public synchronized boolean hasAttachment() { 533 return mHasAttachment; 534 } 535 536 /** 537 * Returns true if any messages in the conversation are in an error state. 538 */ 539 public synchronized boolean hasError() { 540 return mHasError; 541 } 542 543 /** 544 * Returns true if this conversation is selected for a multi-operation. 545 */ 546 public synchronized boolean isChecked() { 547 return mIsChecked; 548 } 549 550 public synchronized void setIsChecked(boolean isChecked) { 551 mIsChecked = isChecked; 552 } 553 554 private static long getOrCreateThreadId(Context context, ContactList list) { 555 HashSet<String> recipients = new HashSet<String>(); 556 Contact cacheContact = null; 557 for (Contact c : list) { 558 cacheContact = Contact.get(c.getNumber(), false); 559 if (cacheContact != null) { 560 recipients.add(cacheContact.getNumber()); 561 } else { 562 recipients.add(c.getNumber()); 563 } 564 } 565 long retVal = Threads.getOrCreateThreadId(context, recipients); 566 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 567 LogTag.debug("[Conversation] getOrCreateThreadId for (%s) returned %d", 568 recipients, retVal); 569 } 570 571 return retVal; 572 } 573 574 /* 575 * The primary key of a conversation is its recipient set; override 576 * equals() and hashCode() to just pass through to the internal 577 * recipient sets. 578 */ 579 @Override 580 public synchronized boolean equals(Object obj) { 581 try { 582 Conversation other = (Conversation)obj; 583 return (mRecipients.equals(other.mRecipients)); 584 } catch (ClassCastException e) { 585 return false; 586 } 587 } 588 589 @Override 590 public synchronized int hashCode() { 591 return mRecipients.hashCode(); 592 } 593 594 @Override 595 public synchronized String toString() { 596 return String.format("[%s] (tid %d)", mRecipients.serialize(), mThreadId); 597 } 598 599 /** 600 * Remove any obsolete conversations sitting around on disk. Obsolete threads are threads 601 * that aren't referenced by any message in the pdu or sms tables. 602 */ 603 public static void asyncDeleteObsoleteThreads(AsyncQueryHandler handler, int token) { 604 handler.startDelete(token, null, Threads.OBSOLETE_THREADS_URI, null, null); 605 } 606 607 /** 608 * Start a query for all conversations in the database on the specified 609 * AsyncQueryHandler. 610 * 611 * @param handler An AsyncQueryHandler that will receive onQueryComplete 612 * upon completion of the query 613 * @param token The token that will be passed to onQueryComplete 614 */ 615 public static void startQueryForAll(AsyncQueryHandler handler, int token) { 616 handler.cancelOperation(token); 617 618 // This query looks like this in the log: 619 // I/Database( 147): elapsedTime4Sql|/data/data/com.android.providers.telephony/databases/ 620 // mmssms.db|2.253 ms|SELECT _id, date, message_count, recipient_ids, snippet, snippet_cs, 621 // read, error, has_attachment FROM threads ORDER BY date DESC 622 623 startQuery(handler, token, null); 624 } 625 626 /** 627 * Start a query for in the database on the specified AsyncQueryHandler with the specified 628 * "where" clause. 629 * 630 * @param handler An AsyncQueryHandler that will receive onQueryComplete 631 * upon completion of the query 632 * @param token The token that will be passed to onQueryComplete 633 * @param selection A where clause (can be null) to select particular conv items. 634 */ 635 public static void startQuery(AsyncQueryHandler handler, int token, String selection) { 636 handler.cancelOperation(token); 637 638 // This query looks like this in the log: 639 // I/Database( 147): elapsedTime4Sql|/data/data/com.android.providers.telephony/databases/ 640 // mmssms.db|2.253 ms|SELECT _id, date, message_count, recipient_ids, snippet, snippet_cs, 641 // read, error, has_attachment FROM threads ORDER BY date DESC 642 643 handler.startQuery(token, null, sAllThreadsUri, 644 ALL_THREADS_PROJECTION, selection, null, Conversations.DEFAULT_SORT_ORDER); 645 } 646 647 /** 648 * Start a delete of the conversation with the specified thread ID. 649 * 650 * @param handler An AsyncQueryHandler that will receive onDeleteComplete 651 * upon completion of the conversation being deleted 652 * @param token The token that will be passed to onDeleteComplete 653 * @param deleteAll Delete the whole thread including locked messages 654 * @param threadId Thread ID of the conversation to be deleted 655 */ 656 public static void startDelete(AsyncQueryHandler handler, int token, boolean deleteAll, 657 long threadId) { 658 Uri uri = ContentUris.withAppendedId(Threads.CONTENT_URI, threadId); 659 String selection = deleteAll ? null : "locked=0"; 660 PduCache.getInstance().purge(uri); 661 handler.startDelete(token, new Long(threadId), uri, selection, null); 662 } 663 664 /** 665 * Start deleting all conversations in the database. 666 * @param handler An AsyncQueryHandler that will receive onDeleteComplete 667 * upon completion of all conversations being deleted 668 * @param token The token that will be passed to onDeleteComplete 669 * @param deleteAll Delete the whole thread including locked messages 670 */ 671 public static void startDeleteAll(AsyncQueryHandler handler, int token, boolean deleteAll) { 672 String selection = deleteAll ? null : "locked=0"; 673 PduCache.getInstance().purge(Threads.CONTENT_URI); 674 handler.startDelete(token, new Long(-1), Threads.CONTENT_URI, selection, null); 675 } 676 677 /** 678 * Check for locked messages in all threads or a specified thread. 679 * @param handler An AsyncQueryHandler that will receive onQueryComplete 680 * upon completion of looking for locked messages 681 * @param threadIds A list of threads to search. null means all threads 682 * @param token The token that will be passed to onQueryComplete 683 */ 684 public static void startQueryHaveLockedMessages(AsyncQueryHandler handler, 685 Collection<Long> threadIds, 686 int token) { 687 handler.cancelOperation(token); 688 Uri uri = MmsSms.CONTENT_LOCKED_URI; 689 690 String selection = null; 691 if (threadIds != null) { 692 StringBuilder buf = new StringBuilder(); 693 int i = 0; 694 695 for (long threadId : threadIds) { 696 if (i++ > 0) { 697 buf.append(" OR "); 698 } 699 // We have to build the selection arg into the selection because deep down in 700 // provider, the function buildUnionSubQuery takes selectionArgs, but ignores it. 701 buf.append(Mms.THREAD_ID).append("=").append(Long.toString(threadId)); 702 } 703 selection = buf.toString(); 704 } 705 handler.startQuery(token, threadIds, uri, 706 ALL_THREADS_PROJECTION, selection, null, Conversations.DEFAULT_SORT_ORDER); 707 } 708 709 /** 710 * Check for locked messages in all threads or a specified thread. 711 * @param handler An AsyncQueryHandler that will receive onQueryComplete 712 * upon completion of looking for locked messages 713 * @param threadId The threadId of the thread to search. -1 means all threads 714 * @param token The token that will be passed to onQueryComplete 715 */ 716 public static void startQueryHaveLockedMessages(AsyncQueryHandler handler, 717 long threadId, 718 int token) { 719 ArrayList<Long> threadIds = null; 720 if (threadId != -1) { 721 threadIds = new ArrayList<Long>(); 722 threadIds.add(threadId); 723 } 724 startQueryHaveLockedMessages(handler, threadIds, token); 725 } 726 727 /** 728 * Fill the specified conversation with the values from the specified 729 * cursor, possibly setting recipients to empty if {@value allowQuery} 730 * is false and the recipient IDs are not in cache. The cursor should 731 * be one made via {@link #startQueryForAll}. 732 */ 733 private static void fillFromCursor(Context context, Conversation conv, 734 Cursor c, boolean allowQuery) { 735 synchronized (conv) { 736 conv.mThreadId = c.getLong(ID); 737 conv.mDate = c.getLong(DATE); 738 conv.mMessageCount = c.getInt(MESSAGE_COUNT); 739 740 // Replace the snippet with a default value if it's empty. 741 String snippet = MessageUtils.extractEncStrFromCursor(c, SNIPPET, SNIPPET_CS); 742 if (TextUtils.isEmpty(snippet)) { 743 snippet = context.getString(R.string.no_subject_view); 744 } 745 conv.mSnippet = snippet; 746 747 conv.setHasUnreadMessages(c.getInt(READ) == 0); 748 conv.mHasError = (c.getInt(ERROR) != 0); 749 conv.mHasAttachment = (c.getInt(HAS_ATTACHMENT) != 0); 750 } 751 // Fill in as much of the conversation as we can before doing the slow stuff of looking 752 // up the contacts associated with this conversation. 753 String recipientIds = c.getString(RECIPIENT_IDS); 754 ContactList recipients = ContactList.getByIds(recipientIds, allowQuery); 755 synchronized (conv) { 756 conv.mRecipients = recipients; 757 } 758 759 if (Log.isLoggable(LogTag.THREAD_CACHE, Log.VERBOSE)) { 760 Log.d(TAG, "fillFromCursor: conv=" + conv + ", recipientIds=" + recipientIds); 761 } 762 } 763 764 /** 765 * Private cache for the use of the various forms of Conversation.get. 766 */ 767 private static class Cache { 768 private static Cache sInstance = new Cache(); 769 static Cache getInstance() { return sInstance; } 770 private final HashSet<Conversation> mCache; 771 private Cache() { 772 mCache = new HashSet<Conversation>(10); 773 } 774 775 /** 776 * Return the conversation with the specified thread ID, or 777 * null if it's not in cache. 778 */ 779 static Conversation get(long threadId) { 780 synchronized (sInstance) { 781 if (Log.isLoggable(LogTag.THREAD_CACHE, Log.VERBOSE)) { 782 LogTag.debug("Conversation get with threadId: " + threadId); 783 } 784 for (Conversation c : sInstance.mCache) { 785 if (DEBUG) { 786 LogTag.debug("Conversation get() threadId: " + threadId + 787 " c.getThreadId(): " + c.getThreadId()); 788 } 789 if (c.getThreadId() == threadId) { 790 return c; 791 } 792 } 793 } 794 return null; 795 } 796 797 /** 798 * Return the conversation with the specified recipient 799 * list, or null if it's not in cache. 800 */ 801 static Conversation get(ContactList list) { 802 synchronized (sInstance) { 803 if (Log.isLoggable(LogTag.THREAD_CACHE, Log.VERBOSE)) { 804 LogTag.debug("Conversation get with ContactList: " + list); 805 } 806 for (Conversation c : sInstance.mCache) { 807 if (c.getRecipients().equals(list)) { 808 return c; 809 } 810 } 811 } 812 return null; 813 } 814 815 /** 816 * Put the specified conversation in the cache. The caller 817 * should not place an already-existing conversation in the 818 * cache, but rather update it in place. 819 */ 820 static void put(Conversation c) { 821 synchronized (sInstance) { 822 // We update cache entries in place so people with long- 823 // held references get updated. 824 if (Log.isLoggable(LogTag.THREAD_CACHE, Log.VERBOSE)) { 825 Log.d(TAG, "Conversation.Cache.put: conv= " + c + ", hash: " + c.hashCode()); 826 } 827 828 if (sInstance.mCache.contains(c)) { 829 if (DEBUG) { 830 dumpCache(); 831 } 832 throw new IllegalStateException("cache already contains " + c + 833 " threadId: " + c.mThreadId); 834 } 835 sInstance.mCache.add(c); 836 } 837 } 838 839 /** 840 * Replace the specified conversation in the cache. This is used in cases where we 841 * lookup a conversation in the cache by threadId, but don't find it. The caller 842 * then builds a new conversation (from the cursor) and tries to add it, but gets 843 * an exception that the conversation is already in the cache, because the hash 844 * is based on the recipients and it's there under a stale threadId. In this function 845 * we remove the stale entry and add the new one. Returns true if the operation is 846 * successful 847 */ 848 static boolean replace(Conversation c) { 849 synchronized (sInstance) { 850 if (Log.isLoggable(LogTag.THREAD_CACHE, Log.VERBOSE)) { 851 LogTag.debug("Conversation.Cache.put: conv= " + c + ", hash: " + c.hashCode()); 852 } 853 854 if (!sInstance.mCache.contains(c)) { 855 if (DEBUG) { 856 dumpCache(); 857 } 858 return false; 859 } 860 // Here it looks like we're simply removing and then re-adding the same object 861 // to the hashset. Because the hashkey is the conversation's recipients, and not 862 // the thread id, we'll actually remove the object with the stale threadId and 863 // then add the the conversation with updated threadId, both having the same 864 // recipients. 865 sInstance.mCache.remove(c); 866 sInstance.mCache.add(c); 867 return true; 868 } 869 } 870 871 static void remove(long threadId) { 872 synchronized (sInstance) { 873 if (DEBUG) { 874 LogTag.debug("remove threadid: " + threadId); 875 dumpCache(); 876 } 877 for (Conversation c : sInstance.mCache) { 878 if (c.getThreadId() == threadId) { 879 sInstance.mCache.remove(c); 880 return; 881 } 882 } 883 } 884 } 885 886 static void dumpCache() { 887 synchronized (sInstance) { 888 LogTag.debug("Conversation dumpCache: "); 889 for (Conversation c : sInstance.mCache) { 890 LogTag.debug(" conv: " + c.toString() + " hash: " + c.hashCode()); 891 } 892 } 893 } 894 895 /** 896 * Remove all conversations from the cache that are not in 897 * the provided set of thread IDs. 898 */ 899 static void keepOnly(Set<Long> threads) { 900 synchronized (sInstance) { 901 Iterator<Conversation> iter = sInstance.mCache.iterator(); 902 while (iter.hasNext()) { 903 Conversation c = iter.next(); 904 if (!threads.contains(c.getThreadId())) { 905 iter.remove(); 906 } 907 } 908 } 909 if (DEBUG) { 910 LogTag.debug("after keepOnly"); 911 dumpCache(); 912 } 913 } 914 } 915 916 /** 917 * Set up the conversation cache. To be called once at application 918 * startup time. 919 */ 920 public static void init(final Context context) { 921 Thread thread = new Thread(new Runnable() { 922 @Override 923 public void run() { 924 cacheAllThreads(context); 925 } 926 }, "Conversation.init"); 927 thread.setPriority(Thread.MIN_PRIORITY); 928 thread.start(); 929 } 930 931 public static void markAllConversationsAsSeen(final Context context) { 932 if (DEBUG) { 933 LogTag.debug("Conversation.markAllConversationsAsSeen"); 934 } 935 936 Thread thread = new Thread(new Runnable() { 937 @Override 938 public void run() { 939 blockingMarkAllSmsMessagesAsSeen(context); 940 blockingMarkAllMmsMessagesAsSeen(context); 941 942 // Always update notifications regardless of the read state. 943 MessagingNotification.blockingUpdateAllNotifications(context); 944 } 945 }, "Conversation.markAllConversationsAsSeen"); 946 thread.setPriority(Thread.MIN_PRIORITY); 947 thread.start(); 948 } 949 950 private static void blockingMarkAllSmsMessagesAsSeen(final Context context) { 951 ContentResolver resolver = context.getContentResolver(); 952 Cursor cursor = resolver.query(Sms.Inbox.CONTENT_URI, 953 SEEN_PROJECTION, 954 "seen=0", 955 null, 956 null); 957 958 int count = 0; 959 960 if (cursor != null) { 961 try { 962 count = cursor.getCount(); 963 } finally { 964 cursor.close(); 965 } 966 } 967 968 if (count == 0) { 969 return; 970 } 971 972 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 973 Log.d(TAG, "mark " + count + " SMS msgs as seen"); 974 } 975 976 ContentValues values = new ContentValues(1); 977 values.put("seen", 1); 978 979 resolver.update(Sms.Inbox.CONTENT_URI, 980 values, 981 "seen=0", 982 null); 983 } 984 985 private static void blockingMarkAllMmsMessagesAsSeen(final Context context) { 986 ContentResolver resolver = context.getContentResolver(); 987 Cursor cursor = resolver.query(Mms.Inbox.CONTENT_URI, 988 SEEN_PROJECTION, 989 "seen=0", 990 null, 991 null); 992 993 int count = 0; 994 995 if (cursor != null) { 996 try { 997 count = cursor.getCount(); 998 } finally { 999 cursor.close(); 1000 } 1001 } 1002 1003 if (count == 0) { 1004 return; 1005 } 1006 1007 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { 1008 Log.d(TAG, "mark " + count + " MMS msgs as seen"); 1009 } 1010 1011 ContentValues values = new ContentValues(1); 1012 values.put("seen", 1); 1013 1014 resolver.update(Mms.Inbox.CONTENT_URI, 1015 values, 1016 "seen=0", 1017 null); 1018 1019 } 1020 1021 /** 1022 * Are we in the process of loading and caching all the threads?. 1023 */ 1024 public static boolean loadingThreads() { 1025 synchronized (Cache.getInstance()) { 1026 return mLoadingThreads; 1027 } 1028 } 1029 1030 private static void cacheAllThreads(Context context) { 1031 if (Log.isLoggable(LogTag.THREAD_CACHE, Log.VERBOSE)) { 1032 LogTag.debug("[Conversation] cacheAllThreads: begin"); 1033 } 1034 synchronized (Cache.getInstance()) { 1035 if (mLoadingThreads) { 1036 return; 1037 } 1038 mLoadingThreads = true; 1039 } 1040 1041 // Keep track of what threads are now on disk so we 1042 // can discard anything removed from the cache. 1043 HashSet<Long> threadsOnDisk = new HashSet<Long>(); 1044 1045 // Query for all conversations. 1046 Cursor c = context.getContentResolver().query(sAllThreadsUri, 1047 ALL_THREADS_PROJECTION, null, null, null); 1048 try { 1049 if (c != null) { 1050 while (c.moveToNext()) { 1051 long threadId = c.getLong(ID); 1052 threadsOnDisk.add(threadId); 1053 1054 // Try to find this thread ID in the cache. 1055 Conversation conv; 1056 synchronized (Cache.getInstance()) { 1057 conv = Cache.get(threadId); 1058 } 1059 1060 if (conv == null) { 1061 // Make a new Conversation and put it in 1062 // the cache if necessary. 1063 conv = new Conversation(context, c, true); 1064 try { 1065 synchronized (Cache.getInstance()) { 1066 Cache.put(conv); 1067 } 1068 } catch (IllegalStateException e) { 1069 LogTag.error("Tried to add duplicate Conversation to Cache" + 1070 " for threadId: " + threadId + " new conv: " + conv); 1071 if (!Cache.replace(conv)) { 1072 LogTag.error("cacheAllThreads cache.replace failed on " + conv); 1073 } 1074 } 1075 } else { 1076 // Or update in place so people with references 1077 // to conversations get updated too. 1078 fillFromCursor(context, conv, c, true); 1079 } 1080 } 1081 } 1082 } finally { 1083 if (c != null) { 1084 c.close(); 1085 } 1086 synchronized (Cache.getInstance()) { 1087 mLoadingThreads = false; 1088 } 1089 } 1090 1091 // Purge the cache of threads that no longer exist on disk. 1092 Cache.keepOnly(threadsOnDisk); 1093 1094 if (Log.isLoggable(LogTag.THREAD_CACHE, Log.VERBOSE)) { 1095 LogTag.debug("[Conversation] cacheAllThreads: finished"); 1096 Cache.dumpCache(); 1097 } 1098 } 1099 1100 private boolean loadFromThreadId(long threadId, boolean allowQuery) { 1101 Cursor c = mContext.getContentResolver().query(sAllThreadsUri, ALL_THREADS_PROJECTION, 1102 "_id=" + Long.toString(threadId), null, null); 1103 try { 1104 if (c.moveToFirst()) { 1105 fillFromCursor(mContext, this, c, allowQuery); 1106 1107 if (threadId != mThreadId) { 1108 LogTag.error("loadFromThreadId: fillFromCursor returned differnt thread_id!" + 1109 " threadId=" + threadId + ", mThreadId=" + mThreadId); 1110 } 1111 } else { 1112 LogTag.error("loadFromThreadId: Can't find thread ID " + threadId); 1113 return false; 1114 } 1115 } finally { 1116 c.close(); 1117 } 1118 return true; 1119 } 1120 1121 public static String getRecipients(Uri uri) { 1122 String base = uri.getSchemeSpecificPart(); 1123 int pos = base.indexOf('?'); 1124 return (pos == -1) ? base : base.substring(0, pos); 1125 } 1126 1127 public static void dump() { 1128 Cache.dumpCache(); 1129 } 1130 1131 public static void dumpThreadsTable(Context context) { 1132 LogTag.debug("**** Dump of threads table ****"); 1133 Cursor c = context.getContentResolver().query(sAllThreadsUri, 1134 ALL_THREADS_PROJECTION, null, null, "date ASC"); 1135 try { 1136 c.moveToPosition(-1); 1137 while (c.moveToNext()) { 1138 String snippet = MessageUtils.extractEncStrFromCursor(c, SNIPPET, SNIPPET_CS); 1139 Log.d(TAG, "dumpThreadsTable threadId: " + c.getLong(ID) + 1140 " " + ThreadsColumns.DATE + " : " + c.getLong(DATE) + 1141 " " + ThreadsColumns.MESSAGE_COUNT + " : " + c.getInt(MESSAGE_COUNT) + 1142 " " + ThreadsColumns.SNIPPET + " : " + snippet + 1143 " " + ThreadsColumns.READ + " : " + c.getInt(READ) + 1144 " " + ThreadsColumns.ERROR + " : " + c.getInt(ERROR) + 1145 " " + ThreadsColumns.HAS_ATTACHMENT + " : " + c.getInt(HAS_ATTACHMENT) + 1146 " " + ThreadsColumns.RECIPIENT_IDS + " : " + c.getString(RECIPIENT_IDS)); 1147 1148 ContactList recipients = ContactList.getByIds(c.getString(RECIPIENT_IDS), false); 1149 Log.d(TAG, "----recipients: " + recipients.serialize()); 1150 } 1151 } finally { 1152 c.close(); 1153 } 1154 } 1155 1156 static final String[] SMS_PROJECTION = new String[] { 1157 BaseColumns._ID, 1158 // For SMS 1159 Sms.THREAD_ID, 1160 Sms.ADDRESS, 1161 Sms.BODY, 1162 Sms.DATE, 1163 Sms.READ, 1164 Sms.TYPE, 1165 Sms.STATUS, 1166 Sms.LOCKED, 1167 Sms.ERROR_CODE, 1168 }; 1169 1170 // The indexes of the default columns which must be consistent 1171 // with above PROJECTION. 1172 static final int COLUMN_ID = 0; 1173 static final int COLUMN_THREAD_ID = 1; 1174 static final int COLUMN_SMS_ADDRESS = 2; 1175 static final int COLUMN_SMS_BODY = 3; 1176 static final int COLUMN_SMS_DATE = 4; 1177 static final int COLUMN_SMS_READ = 5; 1178 static final int COLUMN_SMS_TYPE = 6; 1179 static final int COLUMN_SMS_STATUS = 7; 1180 static final int COLUMN_SMS_LOCKED = 8; 1181 static final int COLUMN_SMS_ERROR_CODE = 9; 1182 1183 public static void dumpSmsTable(Context context) { 1184 LogTag.debug("**** Dump of sms table ****"); 1185 Cursor c = context.getContentResolver().query(Sms.CONTENT_URI, 1186 SMS_PROJECTION, null, null, "_id DESC"); 1187 try { 1188 // Only dump the latest 20 messages 1189 c.moveToPosition(-1); 1190 while (c.moveToNext() && c.getPosition() < 20) { 1191 String body = c.getString(COLUMN_SMS_BODY); 1192 LogTag.debug("dumpSmsTable " + BaseColumns._ID + ": " + c.getLong(COLUMN_ID) + 1193 " " + Sms.THREAD_ID + " : " + c.getLong(DATE) + 1194 " " + Sms.ADDRESS + " : " + c.getString(COLUMN_SMS_ADDRESS) + 1195 " " + Sms.BODY + " : " + body.substring(0, Math.min(body.length(), 8)) + 1196 " " + Sms.DATE + " : " + c.getLong(COLUMN_SMS_DATE) + 1197 " " + Sms.TYPE + " : " + c.getInt(COLUMN_SMS_TYPE)); 1198 } 1199 } finally { 1200 c.close(); 1201 } 1202 } 1203 1204 /** 1205 * verifySingleRecipient takes a threadId and a string recipient [phone number or email 1206 * address]. It uses that threadId to lookup the row in the threads table and grab the 1207 * recipient ids column. The recipient ids column contains a space-separated list of 1208 * recipient ids. These ids are keys in the canonical_addresses table. The recipient is 1209 * compared against what's stored in the mmssms.db, but only if the recipient id list has 1210 * a single address. 1211 * @param context is used for getting a ContentResolver 1212 * @param threadId of the thread we're sending to 1213 * @param recipientStr is a phone number or email address 1214 * @return the verified number or email of the recipient 1215 */ 1216 public static String verifySingleRecipient(final Context context, 1217 final long threadId, final String recipientStr) { 1218 if (threadId <= 0) { 1219 LogTag.error("verifySingleRecipient threadId is ZERO, recipient: " + recipientStr); 1220 LogTag.dumpInternalTables(context); 1221 return recipientStr; 1222 } 1223 Cursor c = context.getContentResolver().query(sAllThreadsUri, ALL_THREADS_PROJECTION, 1224 "_id=" + Long.toString(threadId), null, null); 1225 if (c == null) { 1226 LogTag.error("verifySingleRecipient threadId: " + threadId + 1227 " resulted in NULL cursor , recipient: " + recipientStr); 1228 LogTag.dumpInternalTables(context); 1229 return recipientStr; 1230 } 1231 String address = recipientStr; 1232 String recipientIds; 1233 try { 1234 if (!c.moveToFirst()) { 1235 LogTag.error("verifySingleRecipient threadId: " + threadId + 1236 " can't moveToFirst , recipient: " + recipientStr); 1237 LogTag.dumpInternalTables(context); 1238 return recipientStr; 1239 } 1240 recipientIds = c.getString(RECIPIENT_IDS); 1241 } finally { 1242 c.close(); 1243 } 1244 String[] ids = recipientIds.split(" "); 1245 1246 if (ids.length != 1) { 1247 // We're only verifying the situation where we have a single recipient input against 1248 // a thread with a single recipient. If the thread has multiple recipients, just 1249 // assume the input number is correct and return it. 1250 return recipientStr; 1251 } 1252 1253 // Get the actual number from the canonical_addresses table for this recipientId 1254 address = RecipientIdCache.getSingleAddressFromCanonicalAddressInDb(context, ids[0]); 1255 1256 if (TextUtils.isEmpty(address)) { 1257 LogTag.error("verifySingleRecipient threadId: " + threadId + 1258 " getSingleNumberFromCanonicalAddresses returned empty number for: " + 1259 ids[0] + " recipientIds: " + recipientIds); 1260 LogTag.dumpInternalTables(context); 1261 return recipientStr; 1262 } 1263 if (PhoneNumberUtils.compareLoosely(recipientStr, address)) { 1264 // Bingo, we've got a match. We're returning the input number because of area 1265 // codes. We could have a number in the canonical_address name of "232-1012" and 1266 // assume the user's phone's area code is 650. If the user sends a message to 1267 // "(415) 232-1012", it will loosely match "232-1202". If we returned the value 1268 // from the table (232-1012), the message would go to the wrong person (to the 1269 // person in the 650 area code rather than in the 415 area code). 1270 return recipientStr; 1271 } 1272 1273 if (context instanceof Activity) { 1274 LogTag.warnPossibleRecipientMismatch("verifySingleRecipient for threadId: " + 1275 threadId + " original recipient: " + recipientStr + 1276 " recipient from DB: " + address, (Activity)context); 1277 } 1278 LogTag.dumpInternalTables(context); 1279 if (Log.isLoggable(LogTag.THREAD_CACHE, Log.VERBOSE)) { 1280 LogTag.debug("verifySingleRecipient for threadId: " + 1281 threadId + " original recipient: " + recipientStr + 1282 " recipient from DB: " + address); 1283 } 1284 return address; 1285 } 1286} 1287