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