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