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