package cn.edu.tsinghua.hpc.tmms.data; import java.util.HashSet; import java.util.Iterator; import java.util.Set; import android.content.AsyncQueryHandler; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.net.Uri; import android.provider.Telephony.Sms.Conversations; import android.provider.Telephony.Threads; import android.text.TextUtils; import android.util.Log; import cn.edu.tsinghua.hpc.tmms.LogTag; import cn.edu.tsinghua.hpc.tmms.R; import cn.edu.tsinghua.hpc.tmms.syncaction.SyncState; import cn.edu.tsinghua.hpc.tmms.transaction.MessagingNotification; import cn.edu.tsinghua.hpc.tmms.ui.MessageUtils; import cn.edu.tsinghua.hpc.tmms.util.DraftCache; import cn.edu.tsinghua.hpc.tmms.util.TTelephony.TMmsSms; import cn.edu.tsinghua.hpc.tmms.util.TTelephony.TThreads; /** * An interface for finding information about conversations and/or creating new * ones. */ public class Conversation { private static final String TAG = "Conversation"; private static final boolean DEBUG = false; private static final Uri sAllThreadsUri = TThreads.CONTENT_URI.buildUpon() .appendQueryParameter("simple", "true").build(); private static final String[] ALL_THREADS_PROJECTION = { Threads._ID, Threads.DATE, Threads.MESSAGE_COUNT, Threads.RECIPIENT_IDS, Threads.SNIPPET, Threads.SNIPPET_CHARSET, Threads.READ, Threads.ERROR, Threads.HAS_ATTACHMENT }; private static final int ID = 0; private static final int DATE = 1; private static final int MESSAGE_COUNT = 2; private static final int RECIPIENT_IDS = 3; private static final int SNIPPET = 4; private static final int SNIPPET_CS = 5; private static final int READ = 6; private static final int ERROR = 7; private static final int HAS_ATTACHMENT = 8; private final Context mContext; // The thread ID of this conversation. Can be zero in the case of a // new conversation where the recipient set is changing as the user // types and we have not hit the database yet to create a thread. private long mThreadId; private ContactList mRecipients; // The current set of recipients. private long mDate; // The last update time. private int mMessageCount; // Number of messages. private String mSnippet; // Text of the most recent message. private boolean mHasUnreadMessages; // True if there are unread messages. private boolean mHasAttachment; // True if any message has an attachment. private boolean mHasError; // True if any message is in an error state. private static ContentValues mReadContentValues; private static boolean mLoadingThreads; private Conversation(Context context) { mContext = context; mRecipients = new ContactList(); mThreadId = 0; } private Conversation(Context context, long threadId, boolean allowQuery) { mContext = context; if (!loadFromThreadId(threadId, allowQuery)) { mRecipients = new ContactList(); mThreadId = 0; } } private Conversation(Context context, Cursor cursor, boolean allowQuery) { mContext = context; fillFromCursor(context, this, cursor, allowQuery); } /** * Create a new conversation with no recipients. {@link setRecipients} can * be called as many times as you like; the conversation will not be created * in the database until {@link ensureThreadId} is called. */ public static Conversation createNew(Context context) { return new Conversation(context); } /** * Find the conversation matching the provided thread ID. */ public static Conversation get(Context context, long threadId, boolean allowQuery) { Conversation conv = Cache.get(threadId); if (conv != null){ return conv; }else{ conv = new Conversation(context, threadId, allowQuery); try { Cache.put(conv); } catch (IllegalStateException e) { LogTag.error("Tried to add duplicate Conversation to Cache"); } return conv; } } /** * Find the conversation matching the provided recipient set. When called * with an empty recipient list, equivalent to {@link createEmpty}. */ public static Conversation get(Context context, ContactList recipients, boolean allowQuery) { // If there are no recipients in the list, make a new conversation. if (recipients.size() < 1) { return createNew(context); } synchronized (Cache.getInstance()) { Conversation conv = Cache.get(recipients); if (conv != null) return conv; long threadId = getOrCreateThreadId(context, recipients); conv = new Conversation(context, threadId, allowQuery); try { Cache.put(conv); } catch (IllegalStateException e) { LogTag.error("Tried to add duplicate Conversation to Cache"); } return conv; } } /** * Find the conversation matching in the specified Uri. Example forms: * {@value content://mms-sms/conversations/3} or {@value sms:+12124797990}. * When called with a null Uri, equivalent to {@link createEmpty}. */ public static Conversation get(Context context, Uri uri, boolean allowQuery) { if (uri == null) { return createNew(context); } if (DEBUG) { Log.v(TAG, "Conversation get URI: " + uri); } // Handle a conversation URI if (uri.getPathSegments().size() >= 2) { try { long threadId = Long.parseLong(uri.getPathSegments().get(1)); if (DEBUG) { Log.v(TAG, "Conversation get threadId: " + threadId); } return get(context, threadId, allowQuery); } catch (NumberFormatException exception) { LogTag.error("Invalid URI: " + uri); } } String recipient = uri.getSchemeSpecificPart(); return get(context, ContactList.getByNumbers(recipient, allowQuery /* don't block */, true /* replace number */), allowQuery); } /** * Returns true if the recipient in the uri matches the recipient list in * this conversation. */ public boolean sameRecipient(Uri uri) { int size = mRecipients.size(); if (size > 1) { return false; } if (uri == null) { return size == 0; } if (uri.getPathSegments().size() >= 2) { return false; // it's a thread id for a conversation } String recipient = uri.getSchemeSpecificPart(); ContactList incomingRecipient = ContactList.getByNumbers(recipient, false /* don't block */, false /* don't replace number */); return mRecipients.equals(incomingRecipient); } /** * Returns a temporary Conversation (not representing one on disk) wrapping * the contents of the provided cursor. The cursor should be the one * returned to your AsyncQueryHandler passed in to {@link startQueryForAll}. * The recipient list of this conversation can be empty if the results were * not in cache. */ // TODO: check why can't load a cached Conversation object here. public static Conversation from(Context context, Cursor cursor) { return new Conversation(context, cursor, false); } private void buildReadContentValues() { if (mReadContentValues == null) { mReadContentValues = new ContentValues(1); mReadContentValues.put("read", 1); } } /** * Marks all messages in this conversation as read and updates relevant * notifications. This method returns immediately; work is dispatched to a * background thread. */ public synchronized void markAsRead() { // If we have no Uri to mark (as in the case of a conversation that // has not yet made its way to disk), there's nothing to do. final Uri threadUri = getUri(); new Thread(new Runnable() { public void run() { if (threadUri != null) { buildReadContentValues(); mContext.getContentResolver().update(threadUri, mReadContentValues, "read=0", null); mHasUnreadMessages = false; } // Always update notifications regardless of the read state. MessagingNotification.updateAllNotifications(mContext); } }).start(); } /** * Returns a content:// URI referring to this conversation, or null if it * does not exist on disk yet. */ public synchronized Uri getUri() { if (mThreadId <= 0) return null; return ContentUris.withAppendedId(TThreads.CONTENT_URI, mThreadId); } /** * Return the Uri for all messages in the given thread ID. * * @deprecated */ public static Uri getUri(long threadId) { // TODO: Callers using this should really just have a Conversation // and call getUri() on it, but this guarantees no blocking. return ContentUris.withAppendedId(TThreads.CONTENT_URI, threadId); } /** * Returns the thread ID of this conversation. Can be zero if * {@link ensureThreadId} has not been called yet. */ public synchronized long getThreadId() { return mThreadId; } /** * Guarantees that the conversation has been created in the database. This * will make a blocking database call if it hasn't. * * @return The thread ID of this conversation in the database */ public synchronized long ensureThreadId() { if (DEBUG) { LogTag.debug("ensureThreadId before: " + mThreadId); } if (mThreadId <= 0) { mThreadId = getOrCreateThreadId(mContext, mRecipients); } if (DEBUG) { LogTag.debug("ensureThreadId after: " + mThreadId); } return mThreadId; } public synchronized void clearThreadId() { // remove ourself from the cache if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { LogTag.debug("clearThreadId old threadId was: " + mThreadId + " now zero"); } Cache.remove(mThreadId); mThreadId = 0; } /** * Sets the list of recipients associated with this conversation. If called, * {@link ensureThreadId} must be called before the next operation that * depends on this conversation existing in the database (e.g. storing a * draft message to it). */ public synchronized void setRecipients(ContactList list) { mRecipients = list; // Invalidate thread ID because the recipient set has changed. mThreadId = 0; } /** * Returns the recipient set of this conversation. */ public synchronized ContactList getRecipients() { return mRecipients; } /** * Returns true if a draft message exists in this conversation. */ public synchronized boolean hasDraft() { if (mThreadId <= 0) return false; return DraftCache.getInstance().hasDraft(mThreadId); } /** * Sets whether or not this conversation has a draft message. */ public synchronized void setDraftState(boolean hasDraft) { if (mThreadId <= 0) return; DraftCache.getInstance().setDraftState(mThreadId, hasDraft); } /** * Returns the time of the last update to this conversation in milliseconds, * on the {@link System.currentTimeMillis} timebase. */ public synchronized long getDate() { return mDate; } /** * Returns the number of messages in this conversation, excluding the draft * (if it exists). */ public synchronized int getMessageCount() { return mMessageCount; } /** * Returns a snippet of text from the most recent message in the * conversation. */ public synchronized String getSnippet() { return mSnippet; } /** * Returns true if there are any unread messages in the conversation. */ public synchronized boolean hasUnreadMessages() { return mHasUnreadMessages; } /** * Returns true if any messages in the conversation have attachments. */ public synchronized boolean hasAttachment() { return mHasAttachment; } /** * Returns true if any messages in the conversation are in an error state. */ public synchronized boolean hasError() { return mHasError; } private static long getOrCreateThreadId(Context context, ContactList list) { HashSet<String> recipients = new HashSet<String>(); Contact cacheContact = null; for (Contact c : list) { cacheContact = Contact.get(c.getNumber(), false); if (cacheContact != null) { recipients.add(cacheContact.getNumber()); } else { recipients.add(c.getNumber()); } } Log.d(TAG,"getOrCreateThreadId: "+recipients); if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { Log.d(TAG,"getOrCreateThreadId before"); LogTag.debug("getOrCreateThreadId %s", recipients); } return TThreads.getOrCreateThreadId(context, recipients); } /* * The primary key of a conversation is its recipient set; override equals() * and hashCode() to just pass through to the internal recipient sets. */ @Override public synchronized boolean equals(Object obj) { try { Conversation other = (Conversation) obj; return (mRecipients.equals(other.mRecipients)); } catch (ClassCastException e) { return false; } } @Override public synchronized int hashCode() { return mRecipients.hashCode(); } @Override public synchronized String toString() { return String.format("[%s] (tid %d)", mRecipients.serialize(), mThreadId); } /** * Remove any obsolete conversations sitting around on disk. * * @deprecated */ public static void cleanup(Context context) { // TODO: Get rid of this awful hack. context.getContentResolver().delete(TThreads.OBSOLETE_THREADS_URI, null, null); } /** * Start a query for all conversations in the database on the specified * AsyncQueryHandler. * * @param handler * An AsyncQueryHandler that will receive onQueryComplete upon * completion of the query * @param token * The token that will be passed to onQueryComplete */ public static void startQueryForAll(AsyncQueryHandler handler, int token) { String selection = "(sync_state = '" + SyncState.SYNC_STATE_PRESENT + "' OR sync_state = '" + SyncState.SYNC_STATE_NOT_SYNC + "') and message_count != 0"; // String selection = "sync_state = '" + SyncState.SYNC_STATE_PRESENT // + "' OR sync_state = '" + SyncState.SYNC_STATE_NOT_SYNC + "' OR sync_state = '" + SyncState.SYNC_STATE_RECOVER+ "'"; // String selection = "message_count != 0"; handler.cancelOperation(token); handler.startQuery(token, null, sAllThreadsUri, ALL_THREADS_PROJECTION, selection, null, Conversations.DEFAULT_SORT_ORDER); } /** * Start a delete of the conversation with the specified thread ID. * * @param handler * An AsyncQueryHandler that will receive onDeleteComplete upon * completion of the conversation being deleted * @param token * The token that will be passed to onDeleteComplete * @param deleteAll * Delete the whole thread including locked messages * @param threadId * Thread ID of the conversation to be deleted * @param finalDelete * If this delete action is final delete */ public static void startDelete(AsyncQueryHandler handler, int token, boolean deleteAll, long threadId, boolean finalDelete) { Uri uri = ContentUris.withAppendedId(TThreads.CONTENT_URI, threadId); String selection = deleteAll ? null : "locked=0"; // handler.startDelete(token, null, uri, selection, null); ContentValues values = new ContentValues(); if (!finalDelete) { values.put("sync_state", SyncState.SYNC_STATE_DELETED); } else { values.put("sync_state", SyncState.SYNC_STATE_REMOVED); } handler.startUpdate(token, null, uri, values, selection, null); } /** * Start deleting all conversations in the database. * * @param handler * An AsyncQueryHandler that will receive onDeleteComplete upon * completion of all conversations being deleted * @param token * The token that will be passed to onDeleteComplete * @param deleteAll * Delete the whole thread including locked messages * @param finalDelete * If this delete action is final delete */ public static void startDeleteAll(AsyncQueryHandler handler, int token, boolean deleteAll, boolean finalDelete) { String selection = deleteAll ? null : "locked=0"; // handler.startDelete(token, null, Threads.CONTENT_URI, selection, // null); ContentValues values = new ContentValues(); if (!finalDelete) { values.put("sync_state", SyncState.SYNC_STATE_DELETED); } else { values.put("sync_state", SyncState.SYNC_STATE_REMOVED); } handler.startUpdate(token, null, TThreads.CONTENT_URI, values, selection, null); } /** * Check for locked messages in all threads or a specified thread. * * @param handler * An AsyncQueryHandler that will receive onQueryComplete upon * completion of looking for locked messages * @param threadId * The threadId of the thread to search. -1 means all threads * @param token * The token that will be passed to onQueryComplete */ public static void startQueryHaveLockedMessages(AsyncQueryHandler handler, long threadId, int token) { handler.cancelOperation(token); Uri uri = TMmsSms.CONTENT_LOCKED_URI; if (threadId != -1) { uri = ContentUris.withAppendedId(uri, threadId); } handler.startQuery(token, new Long(threadId), uri, ALL_THREADS_PROJECTION, null, null, Conversations.DEFAULT_SORT_ORDER); } /** * Fill the specified conversation with the values from the specified * cursor, possibly setting recipients to empty if {@value allowQuery} is * false and the recipient IDs are not in cache. The cursor should be one * made via {@link startQueryForAll}. */ private static void fillFromCursor(Context context, Conversation conv, Cursor c, boolean allowQuery) { synchronized (conv) { conv.mThreadId = c.getLong(ID); conv.mDate = c.getLong(DATE); conv.mMessageCount = c.getInt(MESSAGE_COUNT); // Replace the snippet with a default value if it's empty. String snippet = MessageUtils.extractEncStrFromCursor(c, SNIPPET, SNIPPET_CS); if (TextUtils.isEmpty(snippet)) { snippet = context.getString(R.string.no_subject_view); } conv.mSnippet = snippet; conv.mHasUnreadMessages = (c.getInt(READ) == 0); conv.mHasError = (c.getInt(ERROR) != 0); conv.mHasAttachment = (c.getInt(HAS_ATTACHMENT) != 0); } // Fill in as much of the conversation as we can before doing the slow // stuff of looking // up the contacts associated with this conversation. String recipientIds = c.getString(RECIPIENT_IDS); ContactList recipients = ContactList.getByIds(recipientIds, allowQuery); synchronized (conv) { conv.mRecipients = recipients; } } /** * Private cache for the use of the various forms of Conversation.get. */ private static class Cache { private static Cache sInstance = new Cache(); static Cache getInstance() { return sInstance; } private final HashSet<Conversation> mCache; private Cache() { mCache = new HashSet<Conversation>(10); } /** * Return the conversation with the specified thread ID, or null if it's * not in cache. */ static Conversation get(long threadId) { synchronized (sInstance) { if (DEBUG) { LogTag.debug("Conversation get with threadId: " + threadId); } dumpCache(); for (Conversation c : sInstance.mCache) { if (DEBUG) { LogTag.debug("Conversation get() threadId: " + threadId + " c.getThreadId(): " + c.getThreadId()); } if (c.getThreadId() == threadId) { return c; } } } return null; } /** * Return the conversation with the specified recipient list, or null if * it's not in cache. */ static Conversation get(ContactList list) { synchronized (sInstance) { if (DEBUG) { LogTag.debug("Conversation get with ContactList: " + list); dumpCache(); } for (Conversation c : sInstance.mCache) { if (c.getRecipients().equals(list)) { return c; } } } return null; } /** * Put the specified conversation in the cache. The caller should not * place an already-existing conversation in the cache, but rather * update it in place. */ static void put(Conversation c) { synchronized (sInstance) { // We update cache entries in place so people with long- // held references get updated. if (DEBUG) { LogTag.debug("Conversation c: " + c + " put with threadid: " + c.getThreadId() + " c.hash: " + c.hashCode()); dumpCache(); } if (sInstance.mCache.contains(c)) { throw new IllegalStateException("cache already contains " + c + " threadId: " + c.mThreadId); } sInstance.mCache.add(c); } } static void remove(long threadId) { if (DEBUG) { LogTag.debug("remove threadid: " + threadId); dumpCache(); } for (Conversation c : sInstance.mCache) { if (c.getThreadId() == threadId) { sInstance.mCache.remove(c); return; } } } static void dumpCache() { if (DEBUG) { synchronized (sInstance) { LogTag.debug("Conversation dumpCache: "); for (Conversation c : sInstance.mCache) { LogTag.debug(" c: " + c + " c.getThreadId(): " + c.getThreadId() + " hash: " + c.hashCode()); } } } } /** * Remove all conversations from the cache that are not in the provided * set of thread IDs. */ static void keepOnly(Set<Long> threads) { synchronized (sInstance) { Iterator<Conversation> iter = sInstance.mCache.iterator(); while (iter.hasNext()) { Conversation c = iter.next(); if (!threads.contains(c.getThreadId())) { iter.remove(); } } } } } /** * Set up the conversation cache. To be called once at application startup * time. */ public static void init(final Context context) { new Thread(new Runnable() { public void run() { cacheAllThreads(context); } }).start(); } /** * Are we in the process of loading and caching all the threads?. */ public static boolean loadingThreads() { synchronized (Cache.getInstance()) { return mLoadingThreads; } } private static void cacheAllThreads(Context context) { if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) { LogTag.debug("[Conversation] cacheAllThreads"); } synchronized (Cache.getInstance()) { if (mLoadingThreads) { return; } mLoadingThreads = true; } // Keep track of what threads are now on disk so we // can discard anything removed from the cache. HashSet<Long> threadsOnDisk = new HashSet<Long>(); // Query for all conversations. Cursor c = context.getContentResolver().query(sAllThreadsUri, ALL_THREADS_PROJECTION, null, null, null); try { if (c != null) { while (c.moveToNext()) { long threadId = c.getLong(ID); threadsOnDisk.add(threadId); // Try to find this thread ID in the cache. Conversation conv; synchronized (Cache.getInstance()) { conv = Cache.get(threadId); } if (conv == null) { // Make a new Conversation and put it in // the cache if necessary. conv = new Conversation(context, c, true); try { synchronized (Cache.getInstance()) { Cache.put(conv); } } catch (IllegalStateException e) { LogTag .error("Tried to add duplicate Conversation to Cache"); } } else { // Or update in place so people with references // to conversations get updated too. fillFromCursor(context, conv, c, true); } } } } finally { if (c != null) { c.close(); } synchronized (Cache.getInstance()) { mLoadingThreads = false; } } // Purge the cache of threads that no longer exist on disk. Cache.keepOnly(threadsOnDisk); } private boolean loadFromThreadId(long threadId, boolean allowQuery) { Cursor c = mContext.getContentResolver().query(sAllThreadsUri, ALL_THREADS_PROJECTION, "_id=" + Long.toString(threadId), null, null); try { if (c.moveToFirst()) { fillFromCursor(mContext, this, c, allowQuery); } else { LogTag.error("loadFromThreadId: Can't find thread ID " + threadId); return false; } } finally { c.close(); } return true; } }