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