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