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