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