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