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