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