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