ConversationCursor.java revision e5ca52d91ee8482a81abbd80d625ef592db6fa8d
1/*******************************************************************************
2 *      Copyright (C) 2012 Google Inc.
3 *      Licensed to The Android Open Source Project.
4 *
5 *      Licensed under the Apache License, Version 2.0 (the "License");
6 *      you may not use this file except in compliance with the License.
7 *      You may obtain a copy of the License at
8 *
9 *           http://www.apache.org/licenses/LICENSE-2.0
10 *
11 *      Unless required by applicable law or agreed to in writing, software
12 *      distributed under the License is distributed on an "AS IS" BASIS,
13 *      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 *      See the License for the specific language governing permissions and
15 *      limitations under the License.
16 *******************************************************************************/
17
18package com.android.mail.browse;
19
20import android.app.Activity;
21import android.content.ContentProvider;
22import android.content.ContentProviderOperation;
23import android.content.ContentResolver;
24import android.content.ContentValues;
25import android.content.Context;
26import android.content.OperationApplicationException;
27import android.database.CharArrayBuffer;
28import android.database.ContentObserver;
29import android.database.Cursor;
30import android.database.CursorWrapper;
31import android.database.DataSetObserver;
32import android.net.Uri;
33import android.os.AsyncTask;
34import android.os.Bundle;
35import android.os.Handler;
36import android.os.Looper;
37import android.os.RemoteException;
38import android.os.SystemClock;
39import android.support.v4.util.SparseArrayCompat;
40import android.text.TextUtils;
41import android.util.Log;
42
43import com.android.mail.providers.Conversation;
44import com.android.mail.providers.Folder;
45import com.android.mail.providers.FolderList;
46import com.android.mail.providers.UIProvider;
47import com.android.mail.providers.UIProvider.ConversationListQueryParameters;
48import com.android.mail.providers.UIProvider.ConversationOperations;
49import com.android.mail.ui.ConversationListFragment;
50import com.android.mail.utils.LogTag;
51import com.android.mail.utils.LogUtils;
52import com.android.mail.utils.NotificationActionUtils;
53import com.android.mail.utils.NotificationActionUtils.NotificationAction;
54import com.android.mail.utils.NotificationActionUtils.NotificationActionType;
55import com.android.mail.utils.Utils;
56import com.google.common.annotations.VisibleForTesting;
57import com.google.common.collect.ImmutableMap;
58import com.google.common.collect.ImmutableSet;
59import com.google.common.collect.Lists;
60import com.google.common.collect.Sets;
61
62import java.util.ArrayList;
63import java.util.Arrays;
64import java.util.Collection;
65import java.util.Collections;
66import java.util.HashMap;
67import java.util.Iterator;
68import java.util.List;
69import java.util.Map;
70import java.util.Set;
71
72/**
73 * ConversationCursor is a wrapper around a conversation list cursor that provides update/delete
74 * caching for quick UI response. This is effectively a singleton class, as the cache is
75 * implemented as a static HashMap.
76 */
77public final class ConversationCursor implements Cursor, ConversationCursorMarkSeenListener {
78
79    private static final boolean ENABLE_CONVERSATION_PRECACHING = true;
80
81    private static final String LOG_TAG = LogTag.getLogTag();
82    /** Turn to true for debugging. */
83    private static final boolean DEBUG = false;
84    /** A deleted row is indicated by the presence of DELETED_COLUMN in the cache map */
85    private static final String DELETED_COLUMN = "__deleted__";
86    /** An row cached during a requery is indicated by the presence of REQUERY_COLUMN in the map */
87    private static final String UPDATE_TIME_COLUMN = "__updatetime__";
88    /**
89     * A sentinel value for the "index" of the deleted column; it's an int that is otherwise invalid
90     */
91    private static final int DELETED_COLUMN_INDEX = -1;
92    /**
93     * If a cached value within 10 seconds of a refresh(), preserve it. This time has been
94     * chosen empirically (long enough for UI changes to propagate in any reasonable case)
95     */
96    private static final long REQUERY_ALLOWANCE_TIME = 10000L;
97
98    /**
99     * The index of the Uri whose data is reflected in the cached row. Updates/Deletes to this Uri
100     * are cached
101     */
102    private static final int URI_COLUMN_INDEX = UIProvider.CONVERSATION_URI_COLUMN;
103
104    /** The resolver for the cursor instantiator's context */
105    private final ContentResolver mResolver;
106
107    /** Our sequence count (for changes sent to underlying provider) */
108    private static int sSequence = 0;
109    @VisibleForTesting
110    static ConversationProvider sProvider;
111
112    /** The cursor underlying the caching cursor */
113    @VisibleForTesting
114    UnderlyingCursorWrapper mUnderlyingCursor;
115    /** The new cursor obtained via a requery */
116    private volatile UnderlyingCursorWrapper mRequeryCursor;
117    /** A mapping from Uri to updated ContentValues */
118    private final HashMap<String, ContentValues> mCacheMap = new HashMap<String, ContentValues>();
119    /** Cache map lock (will be used only very briefly - few ms at most) */
120    private final Object mCacheMapLock = new Object();
121    /** The listeners registered for this cursor */
122    private final List<ConversationListener> mListeners = Lists.newArrayList();
123    /**
124     * The ConversationProvider instance // The runnable executing a refresh (query of underlying
125     * provider)
126     */
127    private RefreshTask mRefreshTask;
128    /** Set when we've sent refreshReady() to listeners */
129    private boolean mRefreshReady = false;
130    /** Set when we've sent refreshRequired() to listeners */
131    private boolean mRefreshRequired = false;
132    /** Whether our first query on this cursor should include a limit */
133    private boolean mInitialConversationLimit = false;
134    /** A list of mostly-dead items */
135    private final List<Conversation> mMostlyDead = Lists.newArrayList();
136    /** A list of items pending removal from a notification action. These may be undone later. */
137    private final Set<Conversation> mNotificationTempDeleted = Sets.newHashSet();
138    /** The name of the loader */
139    private final String mName;
140    /** Column names for this cursor */
141    private String[] mColumnNames;
142    // Column names as above, as a Set for quick membership checking
143    private Set<String> mColumnNameSet;
144    /** An observer on the underlying cursor (so we can detect changes from outside the UI) */
145    private final CursorObserver mCursorObserver;
146    /** Whether our observer is currently registered with the underlying cursor */
147    private boolean mCursorObserverRegistered = false;
148    /** Whether our loader is paused */
149    private boolean mPaused = false;
150    /** Whether or not sync from underlying provider should be deferred */
151    private boolean mDeferSync = false;
152
153    /** The current position of the cursor */
154    private int mPosition = -1;
155
156    /**
157     * The number of cached deletions from this cursor (used to quickly generate an accurate count)
158     */
159    private int mDeletedCount = 0;
160
161    /** Parameters passed to the underlying query */
162    private Uri qUri;
163    private String[] qProjection;
164
165    private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper());
166
167    private void setCursor(UnderlyingCursorWrapper cursor) {
168        // If we have an existing underlying cursor, make sure it's closed
169        if (mUnderlyingCursor != null) {
170            close();
171        }
172        mColumnNames = cursor.getColumnNames();
173        ImmutableSet.Builder<String> builder = ImmutableSet.builder();
174        for (String name : mColumnNames) {
175            builder.add(name);
176        }
177        mColumnNameSet = builder.build();
178        mRefreshRequired = false;
179        mRefreshReady = false;
180        mRefreshTask = null;
181        resetCursor(cursor);
182
183        resetNotificationActions();
184        handleNotificationActions();
185    }
186
187    public ConversationCursor(Activity activity, Uri uri, boolean initialConversationLimit,
188            String name) {
189        mInitialConversationLimit = initialConversationLimit;
190        mResolver = activity.getApplicationContext().getContentResolver();
191        qUri = uri;
192        mName = name;
193        qProjection = UIProvider.CONVERSATION_PROJECTION;
194        mCursorObserver = new CursorObserver(new Handler(Looper.getMainLooper()));
195    }
196
197    /**
198     * Create a ConversationCursor; this should be called by the ListActivity using that cursor
199     */
200    public void load() {
201        synchronized (mCacheMapLock) {
202            try {
203                // Create new ConversationCursor
204                LogUtils.d(LOG_TAG, "Create: initial creation");
205                setCursor(doQuery(mInitialConversationLimit));
206            } finally {
207                // If we used a limit, queue up a query without limit
208                if (mInitialConversationLimit) {
209                    mInitialConversationLimit = false;
210                    refresh();
211                }
212            }
213        }
214    }
215
216    /**
217     * Pause notifications to UI
218     */
219    public void pause() {
220        if (DEBUG) {
221            LogUtils.i(LOG_TAG, "[Paused: %s]", mName);
222        }
223        mPaused = true;
224    }
225
226    /**
227     * Resume notifications to UI; if any are pending, send them
228     */
229    public void resume() {
230        if (DEBUG) {
231            LogUtils.i(LOG_TAG, "[Resumed: %s]", mName);
232        }
233        mPaused = false;
234        checkNotifyUI();
235    }
236
237    private void checkNotifyUI() {
238        LogUtils.d(
239                LOG_TAG,
240                "Received notify ui callback and sending a notification is enabled?" +
241                " %s and refresh ready ? %s",
242                (!mPaused && !mDeferSync),
243                (mRefreshReady || (mRefreshRequired && mRefreshTask == null)));
244        if (!mPaused && !mDeferSync) {
245            if (mRefreshRequired && (mRefreshTask == null)) {
246                notifyRefreshRequired();
247            } else if (mRefreshReady) {
248                notifyRefreshReady();
249            }
250        } else {
251            LogUtils.d(LOG_TAG, "[checkNotifyUI: %s%s",
252                    (mPaused ? "Paused " : ""), (mDeferSync ? "Defer" : ""));
253        }
254    }
255
256    public Set<Long> getConversationIds() {
257        return mUnderlyingCursor != null ? mUnderlyingCursor.conversationIds() : null;
258    }
259
260    private static class UnderlyingRowData {
261        public final String wrappedUri;
262        public final String innerUri;
263        public final Conversation conversation;
264
265        public UnderlyingRowData(String wrappedUri, String innerUri, Conversation conversation) {
266            this.wrappedUri = wrappedUri;
267            this.innerUri = innerUri;
268            this.conversation = conversation;
269        }
270    }
271
272    /**
273     * Simple wrapper for a cursor that provides methods for quickly determining
274     * the existence of a row.
275     */
276    private static class UnderlyingCursorWrapper extends CursorWrapper {
277        // Ideally these two objects could be combined into a Map from
278        // conversationId -> position, but the cached values uses the conversation
279        // uri as a key.
280        private final Map<String, Integer> mConversationUriPositionMap;
281        private final Map<Long, Integer> mConversationIdPositionMap;
282        private final List<UnderlyingRowData> mRowCache;
283
284        public UnderlyingCursorWrapper(Cursor result) {
285            super(result);
286            long start = SystemClock.uptimeMillis();
287            final ImmutableMap.Builder<String, Integer> conversationUriPositionMapBuilder =
288                    new ImmutableMap.Builder<String, Integer>();
289            final ImmutableMap.Builder<Long, Integer> conversationIdPositionMapBuilder =
290                    new ImmutableMap.Builder<Long, Integer>();
291            final UnderlyingRowData[] cache;
292            final int count;
293            final boolean networkWasEnabled;
294            if (result != null) {
295                // We don't want iterating over this cursor to trigger a network request
296                networkWasEnabled = Utils.disableConversationCursorNetworkAccess(result);
297            } else {
298                networkWasEnabled = false;
299            }
300            if (result != null && result.moveToFirst()) {
301                count = result.getCount();
302                cache = new UnderlyingRowData[count];
303                int i = 0;
304                do {
305                    final Conversation c;
306                    final String innerUriString;
307                    final String wrappedUriString;
308                    final long convId;
309
310                    if (ENABLE_CONVERSATION_PRECACHING) {
311                        c = new Conversation(this);
312                        innerUriString = c.uri.toString();
313                        wrappedUriString = uriToCachingUriString(c.uri);
314                        convId = c.id;
315                    } else {
316                        c = null;
317                        innerUriString = result.getString(URI_COLUMN_INDEX);
318                        wrappedUriString = uriToCachingUriString(Uri.parse(innerUriString));
319                        convId = result.getLong(UIProvider.CONVERSATION_ID_COLUMN);
320                    }
321                    conversationUriPositionMapBuilder.put(innerUriString, i);
322                    conversationIdPositionMapBuilder.put(convId, i);
323                    cache[i] = new UnderlyingRowData(
324                            wrappedUriString,
325                            innerUriString,
326                            c);
327
328                } while (result.moveToPosition(++i));
329            } else {
330                count = 0;
331                cache = new UnderlyingRowData[0];
332            }
333            if (networkWasEnabled) {
334                Utils.enableConversationCursorNetworkAccess(result);
335            }
336            mConversationUriPositionMap = conversationUriPositionMapBuilder.build();
337            mConversationIdPositionMap = conversationIdPositionMapBuilder.build();
338            mRowCache = Collections.unmodifiableList(Arrays.asList(cache));
339            long end = SystemClock.uptimeMillis();
340            LogUtils.i(LOG_TAG, "*** ConversationCursor pre-loading took" +
341                    " %sms n=%s CONV_PRECACHING=%s",
342                    (end-start), count, ENABLE_CONVERSATION_PRECACHING);
343        }
344
345        public boolean contains(String uri) {
346            return mConversationUriPositionMap.containsKey(uri);
347        }
348
349        public Set<Long> conversationIds() {
350            return mConversationIdPositionMap.keySet();
351        }
352
353        public int getPosition(long conversationId) {
354            final Integer position = mConversationIdPositionMap.get(conversationId);
355            return position != null ? position.intValue() : -1;
356        }
357
358        public int getPosition(String conversationUri) {
359            final Integer position = mConversationUriPositionMap.get(conversationUri);
360            return position != null ? position.intValue() : -1;
361        }
362
363        public String getWrappedUri() {
364            return mRowCache.get(getPosition()).wrappedUri;
365        }
366
367        public String getInnerUri() {
368            return mRowCache.get(getPosition()).innerUri;
369        }
370
371        public Conversation getConversation() {
372            return mRowCache.get(getPosition()).conversation;
373        }
374    }
375
376    /**
377     * Runnable that performs the query on the underlying provider
378     */
379    private class RefreshTask extends AsyncTask<Void, Void, Void> {
380        private UnderlyingCursorWrapper mCursor = null;
381
382        private RefreshTask() {
383        }
384
385        @Override
386        protected Void doInBackground(Void... params) {
387            if (DEBUG) {
388                LogUtils.i(LOG_TAG, "[Start refresh of %s: %d]", mName, hashCode());
389            }
390            // Get new data
391            mCursor = doQuery(false);
392            // Make sure window is full
393            mCursor.getCount();
394            return null;
395        }
396
397        @Override
398        protected void onPostExecute(Void param) {
399            synchronized(mCacheMapLock) {
400                LogUtils.d(
401                        LOG_TAG,
402                        "Received notify ui callback and sending a notification is enabled? %s",
403                        (!mPaused && !mDeferSync));
404                // If cursor got closed (e.g. reset loader) in the meantime, cancel the refresh
405                if (isClosed()) {
406                    onCancelled();
407                    return;
408                }
409                mRequeryCursor = mCursor;
410                mRefreshReady = true;
411                if (DEBUG) {
412                    LogUtils.i(LOG_TAG, "[Query done %s: %d]", mName, hashCode());
413                }
414                if (!mDeferSync && !mPaused) {
415                    notifyRefreshReady();
416                }
417            }
418        }
419
420        @Override
421        protected void onCancelled() {
422            if (DEBUG) {
423                LogUtils.i(LOG_TAG, "[Ignoring refresh result: %d]", hashCode());
424            }
425            if (mCursor != null) {
426                mCursor.close();
427            }
428        }
429    }
430
431    private UnderlyingCursorWrapper doQuery(boolean withLimit) {
432        Uri uri = qUri;
433        if (withLimit) {
434            uri = uri.buildUpon().appendQueryParameter(ConversationListQueryParameters.LIMIT,
435                    ConversationListQueryParameters.DEFAULT_LIMIT).build();
436        }
437        long time = System.currentTimeMillis();
438
439        final Cursor result = mResolver.query(uri, qProjection, null, null, null);
440        if (result == null) {
441            Log.w(LOG_TAG, "doQuery returning null cursor, uri: " + uri);
442        } else if (DEBUG) {
443            time = System.currentTimeMillis() - time;
444            LogUtils.i(LOG_TAG, "ConversationCursor query: %s, %dms, %d results",
445                    uri, time, result.getCount());
446        }
447        return new UnderlyingCursorWrapper(result);
448    }
449
450    static boolean offUiThread() {
451        return Looper.getMainLooper().getThread() != Thread.currentThread();
452    }
453
454    /**
455     * Reset the cursor; this involves clearing out our cache map and resetting our various counts
456     * The cursor should be reset whenever we get fresh data from the underlying cursor. The cache
457     * is locked during the reset, which will block the UI, but for only a very short time
458     * (estimated at a few ms, but we can profile this; remember that the cache will usually
459     * be empty or have a few entries)
460     */
461    private void resetCursor(UnderlyingCursorWrapper newCursorWrapper) {
462        synchronized (mCacheMapLock) {
463            // Walk through the cache
464            final Iterator<Map.Entry<String, ContentValues>> iter =
465                    mCacheMap.entrySet().iterator();
466            final long now = System.currentTimeMillis();
467            while (iter.hasNext()) {
468                Map.Entry<String, ContentValues> entry = iter.next();
469                final ContentValues values = entry.getValue();
470                final String key = entry.getKey();
471                boolean withinTimeWindow = false;
472                boolean removed = false;
473                if (values != null) {
474                    Long updateTime = values.getAsLong(UPDATE_TIME_COLUMN);
475                    if (updateTime != null && ((now - updateTime) < REQUERY_ALLOWANCE_TIME)) {
476                        LogUtils.d(LOG_TAG, "IN resetCursor, keep recent changes to %s", key);
477                        withinTimeWindow = true;
478                    } else if (updateTime == null) {
479                        LogUtils.e(LOG_TAG, "null updateTime from mCacheMap for key: %s", key);
480                    }
481                    if (values.containsKey(DELETED_COLUMN)) {
482                        // Item is deleted locally AND deleted in the new cursor.
483                        if (!newCursorWrapper.contains(key)) {
484                            // Keep the deleted count up-to-date; remove the
485                            // cache entry
486                            mDeletedCount--;
487                            removed = true;
488                            LogUtils.d(LOG_TAG,
489                                    "IN resetCursor, sDeletedCount decremented to: %d by %s",
490                                    mDeletedCount, key);
491                        }
492                    }
493                } else {
494                    LogUtils.e(LOG_TAG, "null ContentValues from mCacheMap for key: %s", key);
495                }
496                // Remove the entry if it was time for an update or the item was deleted by the user.
497                if (!withinTimeWindow || removed) {
498                    iter.remove();
499                }
500            }
501
502            // Swap cursor
503            if (mUnderlyingCursor != null) {
504                close();
505            }
506            mUnderlyingCursor = newCursorWrapper;
507
508            mPosition = -1;
509            mUnderlyingCursor.moveToPosition(mPosition);
510            if (!mCursorObserverRegistered) {
511                mUnderlyingCursor.registerContentObserver(mCursorObserver);
512                mCursorObserverRegistered = true;
513            }
514            mRefreshRequired = false;
515        }
516    }
517
518    /**
519     * Returns the conversation uris for the Conversations that the ConversationCursor is treating
520     * as deleted.  This is an optimization to allow clients to determine if an item has been
521     * removed, without having to iterate through the whole cursor
522     */
523    public Set<String> getDeletedItems() {
524        synchronized (mCacheMapLock) {
525            // Walk through the cache and return the list of uris that have been deleted
526            final Set<String> deletedItems = Sets.newHashSet();
527            final Iterator<Map.Entry<String, ContentValues>> iter =
528                    mCacheMap.entrySet().iterator();
529            while (iter.hasNext()) {
530                final Map.Entry<String, ContentValues> entry = iter.next();
531                final ContentValues values = entry.getValue();
532                if (values.containsKey(DELETED_COLUMN)) {
533                    // Since clients of the conversation cursor see conversation ConversationCursor
534                    // provider uris, we need to make sure that this also returns these uris
535                    final Uri conversationUri = Uri.parse(entry.getKey());
536                    deletedItems.add(uriToCachingUriString(conversationUri)) ;
537                }
538            }
539            return deletedItems;
540        }
541    }
542
543    /**
544     * Returns the position of a conversation in the underlying cursor, without adjusting for the
545     * cache. Notably, conversations which are marked as deleted in the cache but which haven't yet
546     * been deleted in the underlying cursor will return non-negative here.
547     * @param conversationId The id of the conversation we are looking for.
548     * @return The position of the conversation in the underlying cursor, or -1 if not there.
549     */
550    public int getUnderlyingPosition(final long conversationId) {
551        return mUnderlyingCursor.getPosition(conversationId);
552    }
553
554    /**
555     * Returns the position, in the ConversationCursor, of the Conversation with the specified id.
556     * The returned position will take into account any items that have been deleted.
557     */
558    public int getConversationPosition(long conversationId) {
559        final int underlyingPosition = mUnderlyingCursor.getPosition(conversationId);
560        if (underlyingPosition < 0) {
561            // The conversation wasn't found in the underlying cursor, return the underlying result.
562            return underlyingPosition;
563        }
564
565        // Walk through each of the deleted items.  If the deleted item is before the underlying
566        // position, decrement the position
567        synchronized (mCacheMapLock) {
568            int updatedPosition = underlyingPosition;
569            final Iterator<Map.Entry<String, ContentValues>> iter =
570                    mCacheMap.entrySet().iterator();
571            while (iter.hasNext()) {
572                final Map.Entry<String, ContentValues> entry = iter.next();
573                final ContentValues values = entry.getValue();
574                if (values.containsKey(DELETED_COLUMN)) {
575                    // Since clients of the conversation cursor see conversation ConversationCursor
576                    // provider uris, we need to make sure that this also returns these uris
577                    final String conversationUri = entry.getKey();
578                    final int deletedItemPosition = mUnderlyingCursor.getPosition(conversationUri);
579                    if (deletedItemPosition == underlyingPosition) {
580                        // The requested items has been deleted.
581                        return -1;
582                    }
583
584                    if (deletedItemPosition >= 0 && deletedItemPosition < underlyingPosition) {
585                        // This item has been deleted, but is still in the underlying cursor, at
586                        // a position before the requested item.  Decrement the position of the
587                        // requested item.
588                        updatedPosition--;
589                    }
590                }
591            }
592            return updatedPosition;
593        }
594    }
595
596    /**
597     * Add a listener for this cursor; we'll notify it when our data changes
598     */
599    public void addListener(ConversationListener listener) {
600        synchronized (mListeners) {
601            if (!mListeners.contains(listener)) {
602                mListeners.add(listener);
603            } else {
604                LogUtils.d(LOG_TAG, "Ignoring duplicate add of listener");
605            }
606        }
607    }
608
609    /**
610     * Remove a listener for this cursor
611     */
612    public void removeListener(ConversationListener listener) {
613        synchronized(mListeners) {
614            mListeners.remove(listener);
615        }
616    }
617
618    /**
619     * Generate a forwarding Uri to ConversationProvider from an original Uri.  We do this by
620     * changing the authority to ours, but otherwise leaving the Uri intact.
621     * NOTE: This won't handle query parameters, so the functionality will need to be added if
622     * parameters are used in the future
623     * @param uri the uri
624     * @return a forwarding uri to ConversationProvider
625     */
626    private static String uriToCachingUriString (Uri uri) {
627        final String provider = uri.getAuthority();
628        return uri.getScheme() + "://" + ConversationProvider.AUTHORITY
629                + "/" + provider + uri.getPath();
630    }
631
632    /**
633     * Regenerate the original Uri from a forwarding (ConversationProvider) Uri
634     * NOTE: See note above for uriToCachingUri
635     * @param uri the forwarding Uri
636     * @return the original Uri
637     */
638    private static Uri uriFromCachingUri(Uri uri) {
639        String authority = uri.getAuthority();
640        // Don't modify uri's that aren't ours
641        if (!authority.equals(ConversationProvider.AUTHORITY)) {
642            return uri;
643        }
644        List<String> path = uri.getPathSegments();
645        Uri.Builder builder = new Uri.Builder().scheme(uri.getScheme()).authority(path.get(0));
646        for (int i = 1; i < path.size(); i++) {
647            builder.appendPath(path.get(i));
648        }
649        return builder.build();
650    }
651
652    private static String uriStringFromCachingUri(Uri uri) {
653        Uri underlyingUri = uriFromCachingUri(uri);
654        // Remember to decode the underlying Uri as it might be encoded (as w/ Gmail)
655        return Uri.decode(underlyingUri.toString());
656    }
657
658    public void setConversationColumn(Uri conversationUri, String columnName, Object value) {
659        final String uriStr = uriStringFromCachingUri(conversationUri);
660        synchronized (mCacheMapLock) {
661            cacheValue(uriStr, columnName, value);
662        }
663        notifyDataChanged();
664    }
665
666    /**
667     * Cache a column name/value pair for a given Uri
668     * @param uriString the Uri for which the column name/value pair applies
669     * @param columnName the column name
670     * @param value the value to be cached
671     */
672    private void cacheValue(String uriString, String columnName, Object value) {
673        // Calling this method off the UI thread will mess with ListView's reading of the cursor's
674        // count
675        if (offUiThread()) {
676            LogUtils.e(LOG_TAG, new Error(),
677                    "cacheValue incorrectly being called from non-UI thread");
678        }
679
680        synchronized (mCacheMapLock) {
681            // Get the map for our uri
682            ContentValues map = mCacheMap.get(uriString);
683            // Create one if necessary
684            if (map == null) {
685                map = new ContentValues();
686                mCacheMap.put(uriString, map);
687            }
688            // If we're caching a deletion, add to our count
689            if (columnName == DELETED_COLUMN) {
690                final boolean state = (Boolean)value;
691                final boolean hasValue = map.get(columnName) != null;
692                if (state && !hasValue) {
693                    mDeletedCount++;
694                    if (DEBUG) {
695                        LogUtils.i(LOG_TAG, "Deleted %s, incremented deleted count=%d", uriString,
696                                mDeletedCount);
697                    }
698                } else if (!state && hasValue) {
699                    mDeletedCount--;
700                    map.remove(columnName);
701                    if (DEBUG) {
702                        LogUtils.i(LOG_TAG, "Undeleted %s, decremented deleted count=%d", uriString,
703                                mDeletedCount);
704                    }
705                    return;
706                } else if (!state) {
707                    // Trying to undelete, but it's not deleted; just return
708                    if (DEBUG) {
709                        LogUtils.i(LOG_TAG, "Undeleted %s, IGNORING, deleted count=%d", uriString,
710                                mDeletedCount);
711                    }
712                    return;
713                }
714            }
715            putInValues(map, columnName, value);
716            map.put(UPDATE_TIME_COLUMN, System.currentTimeMillis());
717            if (DEBUG && (columnName != DELETED_COLUMN)) {
718                LogUtils.i(LOG_TAG, "Caching value for %s: %s", uriString, columnName);
719            }
720        }
721    }
722
723    /**
724     * Get the cached value for the provided column; we special case -1 as the "deleted" column
725     * @param columnIndex the index of the column whose cached value we want to retrieve
726     * @return the cached value for this column, or null if there is none
727     */
728    private Object getCachedValue(int columnIndex) {
729        final String uri = mUnderlyingCursor.getInnerUri();
730        return getCachedValue(uri, columnIndex);
731    }
732
733    private Object getCachedValue(String uri, int columnIndex) {
734        ContentValues uriMap = mCacheMap.get(uri);
735        if (uriMap != null) {
736            String columnName;
737            if (columnIndex == DELETED_COLUMN_INDEX) {
738                columnName = DELETED_COLUMN;
739            } else {
740                columnName = mColumnNames[columnIndex];
741            }
742            return uriMap.get(columnName);
743        }
744        return null;
745    }
746
747    /**
748     * When the underlying cursor changes, we want to alert the listener
749     */
750    private void underlyingChanged() {
751        synchronized(mCacheMapLock) {
752            if (mCursorObserverRegistered) {
753                try {
754                    mUnderlyingCursor.unregisterContentObserver(mCursorObserver);
755                } catch (IllegalStateException e) {
756                    // Maybe the cursor was GC'd?
757                }
758                mCursorObserverRegistered = false;
759            }
760            mRefreshRequired = true;
761            if (!mPaused) {
762                notifyRefreshRequired();
763            }
764        }
765    }
766
767    /**
768     * Must be called on UI thread; notify listeners that a refresh is required
769     */
770    private void notifyRefreshRequired() {
771        if (DEBUG) {
772            LogUtils.i(LOG_TAG, "[Notify %s: onRefreshRequired()]", mName);
773        }
774        if (!mDeferSync) {
775            synchronized(mListeners) {
776                for (ConversationListener listener: mListeners) {
777                    listener.onRefreshRequired();
778                }
779            }
780        }
781    }
782
783    /**
784     * Must be called on UI thread; notify listeners that a new cursor is ready
785     */
786    private void notifyRefreshReady() {
787        if (DEBUG) {
788            LogUtils.i(LOG_TAG, "[Notify %s: onRefreshReady(), %d listeners]",
789                    mName, mListeners.size());
790        }
791        synchronized(mListeners) {
792            for (ConversationListener listener: mListeners) {
793                listener.onRefreshReady();
794            }
795        }
796    }
797
798    /**
799     * Must be called on UI thread; notify listeners that data has changed
800     */
801    private void notifyDataChanged() {
802        if (DEBUG) {
803            LogUtils.i(LOG_TAG, "[Notify %s: onDataSetChanged()]", mName);
804        }
805        synchronized(mListeners) {
806            for (ConversationListener listener: mListeners) {
807                listener.onDataSetChanged();
808            }
809        }
810
811        handleNotificationActions();
812    }
813
814    /**
815     * Put the refreshed cursor in place (called by the UI)
816     */
817    public void sync() {
818        if (mRequeryCursor == null) {
819            // This can happen during an animated deletion, if the UI isn't keeping track, or
820            // if a new query intervened (i.e. user changed folders)
821            if (DEBUG) {
822                LogUtils.i(LOG_TAG, "[sync() %s; no requery cursor]", mName);
823            }
824            return;
825        }
826        synchronized(mCacheMapLock) {
827            if (DEBUG) {
828                LogUtils.i(LOG_TAG, "[sync() %s]", mName);
829            }
830            resetCursor(mRequeryCursor);
831            mRequeryCursor = null;
832            mRefreshTask = null;
833            mRefreshReady = false;
834        }
835        notifyDataChanged();
836    }
837
838    public boolean isRefreshRequired() {
839        return mRefreshRequired;
840    }
841
842    public boolean isRefreshReady() {
843        return mRefreshReady;
844    }
845
846    /**
847     * When we get a requery from the UI, we'll do it, but also clear the cache. The listener is
848     * notified when the requery is complete
849     * NOTE: This will have to change, of course, when we start using loaders...
850     */
851    public boolean refresh() {
852        if (DEBUG) {
853            LogUtils.i(LOG_TAG, "[refresh() %s]", mName);
854        }
855        synchronized(mCacheMapLock) {
856            if (mRefreshTask != null) {
857                if (DEBUG) {
858                    LogUtils.i(LOG_TAG, "[refresh() %s returning; already running %d]",
859                            mName, mRefreshTask.hashCode());
860                }
861                return false;
862            }
863            mRefreshTask = new RefreshTask();
864            mRefreshTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
865        }
866        return true;
867    }
868
869    public void disable() {
870        close();
871        mCacheMap.clear();
872        mListeners.clear();
873        mUnderlyingCursor = null;
874    }
875
876    @Override
877    public void close() {
878        if (mUnderlyingCursor != null && !mUnderlyingCursor.isClosed()) {
879            // Unregister our observer on the underlying cursor and close as usual
880            if (mCursorObserverRegistered) {
881                try {
882                    mUnderlyingCursor.unregisterContentObserver(mCursorObserver);
883                } catch (IllegalStateException e) {
884                    // Maybe the cursor got GC'd?
885                }
886                mCursorObserverRegistered = false;
887            }
888            mUnderlyingCursor.close();
889        }
890    }
891
892    /**
893     * Move to the next not-deleted item in the conversation
894     */
895    @Override
896    public boolean moveToNext() {
897        while (true) {
898            boolean ret = mUnderlyingCursor.moveToNext();
899            if (!ret) {
900                mPosition = getCount();
901                if (DEBUG) {
902                    LogUtils.i(LOG_TAG, "*** moveToNext returns false: pos = %d, und = %d" +
903                            ", del = %d", mPosition, mUnderlyingCursor.getPosition(),
904                            mDeletedCount);
905                }
906                return false;
907            }
908            if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue;
909            mPosition++;
910            return true;
911        }
912    }
913
914    /**
915     * Move to the previous not-deleted item in the conversation
916     */
917    @Override
918    public boolean moveToPrevious() {
919        while (true) {
920            boolean ret = mUnderlyingCursor.moveToPrevious();
921            if (!ret) {
922                // Make sure we're before the first position
923                mPosition = -1;
924                return false;
925            }
926            if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue;
927            mPosition--;
928            return true;
929        }
930    }
931
932    @Override
933    public int getPosition() {
934        return mPosition;
935    }
936
937    /**
938     * The actual cursor's count must be decremented by the number we've deleted from the UI
939     */
940    @Override
941    public int getCount() {
942        if (mUnderlyingCursor == null) {
943            throw new IllegalStateException(
944                    "getCount() on disabled cursor: " + mName + "(" + qUri + ")");
945        }
946        return mUnderlyingCursor.getCount() - mDeletedCount;
947    }
948
949    @Override
950    public boolean moveToFirst() {
951        if (mUnderlyingCursor == null) {
952            throw new IllegalStateException(
953                    "moveToFirst() on disabled cursor: " + mName + "(" + qUri + ")");
954        }
955        mUnderlyingCursor.moveToPosition(-1);
956        mPosition = -1;
957        return moveToNext();
958    }
959
960    @Override
961    public boolean moveToPosition(int pos) {
962        if (mUnderlyingCursor == null) {
963            throw new IllegalStateException(
964                    "moveToPosition() on disabled cursor: " + mName + "(" + qUri + ")");
965        }
966        // Handle the "move to first" case before anything else; moveToPosition(0) in an empty
967        // SQLiteCursor moves the position to 0 when returning false, which we will mirror.
968        // But we don't want to return true on a subsequent "move to first", which we would if we
969        // check pos vs mPosition first
970        if (mUnderlyingCursor.getPosition() == -1) {
971            LogUtils.d(LOG_TAG, "*** Underlying cursor position is -1 asking to move from %d to %d",
972                    mPosition, pos);
973        }
974        if (pos == 0) {
975            return moveToFirst();
976        } else if (pos < 0) {
977            mPosition = -1;
978            mUnderlyingCursor.moveToPosition(mPosition);
979            return false;
980        } else if (pos == mPosition) {
981            // Return false if we're past the end of the cursor
982            return pos < getCount();
983        } else if (pos > mPosition) {
984            while (pos > mPosition) {
985                if (!moveToNext()) {
986                    return false;
987                }
988            }
989            return true;
990        } else if ((pos >= 0) && (mPosition - pos) > pos) {
991            // Optimization if it's easier to move forward to position instead of backward
992            if (DEBUG) {
993                LogUtils.i(LOG_TAG, "*** Move from %d to %d, starting from first", mPosition, pos);
994            }
995            moveToFirst();
996            return moveToPosition(pos);
997        } else {
998            while (pos < mPosition) {
999                if (!moveToPrevious()) {
1000                    return false;
1001                }
1002            }
1003            return true;
1004        }
1005    }
1006
1007    /**
1008     * Make sure mPosition is correct after locally deleting/undeleting items
1009     */
1010    private void recalibratePosition() {
1011        final int pos = mPosition;
1012        moveToFirst();
1013        moveToPosition(pos);
1014    }
1015
1016    @Override
1017    public boolean moveToLast() {
1018        throw new UnsupportedOperationException("moveToLast unsupported!");
1019    }
1020
1021    @Override
1022    public boolean move(int offset) {
1023        throw new UnsupportedOperationException("move unsupported!");
1024    }
1025
1026    /**
1027     * We need to override all of the getters to make sure they look at cached values before using
1028     * the values in the underlying cursor
1029     */
1030    @Override
1031    public double getDouble(int columnIndex) {
1032        Object obj = getCachedValue(columnIndex);
1033        if (obj != null) return (Double)obj;
1034        return mUnderlyingCursor.getDouble(columnIndex);
1035    }
1036
1037    @Override
1038    public float getFloat(int columnIndex) {
1039        Object obj = getCachedValue(columnIndex);
1040        if (obj != null) return (Float)obj;
1041        return mUnderlyingCursor.getFloat(columnIndex);
1042    }
1043
1044    @Override
1045    public int getInt(int columnIndex) {
1046        Object obj = getCachedValue(columnIndex);
1047        if (obj != null) return (Integer)obj;
1048        return mUnderlyingCursor.getInt(columnIndex);
1049    }
1050
1051    @Override
1052    public long getLong(int columnIndex) {
1053        Object obj = getCachedValue(columnIndex);
1054        if (obj != null) return (Long)obj;
1055        return mUnderlyingCursor.getLong(columnIndex);
1056    }
1057
1058    @Override
1059    public short getShort(int columnIndex) {
1060        Object obj = getCachedValue(columnIndex);
1061        if (obj != null) return (Short)obj;
1062        return mUnderlyingCursor.getShort(columnIndex);
1063    }
1064
1065    @Override
1066    public String getString(int columnIndex) {
1067        // If we're asking for the Uri for the conversation list, we return a forwarding URI
1068        // so that we can intercept update/delete and handle it ourselves
1069        if (columnIndex == URI_COLUMN_INDEX) {
1070            return mUnderlyingCursor.getWrappedUri();
1071        }
1072        Object obj = getCachedValue(columnIndex);
1073        if (obj != null) return (String)obj;
1074        return mUnderlyingCursor.getString(columnIndex);
1075    }
1076
1077    @Override
1078    public byte[] getBlob(int columnIndex) {
1079        Object obj = getCachedValue(columnIndex);
1080        if (obj != null) return (byte[])obj;
1081        return mUnderlyingCursor.getBlob(columnIndex);
1082    }
1083
1084    public Conversation getConversation() {
1085        Conversation c = mUnderlyingCursor.getConversation();
1086
1087        if (c == null) {
1088            // not pre-cached. fall back to just-in-time construction.
1089            c = new Conversation(this);
1090        } else {
1091            // apply any cached values
1092            // but skip over any cached values that aren't part of the cursor projection
1093            final ContentValues values = mCacheMap.get(mUnderlyingCursor.getInnerUri());
1094            if (values != null) {
1095                final ContentValues queryableValues = new ContentValues();
1096                for (String key : values.keySet()) {
1097                    if (!mColumnNameSet.contains(key)) {
1098                        continue;
1099                    }
1100                    putInValues(queryableValues, key, values.get(key));
1101                }
1102                if (queryableValues.size() > 0) {
1103                    // copy-on-write to help ensure the underlying cached Conversation is immutable
1104                    // of course, any callers this method should also try not to modify them
1105                    // overmuch...
1106                    c = new Conversation(c);
1107                    c.applyCachedValues(queryableValues);
1108                }
1109            }
1110        }
1111
1112        return c;
1113    }
1114
1115    private static void putInValues(ContentValues dest, String key, Object value) {
1116        // ContentValues has no generic "put", so we must test.  For now, the only classes
1117        // of values implemented are Boolean/Integer/String/Blob, though others are trivially
1118        // added
1119        if (value instanceof Boolean) {
1120            dest.put(key, ((Boolean) value).booleanValue() ? 1 : 0);
1121        } else if (value instanceof Integer) {
1122            dest.put(key, (Integer) value);
1123        } else if (value instanceof String) {
1124            dest.put(key, (String) value);
1125        } else if (value instanceof byte[]) {
1126            dest.put(key, (byte[])value);
1127        } else {
1128            final String cname = value.getClass().getName();
1129            throw new IllegalArgumentException("Value class not compatible with cache: "
1130                    + cname);
1131        }
1132    }
1133
1134    /**
1135     * Observer of changes to underlying data
1136     */
1137    private class CursorObserver extends ContentObserver {
1138        public CursorObserver(Handler handler) {
1139            super(handler);
1140        }
1141
1142        @Override
1143        public void onChange(boolean selfChange) {
1144            // If we're here, then something outside of the UI has changed the data, and we
1145            // must query the underlying provider for that data;
1146            ConversationCursor.this.underlyingChanged();
1147        }
1148    }
1149
1150    /**
1151     * ConversationProvider is the ContentProvider for our forwarding Uri's; it passes queries
1152     * and inserts directly, and caches updates/deletes before passing them through.  The caching
1153     * will cause a redraw of the list with updated values.
1154     */
1155    public abstract static class ConversationProvider extends ContentProvider {
1156        public static String AUTHORITY;
1157        private ContentResolver mResolver;
1158
1159        /**
1160         * Allows the implementing provider to specify the authority that should be used.
1161         */
1162        protected abstract String getAuthority();
1163
1164        @Override
1165        public boolean onCreate() {
1166            sProvider = this;
1167            AUTHORITY = getAuthority();
1168            mResolver = getContext().getContentResolver();
1169            return true;
1170        }
1171
1172        @Override
1173        public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
1174                String sortOrder) {
1175            return mResolver.query(
1176                    uriFromCachingUri(uri), projection, selection, selectionArgs, sortOrder);
1177        }
1178
1179        @Override
1180        public Uri insert(Uri uri, ContentValues values) {
1181            insertLocal(uri, values);
1182            return ProviderExecute.opInsert(mResolver, uri, values);
1183        }
1184
1185        @Override
1186        public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
1187            throw new IllegalStateException("Unexpected call to ConversationProvider.update");
1188        }
1189
1190        @Override
1191        public int delete(Uri uri, String selection, String[] selectionArgs) {
1192            throw new IllegalStateException("Unexpected call to ConversationProvider.delete");
1193        }
1194
1195        @Override
1196        public String getType(Uri uri) {
1197            return null;
1198        }
1199
1200        /**
1201         * Quick and dirty class that executes underlying provider CRUD operations on a background
1202         * thread.
1203         */
1204        static class ProviderExecute implements Runnable {
1205            static final int DELETE = 0;
1206            static final int INSERT = 1;
1207            static final int UPDATE = 2;
1208
1209            final int mCode;
1210            final Uri mUri;
1211            final ContentValues mValues; //HEHEH
1212            final ContentResolver mResolver;
1213
1214            ProviderExecute(int code, ContentResolver resolver, Uri uri, ContentValues values) {
1215                mCode = code;
1216                mUri = uriFromCachingUri(uri);
1217                mValues = values;
1218                mResolver = resolver;
1219            }
1220
1221            static Uri opInsert(ContentResolver resolver, Uri uri, ContentValues values) {
1222                ProviderExecute e = new ProviderExecute(INSERT, resolver, uri, values);
1223                if (offUiThread()) return (Uri)e.go();
1224                new Thread(e).start();
1225                return null;
1226            }
1227
1228            @Override
1229            public void run() {
1230                go();
1231            }
1232
1233            public Object go() {
1234                switch(mCode) {
1235                    case DELETE:
1236                        return mResolver.delete(mUri, null, null);
1237                    case INSERT:
1238                        return mResolver.insert(mUri, mValues);
1239                    case UPDATE:
1240                        return mResolver.update(mUri,  mValues, null, null);
1241                    default:
1242                        return null;
1243                }
1244            }
1245        }
1246
1247        private void insertLocal(Uri uri, ContentValues values) {
1248            // Placeholder for now; there's no local insert
1249        }
1250
1251        private int mUndoSequence = 0;
1252        private ArrayList<Uri> mUndoDeleteUris = new ArrayList<Uri>();
1253
1254        void addToUndoSequence(Uri uri) {
1255            if (sSequence != mUndoSequence) {
1256                mUndoSequence = sSequence;
1257                mUndoDeleteUris.clear();
1258            }
1259            mUndoDeleteUris.add(uri);
1260        }
1261
1262        @VisibleForTesting
1263        void deleteLocal(Uri uri, ConversationCursor conversationCursor) {
1264            String uriString = uriStringFromCachingUri(uri);
1265            conversationCursor.cacheValue(uriString, DELETED_COLUMN, true);
1266            addToUndoSequence(uri);
1267        }
1268
1269        @VisibleForTesting
1270        void undeleteLocal(Uri uri, ConversationCursor conversationCursor) {
1271            String uriString = uriStringFromCachingUri(uri);
1272            conversationCursor.cacheValue(uriString, DELETED_COLUMN, false);
1273        }
1274
1275        void setMostlyDead(Conversation conv, ConversationCursor conversationCursor) {
1276            Uri uri = conv.uri;
1277            String uriString = uriStringFromCachingUri(uri);
1278            conversationCursor.setMostlyDead(uriString, conv);
1279            addToUndoSequence(uri);
1280        }
1281
1282        void commitMostlyDead(Conversation conv, ConversationCursor conversationCursor) {
1283            conversationCursor.commitMostlyDead(conv);
1284        }
1285
1286        boolean clearMostlyDead(Uri uri, ConversationCursor conversationCursor) {
1287            String uriString =  uriStringFromCachingUri(uri);
1288            return conversationCursor.clearMostlyDead(uriString);
1289        }
1290
1291        public void undo(ConversationCursor conversationCursor) {
1292            if (sSequence == mUndoSequence) {
1293                for (Uri uri: mUndoDeleteUris) {
1294                    if (!clearMostlyDead(uri, conversationCursor)) {
1295                        undeleteLocal(uri, conversationCursor);
1296                    }
1297                }
1298                mUndoSequence = 0;
1299                conversationCursor.recalibratePosition();
1300                // Notify listeners that there was a change to the underlying
1301                // cursor to add back in some items.
1302                conversationCursor.notifyDataChanged();
1303            }
1304        }
1305
1306        @VisibleForTesting
1307        void updateLocal(Uri uri, ContentValues values, ConversationCursor conversationCursor) {
1308            if (values == null) {
1309                return;
1310            }
1311            String uriString = uriStringFromCachingUri(uri);
1312            for (String columnName: values.keySet()) {
1313                conversationCursor.cacheValue(uriString, columnName, values.get(columnName));
1314            }
1315        }
1316
1317        public int apply(Collection<ConversationOperation> ops,
1318                ConversationCursor conversationCursor) {
1319            final HashMap<String, ArrayList<ContentProviderOperation>> batchMap =
1320                    new HashMap<String, ArrayList<ContentProviderOperation>>();
1321            // Increment sequence count
1322            sSequence++;
1323
1324            // Execute locally and build CPO's for underlying provider
1325            boolean recalibrateRequired = false;
1326            for (ConversationOperation op: ops) {
1327                Uri underlyingUri = uriFromCachingUri(op.mUri);
1328                String authority = underlyingUri.getAuthority();
1329                ArrayList<ContentProviderOperation> authOps = batchMap.get(authority);
1330                if (authOps == null) {
1331                    authOps = new ArrayList<ContentProviderOperation>();
1332                    batchMap.put(authority, authOps);
1333                }
1334                ContentProviderOperation cpo = op.execute(underlyingUri);
1335                if (cpo != null) {
1336                    authOps.add(cpo);
1337                }
1338                // Keep track of whether our operations require recalibrating the cursor position
1339                if (op.mRecalibrateRequired) {
1340                    recalibrateRequired = true;
1341                }
1342            }
1343
1344            // Recalibrate cursor position if required
1345            if (recalibrateRequired) {
1346                conversationCursor.recalibratePosition();
1347            }
1348
1349            // Notify listeners that data has changed
1350            conversationCursor.notifyDataChanged();
1351
1352            // Send changes to underlying provider
1353            final boolean notUiThread = offUiThread();
1354            for (final String authority: batchMap.keySet()) {
1355                final ArrayList<ContentProviderOperation> opList = batchMap.get(authority);
1356                if (notUiThread) {
1357                    try {
1358                        mResolver.applyBatch(authority, opList);
1359                    } catch (RemoteException e) {
1360                    } catch (OperationApplicationException e) {
1361                    }
1362                } else {
1363                    new Thread(new Runnable() {
1364                        @Override
1365                        public void run() {
1366                            try {
1367                                mResolver.applyBatch(authority, opList);
1368                            } catch (RemoteException e) {
1369                            } catch (OperationApplicationException e) {
1370                            }
1371                        }
1372                    }).start();
1373                }
1374            }
1375            return sSequence;
1376        }
1377    }
1378
1379    void setMostlyDead(String uriString, Conversation conv) {
1380        LogUtils.d(LOG_TAG, "[Mostly dead, deferring: %s] ", uriString);
1381        cacheValue(uriString,
1382                UIProvider.ConversationColumns.FLAGS, Conversation.FLAG_MOSTLY_DEAD);
1383        conv.convFlags |= Conversation.FLAG_MOSTLY_DEAD;
1384        mMostlyDead.add(conv);
1385        mDeferSync = true;
1386    }
1387
1388    void commitMostlyDead(Conversation conv) {
1389        conv.convFlags &= ~Conversation.FLAG_MOSTLY_DEAD;
1390        mMostlyDead.remove(conv);
1391        LogUtils.d(LOG_TAG, "[All dead: %s]", conv.uri);
1392        if (mMostlyDead.isEmpty()) {
1393            mDeferSync = false;
1394            checkNotifyUI();
1395        }
1396    }
1397
1398    boolean clearMostlyDead(String uriString) {
1399        Object val = getCachedValue(uriString,
1400                UIProvider.CONVERSATION_FLAGS_COLUMN);
1401        if (val != null) {
1402            int flags = ((Integer)val).intValue();
1403            if ((flags & Conversation.FLAG_MOSTLY_DEAD) != 0) {
1404                cacheValue(uriString, UIProvider.ConversationColumns.FLAGS,
1405                        flags &= ~Conversation.FLAG_MOSTLY_DEAD);
1406                return true;
1407            }
1408        }
1409        return false;
1410    }
1411
1412
1413
1414
1415    /**
1416     * ConversationOperation is the encapsulation of a ContentProvider operation to be performed
1417     * atomically as part of a "batch" operation.
1418     */
1419    public class ConversationOperation {
1420        private static final int MOSTLY = 0x80;
1421        public static final int DELETE = 0;
1422        public static final int INSERT = 1;
1423        public static final int UPDATE = 2;
1424        public static final int ARCHIVE = 3;
1425        public static final int MUTE = 4;
1426        public static final int REPORT_SPAM = 5;
1427        public static final int REPORT_NOT_SPAM = 6;
1428        public static final int REPORT_PHISHING = 7;
1429        public static final int DISCARD_DRAFTS = 8;
1430        public static final int MOSTLY_ARCHIVE = MOSTLY | ARCHIVE;
1431        public static final int MOSTLY_DELETE = MOSTLY | DELETE;
1432        public static final int MOSTLY_DESTRUCTIVE_UPDATE = MOSTLY | UPDATE;
1433
1434        private final int mType;
1435        private final Uri mUri;
1436        private final Conversation mConversation;
1437        private final ContentValues mValues;
1438        // True if an updated item should be removed locally (from ConversationCursor)
1439        // This would be the case for a folder change in which the conversation is no longer
1440        // in the folder represented by the ConversationCursor
1441        private final boolean mLocalDeleteOnUpdate;
1442        // After execution, this indicates whether or not the operation requires recalibration of
1443        // the current cursor position (i.e. it removed or added items locally)
1444        private boolean mRecalibrateRequired = true;
1445        // Whether this item is already mostly dead
1446        private final boolean mMostlyDead;
1447
1448        public ConversationOperation(int type, Conversation conv) {
1449            this(type, conv, null);
1450        }
1451
1452        public ConversationOperation(int type, Conversation conv, ContentValues values) {
1453            mType = type;
1454            mUri = conv.uri;
1455            mConversation = conv;
1456            mValues = values;
1457            mLocalDeleteOnUpdate = conv.localDeleteOnUpdate;
1458            mMostlyDead = conv.isMostlyDead();
1459        }
1460
1461        private ContentProviderOperation execute(Uri underlyingUri) {
1462            Uri uri = underlyingUri.buildUpon()
1463                    .appendQueryParameter(UIProvider.SEQUENCE_QUERY_PARAMETER,
1464                            Integer.toString(sSequence))
1465                    .build();
1466            ContentProviderOperation op = null;
1467            switch(mType) {
1468                case UPDATE:
1469                    if (mLocalDeleteOnUpdate) {
1470                        sProvider.deleteLocal(mUri, ConversationCursor.this);
1471                    } else {
1472                        sProvider.updateLocal(mUri, mValues, ConversationCursor.this);
1473                        mRecalibrateRequired = false;
1474                    }
1475                    if (!mMostlyDead) {
1476                        op = ContentProviderOperation.newUpdate(uri)
1477                                .withValues(mValues)
1478                                .build();
1479                    } else {
1480                        sProvider.commitMostlyDead(mConversation, ConversationCursor.this);
1481                    }
1482                    break;
1483                case MOSTLY_DESTRUCTIVE_UPDATE:
1484                    sProvider.setMostlyDead(mConversation, ConversationCursor.this);
1485                    op = ContentProviderOperation.newUpdate(uri).withValues(mValues).build();
1486                    break;
1487                case INSERT:
1488                    sProvider.insertLocal(mUri, mValues);
1489                    op = ContentProviderOperation.newInsert(uri)
1490                            .withValues(mValues).build();
1491                    break;
1492                // Destructive actions below!
1493                // "Mostly" operations are reflected globally, but not locally, except to set
1494                // FLAG_MOSTLY_DEAD in the conversation itself
1495                case DELETE:
1496                    sProvider.deleteLocal(mUri, ConversationCursor.this);
1497                    if (!mMostlyDead) {
1498                        op = ContentProviderOperation.newDelete(uri).build();
1499                    } else {
1500                        sProvider.commitMostlyDead(mConversation, ConversationCursor.this);
1501                    }
1502                    break;
1503                case MOSTLY_DELETE:
1504                    sProvider.setMostlyDead(mConversation,ConversationCursor.this);
1505                    op = ContentProviderOperation.newDelete(uri).build();
1506                    break;
1507                case ARCHIVE:
1508                    sProvider.deleteLocal(mUri, ConversationCursor.this);
1509                    if (!mMostlyDead) {
1510                        // Create an update operation that represents archive
1511                        op = ContentProviderOperation.newUpdate(uri).withValue(
1512                                ConversationOperations.OPERATION_KEY,
1513                                ConversationOperations.ARCHIVE)
1514                                .build();
1515                    } else {
1516                        sProvider.commitMostlyDead(mConversation, ConversationCursor.this);
1517                    }
1518                    break;
1519                case MOSTLY_ARCHIVE:
1520                    sProvider.setMostlyDead(mConversation, ConversationCursor.this);
1521                    // Create an update operation that represents archive
1522                    op = ContentProviderOperation.newUpdate(uri).withValue(
1523                            ConversationOperations.OPERATION_KEY, ConversationOperations.ARCHIVE)
1524                            .build();
1525                    break;
1526                case MUTE:
1527                    if (mLocalDeleteOnUpdate) {
1528                        sProvider.deleteLocal(mUri, ConversationCursor.this);
1529                    }
1530
1531                    // Create an update operation that represents mute
1532                    op = ContentProviderOperation.newUpdate(uri).withValue(
1533                            ConversationOperations.OPERATION_KEY, ConversationOperations.MUTE)
1534                            .build();
1535                    break;
1536                case REPORT_SPAM:
1537                case REPORT_NOT_SPAM:
1538                    sProvider.deleteLocal(mUri, ConversationCursor.this);
1539
1540                    final String operation = mType == REPORT_SPAM ?
1541                            ConversationOperations.REPORT_SPAM :
1542                            ConversationOperations.REPORT_NOT_SPAM;
1543
1544                    // Create an update operation that represents report spam
1545                    op = ContentProviderOperation.newUpdate(uri).withValue(
1546                            ConversationOperations.OPERATION_KEY, operation).build();
1547                    break;
1548                case REPORT_PHISHING:
1549                    sProvider.deleteLocal(mUri, ConversationCursor.this);
1550
1551                    // Create an update operation that represents report phishing
1552                    op = ContentProviderOperation.newUpdate(uri).withValue(
1553                            ConversationOperations.OPERATION_KEY,
1554                            ConversationOperations.REPORT_PHISHING).build();
1555                    break;
1556                case DISCARD_DRAFTS:
1557                    sProvider.deleteLocal(mUri, ConversationCursor.this);
1558
1559                    // Create an update operation that represents discarding drafts
1560                    op = ContentProviderOperation.newUpdate(uri).withValue(
1561                            ConversationOperations.OPERATION_KEY,
1562                            ConversationOperations.DISCARD_DRAFTS).build();
1563                    break;
1564                default:
1565                    throw new UnsupportedOperationException(
1566                            "No such ConversationOperation type: " + mType);
1567            }
1568
1569            return op;
1570        }
1571    }
1572
1573    /**
1574     * For now, a single listener can be associated with the cursor, and for now we'll just
1575     * notify on deletions
1576     */
1577    public interface ConversationListener {
1578        /**
1579         * Data in the underlying provider has changed; a refresh is required to sync up
1580         */
1581        public void onRefreshRequired();
1582        /**
1583         * We've completed a requested refresh of the underlying cursor
1584         */
1585        public void onRefreshReady();
1586        /**
1587         * The data underlying the cursor has changed; the UI should redraw the list
1588         */
1589        public void onDataSetChanged();
1590    }
1591
1592    @Override
1593    public boolean isFirst() {
1594        throw new UnsupportedOperationException();
1595    }
1596
1597    @Override
1598    public boolean isLast() {
1599        throw new UnsupportedOperationException();
1600    }
1601
1602    @Override
1603    public boolean isBeforeFirst() {
1604        throw new UnsupportedOperationException();
1605    }
1606
1607    @Override
1608    public boolean isAfterLast() {
1609        throw new UnsupportedOperationException();
1610    }
1611
1612    @Override
1613    public int getColumnIndex(String columnName) {
1614        return mUnderlyingCursor.getColumnIndex(columnName);
1615    }
1616
1617    @Override
1618    public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException {
1619        return mUnderlyingCursor.getColumnIndexOrThrow(columnName);
1620    }
1621
1622    @Override
1623    public String getColumnName(int columnIndex) {
1624        return mUnderlyingCursor.getColumnName(columnIndex);
1625    }
1626
1627    @Override
1628    public String[] getColumnNames() {
1629        return mUnderlyingCursor.getColumnNames();
1630    }
1631
1632    @Override
1633    public int getColumnCount() {
1634        return mUnderlyingCursor.getColumnCount();
1635    }
1636
1637    @Override
1638    public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) {
1639        throw new UnsupportedOperationException();
1640    }
1641
1642    @Override
1643    public int getType(int columnIndex) {
1644        return mUnderlyingCursor.getType(columnIndex);
1645    }
1646
1647    @Override
1648    public boolean isNull(int columnIndex) {
1649        throw new UnsupportedOperationException();
1650    }
1651
1652    @Override
1653    public void deactivate() {
1654        throw new UnsupportedOperationException();
1655    }
1656
1657    @Override
1658    public boolean isClosed() {
1659        return mUnderlyingCursor == null || mUnderlyingCursor.isClosed();
1660    }
1661
1662    @Override
1663    public void registerContentObserver(ContentObserver observer) {
1664        // Nope. We never notify of underlying changes on this channel, since the cursor watches
1665        // internally and offers onRefreshRequired/onRefreshReady to accomplish the same thing.
1666    }
1667
1668    @Override
1669    public void unregisterContentObserver(ContentObserver observer) {
1670        // See above.
1671    }
1672
1673    @Override
1674    public void registerDataSetObserver(DataSetObserver observer) {
1675        // Nope. We use ConversationListener to accomplish this.
1676    }
1677
1678    @Override
1679    public void unregisterDataSetObserver(DataSetObserver observer) {
1680        // See above.
1681    }
1682
1683    @Override
1684    public void setNotificationUri(ContentResolver cr, Uri uri) {
1685        throw new UnsupportedOperationException();
1686    }
1687
1688    @Override
1689    public boolean getWantsAllOnMoveCalls() {
1690        throw new UnsupportedOperationException();
1691    }
1692
1693    @Override
1694    public Bundle getExtras() {
1695        return mUnderlyingCursor != null ? mUnderlyingCursor.getExtras() : Bundle.EMPTY;
1696    }
1697
1698    @Override
1699    public Bundle respond(Bundle extras) {
1700        if (mUnderlyingCursor != null) {
1701            return mUnderlyingCursor.respond(extras);
1702        }
1703        return Bundle.EMPTY;
1704    }
1705
1706    @Override
1707    public boolean requery() {
1708        return true;
1709    }
1710
1711    // Below are methods that update Conversation data (update/delete)
1712
1713    public int updateBoolean(Conversation conversation, String columnName, boolean value) {
1714        return updateBoolean(Arrays.asList(conversation), columnName, value);
1715    }
1716
1717    /**
1718     * Update an integer column for a group of conversations (see updateValues below)
1719     */
1720    public int updateInt(Collection<Conversation> conversations, String columnName,
1721            int value) {
1722        if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
1723            LogUtils.d(LOG_TAG, "ConversationCursor.updateInt(conversations=%s, columnName=%s)",
1724                    conversations.toArray(), columnName);
1725        }
1726        ContentValues cv = new ContentValues();
1727        cv.put(columnName, value);
1728        return updateValues(conversations, cv);
1729    }
1730
1731    /**
1732     * Update a string column for a group of conversations (see updateValues below)
1733     */
1734    public int updateBoolean(Collection<Conversation> conversations, String columnName,
1735            boolean value) {
1736        ContentValues cv = new ContentValues();
1737        cv.put(columnName, value);
1738        return updateValues(conversations, cv);
1739    }
1740
1741    /**
1742     * Update a string column for a group of conversations (see updateValues below)
1743     */
1744    public int updateString(Collection<Conversation> conversations, String columnName,
1745            String value) {
1746        return updateStrings(conversations, new String[] {
1747                columnName
1748        }, new String[] {
1749                value
1750        });
1751    }
1752
1753    /**
1754     * Update a string columns for a group of conversations (see updateValues below)
1755     */
1756    public int updateStrings(Collection<Conversation> conversations,
1757            String[] columnNames, String[] values) {
1758        ContentValues cv = new ContentValues();
1759        for (int i = 0; i < columnNames.length; i++) {
1760            cv.put(columnNames[i], values[i]);
1761        }
1762        return updateValues(conversations, cv);
1763    }
1764
1765    /**
1766     * Update a boolean column for a group of conversations, immediately in the UI and in a single
1767     * transaction in the underlying provider
1768     * @param conversations a collection of conversations
1769     * @param values the data to update
1770     * @return the sequence number of the operation (for undo)
1771     */
1772    public int updateValues(Collection<Conversation> conversations, ContentValues values) {
1773        return apply(
1774                getOperationsForConversations(conversations, ConversationOperation.UPDATE, values));
1775    }
1776
1777    /**
1778     * Apply many operations in a single batch transaction.
1779     * @param op the collection of operations obtained through successive calls to
1780     * {@link #getOperationForConversation(Conversation, int, ContentValues)}.
1781     * @return the sequence number of the operation (for undo)
1782     */
1783    public int updateBulkValues(Collection<ConversationOperation> op) {
1784        return apply(op);
1785    }
1786
1787    private ArrayList<ConversationOperation> getOperationsForConversations(
1788            Collection<Conversation> conversations, int type, ContentValues values) {
1789        final ArrayList<ConversationOperation> ops = Lists.newArrayList();
1790        for (Conversation conv: conversations) {
1791            ops.add(getOperationForConversation(conv, type, values));
1792        }
1793        return ops;
1794    }
1795
1796    public ConversationOperation getOperationForConversation(Conversation conv, int type,
1797            ContentValues values) {
1798        return new ConversationOperation(type, conv, values);
1799    }
1800
1801    public static void addFolderUpdates(ArrayList<Uri> folderUris, ArrayList<Boolean> add,
1802            ContentValues values) {
1803        ArrayList<String> folders = new ArrayList<String>();
1804        for (int i = 0; i < folderUris.size(); i++) {
1805            folders.add(folderUris.get(i).buildUpon().appendPath(add.get(i) + "").toString());
1806        }
1807        values.put(ConversationOperations.FOLDERS_UPDATED,
1808                TextUtils.join(ConversationOperations.FOLDERS_UPDATED_SPLIT_PATTERN, folders));
1809    }
1810
1811    public static void addTargetFolders(Collection<Folder> targetFolders, ContentValues values) {
1812        values.put(Conversation.UPDATE_FOLDER_COLUMN, FolderList.copyOf(targetFolders).toBlob());
1813    }
1814
1815    public ConversationOperation getConversationFolderOperation(Conversation conv,
1816            ArrayList<Uri> folderUris, ArrayList<Boolean> add, Collection<Folder> targetFolders) {
1817        return getConversationFolderOperation(conv, folderUris, add, targetFolders,
1818                new ContentValues());
1819    }
1820
1821    public ConversationOperation getConversationFolderOperation(Conversation conv,
1822            ArrayList<Uri> folderUris, ArrayList<Boolean> add, Collection<Folder> targetFolders,
1823            ContentValues values) {
1824        addFolderUpdates(folderUris, add, values);
1825        addTargetFolders(targetFolders, values);
1826        return getOperationForConversation(conv, ConversationOperation.UPDATE, values);
1827    }
1828
1829    // Convenience methods
1830    private int apply(Collection<ConversationOperation> operations) {
1831        return sProvider.apply(operations, this);
1832    }
1833
1834    private void undoLocal() {
1835        sProvider.undo(this);
1836    }
1837
1838    public void undo(final Context context, final Uri undoUri) {
1839        new Thread(new Runnable() {
1840            @Override
1841            public void run() {
1842                Cursor c = context.getContentResolver().query(undoUri, UIProvider.UNDO_PROJECTION,
1843                        null, null, null);
1844                if (c != null) {
1845                    c.close();
1846                }
1847            }
1848        }).start();
1849        undoLocal();
1850    }
1851
1852    /**
1853     * Delete a group of conversations immediately in the UI and in a single transaction in the
1854     * underlying provider. See applyAction for argument descriptions
1855     */
1856    public int delete(Collection<Conversation> conversations) {
1857        return applyAction(conversations, ConversationOperation.DELETE);
1858    }
1859
1860    /**
1861     * As above, for archive
1862     */
1863    public int archive(Collection<Conversation> conversations) {
1864        return applyAction(conversations, ConversationOperation.ARCHIVE);
1865    }
1866
1867    /**
1868     * As above, for mute
1869     */
1870    public int mute(Collection<Conversation> conversations) {
1871        return applyAction(conversations, ConversationOperation.MUTE);
1872    }
1873
1874    /**
1875     * As above, for report spam
1876     */
1877    public int reportSpam(Collection<Conversation> conversations) {
1878        return applyAction(conversations, ConversationOperation.REPORT_SPAM);
1879    }
1880
1881    /**
1882     * As above, for report not spam
1883     */
1884    public int reportNotSpam(Collection<Conversation> conversations) {
1885        return applyAction(conversations, ConversationOperation.REPORT_NOT_SPAM);
1886    }
1887
1888    /**
1889     * As above, for report phishing
1890     */
1891    public int reportPhishing(Collection<Conversation> conversations) {
1892        return applyAction(conversations, ConversationOperation.REPORT_PHISHING);
1893    }
1894
1895    /**
1896     * Discard the drafts in the specified conversations
1897     */
1898    public int discardDrafts(Collection<Conversation> conversations) {
1899        return applyAction(conversations, ConversationOperation.DISCARD_DRAFTS);
1900    }
1901
1902    /**
1903     * As above, for mostly archive
1904     */
1905    public int mostlyArchive(Collection<Conversation> conversations) {
1906        return applyAction(conversations, ConversationOperation.MOSTLY_ARCHIVE);
1907    }
1908
1909    /**
1910     * As above, for mostly delete
1911     */
1912    public int mostlyDelete(Collection<Conversation> conversations) {
1913        return applyAction(conversations, ConversationOperation.MOSTLY_DELETE);
1914    }
1915
1916    /**
1917     * As above, for mostly destructive updates.
1918     */
1919    public int mostlyDestructiveUpdate(Collection<Conversation> conversations,
1920            ContentValues values) {
1921        return apply(
1922                getOperationsForConversations(conversations,
1923                        ConversationOperation.MOSTLY_DESTRUCTIVE_UPDATE, values));
1924    }
1925
1926    /**
1927     * Convenience method for performing an operation on a group of conversations
1928     * @param conversations the conversations to be affected
1929     * @param opAction the action to take
1930     * @return the sequence number of the operation applied in CC
1931     */
1932    private int applyAction(Collection<Conversation> conversations, int opAction) {
1933        ArrayList<ConversationOperation> ops = Lists.newArrayList();
1934        for (Conversation conv: conversations) {
1935            ConversationOperation op =
1936                    new ConversationOperation(opAction, conv);
1937            ops.add(op);
1938        }
1939        return apply(ops);
1940    }
1941
1942    /**
1943     * Do not make this method dependent on the internal mechanism of the cursor.
1944     * Currently just calls the parent implementation. If this is ever overriden, take care to
1945     * ensure that two references map to the same hashcode. If
1946     * ConversationCursor first == ConversationCursor second,
1947     * then
1948     * first.hashCode() == second.hashCode().
1949     * The {@link ConversationListFragment} relies on this behavior of
1950     * {@link ConversationCursor#hashCode()} to avoid storing dangerous references to the cursor.
1951     * {@inheritDoc}
1952     */
1953    @Override
1954    public int hashCode() {
1955        return super.hashCode();
1956    }
1957
1958    private void resetNotificationActions() {
1959        final boolean changed = !mNotificationTempDeleted.isEmpty();
1960
1961        for (final Conversation conversation : mNotificationTempDeleted) {
1962            sProvider.undeleteLocal(conversation.uri, this);
1963        }
1964
1965        mNotificationTempDeleted.clear();
1966
1967        if (changed) {
1968            mMainThreadHandler.post(new Runnable() {
1969                @Override
1970                public void run() {
1971                    notifyDataChanged();
1972                }
1973            });
1974        }
1975    }
1976
1977    /**
1978     * If a destructive notification action was triggered, but has not yet been processed because an
1979     * "Undo" action is available, we do not want to show the conversation in the list.
1980     */
1981    public void handleNotificationActions() {
1982        final SparseArrayCompat<NotificationAction> undoNotifications =
1983                NotificationActionUtils.sUndoNotifications;
1984
1985        final Set<Conversation> undoConversations =
1986                Sets.newHashSetWithExpectedSize(undoNotifications.size());
1987
1988        boolean changed = false;
1989
1990        for (int i = 0; i < undoNotifications.size(); i++) {
1991            final NotificationAction notificationAction =
1992                    undoNotifications.get(undoNotifications.keyAt(i));
1993
1994            // We only care about notifications that were for this folder or if the action was
1995            // delete
1996            final Folder folder = notificationAction.getFolder();
1997            final boolean deleteAction =
1998                    notificationAction.getNotificationActionType() == NotificationActionType.DELETE;
1999
2000            if (folder.conversationListUri.equals(qUri) || deleteAction) {
2001                // We only care about destructive actions
2002                if (notificationAction.getNotificationActionType().getIsDestructive()) {
2003                    final Conversation conversation = notificationAction.getConversation();
2004
2005                    undoConversations.add(conversation);
2006
2007                    if (!mNotificationTempDeleted.contains(conversation)) {
2008                        sProvider.deleteLocal(conversation.uri, this);
2009                        mNotificationTempDeleted.add(conversation);
2010
2011                        changed = true;
2012                    }
2013                }
2014            }
2015        }
2016
2017        // Remove any conversations from the temporary deleted state if they no longer have an undo
2018        // notification
2019        final Iterator<Conversation> iterator = mNotificationTempDeleted.iterator();
2020        while (iterator.hasNext()) {
2021            final Conversation conversation = iterator.next();
2022
2023            if (!undoConversations.contains(conversation)) {
2024                sProvider.undeleteLocal(conversation.uri, this);
2025                iterator.remove();
2026
2027                changed = true;
2028            }
2029        }
2030
2031        if (changed) {
2032            mMainThreadHandler.post(new Runnable() {
2033                @Override
2034                public void run() {
2035                    notifyDataChanged();
2036                }
2037            });
2038        }
2039    }
2040
2041    /**
2042     * Marks all contents of this cursor as seen. This may have no effect with certain providers.
2043     */
2044    @Override
2045    public void markContentsSeen() {
2046        ConversationCursorMarkSeenListener.MarkSeenHelper.markContentsSeen(mUnderlyingCursor);
2047    }
2048}
2049