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