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