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