1 package com.android.mms.data;
3 import java.util.HashSet;
4 import java.util.Iterator;
7 import android.content.AsyncQueryHandler;
8 import android.content.ContentResolver;
9 import android.content.ContentUris;
10 import android.content.ContentValues;
11 import android.content.Context;
12 import android.database.Cursor;
13 import android.net.Uri;
14 import android.provider.Telephony.Mms;
15 import android.provider.Telephony.MmsSms;
16 import android.provider.Telephony.Sms;
17 import android.provider.Telephony.Threads;
18 import android.provider.Telephony.Sms.Conversations;
19 import android.text.TextUtils;
20 import android.util.Log;
22 import com.android.mms.LogTag;
23 import com.android.mms.R;
24 import com.android.mms.transaction.MessagingNotification;
25 import com.android.mms.ui.MessageUtils;
26 import com.android.mms.util.DraftCache;
29 * An interface for finding information about conversations and/or creating new ones.
31 public class Conversation {
32 private static final String TAG = "Mms/conv";
33 private static final boolean DEBUG = false;
35 private static final Uri sAllThreadsUri =
36 Threads.CONTENT_URI.buildUpon().appendQueryParameter("simple", "true").build();
38 private static final String[] ALL_THREADS_PROJECTION = {
39 Threads._ID, Threads.DATE, Threads.MESSAGE_COUNT, Threads.RECIPIENT_IDS,
40 Threads.SNIPPET, Threads.SNIPPET_CHARSET, Threads.READ, Threads.ERROR,
41 Threads.HAS_ATTACHMENT
44 private static final String[] UNREAD_PROJECTION = {
49 private static final String UNREAD_SELECTION = "(read=0 OR seen=0)";
51 private static final String[] SEEN_PROJECTION = new String[] {
55 private static final int ID = 0;
56 private static final int DATE = 1;
57 private static final int MESSAGE_COUNT = 2;
58 private static final int RECIPIENT_IDS = 3;
59 private static final int SNIPPET = 4;
60 private static final int SNIPPET_CS = 5;
61 private static final int READ = 6;
62 private static final int ERROR = 7;
63 private static final int HAS_ATTACHMENT = 8;
66 private final Context mContext;
68 // The thread ID of this conversation. Can be zero in the case of a
69 // new conversation where the recipient set is changing as the user
70 // types and we have not hit the database yet to create a thread.
71 private long mThreadId;
73 private ContactList mRecipients; // The current set of recipients.
74 private long mDate; // The last update time.
75 private int mMessageCount; // Number of messages.
76 private String mSnippet; // Text of the most recent message.
77 private boolean mHasUnreadMessages; // True if there are unread messages.
78 private boolean mHasAttachment; // True if any message has an attachment.
79 private boolean mHasError; // True if any message is in an error state.
81 private static ContentValues mReadContentValues;
82 private static boolean mLoadingThreads;
83 private boolean mMarkAsReadBlocked;
84 private Object mMarkAsBlockedSyncer = new Object();
86 private Conversation(Context context) {
88 mRecipients = new ContactList();
92 private Conversation(Context context, long threadId, boolean allowQuery) {
94 if (!loadFromThreadId(threadId, allowQuery)) {
95 mRecipients = new ContactList();
100 private Conversation(Context context, Cursor cursor, boolean allowQuery) {
102 fillFromCursor(context, this, cursor, allowQuery);
106 * Create a new conversation with no recipients. {@link #setRecipients} can
107 * be called as many times as you like; the conversation will not be
108 * created in the database until {@link #ensureThreadId} is called.
110 public static Conversation createNew(Context context) {
111 return new Conversation(context);
115 * Find the conversation matching the provided thread ID.
117 public static Conversation get(Context context, long threadId, boolean allowQuery) {
118 Conversation conv = Cache.get(threadId);
122 conv = new Conversation(context, threadId, allowQuery);
125 } catch (IllegalStateException e) {
126 LogTag.error("Tried to add duplicate Conversation to Cache");
132 * Find the conversation matching the provided recipient set.
133 * When called with an empty recipient list, equivalent to {@link #createNew}.
135 public static Conversation get(Context context, ContactList recipients, boolean allowQuery) {
136 // If there are no recipients in the list, make a new conversation.
137 if (recipients.size() < 1) {
138 return createNew(context);
141 Conversation conv = Cache.get(recipients);
145 long threadId = getOrCreateThreadId(context, recipients);
146 conv = new Conversation(context, threadId, allowQuery);
147 Log.d(TAG, "Conversation.get: created new conversation " + /*conv.toString()*/ "xxxxxxx");
149 if (!conv.getRecipients().equals(recipients)) {
150 Log.e(TAG, "Conversation.get: new conv's recipients don't match input recpients "
151 + /*recipients*/ "xxxxxxx");
156 } catch (IllegalStateException e) {
157 LogTag.error("Tried to add duplicate Conversation to Cache");
164 * Find the conversation matching in the specified Uri. Example
165 * forms: {@value content://mms-sms/conversations/3} or
166 * {@value sms:+12124797990}.
167 * When called with a null Uri, equivalent to {@link #createNew}.
169 public static Conversation get(Context context, Uri uri, boolean allowQuery) {
171 return createNew(context);
174 if (DEBUG) Log.v(TAG, "Conversation get URI: " + uri);
176 // Handle a conversation URI
177 if (uri.getPathSegments().size() >= 2) {
179 long threadId = Long.parseLong(uri.getPathSegments().get(1));
181 Log.v(TAG, "Conversation get threadId: " + threadId);
183 return get(context, threadId, allowQuery);
184 } catch (NumberFormatException exception) {
185 LogTag.error("Invalid URI: " + uri);
189 String recipient = uri.getSchemeSpecificPart();
190 return get(context, ContactList.getByNumbers(recipient,
191 allowQuery /* don't block */, true /* replace number */), allowQuery);
195 * Returns true if the recipient in the uri matches the recipient list in this
198 public boolean sameRecipient(Uri uri) {
199 int size = mRecipients.size();
206 if (uri.getPathSegments().size() >= 2) {
207 return false; // it's a thread id for a conversation
209 String recipient = uri.getSchemeSpecificPart();
210 ContactList incomingRecipient = ContactList.getByNumbers(recipient,
211 false /* don't block */, false /* don't replace number */);
212 return mRecipients.equals(incomingRecipient);
216 * Returns a temporary Conversation (not representing one on disk) wrapping
217 * the contents of the provided cursor. The cursor should be the one
218 * returned to your AsyncQueryHandler passed in to {@link #startQueryForAll}.
219 * The recipient list of this conversation can be empty if the results
222 public static Conversation from(Context context, Cursor cursor) {
223 // First look in the cache for the Conversation and return that one. That way, all the
224 // people that are looking at the cached copy will get updated when fillFromCursor() is
225 // called with this cursor.
226 long threadId = cursor.getLong(ID);
228 Conversation conv = Cache.get(threadId);
230 fillFromCursor(context, conv, cursor, false); // update the existing conv in-place
234 Conversation conv = new Conversation(context, cursor, false);
237 } catch (IllegalStateException e) {
238 LogTag.error("Tried to add duplicate Conversation to Cache");
243 private void buildReadContentValues() {
244 if (mReadContentValues == null) {
245 mReadContentValues = new ContentValues(2);
246 mReadContentValues.put("read", 1);
247 mReadContentValues.put("seen", 1);
252 * Marks all messages in this conversation as read and updates
253 * relevant notifications. This method returns immediately;
254 * work is dispatched to a background thread.
256 public void markAsRead() {
257 // If we have no Uri to mark (as in the case of a conversation that
258 // has not yet made its way to disk), there's nothing to do.
259 final Uri threadUri = getUri();
261 new Thread(new Runnable() {
263 synchronized(mMarkAsBlockedSyncer) {
264 if (mMarkAsReadBlocked) {
266 mMarkAsBlockedSyncer.wait();
267 } catch (InterruptedException e) {
271 if (threadUri != null) {
272 buildReadContentValues();
274 // Check the read flag first. It's much faster to do a query than
275 // to do an update. Timing this function show it's about 10x faster to
276 // do the query compared to the update, even when there's nothing to
278 boolean needUpdate = true;
280 Cursor c = mContext.getContentResolver().query(threadUri,
281 UNREAD_PROJECTION, UNREAD_SELECTION, null, null);
284 needUpdate = c.getCount() > 0;
291 LogTag.debug("markAsRead: update read/seen for thread uri: " +
293 mContext.getContentResolver().update(threadUri, mReadContentValues,
294 UNREAD_SELECTION, null);
297 setHasUnreadMessages(false);
301 // Always update notifications regardless of the read state.
302 MessagingNotification.blockingUpdateAllNotifications(mContext);
307 public void blockMarkAsRead(boolean block) {
308 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
309 LogTag.debug("blockMarkAsRead: " + block);
312 synchronized(mMarkAsBlockedSyncer) {
313 if (block != mMarkAsReadBlocked) {
314 mMarkAsReadBlocked = block;
315 if (!mMarkAsReadBlocked) {
316 mMarkAsBlockedSyncer.notifyAll();
324 * Returns a content:// URI referring to this conversation,
325 * or null if it does not exist on disk yet.
327 public synchronized Uri getUri() {
331 return ContentUris.withAppendedId(Threads.CONTENT_URI, mThreadId);
335 * Return the Uri for all messages in the given thread ID.
338 public static Uri getUri(long threadId) {
339 // TODO: Callers using this should really just have a Conversation
340 // and call getUri() on it, but this guarantees no blocking.
341 return ContentUris.withAppendedId(Threads.CONTENT_URI, threadId);
345 * Returns the thread ID of this conversation. Can be zero if
346 * {@link #ensureThreadId} has not been called yet.
348 public synchronized long getThreadId() {
353 * Guarantees that the conversation has been created in the database.
354 * This will make a blocking database call if it hasn't.
356 * @return The thread ID of this conversation in the database
358 public synchronized long ensureThreadId() {
360 LogTag.debug("ensureThreadId before: " + mThreadId);
362 if (mThreadId <= 0) {
363 mThreadId = getOrCreateThreadId(mContext, mRecipients);
366 LogTag.debug("ensureThreadId after: " + mThreadId);
372 public synchronized void clearThreadId() {
373 // remove ourself from the cache
374 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
375 LogTag.debug("clearThreadId old threadId was: " + mThreadId + " now zero");
377 Cache.remove(mThreadId);
383 * Sets the list of recipients associated with this conversation.
384 * If called, {@link #ensureThreadId} must be called before the next
385 * operation that depends on this conversation existing in the
386 * database (e.g. storing a draft message to it).
388 public synchronized void setRecipients(ContactList list) {
391 // Invalidate thread ID because the recipient set has changed.
396 * Returns the recipient set of this conversation.
398 public synchronized ContactList getRecipients() {
403 * Returns true if a draft message exists in this conversation.
405 public synchronized boolean hasDraft() {
409 return DraftCache.getInstance().hasDraft(mThreadId);
413 * Sets whether or not this conversation has a draft message.
415 public synchronized void setDraftState(boolean hasDraft) {
419 DraftCache.getInstance().setDraftState(mThreadId, hasDraft);
423 * Returns the time of the last update to this conversation in milliseconds,
424 * on the {@link System#currentTimeMillis} timebase.
426 public synchronized long getDate() {
431 * Returns the number of messages in this conversation, excluding the draft
434 public synchronized int getMessageCount() {
435 return mMessageCount;
439 * Returns a snippet of text from the most recent message in the conversation.
441 public synchronized String getSnippet() {
446 * Returns true if there are any unread messages in the conversation.
448 public boolean hasUnreadMessages() {
449 synchronized (this) {
450 return mHasUnreadMessages;
454 private void setHasUnreadMessages(boolean flag) {
455 synchronized (this) {
456 mHasUnreadMessages = flag;
461 * Returns true if any messages in the conversation have attachments.
463 public synchronized boolean hasAttachment() {
464 return mHasAttachment;
468 * Returns true if any messages in the conversation are in an error state.
470 public synchronized boolean hasError() {
474 private static long getOrCreateThreadId(Context context, ContactList list) {
475 HashSet<String> recipients = new HashSet<String>();
476 Contact cacheContact = null;
477 for (Contact c : list) {
478 cacheContact = Contact.get(c.getNumber(), false);
479 if (cacheContact != null) {
480 recipients.add(cacheContact.getNumber());
482 recipients.add(c.getNumber());
485 long retVal = Threads.getOrCreateThreadId(context, recipients);
486 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
487 LogTag.debug("[Conversation] getOrCreateThreadId for (%s) returned %d",
495 * The primary key of a conversation is its recipient set; override
496 * equals() and hashCode() to just pass through to the internal
500 public synchronized boolean equals(Object obj) {
502 Conversation other = (Conversation)obj;
503 return (mRecipients.equals(other.mRecipients));
504 } catch (ClassCastException e) {
510 public synchronized int hashCode() {
511 return mRecipients.hashCode();
515 public synchronized String toString() {
516 return String.format("[%s] (tid %d)", mRecipients.serialize(), mThreadId);
520 * Remove any obsolete conversations sitting around on disk. Obsolete threads are threads
521 * that aren't referenced by any message in the pdu or sms tables.
523 public static void asyncDeleteObsoleteThreads(AsyncQueryHandler handler, int token) {
524 handler.startDelete(token, null, Threads.OBSOLETE_THREADS_URI, null, null);
528 * Start a query for all conversations in the database on the specified
531 * @param handler An AsyncQueryHandler that will receive onQueryComplete
532 * upon completion of the query
533 * @param token The token that will be passed to onQueryComplete
535 public static void startQueryForAll(AsyncQueryHandler handler, int token) {
536 handler.cancelOperation(token);
538 // This query looks like this in the log:
539 // I/Database( 147): elapsedTime4Sql|/data/data/com.android.providers.telephony/databases/
540 // mmssms.db|2.253 ms|SELECT _id, date, message_count, recipient_ids, snippet, snippet_cs,
541 // read, error, has_attachment FROM threads ORDER BY date DESC
543 handler.startQuery(token, null, sAllThreadsUri,
544 ALL_THREADS_PROJECTION, null, null, Conversations.DEFAULT_SORT_ORDER);
548 * Start a delete of the conversation with the specified thread ID.
550 * @param handler An AsyncQueryHandler that will receive onDeleteComplete
551 * upon completion of the conversation being deleted
552 * @param token The token that will be passed to onDeleteComplete
553 * @param deleteAll Delete the whole thread including locked messages
554 * @param threadId Thread ID of the conversation to be deleted
556 public static void startDelete(AsyncQueryHandler handler, int token, boolean deleteAll,
558 Uri uri = ContentUris.withAppendedId(Threads.CONTENT_URI, threadId);
559 String selection = deleteAll ? null : "locked=0";
560 handler.startDelete(token, null, uri, selection, null);
564 * Start deleting all conversations in the database.
565 * @param handler An AsyncQueryHandler that will receive onDeleteComplete
566 * upon completion of all conversations being deleted
567 * @param token The token that will be passed to onDeleteComplete
568 * @param deleteAll Delete the whole thread including locked messages
570 public static void startDeleteAll(AsyncQueryHandler handler, int token, boolean deleteAll) {
571 String selection = deleteAll ? null : "locked=0";
572 handler.startDelete(token, null, Threads.CONTENT_URI, selection, null);
576 * Check for locked messages in all threads or a specified thread.
577 * @param handler An AsyncQueryHandler that will receive onQueryComplete
578 * upon completion of looking for locked messages
579 * @param threadId The threadId of the thread to search. -1 means all threads
580 * @param token The token that will be passed to onQueryComplete
582 public static void startQueryHaveLockedMessages(AsyncQueryHandler handler, long threadId,
584 handler.cancelOperation(token);
585 Uri uri = MmsSms.CONTENT_LOCKED_URI;
586 if (threadId != -1) {
587 uri = ContentUris.withAppendedId(uri, threadId);
589 handler.startQuery(token, new Long(threadId), uri,
590 ALL_THREADS_PROJECTION, null, null, Conversations.DEFAULT_SORT_ORDER);
594 * Fill the specified conversation with the values from the specified
595 * cursor, possibly setting recipients to empty if {@value allowQuery}
596 * is false and the recipient IDs are not in cache. The cursor should
597 * be one made via {@link #startQueryForAll}.
599 private static void fillFromCursor(Context context, Conversation conv,
600 Cursor c, boolean allowQuery) {
601 synchronized (conv) {
602 conv.mThreadId = c.getLong(ID);
603 conv.mDate = c.getLong(DATE);
604 conv.mMessageCount = c.getInt(MESSAGE_COUNT);
606 // Replace the snippet with a default value if it's empty.
607 String snippet = MessageUtils.extractEncStrFromCursor(c, SNIPPET, SNIPPET_CS);
608 if (TextUtils.isEmpty(snippet)) {
609 snippet = context.getString(R.string.no_subject_view);
611 conv.mSnippet = snippet;
613 conv.setHasUnreadMessages(c.getInt(READ) == 0);
614 conv.mHasError = (c.getInt(ERROR) != 0);
615 conv.mHasAttachment = (c.getInt(HAS_ATTACHMENT) != 0);
617 // Fill in as much of the conversation as we can before doing the slow stuff of looking
618 // up the contacts associated with this conversation.
619 String recipientIds = c.getString(RECIPIENT_IDS);
620 ContactList recipients = ContactList.getByIds(recipientIds, allowQuery);
621 synchronized (conv) {
622 conv.mRecipients = recipients;
625 if (Log.isLoggable(LogTag.THREAD_CACHE, Log.VERBOSE)) {
626 LogTag.debug("fillFromCursor: conv=" + conv + ", recipientIds=" + recipientIds);
631 * Private cache for the use of the various forms of Conversation.get.
633 private static class Cache {
634 private static Cache sInstance = new Cache();
635 static Cache getInstance() { return sInstance; }
636 private final HashSet<Conversation> mCache;
638 mCache = new HashSet<Conversation>(10);
642 * Return the conversation with the specified thread ID, or
643 * null if it's not in cache.
645 static Conversation get(long threadId) {
646 synchronized (sInstance) {
647 if (Log.isLoggable(LogTag.THREAD_CACHE, Log.VERBOSE)) {
648 LogTag.debug("Conversation get with threadId: " + threadId);
650 for (Conversation c : sInstance.mCache) {
652 LogTag.debug("Conversation get() threadId: " + threadId +
653 " c.getThreadId(): " + c.getThreadId());
655 if (c.getThreadId() == threadId) {
664 * Return the conversation with the specified recipient
665 * list, or null if it's not in cache.
667 static Conversation get(ContactList list) {
668 synchronized (sInstance) {
669 if (Log.isLoggable(LogTag.THREAD_CACHE, Log.VERBOSE)) {
670 LogTag.debug("Conversation get with ContactList: " + list);
672 for (Conversation c : sInstance.mCache) {
673 if (c.getRecipients().equals(list)) {
682 * Put the specified conversation in the cache. The caller
683 * should not place an already-existing conversation in the
684 * cache, but rather update it in place.
686 static void put(Conversation c) {
687 synchronized (sInstance) {
688 // We update cache entries in place so people with long-
689 // held references get updated.
690 if (Log.isLoggable(LogTag.THREAD_CACHE, Log.VERBOSE)) {
691 LogTag.debug("Conversation.Cache.put: conv= " + c + ", hash: " + c.hashCode());
694 if (sInstance.mCache.contains(c)) {
695 throw new IllegalStateException("cache already contains " + c +
696 " threadId: " + c.mThreadId);
698 sInstance.mCache.add(c);
702 static void remove(long threadId) {
704 LogTag.debug("remove threadid: " + threadId);
707 for (Conversation c : sInstance.mCache) {
708 if (c.getThreadId() == threadId) {
709 sInstance.mCache.remove(c);
715 static void dumpCache() {
716 synchronized (sInstance) {
717 LogTag.debug("Conversation dumpCache: ");
718 for (Conversation c : sInstance.mCache) {
719 LogTag.debug(" conv: " + c.toString() + " hash: " + c.hashCode());
725 * Remove all conversations from the cache that are not in
726 * the provided set of thread IDs.
728 static void keepOnly(Set<Long> threads) {
729 synchronized (sInstance) {
730 Iterator<Conversation> iter = sInstance.mCache.iterator();
731 while (iter.hasNext()) {
732 Conversation c = iter.next();
733 if (!threads.contains(c.getThreadId())) {
739 LogTag.debug("after keepOnly");
746 * Set up the conversation cache. To be called once at application
749 public static void init(final Context context) {
750 new Thread(new Runnable() {
752 cacheAllThreads(context);
757 public static void markAllConversationsAsSeen(final Context context) {
759 LogTag.debug("Conversation.markAllConversationsAsSeen");
762 new Thread(new Runnable() {
764 blockingMarkAllSmsMessagesAsSeen(context);
765 blockingMarkAllMmsMessagesAsSeen(context);
767 // Always update notifications regardless of the read state.
768 MessagingNotification.blockingUpdateAllNotifications(context);
773 private static void blockingMarkAllSmsMessagesAsSeen(final Context context) {
774 ContentResolver resolver = context.getContentResolver();
775 Cursor cursor = resolver.query(Sms.Inbox.CONTENT_URI,
783 if (cursor != null) {
785 count = cursor.getCount();
795 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
796 Log.d(TAG, "mark " + count + " SMS msgs as seen");
799 ContentValues values = new ContentValues(1);
800 values.put("seen", 1);
802 resolver.update(Sms.Inbox.CONTENT_URI,
808 private static void blockingMarkAllMmsMessagesAsSeen(final Context context) {
809 ContentResolver resolver = context.getContentResolver();
810 Cursor cursor = resolver.query(Mms.Inbox.CONTENT_URI,
818 if (cursor != null) {
820 count = cursor.getCount();
830 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
831 Log.d(TAG, "mark " + count + " MMS msgs as seen");
834 ContentValues values = new ContentValues(1);
835 values.put("seen", 1);
837 resolver.update(Mms.Inbox.CONTENT_URI,
845 * Are we in the process of loading and caching all the threads?.
847 public static boolean loadingThreads() {
848 synchronized (Cache.getInstance()) {
849 return mLoadingThreads;
853 private static void cacheAllThreads(Context context) {
854 if (Log.isLoggable(LogTag.THREAD_CACHE, Log.VERBOSE)) {
855 LogTag.debug("[Conversation] cacheAllThreads: begin");
857 synchronized (Cache.getInstance()) {
858 if (mLoadingThreads) {
861 mLoadingThreads = true;
864 // Keep track of what threads are now on disk so we
865 // can discard anything removed from the cache.
866 HashSet<Long> threadsOnDisk = new HashSet<Long>();
868 // Query for all conversations.
869 Cursor c = context.getContentResolver().query(sAllThreadsUri,
870 ALL_THREADS_PROJECTION, null, null, null);
873 while (c.moveToNext()) {
874 long threadId = c.getLong(ID);
875 threadsOnDisk.add(threadId);
877 // Try to find this thread ID in the cache.
879 synchronized (Cache.getInstance()) {
880 conv = Cache.get(threadId);
884 // Make a new Conversation and put it in
885 // the cache if necessary.
886 conv = new Conversation(context, c, true);
888 synchronized (Cache.getInstance()) {
891 } catch (IllegalStateException e) {
892 LogTag.error("Tried to add duplicate Conversation to Cache");
895 // Or update in place so people with references
896 // to conversations get updated too.
897 fillFromCursor(context, conv, c, true);
905 synchronized (Cache.getInstance()) {
906 mLoadingThreads = false;
910 // Purge the cache of threads that no longer exist on disk.
911 Cache.keepOnly(threadsOnDisk);
913 if (Log.isLoggable(LogTag.THREAD_CACHE, Log.VERBOSE)) {
914 LogTag.debug("[Conversation] cacheAllThreads: finished");
919 private boolean loadFromThreadId(long threadId, boolean allowQuery) {
920 Cursor c = mContext.getContentResolver().query(sAllThreadsUri, ALL_THREADS_PROJECTION,
921 "_id=" + Long.toString(threadId), null, null);
923 if (c.moveToFirst()) {
924 fillFromCursor(mContext, this, c, allowQuery);
926 if (threadId != mThreadId) {
927 LogTag.error("loadFromThreadId: fillFromCursor returned differnt thread_id!" +
928 " threadId=" + threadId + ", mThreadId=" + mThreadId);
931 LogTag.error("loadFromThreadId: Can't find thread ID " + threadId);