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