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