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