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