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