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