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