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